/* magi observation deck.
*
* Phone-first: every layout decision below is made for a 390x844 screen held
* one-handed, then widened. The desktop rules are additive and near the bottom.
*
* The palette is taken from assets/logo.svg and assets/logo-dark.svg so the UI
* and the mark cannot drift apart. Both themes are written out in full rather
* than derived with color-mix(), because a tint that silently falls back to a
* flat colour on an older phone browser is a contrast bug the operator would
* only find in sunlight.
*/
/* ---- tokens: light ------------------------------------------------------ */
:root {
--bg: #f7f5f1; /* warm paper; pure white is never used */
--surface: #fffdfa;
--sunk: #efeae1;
--ink: #1a1a1a; /* logo wordmark ink */
--ink-2: #55504a;
--ink-3: #6b655c;
--line: #ddd6c9;
--line-2: #c9c0af;
/* Boundary colour for anything interactive. Distinct from --line because a
divider may be faint, while the edge of a control has to clear 3:1. */
--edge: #9d8c6d;
--gold: #c9a227;
/* #c9a227 is only 2.4:1 on paper, so the gold that carries meaning as a
line or indicator is darkened; the fill of the gold button keeps the
logo value, where the ink on top is what needs the ratio. */
--gold-line: #ac8a21;
--teal: #0a7e8c;
--rust: #c5582d;
--violet: #7d4b9e;
--blue: #4a72b8;
--verdict-merged: #b8901f;
--verdict-ready: #0a7e8c;
--verdict-in_progress: #4a72b8;
--verdict-blocked: #c5582d;
--verdict-failed: #a3283c;
--verdict-verified_noop: #7d4b9e;
--verdict-superseded: #857f74;
--verdict-stalled: #b83d86;
/* The mark, exactly as assets/logo.svg draws it. A logotype is exempt from
the contrast minimum and must not be tampered with to satisfy it. */
--mark-a: #c5582d;
--mark-b: #0a7e8c;
--mark-c: #7d4b9e;
--mark-gold: #c9a227;
/* Candidate colours as they appear on data — labels, rails, diagram
strokes — darkened until each one reads as body text on every surface. */
--cand-a: #ad4d28;
--cand-b: #097582;
--cand-c: #7d4b9e;
--cand-d: #806719;
--cand-e: #4369ac;
/* chip fills and the ink that sits on them */
--tint-gold: #f6eccd; --on-gold: #6b5410;
--tint-teal: #d7edef; --on-teal: #05616b;
--tint-blue: #dce6f6; --on-blue: #2d5391;
--tint-rust: #f8e1d6; --on-rust: #8f3a1a;
--tint-ink: #e9e5de; --on-ink: #494440;
--focus: #2d5391;
--shadow: 0 1px 2px rgba(26, 22, 14, .06), 0 4px 14px rgba(26, 22, 14, .05);
--hatch: rgba(143, 58, 26, .13);
/* The same hatching in the verdict colour, for the one card whose answer
cannot be taken back. */
--hatch-gold: rgba(107, 84, 16, .22);
--radius: 12px;
--radius-sm: 8px;
/* 4px spacing scale */
--s1: 4px; --s2: 8px; --s3: 12px; --s4: 16px; --s5: 24px; --s6: 32px; --s7: 48px;
/* type scale */
--t1: .75rem; --t2: .8125rem; --t3: .875rem; --t4: .9375rem;
--t5: 1.0625rem; --t6: 1.3125rem; --t7: 1.625rem;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
--tap: 44px; /* minimum touch target */
--ease: cubic-bezier(.22, 1, .36, 1); /* out-expo; never elastic */
}
/* ---- tokens: dark ------------------------------------------------------ *
* Duplicated for the media query and the explicit override, deliberately:
* an operator who pins a theme must beat their OS setting, and there is no
* single-selector way to say that without nesting. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #191817;
--surface: #211f1d;
--sunk: #131211;
--ink: #f5f5f5;
--ink-2: #b8b8b8;
--ink-3: #9b948a;
--line: #37342e;
--line-2: #4c473e;
--edge: #70695b;
--gold: #e9c14a;
--gold-line: #e9c14a;
--teal: #3aa9b8;
--rust: #e58050;
--violet: #b78fe0;
--blue: #7ea6e8;
--verdict-merged: #e9c14a;
--verdict-ready: #3aa9b8;
--verdict-in_progress: #7ea6e8;
--verdict-blocked: #e58050;
--verdict-failed: #e8607a;
--verdict-verified_noop: #b78fe0;
--verdict-superseded: #9b948a;
--verdict-stalled: #e57ab5;
/* assets/logo-dark.svg, verbatim. */
--mark-a: #e58050;
--mark-b: #3aa9b8;
--mark-c: #b78fe0;
--mark-gold: #e9c14a;
--cand-a: #e58050;
--cand-b: #3aa9b8;
--cand-c: #b78fe0;
--cand-d: #e9c14a;
--cand-e: #7ea6e8;
--tint-gold: #38301a; --on-gold: #edc85c;
--tint-teal: #14313a; --on-teal: #55c3d1;
--tint-blue: #1c2839; --on-blue: #93b8f2;
--tint-rust: #382318; --on-rust: #f09a72;
--tint-ink: #2a2724; --on-ink: #c4bdb3;
--focus: #93b8f2;
--shadow: 0 1px 2px rgba(0, 0, 0, .5), 0 4px 14px rgba(0, 0, 0, .35);
--hatch: rgba(240, 154, 114, .14);
--hatch-gold: rgba(237, 200, 92, .2);
}
}
:root[data-theme="dark"] {
--bg: #191817;
--surface: #211f1d;
--sunk: #131211;
--ink: #f5f5f5;
--ink-2: #b8b8b8;
--ink-3: #9b948a;
--line: #37342e;
--line-2: #4c473e;
--edge: #70695b;
--gold: #e9c14a;
--gold-line: #e9c14a;
--teal: #3aa9b8;
--rust: #e58050;
--violet: #b78fe0;
--blue: #7ea6e8;
--verdict-merged: #e9c14a;
--verdict-ready: #3aa9b8;
--verdict-in_progress: #7ea6e8;
--verdict-blocked: #e58050;
--verdict-failed: #e8607a;
--verdict-verified_noop: #b78fe0;
--verdict-superseded: #9b948a;
--verdict-stalled: #e57ab5;
/* assets/logo-dark.svg, verbatim. */
--mark-a: #e58050;
--mark-b: #3aa9b8;
--mark-c: #b78fe0;
--mark-gold: #e9c14a;
--cand-a: #e58050;
--cand-b: #3aa9b8;
--cand-c: #b78fe0;
--cand-d: #e9c14a;
--cand-e: #7ea6e8;
--tint-gold: #38301a; --on-gold: #edc85c;
--tint-teal: #14313a; --on-teal: #55c3d1;
--tint-blue: #1c2839; --on-blue: #93b8f2;
--tint-rust: #382318; --on-rust: #f09a72;
--tint-ink: #2a2724; --on-ink: #c4bdb3;
--focus: #93b8f2;
--shadow: 0 1px 2px rgba(0, 0, 0, .5), 0 4px 14px rgba(0, 0, 0, .35);
--hatch: rgba(240, 154, 114, .14);
--hatch-gold: rgba(237, 200, 92, .2);
}
:root[data-theme="dark"] { color-scheme: dark; }
:root[data-theme="light"] { color-scheme: light; }
/* ---- base -------------------------------------------------------------- */
*, *::before, *::after { box-sizing: border-box; }
/* The client toggles visibility with the `hidden` attribute, and almost every
element it toggles also carries a `display` rule here, which would otherwise
win over the user-agent default and leave empty cards and blank alert boxes
on screen. */
[hidden] { display: none !important; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font: 400 var(--t4)/1.55 var(--sans);
/* The dock is fixed; this keeps the last card clear of it and of the home
indicator. */
padding-bottom: calc(var(--tap) + var(--s6) + env(safe-area-inset-bottom));
overflow-x: hidden; /* a horizontal scrollbar on a phone is a bug */
-webkit-tap-highlight-color: transparent;
}
h1, h2, h3 { margin: 0; font-weight: 650; letter-spacing: -.01em; line-height: 1.25; }
h1 { font-size: var(--t6); letter-spacing: -.02em; }
h2 { font-size: var(--t4); }
h3 { font-size: var(--t4); }
/* The small-caps treatment belongs to section labels inside a panel, not to
every h2: a card's title is the operator's own task text and must read as
the sentence they wrote. */
.panel h2 {
font-size: var(--t3); text-transform: uppercase;
letter-spacing: .07em; color: var(--ink-3);
}
p { margin: 0; }
ol, ul { margin: 0; padding: 0; list-style: none; }
a { color: inherit; text-decoration: none; }
:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
.sr-only {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip-path: inset(50%); white-space: nowrap; border: 0;
}
.skip {
position: absolute; left: var(--s3); top: -100px; z-index: 40;
background: var(--surface); color: var(--ink);
padding: var(--s3) var(--s4); border: 1px solid var(--edge);
border-radius: var(--radius-sm); font-weight: 600;
}
.skip:focus { top: var(--s3); }
/* ---- header ------------------------------------------------------------ */
.top {
position: sticky; top: 0; z-index: 20;
display: flex; align-items: center; gap: var(--s3);
padding: var(--s2) var(--s4);
padding-top: calc(var(--s2) + env(safe-area-inset-top));
padding-left: calc(var(--s4) + env(safe-area-inset-left));
padding-right: calc(var(--s4) + env(safe-area-inset-right));
background: var(--bg);
border-bottom: 1px solid var(--line);
}
.brand { display: flex; align-items: center; gap: var(--s2); min-height: var(--tap); margin-right: auto; }
.brand-mark { width: 30px; height: 30px; flex: none; }
.brand-text { display: flex; flex-direction: column; line-height: 1.1; }
.brand-name { font-size: var(--t5); font-weight: 700; letter-spacing: -.03em; }
.brand-sub { font-size: var(--t1); color: var(--ink-3); font-style: italic; }
.rail { display: none; }
.icon-btn {
width: var(--tap); height: var(--tap); flex: none;
display: grid; place-items: center;
background: none; border: 1px solid transparent; border-radius: var(--radius-sm);
color: var(--ink-2); cursor: pointer; font: inherit;
}
.icon-btn { position: relative; }
.bell .badge { position: absolute; top: 2px; right: 0; background: var(--rust); color: var(--badge-ink, #fff); }
.icon-btn svg { width: 20px; height: 20px; }
.icon-btn[aria-current="page"] { color: var(--ink); background: var(--sunk); }
/* Sun / moon / auto, drawn in CSS so the toggle costs no markup and no fetch. */
.theme-glyph {
width: 18px; height: 18px; border-radius: 50%;
border: 2px solid currentColor;
background: linear-gradient(90deg, currentColor 50%, transparent 50%);
}
[data-theme="light"] .theme-glyph { background: currentColor; }
[data-theme="dark"] .theme-glyph { background: none; box-shadow: inset -5px 0 0 currentColor; }
/* ---- loop strip -------------------------------------------------------- *
* One slim line: dot, status sentence (ellipsised), an (i) toggle and the
* .daemon-controls cluster (build chip + live buttons). The cluster is always
* exactly one grid item, so how many of its children are visible never changes
* the item count. The long explanation (#loop-why) is folded away: it is the
* tooltip of .daemon-body, opens under the line via the (i) toggle
* (data-more), and opens by itself for a failure, an upgrade note or a stop
* waiting for its second tap. */
.daemon {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 0 var(--s2);
padding: var(--s1) var(--s3);
padding-left: calc(var(--s3) + env(safe-area-inset-left));
padding-right: calc(var(--s3) + env(safe-area-inset-right));
background: var(--sunk);
border-bottom: 1px solid var(--line);
font-size: var(--t1); color: var(--ink-2);
min-height: 26px;
}
.daemon-dot {
width: 8px; height: 8px; flex: none; border-radius: 50%;
background: var(--ink-3);
box-shadow: 0 0 0 2px var(--surface);
}
.daemon-body { min-width: 0; display: block; }
.daemon-text { display: block; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.daemon-text b { color: var(--ink); font-weight: 650; }
.daemon-run { font-family: var(--mono); font-size: var(--t1); }
.daemon-why { font-size: var(--t1); }
.daemon:not([data-more="yes"]):not([data-state="failed"]):not([data-upgrade-failed="yes"]):not(:has([data-armed="yes"])) .daemon-why { display: none; }
.daemon-why:not([hidden]) { display: block; }
.daemon[data-more="yes"] .daemon-body,
.daemon[data-state="failed"] .daemon-body,
.daemon[data-upgrade-failed="yes"] .daemon-body,
.daemon:has([data-armed="yes"]) .daemon-body { grid-column: 1 / -1; grid-row: 2; padding: 2px 0 var(--s1); }
/* An expanded strip is where failures are read: let the sentence wrap. */
.daemon[data-more="yes"] .daemon-text,
.daemon[data-state="failed"] .daemon-text,
.daemon[data-upgrade-failed="yes"] .daemon-text,
.daemon:has([data-armed="yes"]) .daemon-text { white-space: normal; overflow: visible; text-overflow: clip; }
.daemon-more {
appearance: none; border: 1px solid var(--line-2); background: transparent; color: var(--ink-3);
width: 16px; height: 16px; padding: 0; border-radius: 50%;
font: italic 600 10px/1 var(--mono); cursor: pointer;
}
.daemon-more[aria-expanded="true"] { color: var(--ink); border-color: var(--ink-3); }
.daemon-controls {
display: flex;
align-items: center;
gap: var(--s2);
}
.daemon-controls:has([data-armed="yes"]) { grid-column: 1 / -1; grid-row: 3; flex-wrap: wrap; }
.daemon-version {
font-family: var(--mono);
font-size: .6875rem;
color: var(--ink-3);
white-space: nowrap;
}
.daemon:has(#loop-park:not([hidden])) .daemon-version,
.daemon:has(#loop-upgrade:not([hidden])) .daemon-version { display: none; }
.daemon .daemon-btn {
flex: none;
font-size: var(--t1);
min-height: 0; height: 22px; padding: 0 var(--s2);
background: transparent; border-color: transparent; color: var(--ink-2);
text-decoration: underline; text-underline-offset: 2px;
white-space: nowrap;
}
@media (pointer: coarse) { .daemon .daemon-btn { height: 32px; } }
.daemon .daemon-btn[data-kind="start"] { color: var(--ink); font-weight: 650; }
.daemon[data-state="working"] { background: var(--tint-blue); color: var(--on-blue); border-bottom-color: var(--tint-blue); }
.daemon[data-state="working"] .daemon-dot { background: var(--on-blue); animation: pulse 1.8s var(--ease) infinite; box-shadow: 0 0 0 2px var(--tint-blue), 0 0 0 3px var(--on-blue); }
.daemon[data-state="working"] .daemon-text b { color: var(--on-blue); }
.daemon[data-state="idle"] .daemon-dot { background: var(--teal); }
/* Off with an empty queue is a choice, not a fault: neutral, and the control
beside it is an offer. */
.daemon[data-state="off"] .daemon-dot { background: var(--ink-3); box-shadow: 0 0 0 2px var(--sunk), 0 0 0 3px var(--line-2); }
/* Off with runnable tasks in the queue is the state this strip exists for:
nothing will happen until the operator taps. Gold, the colour this
interface uses for "your turn", rather than the rust of a fault — the loop
being off is not a failure, and colouring it like one taught the operator
to ignore it. */
.daemon[data-state="waiting"] { background: var(--tint-gold); color: var(--on-gold); border-bottom-color: var(--gold-line); }
.daemon[data-state="waiting"] .daemon-dot { background: var(--on-gold); box-shadow: 0 0 0 2px var(--tint-gold), 0 0 0 3px var(--on-gold); }
.daemon[data-state="waiting"] .daemon-text b { color: var(--on-gold); }
/* Stopping is in flight, not finished, so it keeps the pulsing dot of work in
progress: a run is still going. */
.daemon[data-state="stopping"] { background: var(--tint-ink); color: var(--on-ink); border-bottom-color: var(--line-2); }
.daemon[data-state="stopping"] .daemon-dot { background: var(--on-ink); animation: pulse 1.8s var(--ease) infinite; box-shadow: 0 0 0 2px var(--tint-ink), 0 0 0 3px var(--on-ink); }
.daemon[data-state="stopping"] .daemon-text b { color: var(--on-ink); }
/* A loop that died is the one state here that is a fault, so it is the only
one in rust: the two ordinary "off" states must not be able to borrow this
colour, or the operator would stop being able to tell a stop from a crash.
The error text itself is shown beside it. */
.daemon[data-state="failed"] { background: var(--tint-rust); color: var(--on-rust); border-bottom-color: var(--tint-rust); }
.daemon[data-state="failed"] .daemon-dot { background: var(--on-rust); box-shadow: 0 0 0 2px var(--tint-rust), 0 0 0 3px var(--on-rust); }
.daemon[data-state="failed"] .daemon-text b { color: var(--on-rust); }
.daemon[data-state="failed"] .daemon-btn { color: var(--ink); }
/* This deck's own upgrade, in flight. The same blue as "working": it is work
in progress, not a fault, and a fault colour here would make an ordinary
restart look like an incident. */
.daemon[data-state="upgrading"] { background: var(--tint-blue); color: var(--on-blue); border-bottom-color: var(--tint-blue); }
.daemon[data-state="upgrading"] .daemon-dot { background: var(--on-blue); animation: pulse 1.8s var(--ease) infinite; box-shadow: 0 0 0 2px var(--tint-blue), 0 0 0 3px var(--on-blue); }
.daemon[data-state="upgrading"] .daemon-text b { color: var(--on-blue); }
/* A past upgrade that did not complete, layered on the dot rather than on
`data-state`: the loop keeps whatever state it actually has regardless of
whether its last upgrade succeeded, and a ring rather than a takeover is
what lets the note sit beside start/stop/park instead of hiding them. */
.daemon[data-upgrade-failed="yes"] .daemon-dot { box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--on-rust); }
/* Someone else's loop. The dashed ring is the same vocabulary the cards use
for "not this page's to settle", and there is no button at all rather than
one that would answer 409. */
.daemon[data-owned="no"] .daemon-dot {
background: none;
border: 2px dashed currentColor;
width: 12px; height: 12px;
animation: none;
}
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
/* ---- ask bar ----------------------------------------------------------- *
* Sits in the same authoritative band as the daemon strip because it answers
* the same question the strip does — "is anything going to happen?" — with
* the one answer the operator can act on. An unanswered question is the only
* state in this product where the machine is burning nothing and moving
* nowhere, so it is loud: a gold rule across the top edge, a filled mark and
* the commit-coloured button. It is also disciplined, because it appears on
* every view: one clamped line of summary, no animated text, and a fixed
* min-height so a second question arriving cannot reflow the page under a
* thumb. */
.ask-bar {
display: flex; align-items: center; gap: var(--s3);
padding: var(--s2) var(--s4);
padding-left: calc(var(--s4) + env(safe-area-inset-left));
padding-right: calc(var(--s4) + env(safe-area-inset-right));
background: var(--tint-gold); color: var(--on-gold);
border-bottom: 1px solid var(--gold-line);
box-shadow: inset 0 3px 0 var(--gold-line);
min-height: 58px;
}
.ask-mark {
position: relative;
width: 26px; height: 26px; flex: none;
display: grid; place-items: center;
border-radius: 50%;
background: var(--on-gold); color: var(--tint-gold);
font: 800 var(--t4)/1 var(--sans);
}
/* A slow expanding ring rather than a flash. It catches the eye on arrival
and then stops being something you have to read past. Drawn on a
pseudo-element so the animation is opacity and transform only, which the
reduced-motion rule at the bottom of this file can flatten to nothing. */
.ask-mark::after {
content: ""; position: absolute; inset: -6px;
border-radius: 50%; border: 2px solid var(--on-gold);
animation: ring 2.8s var(--ease) infinite;
}
@keyframes ring {
0% { transform: scale(.7); opacity: .65; }
60%, 100% { transform: scale(1.15); opacity: 0; }
}
.ask-bar-text { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.3; }
.ask-bar-count { font-size: var(--t3); font-weight: 700; }
.ask-bar-summary {
font-size: var(--t2);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.ask-bar-go { flex: none; padding: 0 var(--s3); }
/* The count on the nav item itself, so an open question is still visible once
the operator has scrolled the bar off the top of a 390px screen. */
.badge {
display: inline-grid; place-items: center;
min-width: 18px; height: 18px; padding: 0 5px;
border-radius: 999px;
background: var(--on-gold); color: var(--tint-gold);
font: 700 var(--t1)/1 var(--sans);
}
.rail-link .badge { margin-left: 6px; }
.dock-glyph { position: relative; display: block; }
.dock-glyph .badge { position: absolute; top: -5px; right: -10px; border: 2px solid var(--surface); }
/* ---- alert ------------------------------------------------------------- */
/* Fixed above the dock rather than at the top of the document.
*
* It used to sit in the flow, under the header. On a phone scrolled down to a
* run's action buttons that is off screen, so tapping Resume and being told
* "the loop is running run b455 right now" looked exactly like a button that
* did nothing - the answer was there, several screens up. An error about the
* thing under your thumb has to be visible from where your thumb is.
*
* Clear of the dock and the home indicator the same way `.run-actions-fab`
* is, and above both so the sheet cannot bury it. */
.alert {
position: fixed; z-index: 25;
left: calc(var(--s4) + env(safe-area-inset-left));
/* Leaves the run-actions FAB's column free: it sits at this exact height
on the right, and an error that covered it would hide the button the
operator reaches for next. */
right: calc(var(--s4) + var(--tap) + var(--s3) + env(safe-area-inset-right));
bottom: calc(var(--tap) + var(--s3) + env(safe-area-inset-bottom));
display: flex; align-items: center; gap: var(--s3);
padding: var(--s3);
background: var(--tint-rust); color: var(--on-rust);
border: 1px solid currentColor; border-radius: var(--radius-sm);
box-shadow: 0 6px 20px rgb(0 0 0 / 28%);
font-size: var(--t3);
}
/* At 720px the dock is hidden, so the offset that clears it would leave the
alert floating above nothing. Same breakpoint as `.dock { display: none }`
rather than a second guess at where wide begins. */
@media (min-width: 720px) {
.alert { bottom: calc(var(--s4) + env(safe-area-inset-bottom)); }
}
.alert-text { flex: 1; min-width: 0; }
/* An older attempt at a task that a later one replaced. Quieter than a
status note: it is about which of two identical-looking cards is the one
that matters, not about what went wrong. */
.card-superseded { color: var(--ink-3); font-style: italic; }
/* ---- views ------------------------------------------------------------- */
main {
padding: var(--s4);
padding-left: calc(var(--s4) + env(safe-area-inset-left));
padding-right: calc(var(--s4) + env(safe-area-inset-right));
max-width: 1200px; margin: 0 auto;
}
.view { display: flex; flex-direction: column; gap: var(--s4); }
.view-head { display: flex; flex-direction: column; gap: var(--s1); }
.view-sub { font-size: var(--t2); color: var(--ink-3); }
.talk-selectors { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--s2); }
/* Each selector is a chip: the label is the pill, the native select inside is
its value. Tapping opens the platform's own sheet in place. */
.talk-agent {
display: flex; flex-direction: column; justify-content: center; gap: 0;
min-width: 0; min-height: 44px; padding: 2px var(--s2);
border: 1px solid var(--edge); border-radius: 999px;
font-size: var(--t2); color: var(--ink-3); background: var(--bg);
}
.talk-agent-label { font-size: 11px; line-height: 1.2; }
.talk-agent select {
appearance: none; -webkit-appearance: none;
min-width: 0; width: 100%; max-width: 100%; min-height: 22px;
border: 0; background: transparent; padding: 0; margin: 0;
font: inherit; color: var(--ink); text-overflow: ellipsis;
}
.talk-agent:has(select:disabled) { opacity: .55; }
/* Persona avatars: fixed size, round, never part of the text flow's width. */
.persona-avatar { flex: none; border-radius: 50%; object-fit: cover; background: var(--edge); }
.persona-avatar[hidden] { display: none; }
.talk-persona-avatar { width: 16px; height: 16px; vertical-align: middle; margin-right: var(--s1); }
.turn-avatar { width: 22px; height: 22px; display: inline-block; vertical-align: middle; margin: 0 var(--s2) var(--s1) 0; }
.talks-actions { display: flex; align-items: center; gap: var(--s3); flex-wrap: wrap; }
.back {
display: inline-flex; align-items: center; gap: var(--s1);
align-self: flex-start; min-height: var(--tap); padding-right: var(--s2);
font-size: var(--t3); color: var(--ink-2); font-weight: 600;
}
.back::before { content: "\2190"; font-size: var(--t5); }
.empty {
display: flex; flex-direction: column; align-items: flex-start; gap: var(--s4);
padding: var(--s5) var(--s4);
border: 1px dashed var(--line-2); border-radius: var(--radius);
color: var(--ink-2); font-size: var(--t3); max-width: 46ch;
}
/* ---- buttons ----------------------------------------------------------- */
.btn {
display: inline-flex; align-items: center; justify-content: center;
min-height: var(--tap); padding: 0 var(--s4);
border-radius: var(--radius-sm); border: 1px solid var(--edge);
background: var(--surface); color: var(--ink);
font: 600 var(--t3)/1 var(--sans); cursor: pointer;
transition: transform .18s var(--ease);
}
.btn:disabled, .btn[aria-disabled="true"] { opacity: .5; cursor: default; }
/* Gold is the verdict colour in the mark, so it is the commit action here and
nowhere else. */
.btn-gold { background: var(--fill); border-color: var(--gold-line); color: #241d05; }
:root[data-theme="dark"] .btn-gold { color: #241d05; }
.btn-quiet { background: transparent; border-color: var(--edge); color: var(--ink-2); }
/* ---- status chips ------------------------------------------------------ *
* Colour is never the only channel. Every status carries a glyph, and the
* three unhappy endings additionally differ in border and fill treatment, so
* `stalled` cannot be mistaken for `ready` in greyscale or by a red-green
* colour-blind operator. */
.chip {
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 9px 3px 7px;
border-radius: 999px; border: 1.5px solid currentColor;
background: var(--tint-ink); color: var(--on-ink);
font-size: var(--t1); font-weight: 700;
text-transform: uppercase; letter-spacing: .06em;
white-space: nowrap;
}
.chip::before { content: attr(data-glyph); font-size: 1.05em; letter-spacing: 0; }
.chip[data-status="merged"] { background: var(--tint-gold); color: var(--on-gold); }
.chip[data-status="ready"] { background: var(--tint-teal); color: var(--on-teal); }
.chip[data-status="prep"] { background: var(--tint-ink); color: var(--on-ink); }
.chip[data-flight="1"] { background: var(--tint-blue); color: var(--on-blue); border-style: dashed; }
.chip[data-status="blocked"] { background: var(--tint-rust); color: var(--on-rust); }
.chip[data-status="failed"] { background: var(--tint-ink); color: var(--on-ink); }
/* unmerged: same "ready" verdict underneath, but `[merge] mode = "none"`
means nobody lands it — dashed like `abandoned`, so it reads as
deliberately left rather than as the plain `ready` teal chip next to it. */
.chip[data-status="unmerged"] { background: var(--tint-ink); color: var(--on-ink); border-style: dashed; }
/* stalled: hatched fill and a double border. The panel collapsed on quota —
the run holds work but reached no verdict, and it must not read as success. */
.chip[data-status="stalled"] {
color: var(--on-rust);
border-style: double; border-width: 3px;
background-color: var(--tint-rust);
background-image: repeating-linear-gradient(-45deg,
var(--hatch) 0 3px, transparent 3px 7px);
}
/* waiting: an agent stopped and asked the owner something. Gold is the
decision colour in the mark, so a decision that is merely owed is gold in a
ring — filled gold stays reserved for a decision that was actually made.
The ring, the `?` glyph and the halted phase rail are three channels saying
the same thing, because `waiting` must never be skimmed as `implementing`:
one is progress, the other is a full stop. */
.chip[data-status="waiting"],
.chip[data-status="open"] {
background: var(--tint-gold); color: var(--on-gold);
outline: 2px solid var(--on-gold); outline-offset: 2px;
}
.chip[data-status="answered"] { background: var(--tint-teal); color: var(--on-teal); }
/* The run went away before anyone answered; the question is history, not work. */
.chip[data-status="abandoned"] { background: var(--tint-ink); color: var(--on-ink); border-style: dashed; }
/* superseded: a run a later attempt at the same task already replaced. Wins
over every `data-status` rule above regardless of source order — same
selector specificity, so it has to come last in the sheet — because a
`BLOCKED`/`FAILED` chip on a run nobody needs to act on anymore is exactly
the false alarm this exists to defuse. Same muted, dashed treatment as
`unmerged`/`abandoned`: still a real record, just not a live one. */
.chip[data-superseded="1"] {
background: var(--tint-ink); color: var(--on-ink); border-style: dashed;
}
/* ---- queue: id search ---------------------------------------------------- *
* A phone's only way to find one task in a backlog of dozens; see
* queue-search-input in index.html. Laid out like .runs-filter's row rather
* than a bare <input> so it reads as a control, not a stray text field. */
.queue-search { display: flex; align-items: center; gap: var(--s3); margin-bottom: var(--s3); }
.queue-search input[type="search"] { flex: 1; margin-top: 0; width: 100%; font-size: 16px; }
.queue-search-status.search-error { color: var(--rust, #b3402a); font-weight: 600; }
/* A search hit's context line: the field it came from, then the text with the
matched words marked. Built from text nodes, never markup. */
.card-snippet { margin: var(--s2) 0 0; font-size: var(--t3); color: var(--ink-2); overflow-wrap: anywhere; }
.card-snippet[hidden] { display: none; }
.card-snippet .snippet-field { margin-right: var(--s2); font-size: var(--t1); color: var(--ink-3); text-transform: uppercase; letter-spacing: .04em; }
.card-snippet mark { background: rgba(240, 190, 40, .45); color: inherit; border-radius: 2px; padding: 0 1px; }
.queue-focus-status { margin: 0 0 var(--s3); font-size: var(--t3); color: var(--ink-2); }
.card-permalink { font-size: var(--t1); color: var(--ink-3); text-decoration: none; }
.card-permalink:focus-visible { color: var(--ink-2); text-decoration: underline; }
@media (hover: hover) and (pointer: fine) {
.card-permalink:hover { color: var(--ink-2); text-decoration: underline; }
}
.queue-search-status { margin: calc(-1 * var(--s2)) 0 var(--s3); font-size: var(--t3); color: var(--ink-2); }
/* The single-hit flash: a plain background pulse rather than color-mix(), for
the same reason the token list above is written out in full per theme. */
.card.card-hit { animation: card-hit 1.6s var(--ease); }
@keyframes card-hit {
0%, 35% { background: var(--sunk); box-shadow: 0 0 0 2px var(--focus); }
100% { background: var(--surface); box-shadow: var(--shadow); }
}
/* ---- cards (runs + queue) ---------------------------------------------- */
.cards { display: flex; flex-direction: column; gap: var(--s3); }
.card {
display: block;
background: var(--surface);
border: 1px solid var(--line);
border-left: 3px solid var(--line-2);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: var(--s3) var(--s4);
}
.card[data-tone="gold"] { border-left-color: var(--gold-line); }
.card[data-tone="teal"] { border-left-color: var(--teal); }
.card[data-tone="blue"] { border-left-color: var(--blue); }
.card[data-tone="rust"] { border-left-color: var(--rust); }
.card[data-tone="ink"] { border-left-color: var(--line-2); }
/* Parked on a question. Gold like a verdict, dashed like everything else in
this interface that is pending rather than settled, so it is not the same
card edge as `merged` at a glance or in greyscale. */
.card[data-tone="wait"] { border-left-color: var(--gold-line); border-left-style: dashed; }
/* Jumped to from the dependency graph: a brief highlight so the card that
scrolled into view is the one the eye lands on, not just wherever the
scroll happened to stop. `animation-fill-mode` is not `forwards` - it
reverts to normal on its own rather than leaving a permanent mark on a
card that gets re-jumped to later. */
@keyframes card-flash { from { box-shadow: 0 0 0 3px var(--focus); } to { box-shadow: var(--shadow); } }
.card-flash { animation: card-flash 1.6s var(--ease); }
.card-top { display: flex; align-items: center; gap: var(--s2); }
.card-when { margin-left: auto; font-size: var(--t1); color: var(--ink-3); white-space: nowrap; }
.card-title {
margin-top: var(--s2);
font-size: var(--t5); font-weight: 600; line-height: 1.32; color: var(--ink);
/* Two lines is the honest budget at 390px; the full text is on the detail
view and in the instruction disclosure. */
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden;
}
.card-meta {
margin-top: var(--s2);
display: flex; flex-wrap: wrap; align-items: center; gap: 0 var(--s2);
font-size: var(--t2); color: var(--ink-2);
}
/* A conversation with something unseen reads heavier at a glance, the same
way an unread thread does anywhere else - the numbered badge next to it
(`.card-top .badge`) carries the count. */
.card[data-unread] .card-title { font-weight: 800; }
/* The separator trails its own item rather than leading the next one. When a
meta line wraps, the dot stays at the end of the previous line instead of
appearing as a stray bullet under the title. `data-sep` is set by the
client, which is the only thing that knows which item is last once some of
them are hidden. */
.card-meta > [data-sep]::after,
.cand-nums > [data-sep]::after {
content: "\00b7"; margin-left: var(--s2); color: var(--line-2);
}
/* Keep Queue ids clear of chips even when the split grid replaces the top
row's flex gap. */
.task-card .card-permalink { margin-inline: var(--s1); }
.repo { font-family: var(--mono); font-size: var(--t1); }
/* Only styled when it actually has an href - a human-filed task's source has
nowhere to go, and stays plain text rather than looking tappable. */
.task-source[href] {
color: inherit;
text-decoration: underline; text-decoration-color: var(--line-2); text-underline-offset: 3px;
}
.win { font-weight: 700; color: var(--on-gold); }
.card-event {
margin-top: var(--s2); padding-left: var(--s3);
border-left: 2px solid var(--line);
font-size: var(--t2); color: var(--ink-2); font-style: italic;
overflow-wrap: anywhere;
}
/* Chat list rows: two lines at every width. Line 1 is the title alone on the
full row; line 2 carries the status chip, unread badge, thinking tag, the
shrinkable meta text and the time. Items are placed explicitly so a hidden
one leaves a zero-width track, and the gaps are margins for the same reason.
The id beats the generic and `main[data-split]` card rules. */
#talks-list .card {
display: grid; grid-template-columns: auto auto auto minmax(0, 1fr) auto;
align-items: center; gap: 0;
}
#talks-list .card-top { display: contents; }
#talks-list .card-title {
grid-row: 1; grid-column: 1 / -1; min-width: 0; margin: 0;
display: block; white-space: nowrap; text-overflow: ellipsis; overflow: hidden;
}
#talks-list .talk-chip { grid-row: 2; grid-column: 1; margin: var(--s2) var(--s2) 0 0; }
#talks-list .talk-unread { grid-row: 2; grid-column: 2; margin: var(--s2) var(--s2) 0 0; }
#talks-list .talk-thinking { grid-row: 2; grid-column: 3; margin: var(--s2) var(--s2) 0 0; }
#talks-list .card-meta {
grid-row: 2; grid-column: 4; min-width: 0; max-width: none; margin: var(--s2) 0 0;
display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
#talks-list .card-when { grid-row: 2; grid-column: 5; margin: var(--s2) 0 0 var(--s2); }
#talks-list .card > .card-event { grid-row: 3; grid-column: 1 / -1; }
#talks-list .card > .card-snippet { grid-row: 4; grid-column: 1 / -1; }
/* Runs list rows: the same two-line treatment as the chat list above. Line 1
is the title alone on the full row; line 2 is the status chip, the
shrinkable meta text (run id, repo, counts) and the time. Everything else
(note, superseded, event, snippet, rail) takes a full-width row below.
`.cards .card.run-card` outranks the generic and `main[data-split]` rules. */
.cards .card.run-card {
display: grid; grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center; gap: 0;
}
.cards .card.run-card .card-top { display: contents; }
.cards .card.run-card .card-title {
grid-row: 1; grid-column: 1 / -1; min-width: 0; margin: 0;
display: block; white-space: nowrap; text-overflow: ellipsis; overflow: hidden;
}
.cards .card.run-card .run-chip { grid-row: 2; grid-column: 1; margin: var(--s2) var(--s2) 0 0; }
.cards .card.run-card .card-meta {
grid-row: 2; grid-column: 2; min-width: 0; max-width: none; margin: var(--s2) 0 0;
display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.cards .card.run-card .card-when { grid-row: 2; grid-column: 3; margin: var(--s2) 0 0 var(--s2); }
.cards .card.run-card > :not(.card-top):not(.card-title):not(.card-meta) { grid-column: 1 / -1; }
.card-note { margin-top: var(--s2); font-size: var(--t2); color: var(--on-rust); font-weight: 600; }
.card-note.note-clamp:not(.expanded) { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 4; line-clamp: 4; overflow: hidden; cursor: pointer; }
.err {
margin-top: var(--s2); padding: var(--s2) var(--s3);
background: var(--tint-rust); color: var(--on-rust);
border-radius: var(--radius-sm);
font-family: var(--mono); font-size: var(--t1); line-height: 1.45;
/* A <pre> defaults to white-space: pre, which let a one-line agent error
push the whole document sideways on a 390px screen. */
white-space: pre-wrap; overflow-wrap: anywhere;
}
/* phase rail: where an in-flight run has got to, since the summary carries no
progress of its own. */
.phases { display: flex; align-items: center; gap: 5px; margin-top: var(--s3); }
.phase { width: 100%; height: 3px; border-radius: 2px; background: var(--line); }
.phase[data-on="1"] { background: var(--blue); }
.phase[data-now="1"] { background: var(--blue); animation: pulse 1.8s var(--ease) infinite; }
/* A run parked on a question keeps its rail, because how far it got still
matters, but the segment it stopped on is bracketed by the empty track
showing through on both sides, and it does not pulse. So the rail says
"stopped here" as a shape and as an absence of motion, not only in gold:
`waiting` and `implementing` have to stay apart in greyscale. */
.phase[data-parked="1"] {
background: var(--gold-line);
border-left: 3px solid var(--line); border-right: 3px solid var(--line);
animation: none;
}
.card-actions { display: flex; gap: var(--s2); margin-top: var(--s3); flex-wrap: wrap; }
.card-actions .btn { flex: 1 1 auto; min-width: 120px; }
.task-priority-box, .task-hold-box, .task-done-box, .task-delete-box { display: contents; }
/* Priority is a step, not a task-sized action - `120px` would make a
button labelled "+" look like a mistake. */
.card-actions .btn-step { flex: 0 0 auto; min-width: var(--tap); padding: 0 var(--s3); }
/* A card's own confirm/input box (hold's reason field, delete's and done's
warnings) always takes the whole action row rather than being squeezed
between the other buttons it replaces. */
.card-actions .stakes-confirm { flex: 1 1 100%; }
/* ---- card tail --------------------------------------------------------- *
* A run card is one big anchor, which is the affordance the phone layout
* depends on, and an anchor may contain neither another anchor nor a button.
* The pull-request link and the Answer action therefore live in a sibling
* strip drawn as the bottom of the same card: same tone rail, no gap, a
* dashed rule marking where the card-wide tap target ends. */
.cards > li { display: flex; flex-direction: column; }
li[data-tail="1"] { border-radius: var(--radius); box-shadow: var(--shadow); }
li[data-tail="1"] > .card, li[data-tail="1"] > .card-tail { box-shadow: none; }
li[data-tail="1"] > .card {
border-bottom: 0;
border-bottom-left-radius: 0; border-bottom-right-radius: 0;
}
.card-tail {
display: flex; align-items: center; gap: var(--s2) var(--s4); flex-wrap: wrap;
padding: 0 var(--s4) var(--s2);
background: var(--surface);
border: 1px solid var(--line); border-top: 1px dashed var(--line);
border-left: 3px solid var(--line-2);
border-radius: 0 0 var(--radius) var(--radius);
}
li[data-tone="gold"] > .card-tail { border-left-color: var(--gold-line); }
li[data-tone="teal"] > .card-tail { border-left-color: var(--teal); }
li[data-tone="blue"] > .card-tail { border-left-color: var(--blue); }
li[data-tone="rust"] > .card-tail { border-left-color: var(--rust); }
li[data-tone="wait"] > .card-tail { border-left-color: var(--gold-line); border-left-style: dashed; }
/* The note explains, the button acts, so they stack in that order whether or
not there is a pull request above them. Explicit `order` rather than DOM
order because a full-width note between two inline items would otherwise
split the pull-request row in half. */
.tail-note { order: 1; flex: 1 1 100%; font-size: var(--t2); color: var(--ink-2); }
.tail-go { order: 2; margin-left: auto; }
/* A pull request is a place, so it is a real link with a real target, marked
as leaving the deck. */
.pr-link {
display: inline-flex; align-items: center; gap: 5px;
min-height: var(--tap);
font-family: var(--mono); font-size: var(--t2); font-weight: 700; color: var(--ink);
text-decoration: underline; text-decoration-color: var(--line-2); text-underline-offset: 3px;
}
.pr-link::after { content: "\2197"; font-family: var(--sans); color: var(--ink-3); }
.pr-round { font-size: var(--t2); color: var(--ink-2); }
/* Check state carries its own glyph and its own sentence. `red` is not a
verdict on the run — it schedules another fixer round — so it deliberately
does not borrow the shape of the blocked chip. Pending spins, because it is
the one check state that resolves on its own without anybody doing
anything. */
.checks {
display: inline-flex; align-items: center; gap: 6px;
font-size: var(--t2); font-weight: 650; color: var(--ink-2);
}
.checks::before {
content: attr(data-glyph);
width: 17px; height: 17px; flex: none;
display: grid; place-items: center;
border: 1.5px solid currentColor; border-radius: 50%;
font-size: var(--t1); font-weight: 700;
}
.checks[data-checks="green"] { color: var(--on-teal); }
.checks[data-checks="red"] { color: var(--on-rust); }
.checks[data-checks="pending"] { color: var(--on-blue); }
.checks[data-checks="pending"]::before { border-style: dashed; animation: spin 3.4s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ---- shared: collapsible list sections ---------------------------------- *
* Both Runs and Backlog split their flat, single-order list into a handful
* of <details> sections — see RUN_SECTIONS and QUEUE_SECTIONS in app.js —
* and share this look for it. Native <details>/<summary> carries the
* keyboard behaviour for free. */
#runs-sections, #queue-sections { display: flex; flex-direction: column; gap: var(--s4); }
.list-section-head {
display: flex; align-items: center; gap: var(--s2);
min-height: var(--tap); cursor: pointer;
}
.list-section-head::marker,
.list-section-head::-webkit-details-marker { display: none; content: ""; }
.list-section-head::before {
content: ""; flex: none;
width: 7px; height: 7px;
border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
color: var(--ink-3);
transform: rotate(-45deg); margin-left: 2px;
transition: transform .18s var(--ease);
}
.list-section[open] > .list-section-head::before { transform: rotate(45deg); }
.list-section-title { font-size: var(--t4); color: var(--ink); }
.list-section-count, .runs-section-folded { font-size: var(--t2); color: var(--ink-3); white-space: nowrap; }
.list-section-count { margin-left: auto; }
.list-section .cards { margin-top: var(--s3); }
/* ---- runs: toolbar -------------------------------------------------------- *
* Search, scope (a native select) and the state segmented control, in one
* static block above the list. Nothing here scrolls on its own. */
.runs-layout { display: block; }
.runs-main { display: flex; flex-direction: column; gap: var(--s4); }
.runs-filter {
display: flex; align-items: center; gap: var(--s3); flex-wrap: wrap;
padding: var(--s1) var(--s3);
background: var(--sunk); border: 1px solid var(--line); border-radius: var(--radius-sm);
font-size: var(--t2); color: var(--ink-2);
}
#runs-filter-text { flex: 1; min-width: 0; }
/* ---- runs: state chips --------------------------------------------------- *
* One segmented control on every width. It wraps rather than scrolling: six
* segments fit in two rows on a phone, and a scroller would hide the very
* segment ("Done") a wrap keeps in view. */
.state-chips { display: flex; flex-wrap: wrap; gap: 2px; }
.state-chip {
display: flex; align-items: center; gap: var(--s2);
min-height: var(--tap); padding: 0 var(--s3);
background: none; border: 1px solid transparent; border-radius: var(--radius-sm);
font: 600 var(--t3)/1.3 var(--sans); color: var(--ink-2); cursor: pointer;
}
@media (hover: hover) and (pointer: fine) {
.state-chip:hover { background: var(--surface); }
}
.state-chip[aria-checked="true"] { color: var(--ink); background: var(--surface); border-color: var(--edge); box-shadow: var(--shadow); }
.state-chip-count { font: 500 var(--t1) var(--mono); color: var(--ink-3); }
/* An older attempt at the same task, folded under the card that replaced it.
`.advanced` (see the queue card's "Full instruction" disclosure) already
supplies the chevron and the flex row for its <summary>; this only adds the
indented list underneath. */
.run-folded { margin-top: var(--s2); }
.run-folded-summary { font-size: var(--t2); color: var(--ink-3); min-height: 36px; }
.run-folded-list {
display: flex; flex-direction: column; gap: var(--s1);
margin-top: var(--s1); padding-left: var(--s3);
border-left: 2px solid var(--line);
}
.run-folded-link {
display: flex; align-items: center; gap: var(--s2);
min-height: var(--tap);
font-size: var(--t2); color: var(--ink-2);
}
.run-folded-id { font-family: var(--mono); color: var(--ink-3); }
.run-folded-status { font-size: var(--t1); color: var(--ink-3); text-transform: uppercase; letter-spacing: .04em; }
.run-folded-when { margin-left: auto; font-size: var(--t1); color: var(--ink-3); white-space: nowrap; }
/* ---- landing ----------------------------------------------------------- */
.land { display: flex; flex-direction: column; gap: var(--s3); }
.land-top { display: flex; align-items: center; gap: var(--s3); flex-wrap: wrap; }
.land-note { font-size: var(--t2); color: var(--ink-2); }
.land .phases { margin-top: 0; }
.phase[data-round="1"] { background: var(--gold-line); }
/* ---- questions --------------------------------------------------------- *
* An open question outranks everything else on screen: it is the only card
* that has stopped work outright, and the only one whose controls are the
* point rather than a detail. It gets the answer buttons in place, because a
* question the operator has to navigate somewhere else to answer is a
* question that stays unanswered until the morning. */
.asks { display: flex; flex-direction: column; gap: var(--s4); }
.ask {
display: flex; flex-direction: column; gap: var(--s3);
padding: var(--s4);
background: var(--surface);
border: 1px solid var(--line);
border-left: 4px solid var(--gold-line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
/* Settled: the record of a decision, not a demand for one. Sunk, flat, thin
edge — still fully readable, because this is what was decided and why. */
.ask[data-state="answered"], .ask[data-state="abandoned"] {
background: var(--sunk);
border-left: 3px solid var(--line-2);
box-shadow: none;
}
.panel-ask { border-left: 4px solid var(--gold-line); }
.panel-ask .ask { background: var(--bg); box-shadow: none; }
/* .panel h2 gives every panel heading the small-caps section treatment. A
question's summary is the operator's decision to make, written by an agent,
and has to read as the sentence it is. */
.panel-ask .ask-summary {
font-size: var(--t5); color: var(--ink);
text-transform: none; letter-spacing: -.015em;
}
.panel-ask .ask[data-state="open"] .ask-summary { font-size: var(--t6); }
.question-identity { min-width: 0; font-size: var(--t2); }
.question-id-short {
width: fit-content; min-height: 44px; padding: var(--s2);
cursor: pointer; color: var(--ink); font-family: var(--mono);
border: 1px solid var(--line); border-radius: var(--radius-sm);
}
.question-id-short:focus-visible { outline: 2px solid var(--gold-line); }
.question-id-tools {
display: flex; flex-wrap: wrap; align-items: center; gap: var(--s2);
padding-top: var(--s2); min-width: 0;
}
.question-id-full { overflow-wrap: anywhere; user-select: text; min-width: 0; }
.question-id-tools .btn { min-height: 44px; }
.question-id-tools [role="status"] { flex-basis: 100%; }
#panel-full-identity { padding: 0 var(--s4) var(--s2); }
.ask-top { display: flex; align-items: center; gap: var(--s2); flex-wrap: wrap; }
.ask-when { margin-left: auto; font-size: var(--t1); color: var(--ink-3); white-space: nowrap; }
/* align-self keeps the box the width of the text, so the focus ring the ask
bar leaves on arrival outlines a heading rather than drawing what looks
like an empty input across the card. */
.ask-summary {
align-self: flex-start; max-width: 100%;
font-size: var(--t5); font-weight: 650; line-height: 1.35; color: var(--ink);
overflow-wrap: anywhere;
}
.ask[data-state="open"] .ask-summary { font-size: var(--t6); letter-spacing: -.015em; }
/* The run link is a real destination on a touch screen, so the metadata row
is given over to a full tap target rather than leaving a 19px link that
only a mouse can hit. */
.ask-where { display: flex; flex-wrap: wrap; align-items: center; gap: 0 var(--s2); min-height: var(--tap); font-size: var(--t2); color: var(--ink-2); }
.ask-where a { display: inline-flex; align-items: center; min-height: var(--tap); text-decoration: underline; text-decoration-color: var(--line-2); text-underline-offset: 3px; }
.ask-seat { font-family: var(--mono); font-size: var(--t1); font-weight: 600; color: var(--ink); }
/* Choices are stacked, left-aligned and full width: they are sentences, not
labels, and a row of them would truncate at 390px. */
.choices { display: flex; flex-direction: column; gap: var(--s2); }
.choices .btn {
justify-content: flex-start; text-align: left;
height: auto; padding: var(--s3) var(--s4);
line-height: 1.35; font-size: var(--t4);
}
.choices .btn::before {
content: "\25c7"; margin-right: var(--s2); flex: none;
color: var(--gold-line); font-size: var(--t5);
}
.choice-item { display: flex; flex-direction: column; gap: var(--s1); min-width: 0; max-width: 100%; }
.choice-effect { font-size: var(--t2); color: var(--ink-3); margin-left: var(--s3); line-height: 1.3; }
.ask-consult-box { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s2); }
.ask-free { display: flex; flex-direction: column; gap: var(--s2); }
.ask-free .btn { align-self: flex-start; }
/* The decision, kept as a quotation, because this is the text the run acted
on and the only durable record of the exchange. */
.answer {
display: flex; flex-direction: column; gap: var(--s1);
padding: var(--s3);
background: var(--tint-teal); color: var(--on-teal);
border-radius: var(--radius-sm);
}
.answer-label { font-size: var(--t1); font-weight: 700; text-transform: uppercase; letter-spacing: .06em; }
.answer-text { font-size: var(--t4); font-weight: 600; white-space: pre-wrap; overflow-wrap: anywhere; }
/* The round trip: what was said after the question itself, oldest first.
Small and quiet compared to `.answer` on purpose - a turn is not a
decision, and must not compete with the summary or the choices for the eye
the way a settled answer is allowed to. */
.ask-thread { display: flex; flex-direction: column; gap: var(--s2); list-style: none; padding: 0; margin: 0; }
.ask-turn {
padding: var(--s2) var(--s3);
border-radius: var(--radius-sm);
font-size: var(--t3);
}
.ask-turn-who { font-weight: 700; text-transform: uppercase; font-size: var(--t1); letter-spacing: .04em; margin-right: var(--s2); }
.ask-turn-when { font-size: var(--t1); color: var(--ink-3); }
.ask-turn-body { margin: var(--s1) 0 0; overflow-wrap: anywhere; }
.ask-turn-note { margin: var(--s2) 0 0; padding: var(--s1) var(--s2); border-left: 3px solid var(--line); font-size: var(--t2); overflow-wrap: anywhere; }
.ask-turn-note-label { display: block; font-weight: 700; font-size: var(--t1); color: var(--ink-3); }
.ask-turn-note-body { margin: var(--s1) 0 0; }
.ask-turn-note-body.md { min-width: 0; max-width: 100%; }
.ask-turn[data-who="operator"] { background: var(--tint-blue); color: var(--on-blue); }
.ask-turn[data-who="agent"] { background: var(--surface); color: var(--ink); border: 1px solid var(--line); }
/* Nobody is waiting on the owner right now - the agent is - so the answer
controls below stay visible but locked, and this is the one line saying
why they are greyed out instead of gone. */
.ask-waiting { font-size: var(--t3); font-weight: 600; color: var(--ink-3); }
.ask-say { display: flex; flex-direction: column; gap: var(--s2); padding-top: var(--s2); border-top: 1px solid var(--line-2); }
.ask-say-label { font-size: var(--t2); color: var(--ink-3); }
.ask-say .btn { align-self: flex-start; }
/* ---- markdown ------------------------------------------------------------
* GFM markdown - a question's detail, a standing chat's agent turn, a run or
* task instruction - is parsed into a node tree server-side (`magi::md`) and
* turned into these elements by `buildMd` in app.js. Never assigned as
* markup: a fence containing a script tag renders as the characters of a
* script tag. */
.md { display: flex; flex-direction: column; gap: var(--s2); font-size: var(--t3); color: var(--ink-2); }
.md p { overflow-wrap: anywhere; }
.md h1, .md h2, .md h3, .md h4, .md h5, .md h6 {
margin: var(--s1) 0 0; font-weight: 700; color: var(--ink); overflow-wrap: anywhere;
}
.md h1 { font-size: var(--t6); }
.md h2 { font-size: var(--t5); }
.md h3 { font-size: var(--t4); }
.md h4, .md h5, .md h6 { font-size: var(--t3); }
.md ul, .md ol { display: flex; flex-direction: column; gap: var(--s1); padding-left: var(--s4); }
.md ol { list-style: decimal; }
.md ul { list-style: none; }
.md li { position: relative; overflow-wrap: anywhere; }
.md ul > li::before { content: "\2013"; position: absolute; left: calc(-1 * var(--s4)); color: var(--ink-3); }
.md li.task {
list-style: none; display: flex; align-items: flex-start; gap: var(--s2);
margin-left: calc(-1 * var(--s4));
}
.md li.task::before { content: none; }
.md li.task > input[type="checkbox"] { margin-top: 4px; accent-color: var(--teal); flex: none; }
.md blockquote {
margin: 0; padding: var(--s1) var(--s3);
border-left: 3px solid var(--line-2);
color: var(--ink-2); font-style: italic;
}
.md hr { border: 0; border-top: 1px solid var(--line); margin: var(--s1) 0; }
.md strong { color: var(--ink); font-weight: 700; }
.md em { font-style: italic; }
.md s { color: var(--ink-3); text-decoration-color: var(--ink-3); }
.md a {
color: var(--blue); text-decoration: underline;
text-decoration-color: var(--line-2); text-underline-offset: 3px;
overflow-wrap: anywhere;
}
.text-link {
color: var(--blue); text-decoration: underline;
text-decoration-color: var(--line-2); text-underline-offset: 3px;
overflow-wrap: anywhere;
}
.md img { max-width: 100%; height: auto; border-radius: var(--radius-sm); display: block; }
.md code {
font-family: var(--mono); font-size: .92em;
background: var(--sunk); border: 1px solid var(--line); border-radius: 4px;
padding: 0 4px; overflow-wrap: anywhere;
}
.md pre {
position: relative;
margin: 0; padding: var(--s3);
background: var(--sunk); border-radius: var(--radius-sm);
font-family: var(--mono); font-size: var(--t1); line-height: 1.5; color: var(--ink);
white-space: pre; overflow-x: auto; overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
}
.md pre code { padding: 0; border: 0; background: none; font-size: inherit; }
/* The fence's info string, carried through as a label only - magi adds no
syntax highlighting dependency, so this names the language without
colouring a single token of it. */
.md pre[data-lang]::before {
content: attr(data-lang);
position: absolute; top: var(--s2); right: var(--s3);
font-family: var(--sans); font-size: var(--t1); font-weight: 700;
letter-spacing: .04em; text-transform: uppercase; color: var(--ink-3);
}
/* A table can be wider than a 390px screen; it scrolls inside its own frame
rather than pushing the page wide. */
.md .table-scroll {
overflow-x: auto; overscroll-behavior-x: contain; -webkit-overflow-scrolling: touch;
border: 1px solid var(--line); border-radius: var(--radius-sm);
}
.md table { border-collapse: collapse; width: 100%; min-width: max-content; font-size: var(--t3); }
.md th, .md td { padding: var(--s2) var(--s3); border-bottom: 1px solid var(--line); text-align: left; white-space: nowrap; }
.md tr:last-child > td { border-bottom: 0; }
.md th { background: var(--sunk); color: var(--ink); font-weight: 700; }
.md td { color: var(--ink-2); }
.md th[data-align="center"], .md td[data-align="center"] { text-align: center; }
.md th[data-align="right"], .md td[data-align="right"] { text-align: right; }
/* ---- panels (run detail) ----------------------------------------------- */
.panel {
display: flex; flex-direction: column; gap: var(--s3);
background: var(--surface);
border: 1px solid var(--line); border-radius: var(--radius);
box-shadow: var(--shadow);
padding: var(--s4);
}
.panel-bar { display: flex; align-items: center; justify-content: space-between; gap: var(--s3); }
.panel-note { font-size: var(--t2); color: var(--ink-3); }
.count { color: var(--ink-3); font-weight: 500; letter-spacing: 0; text-transform: none; }
/* The folded Chat tasks panel (#talk-tasks-panel) prints a status breakdown
in its summary, not just a count, and that string's length depends on how
many distinct statuses are in play - up to five - rather than on how many
tasks were filed. On a phone, a busy conversation spread across every
status does not fit next to "Filed from here" on one line, so the count
is let wrap onto more than one line rather than clipped on the first -
an ellipsis mid-breakdown would hide exactly the states an operator opened
the row to check. What must not happen is the *panel* growing with it: the
whole point of folding is a height that stops depending on how much there
is to report. So the wrapped text is boxed to a fixed number of lines with
`line-clamp` - tall enough that the common case (one or two lines) reads
in full - and that box's height is fixed rather than content-driven, so
one filed task and thirty produce the same collapsed height either way.
The rare worst case (every status non-zero) is where the ellipsis
applies, as the last resort rather than the first, not something this box
is sized to fit in full. The label stays unshrunk so it never eats into
the count's space. */
#talk-tasks-panel > summary { flex-wrap: nowrap; align-items: flex-start; }
#talk-tasks-panel > summary .talk-tasks-label { flex: none; padding-top: .1em; }
#talk-tasks-panel > summary #talk-tasks-count {
flex: 1 1 auto; min-width: 0;
white-space: normal; line-height: 1.3; height: 2.6em;
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
line-clamp: 2; overflow: hidden; text-overflow: ellipsis;
}
.talk-task-link {
display: flex; align-items: center; gap: var(--s2); flex-wrap: wrap;
min-height: var(--tap); color: inherit;
}
.talk-task-link > span:last-child {
text-decoration: underline; text-decoration-color: var(--line-2); text-underline-offset: 3px;
}
.run-stop-reason { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.run-status { display: flex; align-items: center; gap: var(--s2); flex-wrap: wrap; }
/* `.phase` is `width:100%`, so as a flex item with no basis the rail collapses
to nothing beside the chip. Give it its own full-width row underneath. */
.run-status .phases { flex: 1 0 100%; margin-top: 0; }
.instruction { white-space: pre-wrap; overflow-wrap: anywhere; font-size: var(--t4); color: var(--ink); }
/* One question/answer pair per row, the question dimmed like a label and the
answer full-weight like the settled fact it is - see the Queue card's
"Resolved questions" disclosure. */
.task-followup { margin: 0; }
.task-followup dt { color: var(--ink-3); font-size: var(--t4); margin-top: var(--s3); }
.task-followup dt:first-child { margin-top: 0; }
.task-followup dd { margin: 0; display: flex; flex-wrap: wrap; gap: var(--s2); align-items: center; font-size: var(--t4); min-width: 0; overflow-wrap: anywhere; }
.task-flow .flow-node[data-kind="followup"] { background: var(--sunk); border-left: 3px solid var(--teal, var(--gold)); }
.task-answers { margin: 0; }
.task-answers dt { color: var(--ink-3); font-size: var(--t4); margin-top: var(--s3); }
.task-answers dt:first-child { margin-top: 0; }
.task-answers dd { margin: 0; color: var(--ink); font-size: var(--t4); white-space: pre-wrap; overflow-wrap: anywhere; }
.facts { display: grid; grid-template-columns: auto 1fr; gap: var(--s2) var(--s4); margin: 0; font-size: var(--t3); }
.facts dt { color: var(--ink-3); }
.facts dd { margin: 0; color: var(--ink); font-weight: 600; overflow-wrap: anywhere; }
.facts dd.mono { font-family: var(--mono); font-size: var(--t2); font-weight: 500; }
/* convergence diagram: the mark's three monoliths, drawn from the real
candidate list. The winner's stroke survives; the others fade. */
.converge { width: 100%; }
.converge svg { width: 100%; height: auto; display: block; }
.converge text { font: 700 13px var(--sans); }
/* The design-deliberation stage's blended brief, shown under its own
convergence diagram — a merge point, never the Verdict panel's gold
diamond, since this stage blends every seat's idea rather than picking
one. */
.advise-synthesis {
margin-top: var(--s3);
padding: var(--s3);
background: var(--sunk);
border-radius: var(--radius);
font-size: var(--t3);
color: var(--ink-2);
overflow-wrap: anywhere;
}
/* dependency graph: one node per task involved in a "blocked on" chain, laid
out in ranks with an arrow from a blocked task to what it waits on. Fixed
pixel sizing rather than `width: 100%` like `.converge` above - a wide
graph scrolls horizontally instead of squeezing node labels unreadable on
a phone. */
.dep-graph-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.dep-graph { display: block; }
.dep-graph text {
font: 600 var(--t1) var(--sans); fill: var(--ink);
pointer-events: none; user-select: none;
}
.dep-node-meta { font-weight: 400; fill: var(--ink-3); }
.dep-edge { fill: none; stroke: var(--line-2); stroke-width: 1.75; }
.dep-node rect { fill: var(--tint-ink); stroke: var(--line-2); stroke-width: 1.5; }
.dep-node[data-tone="blue"] rect { fill: var(--tint-blue); stroke: var(--blue); }
.dep-node[data-tone="gold"] rect { fill: var(--tint-gold); stroke: var(--gold-line); }
.dep-node[data-tone="rust"] rect { fill: var(--tint-rust); stroke: var(--rust); }
.dep-node[data-tone="teal"] rect { fill: var(--tint-teal); stroke: var(--teal); }
/* An unanswered question waited on by a task - dashed the same way a
waiting run's card border is dashed elsewhere, so a leaf that is a
question reads as a different kind of node from a task at rank 0, not
just a different color. */
.dep-node[data-tone="wait"] rect { fill: var(--tint-gold); stroke: var(--gold-line); stroke-dasharray: 4 3; }
.dep-node { cursor: pointer; }
@media (hover: hover) and (pointer: fine) {
.dep-node:hover rect { stroke-width: 2.5; }
}
.dep-node:focus-visible rect { stroke: var(--focus); stroke-width: 2.5; }
/* ---- stats dashboard ----------------------------------------------------
* No charting library: every shape below is a plain <div> sized with CSS,
* consistent with .dep-graph and .phases elsewhere in this file, which draw
* the same way for the same reason - no build step, no dependency, nothing
* to keep in sync with a bundler. */
/* #stats-body wraps the run-derived section (KPIs, verdicts, agents,
reviewers - hidden together when there are no runs) and the queue panel,
which stays visible on its own regardless - see stats-body's own comment
in index.html. Both need the same flex-column gap .view gives its own
direct children, since neither is a direct child of .view any more. */
#stats-body, #stats-runs-section { display: flex; flex-direction: column; gap: var(--s4); }
/* Repository selector - only shown once more than one repository has runs
recorded, see renderStatsRepoSelector(). Sits under the view head, same
width rhythm as the generic select rule but without a .field wrapper's
extra top margin, since nothing labels it above. */
.stats-repo-select { margin-top: 0; max-width: 320px; }
.stats-tiles {
display: grid; grid-template-columns: repeat(auto-fit, minmax(128px, 1fr));
gap: var(--s3);
}
.stats-tile {
display: flex; flex-direction: column; gap: var(--s1);
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
box-shadow: var(--shadow); padding: var(--s3) var(--s4);
}
.stats-tile-value { font: 700 var(--t7)/1.1 var(--sans); color: var(--ink); font-variant-numeric: tabular-nums; }
.stats-tile-label { font-size: var(--t2); color: var(--ink-3); text-transform: uppercase; letter-spacing: .05em; }
.stats-tile[data-tone="gold"] .stats-tile-value { color: var(--on-gold); }
.stats-tile[data-tone="teal"] .stats-tile-value { color: var(--on-teal); }
.stats-tile[data-tone="rust"] .stats-tile-value { color: var(--on-rust); }
.stats-tile[data-tone="blue"] .stats-tile-value { color: var(--on-blue); }
/* Clickable stat cards (see statsTile's `activate` param in app.js) are a
real <button>, not a div with an onclick, so they need the usual UA
button reset - the rest of the look, including :focus-visible, is
inherited from the plain .stats-tile rule above and the page-wide
:focus-visible rule, no new tokens. Hover reuses the same sunk background
the Runs state chips already use for the same purpose. */
.stats-tile:is(button) { font: inherit; color: inherit; text-align: inherit; cursor: pointer; width: 100%; }
@media (hover: hover) and (pointer: fine) {
.stats-tile:is(button):hover { background: var(--sunk); }
}
.stats-banner {
background: var(--tint-rust); color: var(--on-rust);
border-radius: var(--radius-sm); padding: var(--s3) var(--s4);
font-size: var(--t3);
display: flex; align-items: flex-start; gap: var(--s3);
}
.stats-banner > span { flex: 1; min-width: 0; }
/* The close button keeps the full --tap target but is pulled into the
banner's own padding so it does not make the banner taller. */
.stats-banner-close { color: inherit; margin: calc(var(--s3) * -1) calc(var(--s4) * -1) calc(var(--s3) * -1) 0; }
/* The verdict breakdown: a donut (inline SVG) with the run total in the
centre and a legend beside it. Every bucket has one fixed colour via
--verdict-*, read as --vc by the slice, the legend dot and the KPI tile. */
[data-verdict="merged"] { --vc: var(--verdict-merged); }
[data-verdict="ready"] { --vc: var(--verdict-ready); }
[data-verdict="in_progress"] { --vc: var(--verdict-in_progress); }
[data-verdict="blocked"] { --vc: var(--verdict-blocked); }
[data-verdict="failed"] { --vc: var(--verdict-failed); }
[data-verdict="verified_noop"] { --vc: var(--verdict-verified_noop); }
[data-verdict="superseded"] { --vc: var(--verdict-superseded); }
[data-verdict="stalled"] { --vc: var(--verdict-stalled); }
.stats-tile[data-verdict] .stats-tile-value { color: var(--vc); }
.verdict-donut { display: flex; flex-direction: column; align-items: center; gap: var(--s3); }
@media (min-width: 720px) {
.verdict-donut { flex-direction: row; justify-content: flex-start; gap: var(--s5, 24px); }
}
.verdict-donut-svg { width: 180px; height: 180px; flex: none; }
.donut-ring { fill: none; stroke: var(--sunk); stroke-width: 26; }
.donut-slice { fill: none; stroke: var(--vc); stroke-width: 26; cursor: pointer; }
@media (hover: hover) and (pointer: fine) {
.donut-slice:hover { opacity: .8; }
}
.donut-total { font: 700 30px var(--sans); fill: var(--ink); text-anchor: middle; font-variant-numeric: tabular-nums; }
.donut-total-label { font: 11px var(--sans); fill: var(--ink-3); text-anchor: middle; text-transform: uppercase; letter-spacing: .05em; }
.verdict-legend { list-style: none; margin: 0; padding: 0; width: 100%; max-width: 360px; display: flex; flex-direction: column; gap: 2px; }
.verdict-legend-row {
display: flex; align-items: center; gap: var(--s2); width: 100%; min-height: 36px;
padding: 0 var(--s2); background: transparent; border: 0; border-radius: var(--radius-sm);
font: inherit; font-size: var(--t3); color: var(--ink-2); cursor: pointer; text-align: left;
}
@media (hover: hover) and (pointer: fine) {
.verdict-legend-row:hover { background: var(--sunk); }
}
.verdict-legend-row[data-zero] { opacity: .55; }
.verdict-legend-dot { width: 10px; height: 10px; border-radius: 50%; flex: none; background: var(--vc); }
.verdict-legend-name { flex: 1; }
.verdict-legend-count, .verdict-legend-pct { font-variant-numeric: tabular-nums; color: var(--ink); }
.verdict-legend-pct { min-width: 5ch; text-align: right; color: var(--ink-3); }
/* Per-agent / per-reviewer rate bars: a label row above a filled track,
repeated once per row. */
.bar-rows { display: flex; flex-direction: column; gap: var(--s3); }
.bar-row-head { display: flex; justify-content: space-between; gap: var(--s3); font-size: var(--t3); }
.bar-row-name { color: var(--ink); font-weight: 600; overflow-wrap: anywhere; }
.bar-row-value { color: var(--ink-3); font-variant-numeric: tabular-nums; white-space: nowrap; }
.bar-row-n { margin-left: var(--s2); }
.bar-row-tag { margin-left: var(--s2); padding: 0 var(--s2); border: 1px solid var(--line); border-radius: 4px; color: var(--ink-3); font-size: var(--t4, 0.75em); font-weight: 400; white-space: nowrap; }
.bar-row[data-low-n] .bar-fill { opacity: 0.4; }
.bar-track { height: 10px; border-radius: 5px; background: var(--sunk); overflow: hidden; }
.bar-fill { height: 100%; border-radius: 5px; }
.refl-legend { display: flex; flex-wrap: wrap; gap: var(--s2) var(--s4, 16px); margin: 0 0 var(--s3); padding: 0; list-style: none; font-size: var(--t3); color: var(--ink-3); }
.refl-dot { display: inline-block; width: 10px; height: 10px; margin-right: var(--s2); border-radius: 50%; vertical-align: baseline; }
.refl-strong { background: var(--teal); }
.refl-faint { background: var(--gold); }
.refl-absent { background: var(--rust); }
.refl-track { display: flex; gap: 1px; height: 10px; border-radius: 5px; background: var(--sunk); overflow: hidden; }
.refl-seg { min-width: 3px; height: 100%; }
.refl-value { white-space: normal; text-align: right; }
.bar-row[data-low-n] .refl-seg { opacity: 0.55; }
/* ---- candidates -------------------------------------------------------- */
.cands { display: flex; flex-direction: column; gap: var(--s3); }
.cand {
display: flex; flex-direction: column; gap: var(--s2);
padding: var(--s3);
background: var(--sunk); border-radius: var(--radius-sm);
border-left: 3px solid var(--cand-tone, var(--line-2));
}
.cand[data-winner="1"] { background: var(--tint-gold); }
.cand-head { display: flex; align-items: center; gap: var(--s2); }
.cand-label {
min-width: 28px; width: auto; height: 28px; flex: none;
padding: 0 6px; white-space: nowrap;
display: grid; place-items: center;
border-radius: 14px; border: 2px solid var(--cand-tone, var(--ink-3));
color: var(--cand-tone, var(--ink)); background: var(--surface);
font-weight: 700; font-size: var(--t3);
}
.cand-agent { font-family: var(--mono); font-size: var(--t2); color: var(--ink-2); }
.crown { margin-left: auto; font-size: var(--t1); font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--on-gold); }
.crown::before { content: "\25c6"; margin-right: 4px; }
/* A provisional lead is not a verdict: hollow diamond, neutral ink, no gold. */
.crown[data-provisional] { color: var(--ink-3); }
.crown[data-provisional]::before { content: "\25c7"; }
.cand-nums { display: flex; flex-wrap: wrap; gap: 0 var(--s2); font-size: var(--t2); color: var(--ink-2); }
.cand-summary { font-size: var(--t3); color: var(--ink-2); overflow-wrap: anywhere; }
.cand-summary.md, .finding-detail.md, .ask-turn-body.md, .advise-synthesis.md, .rturn-body.md { min-width: 0; max-width: 100%; }
.cand-summary.md, .rturn-body.md { margin-top: var(--s2); }
.cand-summary.md pre, .finding-detail.md pre, .advise-synthesis.md pre, .rturn-body.md pre { max-width: 100%; overflow-x: auto; }
.stat, .report {
margin: 0;
font-family: var(--mono); font-size: var(--t1); line-height: 1.5;
color: var(--ink-2);
overflow-x: auto; overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
}
/* A diffstat is a handful of short lines, so it wraps rather than clipping a
word off at the card edge. The run report does not: it is column-aligned
terminal output and reflowing it would scramble the tables. */
.stat { white-space: pre-wrap; overflow-wrap: anywhere; overflow-x: visible; }
/* ---- review rounds ----------------------------------------------------- */
.rounds { display: flex; flex-direction: column; gap: var(--s3); }
.round { background: var(--sunk); border-radius: var(--radius-sm); padding: var(--s3); }
.round-head { display: flex; align-items: center; gap: var(--s2); flex-wrap: wrap; }
.round-n { font-weight: 700; font-size: var(--t4); }
.tag {
font-size: var(--t1); font-weight: 700; text-transform: uppercase; letter-spacing: .06em;
padding: 2px 7px; border-radius: 999px; border: 1px solid currentColor;
}
.tag[data-tone="teal"] { background: var(--tint-teal); color: var(--on-teal); }
.tag[data-tone="rust"] { background: var(--tint-rust); color: var(--on-rust); }
.tag.tag-urgent {
background: var(--on-rust); color: var(--tint-rust); border-color: var(--on-rust);
font-weight: 800; flex: none; white-space: nowrap;
}
.tag[data-tone="ink"] { background: var(--tint-ink); color: var(--on-ink); }
.tag[data-tone="gold"] { background: var(--tint-gold); color: var(--on-gold); }
.tag[data-tone="blue"] { background: var(--tint-blue); color: var(--on-blue); }
.round-head .head-sha { margin-left: auto; font-family: var(--mono); font-size: var(--t1); color: var(--ink-3); }
.findings { display: flex; flex-direction: column; gap: var(--s2); margin-top: var(--s3); }
.finding {
padding-left: var(--s3);
border-left: 3px solid var(--line-2);
font-size: var(--t3);
}
.finding[data-sev="blocker"] { border-left-color: var(--rust); }
.finding[data-sev="major"] { border-left-color: var(--gold-line); }
.finding-top { display: flex; align-items: baseline; gap: var(--s2); flex-wrap: wrap; }
.finding-id { font-family: var(--mono); font-size: var(--t1); color: var(--ink-3); }
.finding-sev { font-size: var(--t1); font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--ink-2); }
.finding-title { font-weight: 600; color: var(--ink); }
.finding-where { font-family: var(--mono); font-size: var(--t1); color: var(--ink-3); }
.finding-detail { color: var(--ink-2); margin-top: 2px; overflow-wrap: anywhere; }
.finding-detail.md { font-size: inherit; gap: var(--s1); }
.reviewer { margin-top: var(--s3); font-size: var(--t3); }
.reviewer-name { font-family: var(--mono); font-size: var(--t2); color: var(--ink-3); }
.plain { display: flex; flex-direction: column; gap: var(--s2); font-size: var(--t3); color: var(--ink-2); }
.plain li { display: flex; gap: var(--s2); flex-wrap: wrap; }
.plain .seat { font-family: var(--mono); color: var(--ink); font-weight: 600; }
.report {
max-height: 60vh; overflow-y: auto;
padding: var(--s3);
background: var(--sunk); border-radius: var(--radius-sm);
color: var(--ink);
white-space: pre;
tab-size: 4;
}
.report[data-wrap="1"] { white-space: pre-wrap; overflow-wrap: anywhere; }
/* ---- report cards (Report tab) ----------------------------------------- */
.panel-bar-btns { display: inline-flex; gap: var(--s2); }
.rcards, .rrounds { display: flex; flex-direction: column; gap: var(--s2); min-width: 0; }
.rhead { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s2); padding: var(--s2) 0; min-width: 0; }
.rcard { background: var(--sunk); border-radius: var(--radius-sm); border-left: 3px solid var(--line-2); min-width: 0; max-width: 100%; }
.rcard[data-tone="ok"] { border-left-color: var(--teal); }
.rcard[data-tone="warn"] { border-left-color: var(--gold-line); }
.rcard[data-tone="fail"] { border-left-color: var(--rust); }
.rcard .rcard { background: var(--surface); }
.rcard-sum {
display: flex; flex-wrap: wrap; align-items: center; gap: var(--s2);
padding: var(--s2) var(--s3); cursor: pointer; min-width: 0;
}
.rcard-title { font-weight: 700; font-size: var(--t4); min-width: 0; overflow-wrap: anywhere; }
.rcard-badges { display: inline-flex; flex-wrap: wrap; gap: var(--s1); min-width: 0; }
.rcard-body { padding: 0 var(--s3) var(--s3); display: flex; flex-direction: column; gap: var(--s2); font-size: var(--t3); min-width: 0; overflow-wrap: anywhere; }
.rtag { text-transform: none; letter-spacing: 0; white-space: nowrap; }
.rmeta { margin: 0; font-size: var(--t2); color: var(--ink-2); overflow-wrap: anywhere; }
.rwarn { color: var(--on-rust); }
.rmono { font-family: var(--mono); font-size: var(--t2); overflow-wrap: anywhere; }
.rwide { flex-basis: 100%; font-size: var(--t3); color: var(--ink-2); overflow-wrap: anywhere; }
.rlist, .rfindings { display: flex; flex-direction: column; gap: var(--s2); margin: 0; padding: 0; list-style: none; min-width: 0; }
.rrow { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--s2); min-width: 0; overflow-wrap: anywhere; }
.rblock { display: flex; flex-direction: column; gap: var(--s1); }
.rfinding { padding-left: var(--s3); border-left: 3px solid var(--line-2); min-width: 0; }
.rfinding[data-blocking="1"] { border-left-color: var(--rust); background: var(--hatch); }
.rfinding[data-state="fixed"] { border-left-color: var(--teal); background: none; opacity: .85; }
.rfinding[data-state="declined"] { border-left-style: dashed; }
.rfinding-top { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1); }
.rfinding-title { font-weight: 600; color: var(--ink); overflow-wrap: anywhere; }
.rfinding-why { font-size: var(--t2); color: var(--ink-2); overflow-wrap: anywhere; }
.rout { flex-basis: 100%; min-width: 0; }
.rout > summary { cursor: pointer; font-size: var(--t2); color: var(--ink-3); }
.rpre {
margin: var(--s1) 0 0; padding: var(--s2); max-height: 40vh; overflow: auto;
font-family: var(--mono); font-size: var(--t1); line-height: 1.5; color: var(--ink);
background: var(--sunk); border-radius: var(--radius-sm); white-space: pre-wrap; overflow-wrap: anywhere;
}
/* ---- timeline ---------------------------------------------------------- */
.timeline { display: flex; flex-direction: column; }
.timeline li {
display: grid; grid-template-columns: 62px 1fr; gap: var(--s3);
padding: var(--s2) 0 var(--s2) var(--s3);
border-left: 2px solid var(--line);
font-size: var(--t3);
position: relative;
}
.timeline li::before {
content: ""; position: absolute; left: -5px; top: 15px;
width: 8px; height: 8px; border-radius: 50%;
background: var(--line-2);
}
.timeline li:last-child::before { background: var(--gold-line); }
.event-at { font-family: var(--mono); font-size: var(--t1); color: var(--ink-3); }
.event-body { min-width: 0; }
.event-node { font-weight: 700; font-size: var(--t2); text-transform: uppercase; letter-spacing: .05em; color: var(--ink-3); }
.event-msg { color: var(--ink); overflow-wrap: anywhere; }
/* ---- skeletons --------------------------------------------------------- */
.skeleton { pointer-events: none; }
.skeleton .bar { height: 12px; border-radius: 6px; background: var(--sunk); }
.skeleton .bar + .bar { margin-top: var(--s2); }
.skeleton { animation: fade 1.4s var(--ease) infinite; }
@keyframes fade { 0%, 100% { opacity: 1; } 50% { opacity: .55; } }
/* ---- dock -------------------------------------------------------------- */
.dock {
position: fixed; z-index: 20; left: 0; right: 0; bottom: 0;
display: flex;
background: var(--surface);
border-top: 1px solid var(--line);
padding-bottom: env(safe-area-inset-bottom);
}
.dock-item {
flex: 1;
/* A flex item's automatic minimum size is its content's min-content width
by default, which a long label (a translation, an accessibility text-size
bump, a font substitution) can push past this item's equal share of a
390px screen - and unlike a normal block, a fixed-position row has no
scrollbar to fall back on, so the item that does not fit is simply
invisible past the screen edge instead of wrapping. `min-width: 0` lets
the item actually shrink to its share, and the label below truncates
with an ellipsis rather than forcing the row wider than the dock. */
min-width: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
min-height: var(--tap); padding: var(--s2) var(--s1);
background: none; border: 0; border-top: 2px solid transparent;
color: var(--ink-3); font: 600 var(--t1)/1 var(--sans); cursor: pointer;
}
.dock-item > span:last-child {
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.dock-glyph svg { width: 22px; height: 22px; display: block; }
.dock-item[aria-current="page"] { color: var(--ink); border-top-color: var(--gold-line); }
/* Chat is the only conversational way work gets in from this UI, so the
dock's Chat item is gold — the colour that everywhere else means "the
commit action" — and it stays gold even while a different page is active. */
.dock-item.dock-chat { color: var(--on-gold); }
.dock-item.dock-chat .dock-glyph svg { stroke-width: 2.4; }
/* ---- run actions fab ---------------------------------------------------- *
* The one entry point into Resume / Fold / Delete on a run detail. Fixed so
* it is reachable without scrolling past the report, and shown at every
* width — unlike the dock, it has no wide-screen replacement to hand off
* to. Positioned clear of the dock and the home indicator the same way .say
* clears the dock: tap height plus a gap plus the safe area. */
.run-actions-fab {
position: fixed; z-index: 15;
right: calc(var(--s4) + env(safe-area-inset-right));
bottom: calc(var(--tap) + var(--s3) + env(safe-area-inset-bottom));
width: var(--tap); height: var(--tap); flex: none;
display: flex; align-items: center; justify-content: center;
border-radius: 50%; border: 1px solid var(--gold-line);
background: var(--fill); color: #241d05;
box-shadow: var(--shadow);
cursor: pointer;
}
.run-actions-fab svg { width: 22px; height: 22px; }
/* Clears the fab so the last panel in run detail is never permanently hidden
behind it. */
#view-run,
#view-task { padding-bottom: calc(var(--tap) + var(--s6)); }
/* The fab's sheet has no <form> — its content is built and rerendered
from JS — so it gets its own head/body padding instead of borrowing a
form's, a shape this document no longer has. */
#run-actions-sheet[open],
#task-actions-sheet[open] { display: flex; flex-direction: column; }
#run-actions-sheet .sheet-head,
#task-actions-sheet .sheet-head { flex: none; padding: var(--s4) var(--s4) 0; }
#run-actions-sheet .sheet-body,
#task-actions-sheet .sheet-body {
display: flex; flex-direction: column; gap: var(--s4);
padding: var(--s4);
padding-bottom: calc(var(--s4) + env(safe-area-inset-bottom));
overflow-y: auto; min-height: 0;
}
/* The task edit sheet has no <form> either - the same reason and the same
fixed-head, scrolling-body split as run-actions-sheet above. */
#task-edit-sheet[open] { display: flex; flex-direction: column; }
#task-edit-sheet .sheet-head { flex: none; padding: var(--s4) var(--s4) 0; }
#task-edit-sheet .sheet-body {
display: flex; flex-direction: column; gap: var(--s4);
padding: var(--s4);
padding-bottom: calc(var(--s4) + env(safe-area-inset-bottom));
overflow-y: auto; min-height: 0;
}
/* ---- sheet -------------------------------------------------------------- */
.sheet {
width: 100%; max-width: 560px; padding: 0; margin: auto auto 0;
border: 0; border-radius: var(--radius) var(--radius) 0 0;
background: var(--surface); color: var(--ink);
box-shadow: 0 -8px 40px rgba(0, 0, 0, .28);
max-height: 92dvh;
}
.sheet::backdrop { background: rgba(20, 18, 14, .55); }
.sheet-head { display: flex; align-items: center; justify-content: space-between; gap: var(--s3); }
.sheet-head h2 { font-size: var(--t5); text-transform: none; letter-spacing: -.01em; color: var(--ink); }
.field { display: flex; flex-direction: column; gap: var(--s1); }
.field label { font-size: var(--t3); font-weight: 650; }
.opt { font-weight: 400; color: var(--ink-3); }
.hint { font-size: var(--t2); color: var(--ink-3); }
textarea, input[type="text"], input[type="search"], select {
width: 100%; min-height: var(--tap);
padding: var(--s2) var(--s3);
margin-top: var(--s1);
background: var(--bg); color: var(--ink);
border: 1px solid var(--edge); border-radius: var(--radius-sm);
/* 16px minimum: anything smaller makes iOS zoom the viewport on focus, which
is the horizontal-scroll bug the layout is otherwise free of. */
font: 400 1rem/1.5 var(--sans);
}
textarea { resize: vertical; }
input:focus-visible, textarea:focus-visible, select:focus-visible { border-color: var(--focus); }
/* display:flex removes the native marker, so the chevron is drawn back in;
a disclosure with no affordance reads as dead text. */
.advanced summary {
min-height: var(--tap); display: flex; align-items: center; gap: var(--s2);
font-size: var(--t3); font-weight: 650; color: var(--ink-2); cursor: pointer;
}
.advanced summary::marker, .advanced summary::-webkit-details-marker { display: none; content: ""; }
.advanced summary::before {
content: "";
width: 7px; height: 7px; flex: none;
border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
transform: rotate(-45deg); margin-left: 2px;
transition: transform .18s var(--ease);
}
.advanced[open] > summary::before { transform: rotate(45deg); }
/* Record panels fold into their own summary; keep the body clear of it. */
details.panel > summary + * { margin-top: var(--s2); }
.cand-reviews { margin-top: var(--s2); }
.cand-reviews > .rounds { margin-top: var(--s2); }
.form-error {
padding: var(--s2) var(--s3);
background: var(--tint-rust); color: var(--on-rust);
border-radius: var(--radius-sm); font-size: var(--t3); font-weight: 600;
}
/* ---- agent-authored panels --------------------------------------------- *
* A question may hand over a whole HTML page — a diff, a table of changed
* files, an image — because one line of prose is not enough to judge by. It
* is rendered inside <iframe sandbox> carrying only allow-popups and
* allow-popups-to-escape-sandbox, so no script in it runs and it can reach
* neither this document nor its storage, and the endpoint that serves it sends
* a CSP denying every network request from inside the frame. Never add
* allow-scripts or allow-same-origin.
*
* A frame without script or same-origin access is opaque to this page in both
* directions: its height cannot be measured and there is no channel to ask it.
* So the inline frame is bounded and the full-screen dialog carries the rest,
* and the bounded state is drawn as a deliberate window — its own edge, a fade
* at the sill, and a sentence saying where the rest is — rather than as a
* clipped card.
*/
.ask-panel { display: flex; flex-direction: column; gap: var(--s2); }
.ask-panel-bar { display: flex; align-items: center; gap: var(--s2); flex-wrap: wrap; }
.ask-panel-label {
font-size: var(--t1); font-weight: 700;
text-transform: uppercase; letter-spacing: .06em; color: var(--ink-3);
}
.ask-panel-label::before { content: "\25a4"; margin-right: 6px; font-size: 1.1em; }
.ask-panel-bar .btn { margin-left: auto; }
.frame-wrap {
position: relative;
border: 1px solid var(--line-2); border-radius: var(--radius-sm);
/* Paper, in both themes, and not var(--surface): a panel that sets no
background of its own would otherwise render the agent's default black
text onto the dark surface and be unreadable exactly when it matters. */
background: #fffdfa;
overflow: hidden;
}
.frame-wrap iframe {
display: block; width: 100%; height: 320px; border: 0;
/* The frame scrolls itself; momentum must not escape into the page and
carry the operator away from the choices underneath. */
overscroll-behavior: contain;
}
/* The sill: the panel visibly continues past the bottom edge. Fixed paper
tint because the frame backdrop is fixed paper too. Not the only signal —
the sentence below the frame says it in words. */
.frame-more {
content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 44px;
pointer-events: none;
background: linear-gradient(to bottom, rgba(255, 253, 250, 0) 0%, rgba(255, 253, 250, .96) 82%);
border-bottom: 1px solid var(--line-2);
}
.frame-note { font-size: var(--t2); color: var(--ink-3); }
/* The panel was promised and did not arrive. Stated, not left as a blank
hole, and the choices below stay live: the owner still has to answer. */
.frame-fail {
display: flex; flex-direction: column; gap: var(--s2);
padding: var(--s3);
background: var(--tint-gold); color: var(--on-gold);
border: 1px solid currentColor; border-radius: var(--radius-sm);
font-size: var(--t3); font-weight: 600;
}
.frame-fail .hint { color: inherit; font-weight: 400; }
/* Full screen: on a phone this is the only place a unified diff is legible.
The dialog is the viewport, and the iframe fills it. */
.full {
width: 100%; max-width: none; height: 100dvh; max-height: 100dvh;
padding: 0; margin: 0; border: 0;
background: var(--bg); color: var(--ink);
}
.full[open] { display: flex; flex-direction: column; }
.full::backdrop { background: rgba(20, 18, 14, .8); }
.full-head {
display: flex; align-items: center; justify-content: space-between; gap: var(--s3);
padding: var(--s2) var(--s4);
padding-top: calc(var(--s2) + env(safe-area-inset-top));
padding-left: calc(var(--s4) + env(safe-area-inset-left));
padding-right: calc(var(--s4) + env(safe-area-inset-right));
border-bottom: 1px solid var(--line);
}
.full-head h2 { font-size: var(--t5); text-transform: none; letter-spacing: -.01em; color: var(--ink); }
.full-body { flex: 1; min-height: 0; background: #fffdfa; }
.full-body iframe { display: block; width: 100%; height: 100%; border: 0; }
/* The same full-screen dialog, reused for a tapped attachment instead of a
panel's iframe - a plain same-origin image needs neither a sandbox nor a
CSP, so there is nothing an iframe would add here. */
.full-body img { display: block; width: 100%; height: 100%; object-fit: contain; background: #000; }
/* ---- merge approval ---------------------------------------------------- *
* The land loop asks before it merges, and that is the one question in this
* product whose answer cannot be taken back. "Which cache backend?" and
* "shall I merge this into main?" therefore must not be the same card: this
* one is bordered on all four sides in the verdict gold, capped with a
* hatched rule, and titled by a band that names the consequence.
*
* The guard against a thumb that was only scrolling is two taps, not a
* timer: the first arms, and the confirm row it reveals puts Cancel where
* the arm button just was, so the pixel under the finger is never the
* irreversible one. Nothing is disabled and nothing counts down, so an
* operator who means it is two taps away rather than annoyed.
*/
.ask[data-stakes="merge"] {
border: 1px solid var(--gold-line);
border-left: 6px double var(--gold-line);
box-shadow: var(--shadow), 0 0 0 3px var(--tint-gold);
}
/* Once it is decided, the card stops shouting: the record of a merge
decision keeps the double edge that identifies it and loses the ring and
the hatching, which were there to slow a thumb down. */
.ask[data-stakes="merge"]:not([data-state="open"]) { box-shadow: none; }
.ask:not([data-state="open"]) .stakes-band { background-image: none; }
.stakes-band {
display: flex; align-items: center; gap: var(--s2); flex-wrap: wrap;
margin: calc(-1 * var(--s4)) calc(-1 * var(--s4)) 0;
padding: var(--s2) var(--s4);
background: var(--tint-gold); color: var(--on-gold);
border-bottom: 1px solid var(--gold-line);
border-radius: 0 var(--radius) 0 0;
background-image: repeating-linear-gradient(-45deg,
var(--hatch-gold) 0 4px, transparent 4px 11px);
font-size: var(--t2); font-weight: 700;
text-transform: uppercase; letter-spacing: .06em;
}
.stakes-band::before { content: "\25c6"; letter-spacing: 0; }
.stakes {
display: flex; flex-direction: column; gap: var(--s3);
padding: var(--s3);
background: var(--tint-gold);
border: 1px solid var(--gold-line); border-radius: var(--radius-sm);
}
.stakes-what { font-size: var(--t3); font-weight: 600; color: var(--on-gold); }
.stakes-what .ask-seat { color: var(--on-gold); }
.stakes-arm { align-self: stretch; }
.stakes-confirm { display: flex; flex-direction: column; gap: var(--s3); }
.stakes-warn { font-size: var(--t4); font-weight: 700; color: var(--on-rust); }
.stakes-row { display: flex; gap: var(--s2); }
.stakes-row .btn { flex: 1; }
/* Hold is not a lesser option, it is the safe one, so it keeps a full target
and a real edge rather than being demoted to a text link. */
.stakes-hold { align-self: stretch; }
/* Keep each decision and its explanation together on narrow cards. */
.stakes-action {
display: flex; flex-direction: column; gap: var(--s2); min-width: 0;
padding: var(--s2);
background: var(--surface);
border: 1px solid var(--line); border-radius: var(--radius-sm);
}
.stakes-action > .btn { align-self: stretch; }
.stakes-action .hint { margin: 0; }
.stakes-agent-row {
display: flex; flex-wrap: wrap; align-items: center; gap: var(--s2);
min-width: 0;
}
.stakes-agent-select {
flex: 1 1 120px; min-width: 0; max-width: 100%; min-height: var(--tap);
padding: 0 var(--s2);
border: 1px solid var(--line); border-radius: var(--radius-sm);
background: var(--surface); color: var(--ink); font: inherit;
}
.stakes-agent-row > .btn { flex: 2 1 auto; min-width: 0; }
/* ---- conversation turns -------------------------------------------------- *
* The standing chat's transcript. A turn takes tens of seconds because a real
* model is thinking: everything below is built so that wait reads as work
* rather than as a hang. */
.turns { display: flex; flex-direction: column; gap: var(--s3); }
.turn {
max-width: 92%; min-width: 0;
padding: var(--s3) var(--s4);
border-radius: var(--radius);
font-size: var(--t4);
}
.turn-who {
display: block; margin-bottom: var(--s1);
font-size: var(--t1); font-weight: 700;
letter-spacing: .06em; overflow-wrap: anywhere;
}
.turn-text { white-space: pre-wrap; overflow-wrap: anywhere; }
.turn[data-who="operator"] {
align-self: flex-end;
background: var(--tint-blue); color: var(--on-blue);
border-bottom-right-radius: var(--radius-sm);
}
.turn[data-who="agent"] {
align-self: flex-start;
background: var(--surface); color: var(--ink);
border: 1px solid var(--line); box-shadow: var(--shadow);
border-bottom-left-radius: var(--radius-sm);
}
.turn[data-who="agent"] .turn-who { color: var(--ink-3); }
.turn-avatar:not([hidden]) + .turn-who {
display: inline-block; vertical-align: middle;
max-width: calc(100% - 22px - var(--s2));
}
.turn[data-who="agent"] .md { color: var(--ink); font-size: var(--t4); }
/* MAGI posts: automatic updates and system notices share a dedicated palette. */
.turn[data-who="system"] {
align-self: stretch; max-width: none;
background: var(--tint-gold); color: var(--on-gold);
border: 1px solid currentColor;
font-weight: 600;
}
.turn-at { display: block; margin-top: var(--s1); font-size: var(--t1); }
/* Images attached to a turn, already sent - a row of squares that wraps
rather than one that scrolls sideways, so a narrow phone never has to pan
to see the third picture. `max-width: 100%` on the row plus `flex-wrap` is
what keeps this from ever pushing the bubble wider than `.turn` allows. */
.turn-attachments {
display: flex; flex-wrap: wrap; gap: var(--s2);
margin-top: var(--s2); max-width: 100%;
}
.turn-thumb {
flex: none; width: 96px; height: 96px;
padding: 0; border: 1px solid var(--line); border-radius: var(--radius-sm);
overflow: hidden; background: var(--sunk); cursor: pointer;
}
.turn-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* The honest wait. Three dots breathing at 1.6s is slow enough to read as
thinking rather than as a stuck spinner, and the elapsed seconds beside it
are the proof that something is still counting. */
.waiting {
align-self: flex-start;
display: flex; align-items: center; gap: var(--s3);
padding: var(--s3) var(--s4);
border: 1px dashed var(--line-2); border-radius: var(--radius);
font-size: var(--t3); color: var(--ink-2);
}
.waiting-dots { display: inline-flex; gap: 5px; flex: none; }
.waiting-dots i {
width: 7px; height: 7px; border-radius: 50%;
background: var(--ink-3);
animation: breathe 1.6s var(--ease) infinite;
}
.waiting-dots i:nth-child(2) { animation-delay: .2s; }
.waiting-dots i:nth-child(3) { animation-delay: .4s; }
.waiting-text { flex: 1; min-width: 0; }
/* Tabular, so the number does not shuffle the sentence beside it every
second while the operator is reading it. */
.waiting-secs {
flex: none; font-family: var(--mono); font-size: var(--t2);
font-variant-numeric: tabular-nums; color: var(--ink-3);
}
@keyframes breathe {
0%, 100% { opacity: .25; transform: translateY(0); }
50% { opacity: 1; transform: translateY(-2px); }
}
/* The composer sits above the dock and stays there, because a conversation
grows downwards and hunting for the reply box at the bottom of a long
transcript is the one thing that would make this unusable on a phone. */
.talk-composer {
position: sticky; z-index: 10;
/* Parked one dock above the bottom edge. The dock is its 44px tap target
plus the label row under the glyph, which is what --s3 stands in for
here; at 44px alone the composer's own edge crossed the dock's top rule. */
bottom: calc(var(--tap) + var(--s3) + env(safe-area-inset-bottom));
padding: var(--s2) 0;
background: var(--bg);
}
.say {
display: flex; flex-direction: column; gap: var(--s2);
background: var(--bg);
border-radius: var(--radius);
transition: background-color .15s var(--ease);
}
/* The file-picker / paste / drop trio all land here, so a phone with no
pointer for drag-and-drop still has the other two - this row is never the
only path to an attachment. */
.say-row { display: flex; gap: var(--s2); align-items: flex-end; }
.say-row textarea { margin-top: 0; flex: 1; min-width: 0; }
.say-row .btn { flex: none; }
.attach-btn {
width: var(--tap); height: var(--tap); flex: none;
display: grid; place-items: center; align-self: flex-end;
background: none; border: 1px solid var(--edge); border-radius: var(--radius-sm);
color: var(--ink-2); cursor: pointer;
}
.attach-btn svg { width: 20px; height: 20px; }
/* A drag entering the composer or the transcript above it - see
`wireAttachments` in app.js - gets a visible drop zone rather than silence
right up until the drop lands. */
.say.is-drop-target, .turns.is-drop-target {
background: var(--tint-teal);
outline: 2px dashed var(--teal); outline-offset: -2px;
border-radius: var(--radius);
}
/* Attached-but-not-yet-sent images. A row of small squares rather than the
full-width `.md img` treatment: this is a queue to review before Send, not
the message itself. */
.say-thumbs {
display: flex; gap: var(--s2); flex-wrap: wrap;
}
.say-thumb {
position: relative; flex: none;
width: 64px; height: 64px;
border: 1px solid var(--line); border-radius: var(--radius-sm);
overflow: hidden; background: var(--sunk);
}
.say-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* A full `--tap` (44px) square would cover most of a 64px thumbnail, so the
visible badge stays small and legible in the corner; `padding` below is
what keeps the actual hit area close to that minimum without inflating
the badge itself over the picture. */
.say-thumb-remove {
position: absolute; top: 0; right: 0;
width: 28px; height: 28px; padding: 4px;
display: grid; place-items: center;
background: none; border: 0; cursor: pointer;
color: #fff;
}
.say-thumb-remove::before {
content: ""; position: absolute; inset: 0;
background: rgba(20, 18, 14, .55); border-radius: 0 0 0 var(--radius-sm);
}
.say-thumb-remove svg { position: relative; width: 14px; height: 14px; }
.say-thumb.is-uploading img { opacity: .4; }
.say-thumb-spinner {
position: absolute; inset: 0; display: grid; place-items: center;
}
.say-thumb-spinner i {
width: 18px; height: 18px; border-radius: 50%;
border: 2px solid var(--line-2); border-top-color: var(--ink-2);
animation: spin .7s linear infinite;
}
.say-thumb.is-failed { border-color: var(--on-rust); }
.say-thumb.is-failed img { opacity: .35; }
.say-upload-status {
font-size: var(--t2); color: var(--ink-3);
}
/* ---- wide screens ------------------------------------------------------ *
* Not a stretched phone: the dock becomes a header rail, the run list becomes
* a grid, and the report pane sits beside the analysis instead of below it. */
@media (min-width: 720px) {
body { padding-bottom: var(--s6); }
.dock { display: none; }
.settings-btn { display: none; }
.rail { display: flex; align-items: center; gap: var(--s1); }
.rail-link {
display: inline-flex; align-items: center;
min-height: var(--tap); padding: 0 var(--s3);
border-radius: var(--radius-sm);
font-size: var(--t3); font-weight: 650; color: var(--ink-3);
}
.rail-link[aria-current="page"] { color: var(--ink); background: var(--sunk); }
/* The rail's Chat entry is the primary action: a filled gold button, not
another grey link — the same gold as the dock's chat item, and the only
.btn among the links. */
.rail-link.btn-gold { color: #241d05; background: var(--fill); border-color: var(--gold-line); }
main { padding: var(--s5); }
h1 { font-size: var(--t7); }
.view { gap: var(--s5); }
.sheet {
margin: auto; border-radius: var(--radius);
max-height: min(86dvh, 760px);
}
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); gap: var(--s4); }
.card { display: flex; flex-direction: column; }
.card-event { margin-top: auto; padding-top: var(--s2); }
/* The `li` is the grid item now that a card may carry a tail; the card
itself has to stretch or the tails in a row would not line up. */
.cards > li > .card { flex: 1; }
/* A question is read, not scanned: one column, measured, even when there is
room for two. */
#view-questions .asks { max-width: 780px; }
/* With room, the options sit side by side and stay the width of their own
text; a 780px-wide button labelled "Redis" reads like a mistake. A long
option still wraps onto its own line. */
.choices { flex-direction: row; flex-wrap: wrap; }
.choices .btn { flex: 0 1 auto; }
/* More room means a taller window before the operator has to go full
screen, but the bound stays: the choices must remain on the same screen
as the panel that argues for them. */
.frame-wrap iframe { height: 460px; }
/* The stakes row stops stretching to 780px; the irreversible button being
the width of the card is not emphasis, it is just a big target. */
.stakes-row { justify-content: flex-start; }
.stakes-row .btn { flex: 0 1 auto; min-width: 180px; }
.stakes-arm, .stakes-hold { align-self: flex-start; }
/* A transcript is read, so it is measured; and with the dock gone the
composer sticks to the bottom of the viewport instead of above it. */
#view-talk { max-width: 780px; }
/* The talk list itself stays uncapped, unlike the transcript above: a list
of conversations is a list of cards to scan, not a document to read start
to finish, and a cap here would leave it stuck at two columns at widths
where Queue, the same `.cards` grid, already runs to three. */
.talk-composer { bottom: 0; padding-bottom: var(--s4); }
.turn { max-width: 80%; }
}
/* The two run-detail wrappers are inert on a phone: their children flow into
the section's single column in source order. */
.run-main, .run-side { display: contents; }
@media (min-width: 1080px) {
#view-run {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
align-items: start;
align-content: start;
column-gap: var(--s5);
}
#view-run > .back, #view-run > .view-head { grid-column: 1 / -1; }
/* Real columns here, so the report sits level with the verdict instead of
being pushed to whatever grid row auto-placement gives it. */
.run-main, .run-side {
display: flex; flex-direction: column; gap: var(--s5);
min-width: 0;
}
.run-main { grid-column: 1 / -1; }
.report { max-height: calc(100dvh - 260px); }
.facts { grid-template-columns: 10rem 1fr; }
}
/* ---- motion ------------------------------------------------------------ */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; }
}
/* ---- print ------------------------------------------------------------- */
@media print {
.top, .daemon, .ask-bar, .dock, .back, .sheet, .full, .talk-composer { display: none; }
.frame-wrap iframe { height: 520px; }
.card, .panel { box-shadow: none; break-inside: avoid; }
.report { max-height: none; white-space: pre-wrap; }
}
/* ---- notification centre ------------------------------------------------ */
.notice-tools { display: flex; justify-content: flex-end; }
.notices { display: flex; flex-direction: column; gap: var(--s3); list-style: none; margin: 0; padding: 0; }
.notice {
display: flex; flex-direction: column; gap: var(--s2);
padding: var(--s3) var(--s4);
background: var(--surface); border: 1px solid var(--edge); border-left: 4px solid var(--ink-3);
border-radius: var(--radius);
}
.notice[data-sev="warn"] { border-left-color: var(--gold); }
.notice[data-sev="error"] { border-left-color: var(--rust); }
/* Read is shown by a sunk ground and the lighter weight below, never by
opacity: dimming text drops it under AA on paper. */
.notice[data-read="1"] { background: var(--sunk); }
.notice-msg { font-size: var(--t3); overflow-wrap: anywhere; }
.notice[data-read="0"] .notice-msg { font-weight: 600; }
.notice-meta { display: flex; flex-wrap: wrap; gap: var(--s3); align-items: center; font-size: var(--t2); color: var(--ink-3); }
.notice-meta a { color: var(--ink-2); display: inline-flex; align-items: center; min-height: var(--tap); }
.notice-actions { display: flex; flex-wrap: wrap; gap: var(--s2); }
.notice-actions .btn { flex: 1 1 8rem; }
/* ---- task detail ------------------------------------------------------- */
.task-flow { list-style: none; margin: 0 0 var(--s4); padding: 0; display: flex; flex-direction: column; align-items: stretch; }
.task-flow .flow-node { display: flex; flex-direction: column; gap: var(--s1); padding: var(--s3); background: var(--surface); border: 1px solid var(--line-2); border-radius: var(--radius-sm); color: var(--ink); text-decoration: none; min-width: 0; overflow-wrap: anywhere; }
.task-flow a.flow-node { min-height: var(--tap); }
.task-flow .flow-node[data-kind="start"], .task-flow .flow-node[data-kind="end"] { background: var(--sunk); }
.task-flow .flow-node[data-kind="chat"] { background: var(--sunk); border-left: 3px solid var(--gold); }
.task-flow .flow-node[data-undecided] { border-style: dashed; border-color: var(--rust); }
.task-flow .flow-node[data-unreadable] { border-style: dashed; border-color: var(--line-2); color: var(--ink-3); }
.task-flow .flow-top { display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; }
.task-flow .flow-time { font-size: var(--t2); color: var(--ink-2); white-space: normal; overflow-wrap: anywhere; }
.task-flow span.flow-time { color: var(--ink-3); }
.task-flow .flow-detail { font-size: var(--t2); color: var(--ink-2); }
.task-flow .flow-edge { position: relative; margin: 0; padding: var(--s2) 0 var(--s2) var(--s5); border-left: 2px solid var(--line-2); margin-left: var(--s4); font-size: var(--t2); color: var(--ink-2); overflow-wrap: anywhere; }
.task-flow .flow-edge::after { content: ""; position: absolute; left: -6px; bottom: -1px; border: 5px solid transparent; border-top: 7px solid var(--line-2); border-bottom: 0; }
.task-flow .flow-edge[data-attempt="refunded"] { border-left-style: dashed; }
.task-flow .flow-edge[data-attempt="unknown"] { border-left-style: dotted; }
.task-flow .flow-cost { display: inline-block; margin-left: var(--s2); font-family: var(--mono); font-size: var(--t1); color: var(--ink-3); }
.task-runs-h { font-size: var(--t3); color: var(--ink-2); margin: var(--s3) 0 var(--s2); }
.task-runs { display: flex; flex-direction: column; gap: var(--s3); list-style: none; padding: 0; margin: 0; }
.task-runs li { display: flex; flex-direction: column; gap: var(--s1); padding: var(--s3); border: 1px solid var(--line, currentColor); border-radius: var(--r2, 8px); }
.task-runs .task-run-top { display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; }
.task-runs .task-run-n { font-family: var(--mono); color: var(--ink-3); }
.task-runs .task-run-desc, .task-runs .task-run-out { font-size: var(--t3); color: var(--ink-2); overflow-wrap: anywhere; }
.card-title.is-link { cursor: pointer; }
.back-task { font-size: var(--t3); }
.run-task-line { margin: var(--s1) 0 0; overflow-wrap: anywhere; display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1); }
.task-chip-link {
display: inline-flex; align-items: center; min-height: 44px; padding: 0 var(--s3);
border: 1px solid var(--edge); border-radius: 999px; text-decoration: none;
}
#run-task-box { display: flex; flex-direction: column; gap: var(--s2); padding-bottom: var(--s3); border-bottom: 1px solid var(--edge); }
.sheet-task-link { display: flex; align-items: center; justify-content: center; gap: var(--s2); min-height: 44px; text-decoration: none; }
/* ---- settings ---------------------------------------------------------- */
#settings-sub, .set-note { overflow-wrap: anywhere; }
.set-note { color: var(--ink-2); font-size: var(--t3); max-width: 64ch; margin: 0 0 var(--s4); }
.set-context { display: flex; flex-direction: column; gap: var(--s2); margin-bottom: var(--s4); min-width: 0; }
.set-context select { width: 100%; min-width: 0; max-width: 100%; }
.set-override { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s2); }
.set-role { margin-bottom: var(--s4); }
.set-meta { color: var(--ink-2); font-size: var(--t3); margin: var(--s2) 0; overflow-wrap: anywhere; }
.set-seats { list-style: none; margin: var(--s3) 0; padding: 0; display: flex; flex-direction: column; gap: var(--s2); }
.set-seat { display: flex; align-items: center; gap: var(--s2); flex-wrap: wrap; }
.set-seat-id { flex: 1; min-width: 6ch; font-family: var(--mono, monospace); }
.set-add { display: flex; gap: var(--s2); flex-wrap: wrap; align-items: center; }
.set-actions { display: flex; gap: var(--s3); align-items: center; flex-wrap: wrap; margin-top: var(--s4); }
.set-agents { width: 100%; border-collapse: collapse; font-size: var(--t3); }
.set-agents th, .set-agents td { text-align: left; padding: var(--s2) var(--s3); border-bottom: 1px solid var(--line-2); overflow-wrap: anywhere; }
/* A conversation at 80% or more of its context window. */
.card-meta [data-warn="1"] { color: var(--rust); font-weight: 600; }
/* ---- two-pane master/detail (wide screens) ------------------------------ *
* Must stay in step with SPLIT_QUERY in app.js, which is what puts
* data-split on <main> and <body>. The page itself stops scrolling: the header
* keeps its height, <main> takes the rest, and the list and the preview each
* scroll inside their own column. */
@media (min-width: 1080px) {
body[data-split] {
height: 100dvh; padding-bottom: 0; overflow: hidden;
display: flex; flex-direction: column;
}
main[data-split] {
flex: 1 1 0; min-height: 0; width: 100%; max-width: none; margin: 0; padding: 0;
display: grid;
/* --split-master is set by wireSplitDivider in app.js (clamped there to
360px..100% - 480px); unset, it is the default width. */
grid-template-columns: var(--split-master, minmax(360px, 30%)) minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
}
/* The master column. */
main[data-split] > #view-runs,
main[data-split] > #view-queue,
main[data-split] > #view-talks {
grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;
padding: var(--s3); gap: var(--s3);
border-right: 1px solid var(--line);
}
/* The preview column. */
main[data-split] > #view-run,
main[data-split] > #view-task,
main[data-split] > #view-talk,
main[data-split] > .split-empty {
grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;
padding: var(--s4) var(--s5);
}
main[data-split] > .split-empty:not([hidden]) {
display: flex; align-items: center; justify-content: center;
color: var(--ink-3); font-size: var(--t3); text-align: center;
}
/* Thin, theme-coloured scrollbars for the two panes. */
main[data-split] > #view-runs,
main[data-split] > #view-queue,
main[data-split] > #view-talks,
main[data-split] > #view-run,
main[data-split] > #view-task,
main[data-split] > #view-talk,
main[data-split] > .split-empty {
scrollbar-width: thin;
scrollbar-color: var(--edge) transparent;
}
main[data-split] > #view-runs::-webkit-scrollbar,
main[data-split] > #view-queue::-webkit-scrollbar,
main[data-split] > #view-talks::-webkit-scrollbar,
main[data-split] > #view-run::-webkit-scrollbar,
main[data-split] > #view-task::-webkit-scrollbar,
main[data-split] > #view-talk::-webkit-scrollbar,
main[data-split] > .split-empty::-webkit-scrollbar { width: 8px; height: 8px; }
main[data-split] > #view-runs::-webkit-scrollbar-track,
main[data-split] > #view-queue::-webkit-scrollbar-track,
main[data-split] > #view-talks::-webkit-scrollbar-track,
main[data-split] > #view-run::-webkit-scrollbar-track,
main[data-split] > #view-task::-webkit-scrollbar-track,
main[data-split] > #view-talk::-webkit-scrollbar-track,
main[data-split] > .split-empty::-webkit-scrollbar-track { background: transparent; }
main[data-split] > #view-runs::-webkit-scrollbar-thumb,
main[data-split] > #view-queue::-webkit-scrollbar-thumb,
main[data-split] > #view-talks::-webkit-scrollbar-thumb,
main[data-split] > #view-run::-webkit-scrollbar-thumb,
main[data-split] > #view-task::-webkit-scrollbar-thumb,
main[data-split] > #view-talk::-webkit-scrollbar-thumb,
main[data-split] > .split-empty::-webkit-scrollbar-thumb { background: var(--line-2); border-radius: 999px; }
@media (hover: hover) and (pointer: fine) {
main[data-split] > #view-runs::-webkit-scrollbar-thumb:hover,
main[data-split] > #view-queue::-webkit-scrollbar-thumb:hover,
main[data-split] > #view-talks::-webkit-scrollbar-thumb:hover,
main[data-split] > #view-run::-webkit-scrollbar-thumb:hover,
main[data-split] > #view-task::-webkit-scrollbar-thumb:hover,
main[data-split] > #view-talk::-webkit-scrollbar-thumb:hover,
main[data-split] > .split-empty::-webkit-scrollbar-thumb:hover { background: var(--edge); }
}
/* Draggable divider: a thin strip over the preview column's left padding, so
it never covers the list's scrollbar or its row ends. The visible line is
the list's own border-right; the strip only lights up on hover / focus. */
.split-divider { display: none; }
main[data-split] > .split-divider:not([hidden]) {
display: block; grid-column: 2; grid-row: 1; justify-self: start;
width: 8px; z-index: 2; cursor: col-resize; touch-action: none;
background: transparent; outline: none;
}
@media (hover: hover) and (pointer: fine) {
main[data-split] > .split-divider:hover {
background: linear-gradient(to right, var(--gold-line) 0 2px, transparent 2px);
}
}
main[data-split] > .split-divider:focus-visible,
main[data-split] > .split-divider[data-dragging] {
background: linear-gradient(to right, var(--gold-line) 0 2px, transparent 2px);
}
body[data-split-dragging] { user-select: none; cursor: col-resize; }
/* The row is the way back; a back link would only navigate away. */
main[data-split] #view-run > .back,
main[data-split] #view-task > .back,
main[data-split] #view-talk > .back { display: none; }
/* scrollIntoView and the jump helpers pad for the sticky header, which is
outside the panes here. */
main[data-split] * { scroll-margin-top: 0 !important; }
/* The run detail's two columns follow the viewport elsewhere; in a pane that
is a column short, so they stack until the pane itself is wide. */
main[data-split] #view-run { display: flex; }
main[data-split] .report { max-height: 60dvh; }
@media (min-width: 1500px) {
main[data-split] #view-run { display: grid; }
main[data-split] .report { max-height: calc(100dvh - 220px); }
}
main[data-split] #view-talk { max-width: none; }
main[data-split] .talk-composer { bottom: 0; }
body[data-split] .run-actions-fab { bottom: var(--s4); }
/* The list pane is the one scroll region; the toolbar scrolls with it. */
/* Dense rows: status, title and key meta on one line. Everything else a card
carries waits for the row to be selected. */
main[data-split] .cards { display: flex; flex-direction: column; gap: 2px; }
main[data-split] .cards > li { flex-direction: column; }
main[data-split] .card {
display: flex; flex-direction: row; flex-wrap: wrap; align-items: center; gap: 0 var(--s2);
padding: 6px var(--s3); border-radius: var(--radius-sm);
box-shadow: none; flex: none;
}
main[data-split] .cards > li > .card { flex: none; }
@media (hover: hover) and (pointer: fine) {
main[data-split] .card:hover { background: var(--sunk); }
}
main[data-split] .card[aria-current="true"] {
background: var(--sunk); border-color: var(--gold-line);
outline: 1px solid var(--gold-line);
}
main[data-split] .card-top { display: contents; }
main[data-split] .card-when { order: 3; margin-left: auto; }
main[data-split] .card-title {
order: 1; flex: 1 1 0; min-width: 0; margin: 0;
font-size: var(--t3); line-height: 1.3;
display: block; white-space: nowrap; text-overflow: ellipsis; overflow: hidden;
}
main[data-split] .card-meta {
order: 2; margin: 0; flex: 0 1 auto; min-width: 0; max-width: 40%;
flex-wrap: nowrap; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
main[data-split] .card > .card-note,
main[data-split] .card > .card-event,
main[data-split] .card > .err,
main[data-split] .card > details,
main[data-split] .card > .card-actions,
main[data-split] .card > div:not(.card-top):not(.card-meta),
main[data-split] .cards > li > .card-tail,
main[data-split] .cards > li > .run-folded { display: none; }
/* The selected row opens: the note, and the controls a task needs, stay in
reach without leaving the list. */
main[data-split] .card[aria-current="true"] > .card-note,
main[data-split] .card[aria-current="true"] > .card-event,
main[data-split] .card[aria-current="true"] > .err,
main[data-split] .card[aria-current="true"] > details,
main[data-split] .card[aria-current="true"] > .card-actions,
main[data-split] .card[aria-current="true"] > div:not(.card-top):not(.card-meta),
main[data-split] .cards > li:has(> .card[aria-current="true"]) > .card-tail,
main[data-split] .cards > li:has(> .card[aria-current="true"]) > .run-folded { display: block; }
main[data-split] .card[aria-current="true"] > .card-note,
main[data-split] .card[aria-current="true"] > .card-event,
main[data-split] .card[aria-current="true"] > .err,
main[data-split] .card[aria-current="true"] > details,
main[data-split] .card[aria-current="true"] > .card-actions { order: 9; flex: 1 1 100%; }
main[data-split] .card[aria-current="true"] > .card-actions { display: flex; }
/* A Queue row is the one list row where the <li> itself is the card (Runs
and Chat put an <a class="card"> inside the <li>), so the generic
`.cards > li { flex-direction: column }` above outranks `.card` and would
stack the chips in a centred column with the title squeezed to nothing.
This selector outranks both and gives the row the same two lines as Runs
and Chat: the title alone on line 1, chips / id / meta / time on line 2.
Gaps are margins so a hidden chip leaves no track behind. */
main[data-split] .cards > li.card.task-card {
display: grid; grid-template-columns: auto auto auto auto minmax(0, 1fr) auto;
align-items: center; gap: 0;
}
main[data-split] .task-card .card-title {
grid-row: 1; grid-column: 1 / -1; order: 0; flex: none;
}
main[data-split] .task-card .card-top > :nth-child(1) { grid-row: 2; grid-column: 1; margin: var(--s2) var(--s2) 0 0; }
main[data-split] .task-card .card-top > :nth-child(2) { grid-row: 2; grid-column: 2; margin: var(--s2) var(--s2) 0 0; }
main[data-split] .task-card .card-top > :nth-child(3) { grid-row: 2; grid-column: 3; margin: var(--s2) var(--s2) 0 0; }
main[data-split] .task-card .card-top > :nth-child(4) { grid-row: 2; grid-column: 4; margin: var(--s2) var(--s2) 0 0; }
main[data-split] .task-card .card-meta {
grid-row: 2; grid-column: 5; max-width: none; margin: var(--s2) 0 0;
display: block;
}
/* Inline meta has no flex gap after a dot; preserve the same spacing as
the full card without changing the phone layout's wrapping budget. */
main[data-split] .task-card .card-meta > [data-sep]::after { margin-right: var(--s2); }
main[data-split] .task-card .card-when { grid-row: 2; grid-column: 6; margin: var(--s2) 0 0 var(--s2); }
main[data-split] .task-card > :not(.card-top):not(.card-title):not(.card-meta) { grid-column: 1 / -1; }
main[data-split] .cards > li:has(> .card[aria-current="true"]) > .card-tail { display: flex; }
}
/* ---- Runs page: toolbar, tabs, timeline, deliberation strip ---------------- *
* Scoped to #view-runs / #view-run so Queue and Chat keep the shared rules. */
#view-runs .runs-main { gap: var(--s3); }
#view-runs .runs-toolbar {
display: flex; flex-direction: column; gap: var(--s2);
padding: var(--s2);
background: var(--sunk); border: 1px solid var(--line); border-radius: var(--radius);
}
#view-runs .runs-toolbar-row { display: flex; flex-wrap: wrap; gap: var(--s2); align-items: center; }
#view-runs .runs-toolbar .queue-search { flex: 1 1 14rem; margin: 0; min-width: 0; }
#view-runs .runs-scope { flex: 1 1 10rem; min-width: 0; }
#view-runs .runs-scope select {
width: 100%; min-height: var(--tap); margin: 0; padding: 0 var(--s3);
font: 600 var(--t3)/1.3 var(--sans); color: var(--ink-2);
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-sm);
text-overflow: ellipsis;
}
#view-runs .runs-scope select[data-active] { color: var(--ink); border-color: var(--edge); }
#view-runs .runs-toolbar .state-chips { background: transparent; }
#view-runs .runs-toolbar .runs-filter { border: 0; }
/* Tabs: an underlined row, one tab per hash route. */
#view-run .run-tabs {
display: flex; gap: var(--s1);
border-bottom: 1px solid var(--line);
}
#view-run .run-tab {
display: inline-flex; align-items: center; gap: var(--s2);
min-height: var(--tap); padding: 0 var(--s4);
margin-bottom: -1px;
border-bottom: 2px solid transparent;
font: 700 var(--t2)/1 var(--sans); letter-spacing: .08em; text-transform: uppercase;
color: var(--ink-3); text-decoration: none;
}
@media (hover: hover) and (pointer: fine) {
#view-run .run-tab:hover { color: var(--ink); }
}
#view-run .run-tab[aria-selected="true"] { color: var(--ink); border-bottom-color: var(--gold-line); }
#view-run .run-tab .count { font: 500 var(--t1) var(--mono); letter-spacing: 0; }
#view-run .run-tabpanel { display: flex; flex-direction: column; gap: var(--s4); min-width: 0; }
#view-run .run-main { display: flex; flex-direction: column; gap: var(--s4); min-width: 0; }
#view-run .run-main > .run-tabpanel { width: 100%; }
/* Timeline: one block per run of same-node events. */
#view-run .tl { display: flex; flex-direction: column; gap: var(--s2); }
#view-run .tl-group {
padding: var(--s2) var(--s3);
border-left: 2px solid var(--line-2);
background: var(--sunk); border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
#view-run .tl-group:last-child { border-left-color: var(--gold-line); }
#view-run .tl-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--s2) var(--s3); }
#view-run .tl-node {
font: 700 var(--t2)/1.2 var(--sans); letter-spacing: .08em; text-transform: uppercase; color: var(--ink);
overflow-wrap: anywhere;
}
#view-run .tl-span, #view-run .tl-n { font: 500 var(--t1) var(--mono); color: var(--ink-3); }
#view-run .tl-span { margin-left: auto; }
#view-run .tl-events { display: flex; flex-direction: column; gap: 2px; margin-top: var(--s1); }
#view-run .tl-events li {
display: grid; grid-template-columns: 4.75rem minmax(0, 1fr); gap: var(--s2);
font-size: var(--t3); align-items: baseline;
}
#view-run .tl-events .event-msg { overflow-wrap: anywhere; }
/* Report: monospace box, the one thing on its tab. */
#view-run #run-report { font: 400 var(--t2)/1.55 var(--mono); max-height: calc(100dvh - 18rem); min-height: 12rem; }
/* Design deliberation: one slim strip instead of a diagram. */
#view-run .advise-strip {
display: flex; flex-wrap: wrap; align-items: center; gap: var(--s2);
min-height: 2.5rem;
}
#view-run .advise-chip {
display: inline-flex; align-items: center; gap: var(--s2);
min-height: 1.75rem; padding: 0 var(--s3);
border: 1px solid var(--chip-tone, var(--line-2)); border-radius: 999px;
font-size: var(--t2); color: var(--ink-2); max-width: 100%;
}
#view-run .advise-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--chip-tone, var(--line-2)); }
#view-run .advise-seat { font: 700 var(--t2) var(--mono); color: var(--ink); }
#view-run .advise-agent { color: var(--ink-3); }
#view-run .advise-reflect { font-size: var(--t1); color: var(--ink-3); }
#view-run .advise-chip[data-reflection="absent"] { border-style: dashed; }
#view-run .advise-chip[data-reflection="absent"] .advise-dot { background: transparent; box-shadow: inset 0 0 0 1.5px var(--chip-tone, var(--line-2)); }
#view-run .advise-chip[data-reflection="faint"] { border-style: dotted; }
#view-run .advise-chip[data-dead] { border-style: dashed; }
#view-run .advise-chip[data-dead] .advise-dot { background: transparent; box-shadow: inset 0 0 0 1.5px var(--chip-tone, var(--line-2)); }
#view-run .advise-link { flex: 1 1 1rem; min-width: .75rem; height: 1px; background: var(--line-2); }
#view-run .advise-brief {
display: inline-flex; align-items: center; min-height: 1.75rem; padding: 0 var(--s3);
border-radius: 999px; font: 700 var(--t1) var(--sans); letter-spacing: .08em; text-transform: uppercase;
}
#view-run .advise-brief[data-state="filled"] { background: var(--fill); color: #241d05; }
#view-run .advise-brief[data-state="empty"] { border: 1px dashed var(--line-2); color: var(--ink-3); }
#view-run .advise-proposals { margin-top: var(--s3); }
#view-run .advise-proposals > summary { cursor: pointer; font: 700 var(--t2) var(--sans); letter-spacing: .06em; text-transform: uppercase; color: var(--ink-3); min-height: 32px; }
#view-run .advise-proposals[open] > summary { margin-bottom: var(--s2); }
@media (max-width: 600px) {
#view-run .advise-strip { gap: var(--s1) var(--s2); }
#view-run .advise-agent, #view-run .advise-link { display: none; }
#view-run #verdict-solo .advise-agent { display: inline; overflow-wrap: anywhere; }
#view-run .advise-chip { padding: 0 var(--s2); min-height: 1.625rem; }
}
/* ======================================================================== *
* NERV / MAGI skin.
*
* Deliberately the LAST block: it re-declares the palette tokens at a higher
* specificity than the base themes above, so no rule needs a colour written
* into it and the data-tone / data-status mapping keeps working
* (gold -> NERV orange, teal -> phosphor green, rust -> alarm red).
*
* Two palettes, selected the way the base themes are: light is the default
* (explicit data-theme="light", or auto with a light OS); dark is an explicit
* data-theme="dark", or auto under prefers-color-scheme: dark. An explicit
* choice beats the OS setting. The dark values are written out twice for the
* same reason as in the base tokens: there is no single selector for it.
*
* dark = the black NERV console.
* light = NERV-manual paper: cream ground, near-black ink, thin dark rules,
* orange and alarm red as accents, phosphor green darkened to read.
*
* --fill is the bright orange of a filled button; its label is #241d05 in both
* themes. It is kept apart from --gold, which is a TEXT colour and so has to
* darken on paper. A fill's boundary on paper (2.3:1) is carried by --gold-line.
*
* Contrast (WCAG 2.x, computed; surface / sunk / bg unless noted)
* dark light
* surface / sunk / bg #0d0c0a #000 #050504 #fbf7ea #ebe4d0 #f4efe1
* --ink 17.5 / 16.7 / 18.0 16.4 / 17.6 / 14.8
* --ink-2 11.4 / 10.9 / 11.7 10.1 / 10.8 / 9.1
* --ink-3 7.4 / 7.1 / 7.6 6.6 / 7.1 / 6.0
* --gold (text) 7.8 / 7.5 / 8.1 5.3 / 5.7 / 4.8
* --teal 15.4 / 14.8 / 15.9 5.8 / 6.2 / 5.2
* --rust 6.1 / 5.9 / 6.3 6.0 / 6.5 / 5.5
* --violet 7.8 / 7.5 / 8.0 8.6 / 9.2 / 7.8
* --blue 9.2 / 8.8 / 9.5 7.8 / 8.4 / 7.1
* --cand-d (yellow) 14.1 / 13.5 / 14.5 5.8 / 6.2 / 5.2
* --cand-c (violet) 7.8 / 7.5 / 8.0 6.9 / 7.4 / 6.2
* chips on-gold / tint 8.7 7.5
* chips on-teal / tint 13.0 6.7
* chips on-blue / tint 10.2 7.0
* chips on-rust / tint 7.9 6.9
* chips on-ink / tint 10.0 9.4
* --stamp-ink on tint-rust 5.4 5.1
* provisional stamp (--ink-3 on tint-rust, no opacity) - 5.5
* --badge-ink on --rust (bell badge) 6.3 6.9
* #241d05 on --fill #ff7a1a (every filled button) 6.4 6.4
* alarm banner #fff on #7a0a0a (same in both) 11.2
* Non-text (>= 3:1):
* --edge on surface / sunk / bg 3.9 on bg 3.6 / 3.8 / 3.2
* --line-2 (panel rule) on surface / sunk / bg 4.4 / 4.7 / 4.0 (light)
* --gold-line on surface / sunk / bg 4.8 / 5.1 / 4.3 (light)
* --focus on bg 14.1 (dark #ffd23d) 7.8 (light #1a4a80); light ring on
* the orange fill 3.4. Tap targets stay at --tap (44px).
* ======================================================================== */
html:root {
--nerv-alarm-deep: #7a0a0a;
--nerv-hazard: #ffd23d;
--nerv-stripe-ink: #000;
--radius: 0; --radius-sm: 0;
--mincho: "Hiragino Mincho ProN", "Hiragino Mincho Pro", "Yu Mincho", YuMincho, "MS PMincho", serif;
--sans: var(--mono);
}
/* ---- light (default) ----------------------------------------------------- */
html:root, html:root[data-theme="light"] {
color-scheme: light;
--bg: #f4efe1;
--surface: #fbf7ea;
--sunk: #ebe4d0;
--ink: #14110c;
--ink-2: #3f382c;
--ink-3: #5c5342;
--line: #cfc4a6;
--line-2: #7a6e54;
--edge: #8a7c5c;
--gold: #a84300;
--gold-line: #b34700;
--fill: #ff7a1a;
--teal: #0a6b34;
--rust: #b3150d;
--violet: #5b2a86;
--blue: #1a4a80;
--mark-a: #ff6a00; --mark-b: #12a150; --mark-c: #7d4b9e; --mark-gold: #ff7a1a;
--cand-a: #a84300; --cand-b: #0a6b34; --cand-c: #6a3a96; --cand-d: #765800; --cand-e: #1a4a80;
--tint-gold: #f9e3c4; --on-gold: #7a3000;
--tint-teal: #d3ecd9; --on-teal: #0a5a2c;
--tint-blue: #d8e4f3; --on-blue: #1d4a80;
--tint-rust: #f6d5d0; --on-rust: #8f0f08;
--tint-ink: #e6dfcb; --on-ink: #3a3326;
--focus: #1a4a80;
--shadow: none;
--hatch: rgba(179, 21, 13, .14);
--hatch-gold: rgba(168, 67, 0, .2);
--nerv-alarm: #b3150d;
--stamp-ink: #b3150d;
--badge-ink: #fff;
--nerv-grid: rgba(20, 17, 12, .06);
--nerv-hex: rgba(20, 17, 12, .05);
--nerv-scan: rgba(20, 17, 12, .035);
}
/* ---- dark ---------------------------------------------------------------- */
@media (prefers-color-scheme: dark) {
html:root:not([data-theme="light"]) {
color-scheme: dark;
--bg: #050504;
--surface: #0d0c0a;
--sunk: #000000;
--ink: #f1ede4;
--ink-2: #c8c1b2;
--ink-3: #a39b8a;
--line: #2a2118;
--line-2: #4a3a24;
--edge: #7a6a50;
--gold: #ff7a1a;
--gold-line: #ff7a1a;
--fill: #ff7a1a;
--teal: #3dff8f;
--rust: #ff4a3d;
--violet: #b78fe0;
--blue: #5fb4ff;
--mark-a: #ff7a1a; --mark-b: #3dff8f; --mark-c: #b78fe0; --mark-gold: #ff7a1a;
--cand-a: #ff7a1a; --cand-b: #3dff8f; --cand-c: #b78fe0; --cand-d: #ffd23d; --cand-e: #5fb4ff;
--tint-gold: #2a1606; --on-gold: #ffa24d;
--tint-teal: #082414; --on-teal: #6dffaa;
--tint-blue: #08192b; --on-blue: #8fcaff;
--tint-rust: #2d0c0a; --on-rust: #ff8a80;
--tint-ink: #1a1712; --on-ink: #c8c1b2;
--focus: #ffd23d;
--shadow: 0 1px 2px rgba(0, 0, 0, .5), 0 4px 14px rgba(0, 0, 0, .35);
--hatch: rgba(255, 74, 61, .22);
--hatch-gold: rgba(255, 122, 26, .25);
--nerv-alarm: #e0201a;
--stamp-ink: #ff4a3d;
--badge-ink: #000;
--nerv-grid: rgba(255, 122, 26, .05);
--nerv-hex: rgba(255, 122, 26, .10);
--nerv-scan: rgba(0, 0, 0, .18);
}
}
html:root[data-theme="dark"] {
color-scheme: dark;
--bg: #050504;
--surface: #0d0c0a;
--sunk: #000000;
--ink: #f1ede4;
--ink-2: #c8c1b2;
--ink-3: #a39b8a;
--line: #2a2118;
--line-2: #4a3a24;
--edge: #7a6a50;
--gold: #ff7a1a;
--gold-line: #ff7a1a;
--fill: #ff7a1a;
--teal: #3dff8f;
--rust: #ff4a3d;
--violet: #b78fe0;
--blue: #5fb4ff;
--mark-a: #ff7a1a; --mark-b: #3dff8f; --mark-c: #b78fe0; --mark-gold: #ff7a1a;
--cand-a: #ff7a1a; --cand-b: #3dff8f; --cand-c: #b78fe0; --cand-d: #ffd23d; --cand-e: #5fb4ff;
--tint-gold: #2a1606; --on-gold: #ffa24d;
--tint-teal: #082414; --on-teal: #6dffaa;
--tint-blue: #08192b; --on-blue: #8fcaff;
--tint-rust: #2d0c0a; --on-rust: #ff8a80;
--tint-ink: #1a1712; --on-ink: #c8c1b2;
--focus: #ffd23d;
--shadow: 0 1px 2px rgba(0, 0, 0, .5), 0 4px 14px rgba(0, 0, 0, .35);
--hatch: rgba(255, 74, 61, .22);
--hatch-gold: rgba(255, 122, 26, .25);
--nerv-alarm: #e0201a;
--stamp-ink: #ff4a3d;
--badge-ink: #000;
--nerv-grid: rgba(255, 122, 26, .05);
--nerv-hex: rgba(255, 122, 26, .10);
--nerv-scan: rgba(0, 0, 0, .18);
}
/* Grid + hexagon motifs, gradients only. */
body {
background-color: var(--bg);
background-image:
repeating-linear-gradient(transparent 0 2px, var(--nerv-scan) 2px 3px),
linear-gradient(var(--nerv-grid) 1px, transparent 1px),
linear-gradient(90deg, var(--nerv-grid) 1px, transparent 1px);
background-size: auto, 32px 32px, 32px 32px;
}
@supports (background: conic-gradient(#000, #fff)) {
.top {
background-image: repeating-linear-gradient(transparent 0 2px, var(--nerv-scan) 2px 3px),
conic-gradient(from 30deg at 50% 50%, var(--nerv-hex) 0 60deg, transparent 60deg 120deg,
var(--nerv-hex) 120deg 180deg, transparent 180deg 240deg,
var(--nerv-hex) 240deg 300deg, transparent 300deg 360deg);
background-size: auto, 28px 48px;
}
}
.top { border-bottom: 2px solid var(--gold-line); }
h1, h2, h3, .brand-name { font-family: var(--mincho); font-weight: 900; }
h1 { overflow-wrap: anywhere; letter-spacing: 0; }
.card-title { overflow-wrap: anywhere; min-width: 0; }
.panel, .card { border-radius: 0; border: 1px solid var(--line-2); background: var(--surface); }
.panel h2 {
font-family: var(--mono); font-weight: 700; font-size: var(--t1);
color: var(--gold); letter-spacing: .18em;
border-left: 4px solid var(--gold-line); padding: 2px 0 2px var(--s2);
background: linear-gradient(90deg, var(--tint-gold), transparent);
}
.card[data-status="stalled"] {
border-left: 8px solid transparent;
border-image: repeating-linear-gradient(-45deg, var(--nerv-alarm) 0 6px, var(--nerv-stripe-ink) 6px 12px) 1 / 0 0 0 8px;
}
.btn, .chip, .tag, .icon-btn { border-radius: 0; }
/* ---- stalled alarm banner (stays unlike the quiet ready state) ---------- */
.alert-banner {
margin-bottom: var(--s3); padding: var(--s3) var(--s4);
color: #fff; font-weight: 800; font-size: var(--t3); letter-spacing: .05em;
overflow-wrap: anywhere;
background: var(--nerv-alarm-deep);
border: 8px solid var(--nerv-alarm);
border-image: repeating-linear-gradient(-45deg, var(--nerv-hazard) 0 10px, var(--nerv-stripe-ink) 10px 20px) 8;
}
@media (prefers-reduced-motion: no-preference) {
.alert-banner { animation: nerv-blink 1.4s steps(2, jump-none) infinite; }
@keyframes nerv-blink { 0% { filter: none; } 100% { filter: brightness(1.45); } }
}
/* ---- verdict stamps ------------------------------------------------------ */
.verdict-stamp-row { display: flex; }
.review-stamp-row { margin: var(--s2) 0; }
.stamp {
display: inline-flex; align-items: baseline; gap: var(--s3); flex-wrap: wrap;
padding: var(--s2) var(--s4); border: 3px double currentColor; transform: rotate(-3deg);
font-family: var(--mincho); max-width: 100%;
}
.stamp .stamp-ja { font-size: var(--t7); font-weight: 900; letter-spacing: .2em; }
.stamp .stamp-en { font-family: var(--mono); font-size: var(--t2); letter-spacing: .12em; text-transform: uppercase; overflow-wrap: anywhere; }
.stamp-approved { color: var(--stamp-ink); background: var(--tint-rust); }
.stamp-provisional { color: var(--ink-3); border-style: dashed; }
.tag .stamp-ja { font-family: var(--mincho); font-weight: 900; letter-spacing: .15em; }
/* ---- MAGI seats ----------------------------------------------------------- */
.magi-seats { display: flex; flex-wrap: wrap; gap: var(--s2); }
.magi-seat {
flex: 1 1 140px; min-width: 0; display: flex; flex-direction: column; gap: 2px;
padding: var(--s3); border: 1px solid var(--gold-line); background: var(--sunk);
clip-path: polygon(0 0, calc(100% - 14px) 0, 100% 14px, 100% 100%, 14px 100%, 0 calc(100% - 14px));
}
.magi-name { font-family: var(--mincho); font-weight: 900; letter-spacing: .08em; color: var(--gold); overflow-wrap: anywhere; }
.magi-id { font-size: var(--t1); color: var(--ink-3); }
.magi-state { font-size: var(--t2); min-height: 1.4em; color: var(--on-gold); }
.magi-seat[data-state="quota"] { border-color: var(--nerv-alarm); }
.magi-seat[data-state="quota"] .magi-name, .magi-seat[data-state="quota"] .magi-state { color: var(--on-rust); }
.magi-seat[data-state="active"] { border-style: dashed; }
/* ---- verdict diagram (competition) ----------------------------------------
Scoped to `.cv-duel`, the class only the competition drawing carries, so the
other `.converge` users are untouched. */
.converge svg.cv-duel {
border: 1px solid var(--line-2);
background-color: var(--sunk);
background-image:
repeating-linear-gradient(transparent 0 2px, var(--nerv-scan) 2px 3px),
linear-gradient(var(--nerv-grid) 1px, transparent 1px),
linear-gradient(90deg, var(--nerv-grid) 1px, transparent 1px);
background-size: auto, 16px 16px, 16px 16px;
clip-path: polygon(0 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 12px 100%, 0 calc(100% - 12px));
}
.converge svg.cv-duel text { font-family: var(--mono); font-weight: 700; }
.converge svg.cv-duel .cv-label { font-size: 13px; letter-spacing: .06em; }
.converge svg.cv-duel .cv-plate { font-size: 11px; letter-spacing: .14em; }
/* Review precision scatter: inline SVG, log x. */
.precision-scatter { max-width: 100%; margin-top: var(--s3); }
.scatter-svg { display: block; width: 100%; max-width: 480px; height: auto; }
.scatter-tick { font: 10px var(--sans); fill: var(--ink-3); font-variant-numeric: tabular-nums; }
.scatter-axis { font: 10px var(--sans); fill: var(--ink-3); text-transform: uppercase; letter-spacing: .05em; }
.scatter-label { font: 11px var(--sans); fill: var(--ink-2); }
.scatter-whisker { stroke-width: 2; opacity: .35; }
/* Runs-per-day chart: stacked bars (merged / ready / other) plus a thin
completion-rate line. Values read from the readout above, not tooltips. */
.stats-daily-readout { min-height: 1.4em; font-size: var(--t3); color: var(--ink-2); font-variant-numeric: tabular-nums; margin: var(--s2) 0; }
.stats-daily-svg { width: 100%; height: auto; display: block; touch-action: pan-y; cursor: crosshair; }
.stats-daily-svg:focus-visible { outline: 2px solid var(--ink-3); outline-offset: 2px; }
.daily-base { stroke: var(--sunk); stroke-width: 1; }
.daily-cursor { fill: var(--sunk); opacity: .7; }
.daily-seg[data-s="merged"] { fill: var(--verdict-merged); }
.daily-seg[data-s="ready"] { fill: var(--verdict-ready); }
.daily-seg[data-s="other"] { fill: var(--ink-3); opacity: .6; }
.daily-line { fill: none; stroke: var(--ink); stroke-width: 1; opacity: .55; stroke-linejoin: round; }
.daily-dot { fill: var(--ink); opacity: .55; }
.daily-axis { font: 7px var(--sans); fill: var(--ink-3); }
.stats-daily-legend { display: flex; flex-wrap: wrap; gap: var(--s2) var(--s4); margin: var(--s2) 0 0; font-size: var(--t2); color: var(--ink-3); }
.stats-daily-legend span::before { content: ""; display: inline-block; width: 8px; height: 8px; margin-right: 6px; border-radius: 2px; background: var(--ink-3); }
.stats-daily-legend [data-s="merged"]::before { background: var(--verdict-merged); }
.stats-daily-legend [data-s="ready"]::before { background: var(--verdict-ready); }
.stats-daily-legend [data-s="rate"]::before { height: 2px; background: var(--ink); opacity: .55; }
/* A queued draft is operator text of any length: wrap it instead of letting a
<pre> stretch the page (and the fixed dock) past the viewport. */
#talk-pending { min-width: 0; max-width: 100%; }
.talk-pending-text { margin: var(--s2) 0; max-width: 100%; white-space: pre-wrap; overflow-wrap: anywhere; }
/* Queue controls wrap as groups; confirmations expand inside the card. */
.task-card-actions { align-items: flex-start; }
.task-card-actions .btn { min-width: 0; flex: 0 0 auto; }
.task-card-actions .btn-step { min-width: var(--tap); }
.task-card-actions .task-priority-box { display: flex; align-items: center; gap: var(--s1); }
.task-priority-value { font-size: var(--t2); white-space: nowrap; }
.task-action-menu { margin-left: auto; min-width: 0; max-width: 100%; }
.task-action-menu > summary { list-style: none; cursor: pointer; }
.task-action-menu > summary::-webkit-details-marker { display: none; }
.task-action-menu[open] { flex: 1 1 100%; }
.task-menu-items { display: flex; flex-direction: column; gap: var(--s2); margin-top: var(--s2); }
.task-menu-items .btn { width: 100%; }
.task-menu-items input { width: 100%; min-width: 0; }
.task-menu-items .stakes-row { display: flex; gap: var(--s2); }
.task-menu-items .stakes-row .btn { flex: 1 1 0; min-width: 0; }
#task-edit-sheet .field > input,
#task-edit-sheet .field > textarea,
#task-edit-sheet .field > select { width: 100%; min-width: 0; }
#task-edit-save { width: 100%; }
@media (min-width: 1080px) {
main[data-split] .cards > li.card.task-card { grid-template-columns: minmax(0, 1fr); }
main[data-split] .task-card .card-top {
display: flex; flex-wrap: wrap; grid-row: 2; grid-column: 1 / -1; gap: var(--s2); margin-top: var(--s2);
}
main[data-split] .task-card .card-top > :nth-child(-n+4) { margin: 0; }
main[data-split] .task-card .card-when { margin: 0 0 0 auto; }
main[data-split] .task-card .card-meta {
grid-row: 3; grid-column: 1 / -1; display: flex; flex-wrap: wrap;
white-space: normal; overflow: visible; max-width: none;
}
main[data-split] .cards > li.card.task-card > .task-card-actions { display: flex; margin-top: var(--s2); }
}
/* Replies use the same compact action buttons as the conversation composer. */
.reply-preview { display: flex; align-items: center; gap: .5rem; padding: .5rem; border-left: 3px solid var(--gold); }
.reply-preview > span { flex: 1; min-width: 0; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow-wrap: anywhere; }
.reply-preview[hidden] { display: none; }
.reply-quote { display: block; max-width: 100%; text-align: left; white-space: pre-wrap; overflow-wrap: anywhere; border: 0; border-left: 3px solid var(--gold); padding: .4rem .6rem; color: inherit; background: transparent; font: inherit; font-size: .85em; cursor: pointer; }
.turn-actions .btn, .reply-preview .btn { min-height: 44px; }
.turn.reply-highlight { outline: 2px solid var(--gold); outline-offset: 3px; }
.queued-message { margin-top: .5rem; }
/* Posting comparisons retain the theme's text color over a quiet tint.
Numbered borders connect explanations and spans without relying on hue. */
.posting-comparison { min-width: 0; margin-block: 1.25rem; }
.posting-rules { padding-left: 0; list-style: none; }
.posting-rules li { margin-block: .5rem; padding: .35rem .5rem; border-left: 3px solid hsl(var(--pair-hue) 65% 45%); }
.posting-pair { color: var(--ink); background: hsl(var(--pair-hue) 65% 50% / .16); }
.posting-text { white-space: pre-wrap; overflow-wrap: anywhere; color: var(--ink); background: var(--sunk); line-height: 1.8; }
/* Paint each nested trigger over the same base to preserve its distinct hue. */
.posting-text .posting-pair { background: linear-gradient(hsl(var(--pair-hue) 65% 50% / .16), hsl(var(--pair-hue) 65% 50% / .16)), var(--sunk); border-bottom: 2px solid hsl(var(--pair-hue) 65% 45%); padding-block: .1em; }
.posting-marker { display: inline-block; font-size: .75em; font-weight: 750; border: 1px solid currentColor; border-radius: .2em; padding: 0 .2em; margin-inline: .2em; line-height: 1.4; vertical-align: baseline; }
.posting-label { font-size: .85em; color: var(--ink-2); margin-bottom: .35rem; }
.posting-removed { font-style: italic; }
.magi-pending { background: var(--tint-gold); color: var(--on-gold); padding: var(--s3); border-radius: var(--radius-sm); }
.magi-icon-settings input { display: block; max-width: 100%; margin-block: var(--s3); min-height: 44px; }
.magi-icon-settings button { min-height: 44px; }
.magi-icon-preview { display: block; width: 80px; height: 80px; object-fit: cover; border-radius: 16px; }
/* Update failures stay readable at phone widths, outside the collapsed strip. */
.upgrade-error {
margin: .75rem;
padding: .75rem;
border: 2px solid var(--on-rust);
background: var(--tint-rust);
color: var(--on-rust);
border-radius: .5rem;
overflow-wrap: anywhere;
white-space: normal;
}
.upgrade-error p { margin: 0 0 .75rem; }
/* The completion statement must remain fully readable at phone widths. */
.talk-task-progress { margin-block: var(--s3); overflow-wrap: anywhere; }
#talk-task-progress-open { max-width: 100%; white-space: normal; text-align: left; }
.avatar-settings select { display: block; width: 100%; max-width: 100%; min-height: 44px; margin-block: var(--s3); }
.avatar-settings .set-actions { flex-wrap: wrap; }
/* Shared feedback: tune these tokens without changing individual controls.
--focus follows both explicit and system light/dark themes. */
:root {
--press-color: var(--focus);
--press-opacity: .18;
--press-duration: 600ms;
--press-release: 160ms;
--press-scale: .975;
--press-busy-duration: 900ms;
--press-busy-opacity: .6;
}
:where(button, a[href], summary, [role="button"], [role="link"], [role="tab"], [role="radio"], [role="checkbox"], [role="switch"], [role="menuitem"], [data-press]) {
-webkit-tap-highlight-color: transparent;
}
/* Only nested controls suppress the parent response; decorative SVG/span
descendants also become :active during a real pointer press. */
:where([data-pressed], button:active, a[href]:active, summary:active, [role="button"]:active, [role="link"]:active, [role="tab"]:active, [role="radio"]:active, [role="checkbox"]:active, [role="switch"]:active, [role="menuitem"]:active, [data-press]:active:not(.task-card)):not(:disabled, [aria-disabled="true"], [aria-disabled="true"] *, [inert], [inert] *):not(:has(:where(button, a[href], summary, input, select, textarea, [role="button"], [role="link"], [role="tab"], [role="radio"], [role="checkbox"], [role="switch"], [role="menuitem"], [data-press]):is(:active, [data-pressed]))) {
background-color: var(--sunk);
opacity: .78;
}
/* Scale only ordinary buttons; fixed controls and transform-positioned
elements keep their positioning. The independent scale property composes. */
button.btn[data-pressed]:not(:disabled, [aria-disabled="true"], [aria-disabled="true"] *, [inert] *) {
scale: var(--press-scale);
}
button[aria-busy="true"] {
box-shadow: inset 0 0 0 2px var(--press-color);
animation: press-busy var(--press-busy-duration) ease-in-out infinite alternate;
}
@keyframes press-busy {
from { opacity: 1; }
to { opacity: var(--press-busy-opacity); }
}
#press-feedback, #press-feedback * { pointer-events: none; }
#press-feedback { position: fixed; inset: 0; z-index: 1000; overflow: hidden; }
.press-glow { position: fixed; overflow: hidden; }
.press-glow.is-released { animation: press-fade var(--press-release) ease-out forwards; }
.press-glow > span {
position: absolute;
left: var(--press-x); top: var(--press-y);
width: var(--press-size); height: var(--press-size);
margin-left: calc(var(--press-size) / -2); margin-top: calc(var(--press-size) / -2);
border-radius: 50%;
background: radial-gradient(circle, var(--press-color), transparent 70%);
opacity: var(--press-opacity);
animation: press-glow var(--press-duration) var(--ease) both;
}
@keyframes press-fade { to { opacity: 0; } }
@keyframes press-glow {
from { transform: scale(.15); }
to { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
#press-feedback { display: none; }
button.btn[data-pressed] { scale: none; }
button[aria-busy="true"] { animation: none; opacity: var(--press-busy-opacity); }
}
@media print { #press-feedback { display: none; } }