/* ===========================================================================
 * Flology Web UI — design rules (authoritative; governance anchor in CLAUDE.md R007)
 *
 * Warm Editorial. Hierarchy comes mostly from colour and whitespace — no
 * shadows, no gradients; font-weight is used sparingly — TWO values across the chrome, the
 * body's 400 and --weight-emph one step above it (see its note for why that step is 600 and
 * not 500).
 *
 * WHERE the heavier one is spent is itself a rule: on things there is ONE of on a screen —
 * a heading, a pane's title, an author's own emphasis. Never on a row of a list. A weight
 * every row wears separates no row from any other; it is not emphasis, it is the floor moving
 * up, and the reader pays for it on every row without being told anything. The list rows that
 * used to take it (a schedule's name, a project in the sidebar, the selected run) each already
 * carried a second signal — full contrast against a muted line beneath, an indent, a fill —
 * so the weight was the redundant one of the pair and it is the one that went. Two forces make
 * the cost higher here than the rule-of-thumb suggests: a CJK glyph packs far more strokes into
 * the same em box than a Latin one, so the same 600 reads markedly heavier in the Chinese names
 * this audience uses (R003), and all three themes put dark text on light paper, where a given
 * weight looks heavier than the same weight light-on-dark.
 *
 * One exception, and it is a Light rather than a heavier one: .hint-project, the single 2rem line
 * on an otherwise empty page, takes 300. That is display type outside the chrome scale, it appears
 * once, and it spends weight in the direction this file spends everything else — down. Every CJK
 * family in the stack ships a Light master, so unlike 500 it lands where it is asked to.
 *
 * Two values in the chrome and not three because h1–h6 AND strong/b are reset to --weight-emph below:
 * anything with no weight of its own renders at the UA's bold 700, which is a third value
 * nobody chose and which does not move when this token
 * does. The logo declares the body's 400 explicitly for the same reason — it is an h1.
 * Corners are 2px everywhere; the only round elements are circles —
 * the avatars and the theme-switcher pill. The logo is the only serif;
 * everything else is sans.
 *
 * Restraint with accent — one rule: a FILL of --primary means identity (the assistant
 * avatar, the pin mark) and nothing else; every "you are here" / interaction cue lives on
 * a LINE instead. The line takes whatever form the shape wants — a left bar on a stacked
 * nav row or vertical tab (conversations, Schedules, the Settings side-tabs), a bottom
 * underline on a horizontal segmented control (the frequency tabs), a border on a hovered
 * button, the focus ring, or a left rule on a quoted passage (a blockquote) — but it is always a
 * line, never a wash behind the element. Where accent does become TEXT — a decision's action verb,
 * a card link's hover, a copied confirmation — it is spelled --accent-text, which is a different
 * value for exactly that reason (see its note below); the rule constrains where accent appears,
 * not which token spells it. One named exception to "never a wash": the theme
 * switcher keeps a soft accent WASH for its active dot (on a 28px round control any line
 * reads too loud — the quiet tint is more in the spirit of the rule than a strong ring).
 * No button gets a resting or hover fill — not even Approve; it takes the same accent-border
 * hover as the rest. So the resting UI reads calm and low-key, and accent earns its keep as
 * crisp lines rather than competing colour blocks.
 *
 * Outcome colour is NOT accent, and there is exactly ONE of it: --error. A refusal is the one
 * answer that asks something of the reader — go back and fix that field — and colour is how it
 * says so. It goes on the WORDS: no rule, no fill, no box, which is how every failure in the app
 * is already written, and it is dark enough to be READ at small sizes, not merely seen.
 *
 * A CONFIRMATION IS NOT ITS TWIN, and that took two passes to learn. "Saved." first rode
 * --primary, which spent the accent on something unremarkable and, on Dusk, made "saved" and
 * "refused" the same terracotta. The fix reached for the obvious opposite — a sage --ok facing
 * --error, red against green — which cured the collision but kept the wrong premise underneath
 * it: that a confirmation is the refusal's equal and needs a colour of its own. It is not. It
 * asks nothing of anyone, and it already says so twice (it sits under the button just pressed,
 * and it was not there a moment ago); a colour is a third cue buying weight it has no use for.
 * On a palette this warm the sage was also the one cold thing on the page, so the least
 * consequential message in the UI was the one that pulled the eye. It is --on-surface-muted now
 * and the SHAPE is untouched (see .admin-notice / .admin-error): same line, same slot, colour
 * only where colour does work. Add an outcome colour here, never a second accent — and before
 * adding one at all, check it is not this mistake wearing a new hue.
 *
 * FEEDBACK: nothing floats, and nothing says what you can already see.
 *
 * Every answer this UI gives is IN FLOW. There is no toast, no top-right stack, no modal — the
 * only overlay-shaped thing in the app, the account menu, pushes the nav aside rather than
 * covering it. Two reasons. A message that floats over the page is timed: it decides how long
 * you get to read it, and it is gone if you looked away — which is precisely backwards for the
 * one message that matters, the refusal you now have to act on. And in flow, a message has a
 * PLACE: under the button that produced it, so it says which of the page's forms it is about
 * (form-feedback.html spells out why below the actions row and not above).
 *
 * The corollary, and the part that is easy to get wrong in the other direction: a VISIBLE STATE
 * CHANGE IS THE FEEDBACK. Pausing a schedule re-renders the card as paused; there is nothing for
 * a "Paused." line to add, and adding one is noise that trains the eye to skip the slot where
 * the real refusals appear. So a message is written only when the change is INVISIBLE (a config
 * value that looks identical saved or not — "Saved."), when the action was REFUSED (R005: say
 * what is wrong and what to do), or when the thing that was acted on is GONE and the change
 * therefore shows nowhere (a deleted card, a conversation removed under an open pane).
 *
 * What this does NOT cover — deliberately — is a result nobody is present for: a schedule that
 * fires at 3am and fails. No in-flow slot and no toast can serve that, because the answer
 * arrives with no one at the page. That belongs in something persistent that waits to be read
 * (the inbox), not in a transient banner. Reaching for a floating notification because of that
 * case is solving the wrong problem with the wrong tool.
 *
 * Fonts: the UI text runs on system stacks — no CDN dependency. Math rendering
 * (KaTeX) bundles woff2 faces as static assets embedded in the binary, so they
 * are served from /static/fonts/ rather than fetched from a CDN; offline-safe.
 * Every name in every stack is a face that ships with some real OS; none names a
 * face we would have to fetch, so what we design against is what readers get.
 *
 * Readability. Three numbers, and they are not the same kind of number.
 *
 *   BODY ON PAPER   >= 12:1  — --on-surface, on --surface AND --surface-alt. The reading
 *                              surface: a transcript, a form, a card. Target, not a floor.
 *   BODY ON CHROME  >=  7:1  — --on-secondary, on --secondary AND --secondary-wash. The
 *                              sidebar holds names and nav labels, not prose, and dusk's
 *                              taupe panel is a deliberate identity choice (see its comment)
 *                              that reaching 12 would require abandoning. Scoped on purpose.
 *   EVERYTHING ELSE >= 4.5:1 — WCAG AA for text (1.4.3); R007 requires AA, this is what AA
 *                              costs here. Muted text, accent text, small labels and --error
 *                              (which a confirmation no longer has a twin of — see the header's
 *                              outcome-colour note; "Saved." rides the muted ink above).
 *
 * Those figures, and every ratio recorded on a token below, are token against token. What
 * actually reaches the eye is about 0.1 lower, because a grain overlay sits on top of the whole
 * app (see body::before). The distinction matters in one direction only: the two HARD numbers,
 * 4.5 for text and 3 for an indicator, are verified with the overlay composited in and hold
 * there. The 12 and the 7 are targets, and post-grain the tightest of them land at 11.6–11.8
 * and 6.97 — a rounding away, and not worth repainting a theme for.
 *
 * One more, and it is not about text at all: an INDICATOR >= 3:1 on every ground it lands on
 * (WCAG 1.4.11, which has no AAA tier — 3 is the whole requirement). Indicator, not ring: an
 * indicator is anything whose colour is the only thing telling you a state. The focus ring is
 * the obvious one, but so is the "you are here" bar on a nav row, the pin mark, the active tab's
 * underline, and the spinner that means a tool is still streaming — all of them were painted in
 * the raw brand colour or dimmed with an opacity, and all of them measured 1.6–2.6 somewhere.
 * The composer swaps the ring for a border of its own, and that border is held to the same
 * number: an element that opts out of the shared treatment does not opt out of the floor.
 *
 * Two things this deliberately does NOT cover. Resting borders: --line doubles as divider and
 * control edge at 1.2–1.3, and raising it repaints every input and button in the app, which is
 * a look decision rather than a ratio one. And pure decoration — a blockquote's left rule, the
 * theme dot's wash — which says nothing a reader could miss.
 *
 * A note on opacity, since it caused four of these at once: fading a token is cutting its
 * contrast, and the cut is invisible in the palette below because the token still measures fine
 * there. If something should read quieter, reach for the quieter token, not a veil over the
 * solved one. Of the five that were cut, the sidebar's faded nav rows are the one survivor — and
 * only as --nav-row-fade, a token carrying its own derivation, not a number retyped at six rows.
 * The working dots are the counter-example: they still pulse, but in SCALE, so the motion costs
 * nothing on the contrast side (see .msg-loading). Opacity is fine where the fade is not standing
 * in for a token: disabled controls (exempt from 1.4.3 — .composer-box button, .proposal-btn, the
 * webhook custom fields) and a decorative glyph or icon sitting next to a text label that already
 * says the same thing (the disclosure chevron, the nav icons at 0.7–0.8).
 *
 * --av-ink is the only TOKEN in the file for ink on a FILL rather than on paper: the letter
 * inside a chat avatar, at 12px, on the seven discs of that theme's ladder (see .msg.assistant
 * .avatar). Which way it goes follows from how dark the FILL is, not from how light the theme
 * is — the two light themes run L .53 discs under white, night runs L .70 discs under its own
 * near-black. There used to be a second such token, --on-primary, for a letter drawn directly
 * on --primary; nothing paints that any more (the avatars became a ladder the theme owns, and
 * --primary went back to being a brand fill and a line), so it is gone rather than kept as a
 * value nothing measures against — an unused token drifts silently, because nothing on screen
 * changes when it goes wrong.
 *
 * 4.5 is where accessibility law stops (WCAG AA; AAA is 7, and no major regulation adopts it
 * as a blanket policy). But no mature design system actually SETS body text at 4.5 — GOV.UK
 * runs ~17:1, Carbon ~15:1, Apple's own guidance says 4.5 minimum and 7 preferred, and a
 * measurement of Anthropic's own marketing site found its primary ink at 17.5, its secondary
 * at 12.6, its quietest text at 5.0 and nothing at all in the 4.5–5 band. 4.5 is what the
 * QUIETEST tier is allowed to fall to, not what the main tier is aimed at. Writing all three
 * numbers down keeps that distinction from eroding one palette tweak at a time.
 *
 * "Against every ground it can land on" is the load-bearing half of each line. The tokens
 * below used to be solved against --surface alone: several muted values measured 4.5–4.65
 * there and then dropped to 4.07–4.35 the moment the same text landed on --surface-alt (a
 * hovered row, an active card, an input) — which is where muted text most often lives. The
 * body tokens had the same hole one tier up: day's ink read 12.27 on --surface but 11.46 on
 * --surface-alt, and dusk's sidebar ink 7.75 on --secondary but 6.27 on --secondary-wash,
 * the wash under the row you are actually looking at. A floor that only holds on the
 * background nobody uses is not a floor. So every text token is solved against BOTH of its
 * grounds and carries the two measured ratios in a comment, so the next person to touch the
 * palette can see the constraint instead of re-deriving it.
 *
 * Note which lever moved to reach 12 on dusk: the INK, not the paper. Dusk's warm sand
 * --surface is the theme's identity, and lightening it walks dusk toward Day until the two
 * stop being different themes. It also would not have worked — the old #3d3929 tops out at
 * 11.57 against pure white, so no amount of paper-lightening reaches 12 and the ink had to
 * darken regardless. It was darkened by scaling all three channels by one factor rather than
 * subtracting per channel, which holds the hue (61:57:41 became 30:28:20) instead of drifting
 * the warm brown toward olive as it gets dark.
 *
 * Same reasoning gives accent not one token but THREE, because one hue cannot land on three
 * grounds. --primary is the brand: a fill, and every accent LINE that lands on paper. --accent-text
 * is that hue deepened again for the light paper, because --primary measures 3.1–3.3 there — a
 * legal line, still unreadable as a word. --accent-chrome is that hue solved for the sidebar
 * panel, and dusk shows why it has to exist separately: the panel is a mid taupe, so the
 * deepened value collapses to 1.2–1.4 on it while the brand value only reaches 2.2–2.4. Dusk's
 * chrome accent is therefore LIFTED where its text accent is deepened. Day and night are the
 * other case: all three tokens hold the SAME value, because paper and panel are only 1.04 apart
 * on each (night by being dark, day by design — see its block), so one value clears every ground
 * at once. Whether a theme can collapse the three is a property of that gap, not a preference:
 * dusk's is 7.47, and no single hue clears 3:1 on both ends of a gap that wide, so its three
 * stay three. The three tokens exist for dusk; the other two themes just happen to fill them in
 * with one colour.
 *
 * Which one a rule takes follows from BOTH the ground and whether it is a word or a line, since
 * the two floors are different (4.5:1 vs 3:1, WCAG 1.4.3 / 1.4.11). A word on paper is
 * --accent-text; a LINE or an ICON on the panel is --accent-chrome; a LINE on paper — a quote's
 * rule, a "you are here" bar, an active card's edge — is --primary, and the focus ring on paper
 * is the exception that stays on --accent-text, because a ring you must not miss is worth more
 * than the floor. Note what --accent-chrome is NOT licensed for: a WORD on the panel. It is
 * solved to the indicator's 3, and on dusk it measures 4.18 on --secondary — a legal line and an
 * illegal word. Nothing paints accent text on the panel today; the day something needs to, it
 * owes a fourth value rather than this one, and saying so here is cheaper than discovering it
 * from a contrast report later.
 * Solving the paper lines at 4.5 rather than 3 was a real bug and not just waste: it drew them
 * 1.7x deeper than needed, so beside the nav's bar (solved for a dark panel, therefore lifted)
 * the eye read two colours instead of one accent. Paper and panel cannot hold one value, so ONE
 * HUE LIT TWO WAYS is the most that is on offer, and --primary on both is what delivers it —
 * which in turn is why dusk's --primary is deepened until it is a legal line (see its block).
 *
 * The sidebar's tokens are named for their ROLE and not for their lightness, which is the same
 * choice the type scale below makes and for a sharper reason here: on dusk the wash is DARKER
 * than the pill, so a --secondary-light / --secondary-dark pair would have told the next person
 * the opposite of the truth on the one theme where it matters. A name that has to be checked
 * against the value is worse than no name.
 *
 * The panel splits three ways, and for the reason below: --secondary-wash is the WASH
 * (the block behind an active or hovered row), --secondary-pill is the theme switcher's
 * track, and --secondary-edge is the LINE (the panel's
 * right border, the separators above the workspace links and the account menu, the scroll
 * thumb). They used to be one token, which held only while both wanted to sit on the same
 * side of the panel. On dusk they do not: the panel is mid-dark under light ink, so the wash
 * has to go DARKER — a lighter wash cuts the ink sitting on it, and dusk's sidebar muted was
 * down to 0.12 above AA because of exactly that — while a line has to go LIGHTER or it stops
 * being visible at all. One value cannot be two directions. Day and night keep both tokens
 * at the same value, because their washes and lines still agree; the token exists for dusk,
 * the same way the three accent tokens do.
 *
 * Three themes via <html data-theme="dusk|day|night">; dusk is the default and
 * the brand. Each theme is just one flat block of custom properties below — add
 * a theme by copying a block. Tokens are deliberately few: only the roles the
 * UI actually renders (no unused primary/tertiary scales).
 *
 * TYPE SIZES: four rungs, named for their role — --text-meta (13), --text-ui (14),
 * --text-content (16), --text-title (20), below. Every text size in the app is one of those,
 * the one markdown heading step that sits between two of them (18), or one of the two display
 * sizes (the logo at 24, and the 2rem one-big-line an empty page gets). That is SEVEN sizes for
 * the whole product — count them when you change this sentence, because it has been wrong here
 * before, and a miscount is exactly the kind of drift that lets an eighth in unnoticed. Holding
 * the count down is the point of the scale, not a side effect of it: a size is only a level if a
 * reader can tell it from its neighbour, so every extra rung dilutes the ones that were doing
 * real work. It has been ten, and the ten did not describe ten levels — they described four
 * levels plus six values nobody chose (a 1.05rem here, a 0.85rem there, a rung that existed
 * because one rule wanted to look slightly bigger).
 *
 * When something needs to stand out and no rung fits, the answer is usually NOT a new size.
 * The levers that cost no rung, roughly in order of reach: --weight-emph, the muted ink,
 * whitespace, a rule or an accent line. Rendered markdown headings inside .bubble.rendered are
 * the one place that legitimately owns sizes outside the scale — that is author content, not
 * chrome, and its ladder is stated at .bubble.rendered h1. Its top and bottom rungs happen to
 * land on --text-title and --text-content; only its middle step (18) is a size the chrome has
 * no use for, which is why that ladder is spelled in literals and not in tokens.
 *
 * Two merges are worth knowing about because they look like taste and are not. 12px went away
 * into 13: it was CJK's floor for comfortable reading and the app is routinely CJK, so the
 * rung cost legibility to buy a distinction (chip vs meta) that colour and casing already made.
 * And 15px went into 16 for the iOS input-zoom threshold — see --text-content.
 *
 * Two page families, deliberately different densities. A SINGLE PANE (.admin-pane — Settings'
 * categories, Tables, project settings) is a FOCUS page: you came here to do one thing. It is
 * centred with wide outer margins and opens with 3rem above its header, so the title carries the
 * weight of the whole page (and outranks the 2.25rem between two .admin-sections — space scales
 * with the strength of the boundary). A MASTER-DETAIL pane (.md-form / .md-head / .md-view--pad —
 * Schedules, Webhooks, Models) is a DENSE working surface: a list beside its detail, meant to be
 * scanned and switched through, so it keeps an even 1.75rem. Matching the two was tried and is
 * wrong twice over — it flattens that distinction, and a detail pane butts straight against the
 * master column, so its side inset is a real visual edge and a bigger top just reads lopsided.
 * What the two families DO share is the header vocabulary itself: .admin-head, same size, same
 * weight, same 0.9rem beneath. Same words, different density. Every pane names itself ONCE, with
 * its first head marked --page; there is no second, smaller label above that one. (There was —
 * .admin-crumb, for a pane that had to name a scope the chrome did not otherwise show — and it
 * outlived its cases one at a time until the last two were naming a table and a webhook that the
 * marked card beside them, the URL, and in one case the pane's own title were already saying.)
 *
 * Casing: text-transform: uppercase is for FIXED vocabulary the app itself chose — a column
 * label, a status word, a tab. Anything carrying a name the OPERATOR typed (a project, a
 * schedule, a webhook, a model) renders exactly as they typed it. Two reasons, and the second
 * is the load-bearing one. A name is a proper noun, so rewriting "iOS-app" as "IOS-APP" is the
 * UI editing the user's data. And this product's names are routinely mixed-script: CJK has no
 * case, so uppercase transforms only the Latin half — "Deepmind 新聞摘要" renders "DEEPMIND
 * 新聞摘要", half-shouted, which is worse than either casing chosen whole. What replaced the
 * uppercase everywhere it went: size, colour (muted) and position alone, with the letter-spacing
 * that made all-caps breathe going with it. Un-capsing also costs a rung of apparent size — caps
 * fill the em box to cap-height while lowercase only reaches x-height — so a label that stayed on
 * its old rung reads markedly smaller than it did.
 * Stated in rungs rather than pixels on purpose — the scale below has been rescaled once
 * already, and an argument written in px goes stale the moment the root moves.
 * ======================================================================== */

:root {
  /* Base size for the whole UI; everything else is in rem so this scales it — one number
     moves type AND the spacing around it together, which is why it is the first lever to
     reach for on readability. It sat at 15px for a while, chasing a tighter, more app-like
     density; that is the wrong trade for this product. The operator here is not a developer
     living in a dense tool all day (R003) and the text is routinely mixed CJK/Latin, where
     CJK glyphs carry far more stroke detail per em than Latin and lose it first when scaled
     down. 16px is also the browser's own default, so the app no longer sits a step under what
     an untouched browser would give.
     It is an absolute px and not 100%, which means a reader who RAISED their browser default
     still gets 16 — that is a real cost and it is here on purpose, not by oversight. Honouring
     a raised default scales --sidebar-w and the 22rem master column with it, and this layout
     has no width breakpoints at all: at a 1024px viewport a 20px default leaves the detail
     pane 224px and a 24px one leaves it 64px, i.e. honouring the preference would break the
     page for exactly the reader who set it. Browser zoom (which scales the viewport too) works
     correctly today and is the path that actually serves them. Switching to 100% is the right
     move the moment this grid earns breakpoints; until then it would be a promise we break. */
  font-size: 16px;
  /* Both stacks used to name a face nobody has: "Source Sans 3" here, "Cormorant Garamond"
     below. Neither ships with any OS and R007 rules out fetching one, so on almost every
     machine the browser silently fell through to the next name — but not on every machine,
     and that is the problem. A stack whose first entry is installed for a minority means the
     type we measure the line-height, the size scale and the contrast ratios against is not
     the type most readers get, and a few readers get something we never looked at. Naming
     only faces that are actually present makes the design basis the same thing everywhere.

     The CJK half of the stack needs a word, because it looks like it breaks that rule.
     system-ui already resolves to a face that renders Chinese on every desktop OS, so the
     three named CJK faces are not there to supply coverage — they are there because
     system-ui does NOT pick a CJK face on Linux, where it lands on the fontconfig sans
     default and what that resolves to depends on which packages are installed. PingFang TC
     (macOS/iOS) and Microsoft JhengHei (Windows) ship with their OS and only ever confirm
     what system-ui would have chosen anyway; Noto Sans TC is the one that does real work,
     because it is the face a Linux desktop with CJK support actually has. It is also the one
     that is not guaranteed — but the fallthrough is sans-serif, i.e. the same place we would
     have landed without it, so naming it costs nothing and rescues the case it covers. That
     is the difference from "Source Sans 3" above: that name was FIRST, so a minority of
     machines got type we never looked at. These are LAST, so no one gets anything worse.

     They are Traditional Chinese faces (TC), not SC, and that is deliberate — R003's audience
     is Taiwanese small teams. A Simplified face renders the same code points with different
     glyph shapes for a good number of characters, so the ordering here is a locale choice.

     One thing to check before changing any of this: whether every face that can resolve here
     ships a Medium (500) master. --weight-emph below is 600 because one of them does not, and
     one is enough — see its note. A stack change could make that reasoning stale in either
     direction, so re-derive it there rather than assuming it still holds. */
  --font-body: system-ui, -apple-system, "Segoe UI", Roboto,
               "Noto Sans TC", "PingFang TC", "Microsoft JhengHei", sans-serif;
  --font-logo: Georgia, "Times New Roman", serif;
  --font-mono: "SFMono-Regular", "Cascadia Code", Consolas, "Liberation Mono", Menlo, monospace;
  --radius: 2px;
  /* Width of an accent indicator line — the terracotta rule that marks a selected nav row
     (its left bar) and a quoted passage (a blockquote's left rule). Accent lives on lines,
     not on fills: a crisp rule reads clearer than a faint wash yet paints less area, so the
     UI gains identity without getting loud (R007 restraint-with-accent). */
  --rule: 2px;
  --sidebar-w: 18rem;

  /* The plate that sits behind an image on the dark theme (see .bubble.rendered img). It lives
     here rather than in a theme block because it is not a theme colour: it is the light page
     that published line art was drawn for, which is why the light themes have no use for it.
     A warm off-white rather than pure #fff so a rescued figure still reads as part of this paper.
     --image-plate-ink is the text colour that goes ON that paper — it only ever shows when the
     image fails to load and the browser paints its alt text inside the box. */
  --image-plate: #fbf8f1;
  --image-plate-ink: #1e1c14;

  /* Type scale — see header note. FOUR rungs, named for the ROLE each one plays rather than
     for its size, because that is what a rule is choosing when it reaches for one. The names
     used to be sizes (2xs/xs/sm/base) over four rungs, which stopped working twice over: with
     the top rung at 1rem, "base" no longer meant a base of anything, and a size name gives the
     next person nothing to check a new rule against — "is this xs or sm?" has no answer, while
     "is this meta or chrome?" does.
     The rem values are exact integer px at the 16px root rather than the approximations left
     over from the 15px one (0.733/0.8/0.867/0.933rem), which landed on fractional pixels and
     rounded differently per browser. */
  --text-meta: 0.8125rem; /* 13px — status words, tags, crumbs, card meta, hints */
  --text-ui: 0.875rem;    /* 14px — buttons, links, the whole sidebar, general chrome */
  /* 16px, and it was 15. Not a readability tweak: iOS Safari ZOOMS THE PAGE when you focus an
     input whose font-size is under 16px, and there is no way to opt out that does not also
     disable pinch-zoom (which would be a worse trade). Every text field in the app takes this
     step — that means EVERY one, not just the settings forms: the filter bar, the webhook
     mapping rows, the instructions editor and the task note are fields too, and each of them
     sat on --text-ui until a phone tap on it zoomed the page. A field is a field regardless of
     which pane it is in; the rung is chosen by the threshold, not by how chrome-like the
     surrounding block feels. 15 was one pixel under and bought nothing for it. The other users
     of this rung are the
     NAMES in the app — a card's title, a row's label, the chat header — and a name reading at
     the same size as body prose is right anyway; it is content, not chrome. */
  --text-content: 1rem;   /* 16px — form inputs, names, content-level text */
  /* 20px — the title of a PAGE, as opposed to the title of a section inside one (.admin-head,
     which stays on --text-content). Two rules take it: a settings/table pane's top head and the
     task inbox's. It was a literal 1.25rem at both, plus a third time in the markdown ladder,
     which is how a size gets to exist without anyone having decided it is a level — the header
     note calls that out by name, and this rung was the file's own live example of it. */
  --text-title: 1.25rem;

  /* The one weight above body that this UI uses — a card's name, a table header, an active
     row, a heading. 600 and not 500, which is what these were, because 500 is a NO-OP on ONE
     of the faces this app resolves to, and one is enough: Microsoft JhengHei carries
     Light/Regular/Bold and nothing between, so on Windows a 500 CJK glyph renders identically
     to 400 while the Latin half of the same line DID thicken — a mixed-script name coming out
     half-emphasised, which is worse than no emphasis at all. The claim is deliberately narrow:
     PingFang TC (macOS/iOS) and Noto Sans CJK (Linux) both DO ship a 500 master, so this is not
     "CJK has no Medium" — it is one platform, and the app has to look the same on all three.
     600 rounds to Bold on the face that lacks 500 and to Semibold on the ones that have it,
     landing on both halves everywhere.

     Note which way the matching runs, since it is the opposite of the intuition: CSS Fonts 4
     §5.2 searches UPWARD first for a desired weight above 500, so 600 falls to 700 and never
     to 400. A desired 500 searches up only as far as 500 and then downward, which is why the
     old value collapsed to 400 rather than climbing to 700.

     It is a token rather than the number typed at a dozen rules because the value is derived
     from what the font stack actually ships, not chosen by eye — if that stack changes, this
     is the one place to re-derive. */
  --weight-emph: 600;

  /* The step of quiet a sidebar utility row sits at, relative to the project names above it.
     A token rather than the number typed at each of the six rows that take it, because it is
     not a taste value: it is the largest fade --on-secondary can carry and still clear the 7:1
     tier (0.85, where these rows started, composited back down to 6.89–6.95), so it moves with
     the panel and ink values. See .proj-tasks for the derivation. */
  --nav-row-fade: 0.92;

  /* How far apart two neighbouring avatar discs sit on the hue circle (see .msg.assistant
     .avatar). It is 360deg / avatarPaletteN in serve.go — a DERIVED number, and it is spelled
     as a token rather than typed into the calc() because that division is the one part of the
     ladder that fails silently: raise the palette to eight and the hues keep marching 60deg
     apart, wrap past the anchor and start repeating colours, with nothing on screen saying so.
     TestAvatarLadder asserts the two stay in step. */
  --av-step: 60deg;

  /* Activity-block type marks (see .ic): monochrome line SVGs, inlined (no web font,
     no CDN — same offline rule as the KaTeX fonts). Painted via CSS mask so they take
     currentColor; a bolt marks a tool call, a sparkle marks a thinking block. */
  --ic-bolt: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round' stroke-linecap='round'%3E%3Cpolygon points='13 2 3 14 12 14 11 22 21 10 12 10 13 2'/%3E%3C/svg%3E");
  --ic-think: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z'/%3E%3Cpath d='M18 16l.7 1.8L20.5 18.5l-1.8.7L18 21l-.7-1.8L15.5 18.5l1.8-.7z'/%3E%3C/svg%3E");

  /* Chat-header action marks, same monochrome-mask family as above: an outline
     thumbtack for "pin this conversation", a filled one once it's pinned, two chevrons
     closing onto a line for compact, and a trash can for delete. */
  --ic-pin: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 17v5'/%3E%3Cpath d='M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z'/%3E%3C/svg%3E");
  --ic-pin-filled: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='black' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 17v5' fill='none'/%3E%3Cpath d='M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z'/%3E%3C/svg%3E");
  --ic-fold: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M8 5l4 4 4-4'/%3E%3Cpath d='M8 19l4-4 4 4'/%3E%3C/svg%3E");
  --ic-trash: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 7h16'/%3E%3Cpath d='M6 7V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v2'/%3E%3Cpath d='M6 7l1 13a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-13'/%3E%3C/svg%3E");
  /* A failed tool's mark: a warning triangle that replaces the bolt (see .tool.errored),
     same monochrome-line family but painted --error instead of currentColor. */
  --ic-warn: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z'/%3E%3Cpath d='M12 9v4'/%3E%3Cpath d='M12 17h.01'/%3E%3C/svg%3E");
  /* "Copy raw markdown" under an assistant reply, and the check it flashes to on a
     successful copy — same monochrome-mask family as the marks above. */
  --ic-copy: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='14' height='14' x='8' y='8' rx='2' ry='2'/%3E%3Cpath d='M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2'/%3E%3C/svg%3E");
  --ic-check: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
  /* Attach a picture, and drop one that is waiting to be sent. The mark is a PICTURE, not the
     usual paperclip: only images can be attached (see image.go), and a paperclip promises
     "any file" — for someone who is not going to read a spec, the icon is the spec. */
  --ic-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='16' rx='2'/%3E%3Ccircle cx='8.5' cy='9.5' r='1.5'/%3E%3Cpath d='M21 16l-5-5-6 6-2-2-5 5'/%3E%3C/svg%3E");
  --ic-x: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18M6 6l12 12'/%3E%3C/svg%3E");
  /* Send, in the composer, beside the attach picture icon above — same monochrome-mask family
     so the two share this one size/colour system instead of the send arrow being sized on its
     own (it used to be a bare inline SVG at a hardcoded 20px next to this icon's 0.92em, which
     read visibly heavier for no reason anyone had decided on). */
  --ic-send: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='12' y1='19' x2='12' y2='5'/%3E%3Cpolyline points='5 12 12 5 19 12'/%3E%3C/svg%3E");
  /* Stop, in the same composer slot as Send above (chat.html swaps the two on TurnLive) — a
     filled square rather than the family's usual stroked line, the shape every media transport
     already uses for "end this", so it reads at a glance without the label doing the work. */
  --ic-stop: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='6' y='6' width='12' height='12' rx='2' fill='black'/%3E%3C/svg%3E");
}

/* ── Dusk 🌅 (default, brand) ── */
:root,
[data-theme="dusk"] {
  color-scheme: light;
  /* The brand, and on dusk also the accent AS A LINE ON PAPER: a 2px identity rule owes 3:1,
     not the 4.5:1 a word owes, so drawing it with --accent-text pressed it 1.7x deeper than its
     own floor — which is what made the quote's rule and the nav's bar read as two colours rather
     than one accent (see the blockquote note). Deepened from #c96442, which measured 2.85-3.07
     on the three paper grounds, i.e. under the line floor on two of them: --primary could not
     actually do the decoration job the header claims for it. At #c45f3d it clears all three
     (3.04 quote tint / 3.09 --surface-alt / 3.27 --surface). Not deeper, and the reason is the
     brand rather than a ratio: nothing sits ON this fill any more (the avatars moved to their
     own ladder, see --av-h), so no ink is paying for the depth — what stops it is that each
     step walks the terracotta further from the wordmark it is supposed to be (R007 locks the
     brand). One step clear of the floor is what the line needs; the rest would be spent on
     nothing. This used to be argued from --on-primary landing at 4.53 a step deeper, which was
     a real constraint until the token it named stopped existing. */
  --primary: #c45f3d;        /* terracotta */
  --accent-text: #94482d;    /* accent AS TEXT — 5.20 on --surface, 4.91 on --surface-alt */
  /* accent ON CHROME — the same terracotta lifted until it clears 3:1 on the taupe panel
     (4.18 on --secondary, 5.45 on --secondary-wash). Dusk is the theme that needs a lifted
     accent rather than a deepened one; see the --accent-chrome note in the header. How far it
     is lifted is set by the PANEL, not the wash: one step back toward the brand terracotta
     (#e87d55) measures 3.28 there and the next (#dd7350) 2.91, i.e. under the floor. Bringing
     this closer to --primary means deepening the panel itself, which is a different change. */
  --accent-chrome: #ff8f66;
  --secondary: #4f4539;      /* sidebar — lighter warm taupe, much closer to the main */
  /* The wash, and the one theme where it goes DARKER than the panel (see the --secondary-edge
     note in the header). Everything sitting on it is light ink, so a lighter wash spent the
     contrast twice: it was #5e5247, where the sidebar's muted text measured 4.62 post-grain —
     0.12 above AA, the tightest number in the app. Going the other way costs nothing and buys
     everything: the step off the panel is 1.31 rather than 1.24 (a CLEARER "you are here"),
     the sidebar ink goes 6.97 -> 11.20, the muted 4.62 -> 7.43, the accent bar 3.31 -> 5.32. */
  --secondary-wash: #3c342b;
  --secondary-edge: #5e5247; /* the line — unchanged, and now 1.61 against the wash beside it */
  /* The theme switcher's track. Note it is LIGHTER than --secondary-wash above on this theme
     and darker on the other two — which is why neither is named for its lightness. */
  --secondary-pill: #41382f;
  --surface: #ede5d3;        /* main bg */
  /* input, hovers, active cards, table headers. Held at 1.06 from --surface, not the 1.13 it
     used to be: at #e2d8c2 the step was already too weak to read as a block, but deep enough
     to take 0.3-0.4 off every ink that lands on it — which put four values (the accent as
     text, error, ok, and muted) between 0.01 and 0.43 above AA. Both light themes run this
     step at 1.06-1.07 and structure comes from --line instead; dusk was the outlier paying
     for a distinction it never actually made. */
  --surface-alt: #e7dfcd;
  --on-surface: #1e1c14;   /* 13.60 on --surface, 12.86 on --surface-alt */
  --on-surface-muted: #5f5747; /* 5.70 on --surface, 5.38 on --surface-alt */
  --on-secondary: #fff8ea;   /* 8.86 on --secondary, 11.56 on --secondary-wash */
  /* This used to be the one token with no headroom for the paper grain (see body::before): at
     #d0c7ba it measured a nominal 4.53 on the old lighter wash and painted 4.43 once the overlay
     landed on it — the app's quietest text, under AA, for a texture. It was lifted then by
     equal-ratio channel scaling (208:199:186 became 213:203:190), which holds the warm grey.
     Darkening the wash is what actually removed the constraint: the binding ground is now the
     PANEL at 5.85, not the wash at 7.63, so this value is no longer pinned from below. */
  --on-secondary-muted: #d5cbbe; /* sidebar muted — 5.85 on --secondary, 7.63 on --secondary-wash */
  /* Carries the structure --surface-alt gave up (see it above): 1.56 on --surface, 1.48 on
     --surface-alt, where it used to be 1.31/1.16 — a table whose grid you had to look for.
     Same move day made, and the same reason: draw the structure, do not block it in. */
  --line: #c6b899;
  /* --control-edge is --line's louder sibling, and the split is the whole point: --line DIVIDES
     (a table rule, a card's outline) and may sit near 1.5 because losing it costs nothing but
     tidiness, while --control-edge is the resting border of something a person is meant to PRESS.
     A button's shape IS its affordance — the one cue saying "this is a control" — so WCAG 1.4.11
     asks 3:1 of it, which --line never met (1.36–1.56: a border you have to go looking for). It is
     --on-surface-muted stepped toward the paper, solved on both grounds like every ink here, and
     it clears 3 with the grain overlay's ~0.1 still to pay. */
  --control-edge: #7b7363;   /* 3.74 on --surface, 3.54 on --surface-alt */
  /* TEXT — a failed run's timestamp, an alert status word, a form's refusal — so it is solved
     on both grounds like every other ink here. It was the last one still measured against
     --surface alone (4.55), which put it at 4.03 on --surface-alt, i.e. under an active card.
     Darkened by the same equal-ratio channel scaling the body ink uses, so the red stays red
     (179:64:42 became 164:58:38). Shared with Day, where the near-white surfaces put it at 6.48
     — comfortably clear either way. It had a sage counterpart, --ok, for save confirmations;
     that pairing was the mistake the header's outcome-colour note describes, and the
     confirmation is muted ink now. */
  --error: #a43a26;          /* 5.22 on --surface, 4.93 on --surface-alt */
  /* Chat avatars — the ladder (see .msg.assistant .avatar). The HUE is --primary's own, so
     Flology's disc is the brand hue by construction; its lightness and chroma are the ladder's,
     shared with the six people discs, so the disc is a relative of --primary rather than a copy of
     it (#c45f3d is far more saturated than L .53 / C .085 — putting the brand value itself in
     the ring would have made one disc shout beside six quiet ones, which is the bug this
     replaced). The seven discs measure 4.98–5.41 under the white glyph and 3.98–4.32 against
     the paper. */
  --av-h: 39deg;
  --av-l: 0.53;
  --av-c: 0.085;
  --av-ink: #ffffff;
}

/* ── Day ☀️ — one sheet of paper, structure drawn on it ──
 *
 * Day is the theme where the SIDEBAR IS NOT A SEPARATE COLOUR. Its panel sits 1.04 from the
 * paper — the same gap night runs — so the left column reads as the same sheet, and everything
 * that used to be carried by a darker panel is carried by edges instead: the right border and
 * the active row's wash (--secondary-wash, 1.27 on the panel) and the grain of --line (1.48).
 * That is a deliberate three-way split rather than a middle ground: dusk is the theme with two
 * grounds (7.47), day and night are the themes with one. The value it replaced, #f0ede6 at 1.16,
 * was the worst of the three — too close to read as a panel, too far to read as the same paper —
 * and it also pushed day toward being a washed-out dusk instead of its own thing.
 *
 * The accent is the brand terracotta — the same value dusk carries — and not a hue of day's own.
 * Day is the theme that least needs one: it is the daylight default, the sheet everything else is
 * measured against. The vermilion it used to run (#c1272d, chroma .189 against the brand's .138)
 * made the theme with the least to say the loudest colour in the app, which is backwards for a
 * system that spends accent sparingly.
 *
 * This is not a retreat to the honey that came before the vermilion. Honey could not be ONE value:
 * yellow at a lightness that clears 4.5 as text is no longer yellow, so the old pair was a bright
 * #d4951a as a fill and a bronze #92650a as text and as chrome, and a reader sees two colours
 * rather than one accent used twice. That is a physical limit, not a tuning problem — at L≈31%
 * every yellow IS bronze — and it is why day has no warm hue available to it that is not already
 * something else's. The terracotta has no such limit: it clears every ground this theme has at one
 * value (below), so all three accent tokens hold it. */
[data-theme="day"] {
  color-scheme: light;
  /* One value in all three roles — 5.41 on --surface, 5.05 on --surface-alt as text; 5.18 on
     --secondary, 4.07 on --secondary-wash, 3.45 on --secondary-pill as a state line (owes 3:1).
     See the three-accent note in the header for why day can do this and dusk cannot. */
  --primary: #a94f31;        /* brand terracotta */
  --accent-text: #a94f31;
  --accent-chrome: #a94f31;
  --secondary: #fbf9f4;      /* sidebar — 1.04 from --surface: the same sheet, not a panel */
  --secondary-wash: #e4ded0; /* active row's wash — 1.27 on --secondary. With --secondary-edge
                                 below, this pair is what draws the left column now. */
  --secondary-edge: #e4ded0; /* same value: on a near-white panel the wash and the line still
                                want the same side (see the header note) */
  --secondary-pill: #d5cdbd; /* theme pill — 1.50 on --secondary */
  --surface: #fffefa;
  --surface-alt: #f8f6f1;
  --on-surface: #363122;   /* 12.85 on --surface, 12.01 on --surface-alt */
  --on-surface-muted: #6d6555; /* 5.71 on --surface, 5.34 on --surface-alt */
  --on-secondary: #3a3424;  /* text on the light sidebar — 11.76 on --secondary, 9.23 on --secondary-wash */
  --on-secondary-muted: #5e5b4f; /* sidebar muted — 6.47 on --secondary, 5.08 on --secondary-wash */
  --line: #dcd3bd;           /* 1.48 on --surface, 1.38 on --surface-alt — deepened with the
                                panel, since structure here is drawn rather than blocked in */
  --control-edge: #8a8476;   /* 3.69 on --surface, 3.45 on --surface-alt — see Dusk for why */
  --error: #a43a26;          /* 6.48 on --surface, 6.05 on --surface-alt */
  /* Chat avatars — same ladder, anchored on the accent's hue, which is now the brand's, so the
     seven discs are dusk's discs by construction: 4.98–5.41 under the white glyph, and 4.95–5.36
     against day's lighter paper. */
  --av-h: 39deg;
  --av-l: 0.53;
  --av-c: 0.085;
  --av-ink: #ffffff;
}

/* ── Night 🌙 (Deep Ocean) ── */
[data-theme="night"] {
  color-scheme: dark;
  --primary: #6aadba;        /* muted teal blue */
  --accent-text: #6aadba;    /* accent AS TEXT — 7.00 on --surface; on a dark theme the
                                accent is already the light value, so it needs no deepening */
  --accent-chrome: #6aadba;  /* accent ON CHROME — 7.28 on --secondary, 5.73 on --secondary-wash;
                                night's sidebar is darker than its paper, so the same value clears
                                there too. All three accent tokens coincide on night by construction. */
  --secondary: #12141a;
  /* Night runs the same one-sheet structure as day (see its block): the panel sits 1.04 from
     the paper, so the active row's wash and the sidebar's right border are what actually draw
     the left column — and at #1e2028 they were only 1.13 from the panel, which on a dark ground
     is close to nothing. Lifted to the 1.27 day now uses, so the two one-sheet themes have the
     same step. Everything above it still clears: the sidebar ink 11.38, the muted 4.84, the
     accent line 5.73. Dark grounds compress ratios, so a step that reads has to be measured
     against what sits ON it, not against how big the hex jump looks. */
  --secondary-wash: #262933;
  --secondary-edge: #262933; /* same value: night's panel is near-black, so the wash and the
                                line both have to go lighter (see the header note) */
  --secondary-pill: #0a0b10;
  --surface: #161820;
  --surface-alt: #1c1e26;
  --on-surface: #e0e4ec;   /* 13.90 on --surface, 13.05 on --surface-alt — already clears 12 */
  --on-surface-muted: #8890a0;
  --on-secondary: #e0e4ec;  /* 14.45 on --secondary, 11.38 on --secondary-wash */
  --on-secondary-muted: #92959c; /* sidebar muted — 6.14 on --secondary, 4.84 on --secondary-wash.
                                    Lifted with the wash below it: at #8a8d94 it measured 4.89 on
                                    the old wash and would have landed at 4.32 on the new one. */
  --line: #32353f;           /* 1.45 on --surface, 1.36 on --surface-alt — was 1.27/1.19, i.e. a
                                table whose grid you had to look for. Same move as day's --line. */
  --control-edge: #717886;   /* 3.99 on --surface, 3.75 on --surface-alt — see Dusk for why */
  --error: #f08a7a;          /* 7.27 on --surface, 6.83 on --surface-alt */
  /* Chat avatars — same ladder, and the one theme where it runs LIGHT discs under a dark glyph,
     the way this theme's own paper and ink already run. The light themes' L .53 would put a disc
     at 1.4–1.6 against this paper: not a shape, just a letter floating. At L .70 it is 6.29–6.73,
     and the glyph 6.55–7.01. The anchor lands on #68aab7, which is --primary (#6aadba) to within
     a rounding step — the ladder reproduces the value this theme already used, rather than
     replacing it. */
  --av-h: 211deg;
  --av-l: 0.70;
  --av-c: 0.070;
  --av-ink: #12141a;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  color: var(--on-surface);
  background: var(--surface);
  font-family: var(--font-body);
  font-weight: 400;
  /* 1.6, not the 1.5 this started at. CJK glyphs fill the em box top to bottom where Latin
     lowercase only reaches x-height, so a leading that looks airy in English reads cramped
     the moment a line is half Chinese — and every line in this UI can be. */
  line-height: 1.6;
  /* Let the browser pick the break points instead of taking whatever falls out of the box width.
     `pretty` inherits into everything below, and only touches the END of a paragraph: it pulls a
     word down rather than leaving a one-word last line. Costs nothing where text already fits on
     one line, which is most of this UI; where it doesn't — empty states, hints, the transcript's
     prose — a dangling orphan is exactly the thing that makes a short paragraph read as broken.
     Headings override to `balance` below. Both degrade to plain wrapping on engines without them. */
  text-wrap: pretty;
}

/* Headings balance instead: even line lengths across the whole block, not just a tidy last line.
   This is the editorial half of Warm Editorial (R007) — a two-line pane title breaking 9 words /
   1 word is the same defect a magazine deck would never ship, and these titles are user data
   (project, schedule and table names), so the bad break is not one we can hand-tune away.
   Every heading in the app is a real h1–h3 except these three, which are a <header> and two <div>s
   playing the same role; they are listed rather than re-tagged because their tag is load-bearing
   where they sit. Browsers give up balancing past ~4 lines on their own, so a long markdown heading
   in the transcript quietly falls back to normal wrapping. */
h1, h2, h3, h4, h5, h6,
.md-head,
.runs-head,
.proposal-head {
  text-wrap: balance;
}

/* Headings and inline emphasis take the app's one emphasis weight instead of the UA's bold.
   Without this, anything that forgets to say renders at 700 — a value the header's "two weights"
   claim does not cover, that no token moves, and that lands beside a 600 sibling as a difference
   nobody chose: .inbox-title and .admin-head--page are both 20px page titles, and they were
   700 and 600; the one <strong> outside rendered markdown (table-schema.html's drop-table
   confirmation) was the app's only 700, sitting in the sentence that asks you to approve
   destroying a table. strong/b is reset here rather than in each context that has one, so a
   template can reach for the element the sentence actually means without also having to know
   which stylesheet block it landed in. Scoped to h1–h6 and not to the three div-headings above,
   whose children set their own weights and would inherit this one. */
h1, h2, h3, h4, h5, h6 { font-weight: var(--weight-emph); }
strong, b { font-weight: var(--weight-emph); }

/* A whisper of paper grain over everything (the one purely decorative element).
 *
 * Over, and it stays over — but it is not free. A fixed overlay composites onto every static
 * box in the app, so it costs about 0.1 of contrast everywhere; dusk's sidebar muted ink read
 * a nominal 4.53 and actually painted 4.43, i.e. the app's quietest text was under AA because
 * of a decoration. Putting the grain UNDER the content (z-index: -1) was tried and is worse
 * than it sounds: the sidebar, the cards, the composer and every input paint an opaque
 * background, so "under" means the texture survives only on bare canvas — silently deleting
 * the thing while appearing to keep it. So the grain is a GROUND like any other, and the one
 * token that had no room for it was given room (see dusk's --on-secondary-muted). Raise this
 * opacity and that headroom has to be re-derived; it is not a free dial. */
body::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  opacity: 0.025;
  background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}

.app {
  display: grid;
  grid-template-columns: var(--sidebar-w) 1fr;
  height: 100vh;
  /* The shell is full-bleed by design (edge-to-edge panels, no page margin), which on a notched
   * or rounded screen means the sidebar's first nav row and the composer's last line sit under
   * the hardware. env() is 0 everywhere else, so this costs nothing on the desktop this app is
   * mostly used on. Padding rather than margin: box-sizing is border-box above, so the grid
   * still fills exactly one viewport. */
  padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
}

/* Text for the screen reader only — a name for something the eye reads from position or shape,
 * and the count of a list that changed on its own. Not `display: none` and not `visibility:
 * hidden`: both take it out of the accessibility tree too, which is the whole point of it. The
 * 1px-clipped box is the long-standing recipe that survives every engine. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* The bypass link (index.html explains why the app owes one). Moved off the top edge rather than
 * hidden: `display: none` and `visibility: hidden` are not focusable, so the link would exist and
 * never be reachable — the one bug this pattern has. It wears the app's own form language (an
 * --accent-text edge on --surface, 2px radius) instead of a foreign default-blue bar, and it is
 * the only fixed element above the grain overlay, so it needs a z-index at all. The landing ring
 * comes free: #chat picks up the global :focus-visible outline, which is what tells you the jump
 * actually moved focus rather than just scrolled. */
.skip-link {
  position: fixed;
  top: 0.5rem;
  left: 0.5rem;
  z-index: 50;
  transform: translateY(-200%);
  padding: 0.45rem 0.85rem;
  border: var(--rule) solid var(--accent-text);
  border-radius: 2px;
  background: var(--surface);
  color: var(--accent-text);
  font-size: var(--text-ui);
  font-weight: var(--weight-emph);
  text-decoration: none;
  transition: transform 0.12s ease;
}

/* Opting out of the shared ring for the reason the text fields do (R007 同類走既有元件 cuts both
 * ways): an inset outline sitting exactly on the inside of a 2px accent border reads as one 4px
 * band, not as focus. The border is already the indicator, and appearing at all is the rest of it. */
.skip-link:focus { transform: translateY(0); outline: none; }

/* Every scroller in the shell, in one rule rather than a line repeated in six blocks (the same
 * consolidation the field-focus rules got). Reaching the bottom of a transcript, a nav column or
 * a detail pane must not hand the rest of the gesture to whatever is behind it — on a trackpad
 * that reads as the panel "sticking" and the page lurching; on a touch screen it is the pull-to-
 * refresh that throws away the conversation you were reading. `contain` stops the chaining and
 * nothing else: these panels still scroll exactly as before. */
.nav-scroll,
.transcript,
.composer textarea,
.proposal-prompt,
.md-panel,
.md-view {
  overscroll-behavior: contain;
}

/* ── sidebar ─────────────────────────────────────────── */
.sidebar {
  display: flex;
  flex-direction: column;
  background: var(--secondary);
  color: var(--on-secondary);
  border-right: 1px solid var(--secondary-edge);
  padding: 1.25rem 0.5rem 1rem;
  overflow: hidden;
}

/* The wordmark. It declares the body's own 400 because it is an <h1> and would otherwise take
   the heading reset above — and 400 is what it has always rendered at: it used to ask for 500,
   but Georgia ships 400 and 700 only, and CSS Fonts 4 §5.2 resolves a desired 500 downward to
   400 once nothing at 500 exists. The literal was describing an intent the font could not carry
   out; the number here is the one on screen. */
.logo {
  font-family: var(--font-logo);
  font-size: 1.5rem;
  font-weight: 400;
  letter-spacing: 0.03em;
  margin: 0 0 1rem;
  color: var(--on-secondary);
}
/* The logo links home; padding lives on the anchor so the hover highlight is a tidy chip
 * around the wordmark (and the text keeps its original x-position alongside the nav rows). */
/* The logo links home; like most apps it gets no hover chrome — a background swap on the
 * wordmark reads as a button and looks off. Just the pointer cursor (inherited from <a>). */
.logo a {
  display: inline-block;
  padding: 0.15rem 0.55rem;
  color: inherit;
  text-decoration: none;
}

.nav-scroll {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 0.85rem;
  /* right gutter so rows / active highlight don't butt against the scrollbar */
  padding-right: 0.4rem;
}

.nav-scroll::-webkit-scrollbar { width: 4px; }
.nav-scroll::-webkit-scrollbar-thumb { background: var(--secondary-edge); border-radius: 2px; }

/* ── project tree (two levels: project › its conversations) ── */
/* ONE SIZE FOR THE WHOLE NAV, and the project name is where that was decided.
 *
 * This column used to run four sizes — the project title at the inherited 1rem (16px, never on
 * the scale at all: nothing declared it, it was just whatever body left behind), its utility
 * rows at 15, the workspace links at 14, the conversation previews at 13 — inside 18rem of
 * width. That is a ladder built out of 1px rungs, and 1px cannot carry "which of these
 * contains which": at a glance it reads as rows that failed to line up rather than as depth.
 * The squint test is the standard version of the same check — blur the panel, and if two
 * levels stop being two levels, the size difference was never doing the work.
 *
 * What actually marks depth here is already present and is not size: the disclosure chevron,
 * the accordion opening, the indent-free grouping under an open project, the accent bar on
 * the active row, and the --nav-row-fade step between a name and the quiet rows under it. Size
 * was the redundant lever, so it is the one that went.
 *
 * The name also used to keep --weight-emph, argued for here as the one signal that survives
 * being blurred. That argument was answered by the list it was made about: this column holds
 * one summary per project, so the weight it "kept" is worn by every name in the column and
 * therefore separates none of them from another — it only raises the whole column against the
 * rest of the app (see the header on where the weight is spent). The blur test still passes on
 * the fade step alone, which is what actually distinguishes a name from the rows beneath it.
 *
 * --text-ui and not "14px": the scale below has been rescaled once already, so the rung is
 * the durable statement and a pixel count is not. Sizing the nav at the same rung as buttons
 * and links is also the point — the sidebar is chrome, and chrome is one register. */
.project > summary {
  list-style: none;
  cursor: pointer;
  user-select: none;
  display: flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.4rem 0.55rem;
  /* Square, full stop — see .current-project > summary below for why. */
  border-radius: 0;
  border-left: var(--rule) solid transparent;
  font-size: var(--text-ui);
  /* No font-weight: a workspace has as many of these as it has projects, so it is a list row
     (see the header). It already outranks the utility rows under it three ways — a disclosure
     triangle, full contrast against their --nav-row-fade, and the fact that they are indented
     beneath it. It was also the heaviest text in the whole sidebar, above a .logo that declares
     400 on purpose: the chrome's loudest word was a project name. */
  /* Sticky, because .nav-scroll scrolls and this row is where the app says which project you
     are in on a screen whose pane does not (a run names its schedule, not its project). A long
     session list would carry it off the top and take that answer with it. It sticks inside its
     own <details>, so the next project pushes it out rather than stacking. It costs no scroll
     range — a sticky box
     stays in flow; measured with 16 sessions in a 420px nav, scrollHeight and the reachable
     bottom are identical with it on and off. The opaque background is what it needs to sit
     over the rows passing beneath. :hover below is the only rule that overrides it — the
     current project is marked with the border-left slot now, not a fill, so the marked
     summary rests on this exact value like every other one. */
  position: sticky;
  top: 0;
  z-index: 1;
  background: var(--secondary);
  transition: background-color 0.15s ease;
}

.project > summary::-webkit-details-marker { display: none; }

/* disclosure chevron, rotates when open. Its box is the SAME 14px the child rows' leading
 * icons use (proj-new/schedules/settings), so — now that the children sit flush with the
 * title — the chevron lines up with those icons and the title text lines up with the child
 * text (the "›" centered in the box, like each icon is centered in its own). */
.project > summary::before {
  content: "›";
  flex: 0 0 14px;
  text-align: center;
  font-size: 1rem;
  line-height: 1;
  opacity: 0.55;
  transition: transform 0.15s ease;
}

.project[open] > summary::before { transform: rotate(90deg); }
.project > summary:hover { background: var(--secondary-wash); }

/* The project you are IN. It used to mark itself by being open, which held while every pane also
 * named its project in a crumb above the title — the run page no longer does, and this row is
 * what answers for it. Being open cannot carry that: a person can open another project to look
 * around WITHOUT leaving this one, so open means "I am reading this", not "I am here". The class
 * comes from the URL, which is also what marks the active row below, so the two can never
 * disagree. TWO writers, one rule: the server puts it in the first paint (shellData in serve.go)
 * and app.js keeps it right across htmx navigation, which never re-renders this list — change
 * where the mark comes from and both halves have to move. app.js additionally closes the
 * peeked-open projects when you actually navigate somewhere else, which is the job the browser's
 * exclusive accordion used to do on the wrong event (see nav-projects.html).
 *
 * The accent bar and nothing else — the border-left slot every row in this column already
 * reserves. It spent one revision as a wash instead, on the reading that the current project is a
 * CONTAINER (a fill) and the current row a line inside it, one accent between them. Two things
 * sank that:
 *
 * First, --secondary-wash was already spoken for twice over. It is what :hover paints, and what
 * .active paints, so the mark for "you are in this project" was the same colour as "you are
 * pointing at this" — and on this row specifically that collision has a cost you can measure:
 * hovering the marked project produced NO feedback at all, because the hover rule one line up
 * paints the colour it already had. R007's usable floor asks for an answer to the pointer, so
 * this was a defect, not a preference.
 *
 * The same collision was costing every .active row the same answer, and was fixed in the same
 * sweep rather than left standing as this note's counter-example — see .active:hover below.
 *
 * Second, "container" only held in the one arrangement where it also failed to read. The fill
 * covers the summary alone, never the rows under it — so when the active row sits directly below
 * (＋ New session, the commonest case) two identical washes touch and the boundary between "the
 * project" and "the row" is undrawn; and when the active row is further down the list, the
 * summary's wash is a lone block with five unfilled rows between it and the other one, which
 * reads as a second active row rather than as anything containing anything.
 *
 * So the fill goes back to meaning exactly two things — hover, and the active row — and the bar
 * carries identity for both the project and the row inside it. When the active row IS the first
 * child the two bars meet into one two-row line: that is the arrangement to look at before
 * changing this back, and it is deliberate. The pair is a path (this project, this row), not two
 * markers colliding — the children sit flush with the title on purpose (see below), so a bar that
 * runs from the container onto the row is continuous by construction rather than by accident.
 *
 * That continuity is also why every row in this column is SQUARE — border-radius: 0, not just on
 * the bar's side. border-radius rounds the border-left with it, so at --radius the bar pinches at
 * both ends and the two-row line meets itself through a waist — the one arrangement this note
 * calls deliberate was the one the corner was quietly undrawing. Rounding only the bar's side
 * (`0 var(--radius) var(--radius) 0`) fixed the bar but left the .active wash rounded on the far
 * edge, which is worse once you see it: these rows run edge-to-edge in a column exactly one row
 * wide, so a rounded far corner has no card underneath it to belong to — it reads as a stray
 * corner, not a shape. A row that spans the full column has nothing to round against, so the
 * three rows that reserve the bar slot (.project > summary above, the .nav-scroll button utility
 * row, and the .settings-nav-item/.ws-account-item row that shares its shape) go to `0`. Every
 * remaining accent line (.md-navitem, .md-card, .run-row, .auth-error) already carries no radius;
 * .bubble.rendered blockquote is the one place a rounded far corner is still correct, because a
 * quoted paragraph is an island in the middle of a page, not a row flush with a column's edges.
 *
 * The bar is now the ONLY cue for the current project, so it owes the 3:1 an indicator owes on
 * every theme: --accent-chrome measures 4.18 (dusk), 5.55 (day), 7.28 (night) on --secondary —
 * see each theme's own note. A second FILL colour was the other candidate and is still a dead
 * end: --secondary-edge is the obvious one and it is the SAME value as --secondary-wash on day
 * and night, so it would render as no change at all on two of three themes; distinguishing them
 * means a fourth sidebar token for one marker. */
.nav-scroll .project.current-project > summary { border-left-color: var(--accent-chrome); }

/* A project's children (New session / Schedules / Settings / the session rows) sit flush
 * with the project title, not indented under it — grouping is carried by the open accordion
 * (the chevron and the revealed block), so an extra indent step only eats horizontal room in
 * a narrow nav. The action rows drop their margin-left and the session list its padding-left. */

/* the conversation you're currently viewing — a terracotta left bar over a neutral
 * block fill: the accent line does the "you are here" work (identity on a line, not a
 * wash), the neutral fill keeps the row a scannable block. keyed off .proj-sessions
 * like the JS that marks it; the .nav-scroll prefix lets it outrank the neutral :hover
 * fill so an active row keeps its bar on hover.
 * What that fill does on hover is NOT here: this row shares .active:hover with the six utility
 * rows below (search for it), so the tint and the numbers behind it live there — change this
 * value and that mix is computed from it. */
.nav-scroll .proj-sessions button.active {
  background: var(--secondary-wash);
  border-left-color: var(--accent-chrome);
}

/* The rows stack flush, like every other row in this column. This list and the project list
 * were once the same shape — a <ul> of buttons sharing one rule — and the gap belonged to both.
 * When the projects became a <details> accordion the gap stayed here alone, holding the
 * conversation rows apart to a rhythm nothing else in the nav keeps, and cutting the accent bar
 * that the note above has run continuously down the column. */
.sessions {
  list-style: none;
  margin: 0;
  padding: 0;
}

/* conversation rows are denser than project rows (.nav-scroll prefix to win); flex
 * so a pinned row's mark (see .pin-mark) sits beside the preview text, not above it. */
.nav-scroll .sessions button { padding: 0.32rem 0.55rem; display: flex; align-items: center; gap: 0.3rem; }

/* The pin mark on a session row: filled (not outline, unlike the header's toggle)
 * since a tiny outline shape barely reads at this size — a solid dot-of-a-shape
 * does. Same accent as the header's pinned state, kept to this one small use. */
.pin-mark {
  flex: 0 0 auto;
  width: 0.7rem;
  height: 0.7rem;
  -webkit-mask-image: var(--ic-pin-filled);
          mask-image: var(--ic-pin-filled);
  color: var(--accent-chrome);
}

/* Scoped to the nav so it doesn't reach the .theme-dot buttons in the footer
 * (they style themselves; a broader `.sidebar button` would outrank them). */
.nav-scroll button {
  display: block;
  width: 100%;
  text-align: left;
  border: none;
  border-left: var(--rule) solid transparent;
  /* Square, full stop — see .current-project > summary above for why. */
  border-radius: 0;
  background: transparent;
  padding: 0.4rem 0.55rem;
  font: inherit;
  color: inherit;
  cursor: pointer;
  transition: background-color 0.15s ease;
}

.nav-scroll button:hover { background: var(--secondary-wash); }

/* The single fixed New, between the logo and the scrolling project list (so it
 * stays put while the list scrolls). Accent text + a hairline border set it apart
 * as the primary action; muted + faded when no project is open to target. */
/* New session — borderless; hierarchy from colour + a hover wash, no outline. */

.sessions .prev {
  display: block;
  min-width: 0;
  /* Same rung as every other nav row (see .project > summary). At 13px these sat on the app's
     smallest rung, and they are the one nav row that carries a CJK phrase the operator typed
     rather than a fixed label — the worst place to spend the size. (The rung itself did not go
     away: .empty / .more / .more-link still take it, as the sidebar's non-row asides.) What
     separates these from the rows above is the fade and the tighter row height, not the size. */
  font-size: var(--text-ui);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Who has spoken in a conversation, at the end of its nav row. flex-shrink: 0 so the discs
 * keep their size and the preview takes the squeeze instead — the preview is already
 * ellipsised, and a half-drawn face is worse than a half-drawn sentence.
 *
 * Each disc is a labelled role="img" carrying the full name (the row is a button, so a title
 * alone would be reachable only by hovering). The initial inside it is what makes the identity
 * legible without colour — reused wholesale from the transcript's avatar (R007). */
.sessions .people {
  display: flex;
  align-items: center;
  gap: 0.1rem;
  flex-shrink: 0;
  margin-left: auto;
}

/* Smaller than a message's disc: a nav row is a label, not content. */
.sessions .avatar {
  width: 1.1rem;
  height: 1.1rem;
  font-size: 0.6rem;
}

/* The overflow disc is a count, not a person, so it does not take a person's colour. An
 * outline rather than a fill: the row's hover wash IS --secondary-wash, so a disc filled with
 * it would disappear under the cursor at exactly the moment it is being looked at. Its
 * accessible name lists who it stands for; see navPeopleOf.
 *
 * The two colours are split because the two jobs have different floors, and this disc lands on
 * more grounds than the rest of the nav's muted text does. At 0.55rem the count is small text
 * (4.5, WCAG 1.4.3) and it has to clear that on the ACTIVE row's hover tint too — where
 * --on-secondary-muted measures 4.23 on day and 3.72 on night, which is why the count wears
 * --on-secondary instead (7.70 and 8.75 there). The ring only has to be seen, not read, so it
 * keeps the muted tone and owes the indicator's 3 (1.4.11). It is NOT --secondary-edge: that
 * token is the same value as --secondary-wash on day and night, so the ring would vanish on
 * exactly the hovered row it was drawn to survive. Both floors are asserted, on that tint
 * included — see contrastContract. */
.sessions .avatar.avatar-more {
  background: transparent;
  color: var(--on-secondary);
  border: 1px solid var(--on-secondary-muted);
  font-size: 0.55rem;
}

/* These sit in the sidebar (on --secondary), so they take the sidebar's own muted tone —
 * NOT --on-surface-muted, which is tuned for the light main surface and would drop far
 * below AA on the dark Dusk/Night sidebar. (.admin-empty stays on --on-surface-muted: it
 * lives in the main pane.) */
.empty { color: var(--on-secondary-muted); font-size: var(--text-meta); padding: 0.25rem 0.55rem; }
.more { color: var(--on-secondary-muted); font-size: var(--text-meta); padding: 0.25rem 0.55rem; margin: 0; }
/* "show older" — the session-list expander; quieter than a conversation row (muted, small)
 * so it reads as an affordance, not another session. Reuses the .nav-scroll button base. */
.nav-scroll button.more-link { color: var(--on-secondary-muted); font-size: var(--text-meta); }

/* ── sidebar footer: account trigger + theme switcher ──────────────────── */
/* The signed-in account (bottom-left, .ws-account-trigger — it also holds Settings and Log out
 * in the menu it opens) balanced against the theme switcher (bottom-right). Settings used to sit
 * here as a bare row beside the workspace links; it moved into the account menu, and index.html
 * records why (R011: it is flology's own control panel, not a capability the team builds with). */
.sidebar-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 0.5rem;
  padding-top: 0.15rem;
}

.theme-switcher {
  display: flex;
  align-items: center;
  gap: 2px;
  padding: 3px;
  background: var(--secondary-pill);
  border-radius: 9999px;
}

.theme-dot {
  width: 28px;
  height: 28px;
  padding: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  border-radius: 50%;
  background: transparent;
  /* The sidebar's muted tone, NOT --on-surface-muted — the same rule .empty/.more state a few
     rules up, which this control quietly broke. It sits on --secondary-pill (the pill), where
     the main-surface muted value measured 1.61 on dusk: an icon-only button carrying no text
     label, so WCAG 1.4.11's 3:1 is the number it owes. --on-secondary-muted gives 7.16 / 4.27 / 5.91. */
  color: var(--on-secondary-muted);
  cursor: pointer;
  transition: background-color 0.2s ease, color 0.2s ease;
}

.theme-dot:hover { color: var(--on-secondary); }
/* Active theme: the ONE deliberate exception to "active = line". On a 28px dot a ring or
 * underline reads far louder than the control warrants (R007 restraint), so the theme
 * switcher keeps a soft accent wash + a brighter icon — the quietest possible "selected"
 * mark. In the spirit of the accent rule (stay low-key) even though not its letter. */
.theme-dot.active { background: color-mix(in srgb, var(--primary) 16%, transparent); color: var(--on-secondary); }
/* The selected dot's own hover. :hover above only brightens the icon, and the selected one is
 * already at that colour — so it was the one control in this switcher that answered the pointer
 * with nothing (R007's usable floor), in a row of three where the other two both do. The wash
 * deepens instead of the icon: 26% over the pill puts it ΔE .029 (dusk) / .040 (day) / .061
 * (night) from its resting wash — visible, and no colour the sheet did not already have. The
 * icon rides it at 8.43 / 5.48 / 10.01, well past the 3:1 an icon-only control owes. */
.theme-dot.active:hover { background: color-mix(in srgb, var(--primary) 26%, transparent); }
.theme-dot svg { width: 14px; height: 14px; }

/* ── chat ────────────────────────────────────────────── */
.chat {
  display: flex;
  flex-direction: column;
  min-height: 0;
}

/* ── session header ──────────────────────────────────────── *
 * Same outer-full-width / inner-max-width split as .transcript /
 * .transcript-inner, so the border-bottom spans the whole pane instead of
 * just the centred content column. Sticky (with an opaque background) so it
 * stays put when chat.html is embedded in a scrolling ancestor — the run
 * page's .md-view — even though it's a no-op in the normal session view,
 * where .chat itself never scrolls (only .transcript, inside .chat-form,
 * does). */
.chat-header {
  flex-shrink: 0;
  position: sticky;
  top: 0;
  z-index: 1;
  border-bottom: 1px solid var(--line);
  background: var(--surface);
}
.chat-header-inner {
  max-width: 52rem;
  width: 100%;
  margin: 0 auto;
  /* 0.9rem, not the 0.6rem this carried while the crumb was here: back then the header was two
   * lines of text and stood ~68px tall, and the tight padding was what kept THAT from looming.
   * With the crumb gone the same padding left one line floating in 47px — the title read as a
   * label stuck to the top edge rather than a header with a pane under it. 0.9rem is the rung
   * .runs-head already uses, and lands the header at ~56px: most of the presence the two-line
   * version had, none of the second line. */
  padding: 0.9rem 2rem;
}
/* This conversation is still running on the instructions it started with (chat.html). Muted
 * body text on its own line under the actions — --on-surface-muted clears AA on --surface, and
 * the sentence is the whole cue, so nothing here depends on colour. Deliberately NOT accent and
 * NOT --error: it is neither a decision waiting on somebody nor something that went wrong, it is
 * a standing fact about this conversation, and painting it like an alarm would put a warning on
 * every long-lived conversation in the app the day after an edit (R007 低調). */
.chat-header-note {
  margin: 0.5rem 0 0;
  color: var(--on-surface-muted);
  font-size: var(--text-meta);
  line-height: 1.45;
}
/* No .chat-header-crumb: the chat header used to open with the project name, which the nav now
 * carries on every screen and keeps carrying as the session list scrolls (.current-project
 * above). Deleted rather than hidden — an unused rule is a thing to keep in step with nothing. */
/* Title + pin/delete share one row: the title takes the remaining space (and still
 * truncates with an ellipsis via its own overflow rule), the two action buttons sit
 * quietly at its end — no icon-heavy toolbar, just two small marks (R007). */
.chat-header-row {
  display: flex;
  align-items: center;
  gap: 0.4rem;
}
/* A native <input>, not a <p>: styled to look like static text until focused, so
 * renaming a session (click it, type, Enter/blur to save) needs no separate edit
 * mode or icon — just the input itself, quiet until you touch it (R007). */
.chat-header-title {
  display: block;
  flex: 1;
  min-width: 0;
  margin: 0;
  padding: 0;
  border: none;
  border-bottom: 1px solid transparent;
  background: none;
  font: inherit;
  font-size: var(--text-content);
  font-weight: var(--weight-emph);
  color: var(--on-surface);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  cursor: text;
}
/* The rename field opts out of the shared ring (an inset outline inside a control that IS a bare
 * line of text would sit on the glyphs) and wears its own underline instead — so that underline
 * owes the indicator floor, exactly like the composer's border does. --line was what it used to
 * take, and --line is the resting-divider value: 1.56 / 1.48 / 1.45 (it has since been deepened
 * on all three themes to draw the table grid — see dusk's --line), still nowhere near the 3 an
 * indicator owes, i.e. a field that looks identical focused and unfocused for anyone who cannot
 * see a faint warm grey. The accent is the only cue this control has, so it takes the accent. */
.chat-header-title:focus {
  outline: none;
  border-bottom-color: var(--accent-text);
}
/* A refused rename's sentence, under the title row (app.js: showTitleError). Small, in the
 * error ink it shares with every other refusal, and below the row so appearing never nudges
 * the field the person is about to click back into. */
.chat-header-error {
  font-size: var(--text-meta);
  margin-top: 0.2rem;
}
/* Pin and delete: two quiet icon buttons at the end of the title row. No border, no
 * fill, no hover chrome beyond a slightly darker mark — the icon itself (and, for
 * pin, its filled/outline state) is the whole affordance (R007). */
.chat-header-btn {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 1.6rem;
  height: 1.6rem;
  padding: 0;
  border: none;
  background: none;
  color: var(--on-surface-muted);
  cursor: pointer;
}
.chat-header-btn:hover {
  color: var(--on-surface);
}
.chat-header-btn .ic {
  width: 1rem;
  height: 1rem;
}
.pin-btn.pinned {
  color: var(--accent-text);
}

/* Notice shown to a viewer whose open conversation was deleted by someone else (see
 * showSessionDeleted): a quiet centered line atop the transcript, matched to the muted
 * editorial register — the composer is disabled alongside it. */
.session-closed {
  margin: 0.75rem auto;
  padding: 0.5rem 0.9rem;
  color: var(--on-surface-muted);
  text-align: center;
}

/* One restrained page-load reveal (motion, not shadow) — the editorial entrance.
 * Shared by .chat-hint (empty new-session state). */
@keyframes picker-in {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: none; }
}

.chat-form {
  display: flex;
  flex-direction: column;
  min-height: 0;
  flex: 1;
}

/* Empty new-session: center the greeting and the composer together as a landing prompt,
 * rather than pinning the composer to the bottom of an empty pane. Keyed on the hint's
 * presence (it renders only in the empty state); app.js removes the hint on the first
 * send, so the layout reverts to the normal top-messages / bottom-composer chat. */
.chat-form:has(.chat-hint) { justify-content: center; }
.chat-form:has(.chat-hint) .transcript { flex: 0 0 auto; overflow: visible; }
.chat-form:has(.chat-hint) .transcript-inner { display: none; }
.chat-form:has(.chat-hint) .chat-hint { flex: 0 0 auto; padding: 1rem 2rem 1.5rem; }

/* The transcript scrolls full-width, so its scrollbar sits at the pane's right edge
 * (not floating at the right of a centred column). transcript-inner is the centred,
 * max-width message column.
 *
 * scrollbar-gutter: stable both-edges reserves the scrollbar's space on BOTH edges, so a
 * space-taking (classic) scrollbar can't narrow the transcript on the right only — which
 * would shift the centred transcript-inner left and misalign it with the .composer below
 * (which centres in the full pane width, outside this scroller). Reserving symmetrically
 * keeps transcript-inner centred on the pane's true centre, matching the composer, AND
 * stops the content jumping when the scrollbar appears/disappears. (On overlay-scrollbar
 * platforms the gutter is 0, so this is a no-op there.)
 *
 * Which is why NOTHING tests this declaration, and removing it because nothing does would be
 * the wrong read: headless Chromium draws overlay scrollbars, so the only assertion an e2e
 * could make there is the stylesheet reading itself back. The centring it protects IS tested
 * (appearance.spec.ts, "message prose aligns with the chat header"), but only the half that this
 * line is not responsible for. Verify a change to it on a classic-scrollbar platform by hand. */
.transcript {
  flex: 1;
  overflow-y: auto;
  scrollbar-gutter: stable both-edges;
  display: flex;
  flex-direction: column;
}

/* Empty-state shown for a brand-new session (no messages, no id yet). Sits as a flex
 * sibling to transcript-inner so it fills the transcript height and centres itself. */
.chat-hint {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 3rem 2rem;
  text-align: center;
  gap: 0.85rem;
  animation: picker-in 0.35s ease both;
}

/* Display type, outside the chrome scale — the same 2rem the login wordmark takes, because
   they are the same beat: the one large line on an otherwise empty page. It was 2.25rem, a
   value used exactly once and a step nobody could tell apart from 2rem at a glance. */
.hint-project {
  margin: 0; /* it is an h2 on the welcome page — the UA's heading margins would fight the flex gap */
  font-size: 2rem;
  font-weight: 300;
  letter-spacing: -0.025em;
  color: var(--on-surface);
  line-height: 1.1;
}

.hint-rule {
  display: block;
  width: 2.25rem;
  height: 1.5px;
  background: var(--primary);
}

/* The home pane with no projects yet (welcome.html). It IS .chat-hint — the same single large
   line centred in an empty pane, the same accent rule under it — because it is the same beat, so
   it walks into that component rather than laying out a second centred empty state (R007). Only
   two things are its own, and both are consequences of that reuse: a reading width for the one
   sentence, and a left edge for the form, since .chat-hint centres its text and a label centred
   over its input reads as a caption rather than as the name of the field below it. The field,
   its focus treatment, the button and the refusal slot are all the shared atoms untouched. */
.welcome-lead {
  max-width: 29rem; /* fits the two hard-broken lines (welcome.html) without re-wrapping;
                        measured at 27.1rem for the longer line, plus headroom for other fonts */
  margin: 0.5rem 0 0.75rem;
  color: var(--on-surface-muted); /* ≥4.5:1 on --surface in all three themes (AA) */
  font-size: var(--text-content);
  line-height: 1.6;
}
.welcome-form { width: min(20rem, 100%); text-align: left; }
/* .chat-hint centres a single line and needs no scroller; this pane is a heading, a sentence, a
   field, a button and sometimes a refusal, which is taller than a landscape phone. Without these
   two the overflow goes off BOTH edges of a centred flex box and the button cannot be reached —
   the one action the page has (R007). `safe` is what keeps the top edge reachable once it does. */
.chat-hint--welcome {
  overflow-y: auto;
  justify-content: safe center;
}

.transcript-inner {
  display: flex;
  flex-direction: column;
  gap: 2.5rem;
  max-width: 52rem;
  width: 100%;
  margin: 0 auto;
  padding: 2rem;
}

.transcript::-webkit-scrollbar { width: 6px; }
.transcript::-webkit-scrollbar-thumb { background: var(--line); border-radius: 3px; }

/* Chat-log layout: the prose (sender name + reply text + tool labels) runs down one
 * clean left edge that lines up with the chat-header title above it — max-width 52rem,
 * 2rem padding on both, so the two share a column edge. The avatar is HUNG into that
 * 2rem gutter to the left (absolute, off the body's flow) rather than taking a column
 * that would push the prose ~40px right of the header. Its size AND gap are in rem
 * (1.6rem + 0.4rem = the 2rem gutter), so it fills the gutter exactly at any root font
 * size and never overflows the scroll area. Identity still comes from the avatar + name,
 * just marginal to the text rather than indenting it. */
.msg {
  position: relative;
}

/* The avatar disc itself carries no position: it is the same component in a message row
 * (hung in the gutter) and in a conversation's row in the nav (inline), and a component
 * that hard-codes where it lives can only be reused by copying half of it (R007). Placement
 * belongs to whoever is placing it, so .msg .avatar hangs it and .sessions .avatar leaves it
 * in flow. Size stays here — the disc is the same size in both. */
.avatar {
  width: 1.6rem;
  height: 1.6rem;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: var(--text-meta);
  font-weight: var(--weight-emph);
  line-height: 1;
  user-select: none;
}

.msg .avatar {
  position: absolute;
  right: 100%;          /* right edge at the prose column's left edge */
  top: 0;
  margin-right: 0.4rem; /* the gap to the text; 1.6rem avatar + this = the 2rem gutter */
}
.msg.user .avatar { background: var(--surface-alt); color: var(--on-surface-muted); border: 1px solid var(--line); }

.msg-body { min-width: 0; }

/* The sender row is the same height as the avatar beside it and centres its text,
 * so the avatar glyph and the sender name (a person's own initial and name, or the
 * assistant's F / Flology) share one centreline — they'd
 * drift if the row were only as tall as its text and top-aligned to the avatar.
 * position: relative anchors .msg-loading below, which overlays this row while
 * pending rather than sitting in flex flow after it (see that rule for why).
 *
 * No font-weight: a long conversation has one of these per message, which makes it a list row by
 * the header's test even though it does not look like one. Identity here is carried by the
 * coloured avatar disc beside it — that is the signal a reader actually scans for — and the name
 * is already full contrast at a rung below the prose under it. */
.msg-sender {
  position: relative;
  height: 1.6rem;
  display: flex;
  align-items: center;
  font-size: var(--text-meta);
  color: var(--on-surface);
  margin-bottom: 0.3rem;
}

/* The assistant's identity is constant, so its avatar glyph and sender name stay keyed
 * by role here — the server template and the streaming JS both emit an empty
 * .avatar/.msg-sender for it and never hardcode, so they can't drift. A USER turn's
 * identity is per-person now (see chat.html / app.js appendMessage): its glyph and name
 * are written into the markup, so no ::before for it here. */
.msg.assistant .avatar::before { content: "F"; }
.msg.assistant .msg-sender::before { content: "Flology"; }

/* Every disc in the log — the six per-person colours (avatarClass in serve.go / app.js buckets a
 * username into one, so two people are told apart by colour and not only by a shared first
 * letter) plus Flology's own — comes off ONE LADDER the theme owns: fixed lightness, fixed
 * chroma, hues an even step apart, anchored on the theme's own hue. Flology takes the anchor
 * itself (--k: 0) and the people are interleaved half a step either side, so nobody collides
 * with it.
 *
 * This is not a new palette, it is the old one written down. The six values used to be literal
 * hexes, and converting them to OKLCH shows what they always were: L .48–.54, C .054–.093, H
 * every ~60° around the circle — i.e. this exact formula, hand-expanded, with its anchor frozen
 * at H41. H41 is dusk's terracotta, so dusk looked coherent and the other two themes were
 * quietly borrowing someone else's anchor. Handing the anchor to the theme is the whole change:
 * four tokens per theme replace eighteen constants, and serve.go's bucketing does not move.
 *
 * OKLCH and not HSL because the ladder's premise is that all seven discs are equally light and
 * equally saturated; in HSL equal numbers are not equal perception (a yellow and a blue at the
 * same L differ by ~2x in luminance), so a hand-corrected ladder is the only kind that works
 * there — which is how the frozen palette came to exist. It is native CSS, no dependency (R007).
 *
 * There is deliberately NO plain-colour `background` line in front of the oklch() one. A pair of
 * declarations is the cascade's fallback idiom for an unknown FUNCTION, and it does not work
 * here, because the second one contains var(): a declaration with a var() in it parses as valid
 * whatever the substitution turns out to be, so it wins the cascade first and only then fails —
 * and a value that is invalid at computed-value time falls to the property's INITIAL value, not
 * to the declaration above it (css-variables-1 §3.1). The "muted disc for an old browser" that
 * line looked like it bought was actually a transparent one, i.e. seven letters floating on the
 * paper. Anything real here would be an @supports block, and that is a compat shim for a browser
 * that predates oklch()'s 2023 baseline — not worth its weight (R001, R009).
 *
 * The "You" fallback (a user with no colour class) keeps the plain muted .msg.user .avatar.
 *
 * Both halves name the colour classes one by one rather than saying .avatar, because a disc
 * WITHOUT one has no --k: the oklch() above would then be invalid at computed-value time and
 * fall to the property's initial value, silently, on whichever disc grew out of the pattern.
 * A nav row has such a disc — the +N overflow count, which wears its own colours below. */
.msg.assistant .avatar,
.msg.user .avatar.uc0, .msg.user .avatar.uc1, .msg.user .avatar.uc2,
.msg.user .avatar.uc3, .msg.user .avatar.uc4, .msg.user .avatar.uc5,
.sessions .avatar.uc0, .sessions .avatar.uc1, .sessions .avatar.uc2,
.sessions .avatar.uc3, .sessions .avatar.uc4, .sessions .avatar.uc5 {
  background: oklch(var(--av-l) var(--av-c) calc(var(--av-h) + var(--k) * var(--av-step)));
  color: var(--av-ink);
  border-color: transparent;
}
.msg.assistant .avatar { --k: 0; }
.avatar.uc0 { --k: 0.5; }
.avatar.uc1 { --k: 1.5; }
.avatar.uc2 { --k: 2.5; }
.avatar.uc3 { --k: 3.5; }
.avatar.uc4 { --k: 4.5; }
.avatar.uc5 { --k: 5.5; }

/* A quiet time by the sender (e.g. "10:30 PM") — small and muted so it dates the
 * message without competing with it. Same on the live stream and on reload. */
.msg-time {
  margin-left: 0.5rem;
  color: var(--on-surface-muted);
  font-size: var(--text-meta);
  font-weight: 400;
}

/* An assistant row is appended the instant a turn starts, before anything has actually
 * arrived — .pending (app.js: appendMessage/reveal) marks that window, so the avatar/name
 * stay hidden (not removed — visibility keeps their box so nothing shifts once they appear)
 * until the row's first real content (not just a round-trip opening — see NOT_REVEALING in
 * app.js) arrives. The "working" cue itself is the .turn-loading footer below, shown for the
 * whole turn. The server sets this class too, on the one row it invents rather than replays —
 * a turn that has started but written nothing (markWorking in serve.go) — so a page that loads
 * into that moment looks like the sender's does. Every other row it renders already happened. */
.msg.assistant.pending .avatar,
.msg.assistant.pending .msg-sender {
  visibility: hidden;
}
/* A trajectory event: "amy approved the schedule.", "sam stopped this reply." — the record of
 * something a PERSON did to this conversation (session_event.go). It is not somebody speaking,
 * so it is not shaped like a message: no avatar, no name, no bubble. A centred line of muted
 * meta text with a hairline running out to both margins — the seam where the conversation
 * changed hands, read at a glance and skipped just as easily (R007: layering by space and rule,
 * not decoration). It keeps the transcript's own row rhythm; the rules do the separating.
 *
 * The rules are decoration, not information: the sentence says everything, so a reader who
 * cannot see them (forced-colours, a very low-contrast display) loses nothing. */
.msg.event {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  font-size: var(--text-meta);
  color: var(--on-surface-muted);
}
.msg.event::before,
.msg.event::after {
  content: "";
  flex: 1;
  border-top: 1px solid var(--line);
}

/* Where a compaction happened. Same hairline-and-muted-label shape as an event row above, for
 * the same reason: it is narration about the conversation, not somebody speaking (R007「同類就
 * 走既有元件」). The one difference is that it OPENS — everything it summarized is still in the
 * transcript above it, and the summary the agent now reads instead is folded in here, so anyone
 * can check what it kept. The chevron on the label is the fold affordance, the same "›" the
 * thinking and tool rows use; the rules stay decoration (the sentence says everything). */
.msg.compaction > details > summary {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  list-style: none;
  cursor: pointer;
  user-select: none;
  font-size: var(--text-meta);
  color: var(--on-surface-muted);
}
.msg.compaction > details > summary::-webkit-details-marker { display: none; }
.msg.compaction > details > summary::before,
.msg.compaction > details > summary::after {
  content: "";
  flex: 1;
  border-top: 1px solid var(--line);
}
.msg.compaction > details > summary > span::after {
  content: "›";
  display: inline-block;
  margin-left: 0.35em;
  transition: transform 0.15s ease;
}
.msg.compaction > details[open] > summary > span::after { transform: rotate(90deg); }
/* The summary itself: the same treatment a thinking block's body gets, because it is the same
 * thing — text the agent is working from, kept out of the reading line until asked for. */
.msg.compaction .compaction-body {
  white-space: pre-wrap;
  word-break: break-word;
  margin: 0.5rem 0 0.25rem 0;
  color: var(--on-surface-muted);
  font-size: var(--text-ui);
  line-height: 1.6;
}

/* The compaction in PROGRESS (app.js's showCompacting): the working dots and elapsed seconds a
 * running reply carries, at the foot of the transcript, with a word saying what the wait is for.
 * No avatar and no bubble — nobody is speaking, and when it finishes the row is replaced by the
 * marker above, not by a message. */
.compacting-label {
  font-size: var(--text-meta);
  color: var(--on-surface-muted);
}

/* The three-dot pulse — the "working" animation, reused by the .turn-loading footer below as an
 * inline flex row of dots. */
.msg-loading {
  display: flex;
  align-items: center;
  gap: 0.25rem;
}
.msg-loading span {
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--on-surface-muted);
  animation: msg-loading-pulse 1.2s ease-in-out infinite;
}
.msg-loading span:nth-child(2) { animation-delay: 0.15s; }
.msg-loading span:nth-child(3) { animation-delay: 0.3s; }
/* The pulse is SIZE, not opacity — same reason the tool spinner stopped carrying one. Fading a
 * dot fades its contrast: at the 0.25 this used to rest on for 80% of every cycle, the dots
 * measured 1.41 on all three themes, against the 3 a "still working" indicator owes. And the
 * reduced-motion block below flattens every animation to 0.01ms, so that dim plateau is roughly
 * where they would have stopped — the one state where the dots are the ONLY thing saying a reply
 * is coming. Scaling keeps the full 5.5–5.7 ink at every point of the cycle, and a frozen frame
 * is three visible dots of a slightly odd size rather than three near-invisible ones. */
@keyframes msg-loading-pulse {
  0%, 80%, 100% { transform: scale(0.55); }
  40% { transform: scale(1); }
}
/* The in-progress turn footer: the working dots + an elapsed-seconds count, pinned at the
 * assistant row's tail so every streamed block (thinking, tool, reply) stacks ABOVE it. It stays
 * the whole turn (finalizeTurn removes it), so a quiet stretch — a tool running, a slow model —
 * still reads as alive, and the seconds say how long. Muted metadata tone: motion, not noise. */
.turn-loading {
  display: flex;
  align-items: center;
  gap: 0.45rem;
  margin-top: 0.4rem;
}
.turn-elapsed {
  font-size: var(--text-meta);
  color: var(--on-surface-muted);
  font-variant-numeric: tabular-nums;
}

.bubble {
  white-space: pre-wrap;
  word-break: break-word;
  line-height: 1.7;
  color: var(--on-surface);
}

/* A bubble is prose addressed to the person, so it breaks OUT of the tight activity log
 * with real breathing room above and below — the reply reads as its own beat, not another
 * log line — while thinking/tool blocks stay tightly stacked among themselves (0.15rem, see
 * below). The gap has to live on the BUBBLE's own margins: the thinking/tool that precedes
 * or follows it now sits tight (0.15rem), so it can't supply the air. margin-top gives the
 * space above (it collapses with a neighbour's smaller margin to 0.5rem); margin-bottom the
 * space below — skipped only for the FINAL reply, identified by its trailing .reply-meta
 * (which owns that gap). Exclude by `:has(+ .reply-meta)`, NOT by `:not(:last-child)`: a
 * commentary reply that is the last child of a non-terminal step is still followed by more
 * of the turn, so it must keep its bottom breathing rather than butt against the next block.
 * :not(:empty) leaves a stray empty bubble (a step that opened a reply then called a tool
 * before any text) at zero margin, so it can't inject a phantom gap into the tight log.
 * The first bubble of a turn with no preceding thinking/tool sits right under the sender —
 * reset its top margin so it hugs the name like a user message does, not 0.2rem lower. */
.step > .bubble:not(:empty) { margin-top: 0.5rem; }
.msg-sender + .step > .bubble:first-child { margin-top: 0; }
/* Assistant only: this bottom breathing is for a reply sitting among a turn's other blocks. A
 * user message is a lone bubble in its row, already spaced by the transcript's 2.5rem
 * inter-message gap — adding margin here too made the gap BELOW a user message larger than the
 * one above it (asymmetric). */
.msg.assistant .bubble:not(:has(+ .reply-meta)):not(:empty) { margin-bottom: 0.5rem; }

/* Rendered markdown+math bubbles: off pre-wrap, style HTML elements from marked+KaTeX. */
.bubble.rendered { white-space: normal; }

.bubble.rendered > *:first-child { margin-top: 0; }
.bubble.rendered > *:last-child  { margin-bottom: 0; }

/* Three rungs over a 1rem body: 20 / 18 / 16. h3 is deliberately the SAME SIZE as body text
 * and separates on --weight-emph alone — which only became a real separation once that weight
 * was 600 (see its token: at 500 a CJK h3 was pixel-identical to the paragraph under it, and
 * a three-level reply is what an agent writes by default). Buying h3 its own size instead
 * would mean a fourth rung between 16 and 18, i.e. spending a whole type size on the level
 * that needs the least of it. If --weight-emph ever goes back down, this rung has to move.
 * h2 was 1.1rem = 17.6px, which is the fractional-pixel value the scale note below warns
 * about — it rounded differently per browser for no gain over the integer 18. */
.bubble.rendered h1,
.bubble.rendered h2,
.bubble.rendered h3 { margin: 1em 0 0.4em; font-weight: var(--weight-emph); line-height: 1.3; }
.bubble.rendered h1 { font-size: 1.25rem; }
.bubble.rendered h2 { font-size: 1.125rem; }
.bubble.rendered h3 { font-size: 1rem; }

.bubble.rendered p       { margin: 0.5em 0; }
.bubble.rendered ul,
.bubble.rendered ol      { margin: 0.5em 0; padding-left: 1.5em; }
.bubble.rendered li      { margin: 0.2em 0; }
/* No `strong` rule here — the global strong/b reset above already carries it, and a second copy
   scoped to rendered markdown would be the same declaration twice with only one of them wrong
   when the token's spending rule changes. */
/* Links in rendered content: at rest they take the body text colour (inherit) with the underline
 * as the link cue — quiet, and safely above the AA floor on every theme, where the raw brand
 * value is not (dusk's terracotta lands at ~3:1 on its own sand, under 4.5). The warm
 * accent is spent on HOVER only, a transient interaction cue (R007), not the resting reading state.
 * This also drops the browser default blue that fought the warm palette.
 * Prose links written into the admin pages by hand are the same kind of thing — a word in a
 * sentence you can follow — so they take this rule rather than a parallel one of their own. */
.bubble.rendered a,
.admin-note a       { color: inherit; }
.bubble.rendered a:hover,
.admin-note a:hover { color: var(--accent-text); }
.bubble.rendered hr      { border: none; border-top: 1px solid var(--line); margin: 1em 0; }

/* An image in a reply comes from outside — a diagram or chart the agent found on the web — so
 * we control neither its size nor its background. Bound it to the bubble; an unbounded remote
 * image otherwise blows the text column out sideways. */
.bubble.rendered img { display: block; max-width: 100%; height: auto; margin: 0.75em 0; }

/* On the one dark theme, an image also gets a light plate behind it. Published line art is
 * drawn in near-black ink on a TRANSPARENT background — it assumes the white page it was made
 * for — so on Night that ink lands on a near-black surface and the figure simply is not there.
 * A photo or a screenshot is opaque and covers the plate without ever showing it, so only the
 * figures that need rescuing see it. R007's contrast floor is about being able to read what is
 * on screen, and a picture is not exempt from it.
 *
 * Deliberately NOT applied on Dusk/Day. There the paper is already light, so the plate would be
 * decoration rather than a fix — and for the one case that DOES break on a light theme (art
 * drawn in white ink for a dark page) a light plate makes it worse, not better. Rescuing both
 * directions would need a mid-tone plate that suits neither; transparent-with-dark-ink is
 * overwhelmingly the common case, so we fix that one and leave the rest alone (R001).
 *
 * Theme-scoped rather than a per-theme token because it carries a padding too: a token holds a
 * value, and this rule is a value plus a box.
 *
 * colour goes with the plate: a remote image that fails to load (a dead link, a host that blocks
 * hotlinking — routine for a picture the agent found on the web) renders its ALT TEXT inside this
 * box, and the bubble's text colour on Night is near-white. Light-on-light would be the exact
 * contrast failure the plate exists to fix, so the ink is set alongside the paper. */
[data-theme="night"] .bubble.rendered img {
  padding: 0.5rem;
  background: var(--image-plate);
  color: var(--image-plate-ink);
  border-radius: var(--radius);
}

/* A quote's identity comes from a terracotta left rule over a whisper of a neutral block
 * (R007: layers via colour + whitespace, no shadows). The accent rides the LINE only — a
 * quoted passage is a set-apart editorial element worth the brand mark, and accent-on-a-
 * line is the same vocabulary the nav's left bar uses — while the block behind stays a
 * neutral tint (accent on the rule, not a wash). The quoted text keeps full body contrast
 * (a muted tone would drop it below AA on the light themes).
 *
 * The rule takes --primary, the brand, and NOT --accent-text — the same choice every other
 * accent line on paper makes (.md-navitem.active, .md-card.active). A line owes 3:1, a word owes
 * 4.5:1, so --accent-text draws it 1.7x deeper than its own floor; the extra depth buys nothing
 * and costs everything, because the nav's bar next door is a line too and takes the value solved
 * for the PANEL. This file used to argue the opposite here — that borrowing the nav's vocabulary
 * meant borrowing its token — on the grounds that day and night collapse all three accents to one
 * value, and that dusk's gap would be excused by the two rules sitting on different grounds. The
 * second half of that was wrong, and observably so: with dusk's active wash darkened the nav bar
 * reads brighter still, and a brick rule beside a salmon bar reads as two colours, divider or no
 * divider. Paper and panel cannot hold one value — a light ground needs the accent deep, a dark
 * one needs it lifted — so the most that is available is ONE HUE lit two ways, and that is what
 * --primary buys: both rules are now the brand itself, one deepened by the paper it sits on.
 * Day and night are unaffected either way: all three accent tokens coincide there. */
.bubble.rendered blockquote {
  margin: 0.5em 0;
  padding: 0.4em 0.75em;
  border-left: var(--rule) solid var(--primary);
  border-radius: 0 2px 2px 0;
  /* A shade of the ink itself, so the quote's ground darkens without introducing a colour. 3%,
     not the 4% it started at: body ink owes 12:1 on the paper it lands on, and at 4% the day
     theme's quote ground measured 11.99 — a hundredth under, which is the kind of miss only the
     composited-ground rows in assets_contrast_test.go ever catch. The two are indistinguishable
     to the eye; only one of them is inside the contract. */
  background: color-mix(in srgb, var(--on-surface) 3%, transparent);
  color: var(--on-surface);
}

.bubble.rendered code {
  font-family: var(--font-mono);
  font-size: 0.875em;
  background: var(--surface-alt);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 0.1em 0.3em;
}
.bubble.rendered pre {
  margin: 0.75em 0;
  padding: 0.75em 1em;
  background: var(--surface-alt);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  overflow-x: auto;
}
/* No font-size here: `code` above already sets 0.875em, and repeating it inside pre would
   compound to 0.766em — em is relative to the parent, and the parent IS the code element. */
.bubble.rendered pre code { background: none; border: none; padding: 0; }

/* No font-size shrink on the table either, for the same compounding reason: a `code` cell in a
   0.9em table lands at 0.9 × 0.875 × 16px = 12.6px, below the 13px floor the type scale sets
   for anything readable. A table is already set apart by its rules and its cell padding. */
.bubble.rendered table   { border-collapse: collapse; margin: 0.75em 0; width: 100%; }
.bubble.rendered th,
.bubble.rendered td      { padding: 0.35em 0.6em; border: 1px solid var(--line); text-align: left; }
.bubble.rendered th      { background: var(--surface-alt); font-weight: var(--weight-emph); }

/* auto (not clip) so a formula wider than the bubble is still reachable by scroll;
 * the scrollbar itself is fully hidden (not just its buttons — some Linux/GTK
 * themes paint native stepper arrows on a styled-but-visible scrollbar regardless
 * of ::-webkit-scrollbar-button overrides) rather than styled thin, so no GTK
 * artifact can appear while scrolling still works via wheel/trackpad/keyboard. */
.bubble.rendered .katex-display { overflow-x: auto; margin: 0.75em 0; scrollbar-width: none; }
.bubble.rendered .katex-display::-webkit-scrollbar { display: none; }

.msg.error .bubble { color: var(--error); }
/* A persisted terminal error replayed on reload (viewStep.IsError) — a compaction
   fail-loud message the model also saw, in the error hue so it reads as a stop, not a
   reply. Same --error family as .msg.error above and .tool.errored below. */
.bubble.errored { color: var(--error); }
.error-line { color: var(--error); margin-top: 0.4rem; }

/* Thinking and each tool call are the same family of quiet, foldable activity blocks
 * that sit above the reply: a muted `chevron · icon · summary` row, the detail folded
 * behind it — so they read as the model working, not its answer, and tell apart only by
 * their icon (sparkle vs bolt). One shared summary rule styles both. */
/* Activity blocks stack TIGHT — thinking and tool rows are one working log, so the gap
 * between blocks matches the gap within a .tools block (0.15rem), not a paragraph's worth
 * of air. A reply bubble breaks out of the cluster with its own breathing room above and
 * below (see .bubble margins), so the prose addressed to the person stays a distinct beat. */
.thinking { margin-bottom: 0.15rem; }
.tools { margin-bottom: 0.15rem; display: flex; flex-direction: column; gap: 0.15rem; }
.thinking > summary,
.tool > summary {
  list-style: none;
  cursor: pointer;
  position: relative; /* the chevron hangs off the left edge, out of the summary's flow */
  display: flex;
  align-items: center;
  gap: 0.3rem;
  font-size: var(--text-ui);
  color: var(--on-surface-muted);
  line-height: 1.5;
  user-select: none;
}
.thinking > summary::-webkit-details-marker,
.tool > summary::-webkit-details-marker { display: none; }
/* The chevron is the fold affordance — pulled into the left gutter (off the summary's
 * flow) so the icon and label line up flush with the reply text while it hangs outside.
 * Shown only when there's a body to reveal, so a tool with no rendered result reads as a
 * flat row, not a dead disclosure triangle. */
.thinking > summary::before,
.tool > summary::before {
  content: "›";
  position: absolute;
  left: -0.85em;
  top: 50%;
  transform: translateY(-50%);
  transition: transform 0.15s ease;
}
.thinking[open] > summary::before,
.tool[open] > summary::before { transform: translateY(-50%) rotate(90deg); }
.tool:not(:has(.tool-body)) > summary { cursor: default; }
.tool:not(:has(.tool-body)) > summary::before { visibility: hidden; }
/* The pending cue on a tool row whose arguments are still streaming (its name is known, its
 * call line hasn't landed) — a quiet muted spinner drawn straight from the row's own
 * data-pending marker (one source of truth, no separate element to keep in sync), gone the
 * moment that marker is cleared. ::after, not ::before — that's the fold chevron; the summary
 * is a flex row, so the ring sits after the name with the same gap. Low-key: says "working".
 *
 * Quiet comes from currentColor — the row's own muted ink, already solved at 5.5–5.7 — and NOT
 * from an opacity on top of it. The 0.55 this used to carry composited that down to 2.30–2.56,
 * under the 3:1 a status indicator owes (1.4.11), and worse under reduced motion below, where
 * the ring is the only cue left once the spin stops. Same mistake as the sidebar rows' 0.85:
 * a fade is a contrast cut, so the token has to be the quiet, not a veil over it. */
.tool[data-pending] > summary::after {
  content: "";
  width: 0.7em;
  height: 0.7em;
  border: 1.5px solid currentColor;
  border-top-color: transparent;
  border-radius: 50%;
  flex-shrink: 0;
  animation: tool-spin 0.7s linear infinite;
}
@keyframes tool-spin { to { transform: rotate(360deg); } }
/* With motion off the global rule freezes the spin, which would leave the ring stuck as a
 * broken arc (its top border is transparent to make the moving gap). Close the gap so the
 * frozen cue reads as a plain small ring, not a rendering glitch. */
@media (prefers-reduced-motion: reduce) {
  .tool[data-pending] > summary::after { border-top-color: currentColor; }
}

/* The type mark: a monochrome line icon (see --ic-* tokens) painted in the text colour
 * via CSS mask, sized and weighted to sit as quietly as the chevron beside it. */
.ic {
  flex: 0 0 auto;
  width: 0.92em;
  height: 0.92em;
  background-color: currentColor;
  -webkit-mask-repeat: no-repeat;
          mask-repeat: no-repeat;
  -webkit-mask-position: center;
          mask-position: center;
  -webkit-mask-size: contain;
          mask-size: contain;
}
.ic-bolt  { -webkit-mask-image: var(--ic-bolt);  mask-image: var(--ic-bolt); }
.ic-think { -webkit-mask-image: var(--ic-think); mask-image: var(--ic-think); }
.ic-pin   { -webkit-mask-image: var(--ic-pin);   mask-image: var(--ic-pin); }
.ic-trash { -webkit-mask-image: var(--ic-trash); mask-image: var(--ic-trash); }
.ic-fold  { -webkit-mask-image: var(--ic-fold);  mask-image: var(--ic-fold); }
.ic-copy  { -webkit-mask-image: var(--ic-copy);  mask-image: var(--ic-copy); }
.ic-image { -webkit-mask-image: var(--ic-image); mask-image: var(--ic-image); }
.ic-x     { -webkit-mask-image: var(--ic-x);     mask-image: var(--ic-x); }
.ic-send  { -webkit-mask-image: var(--ic-send);  mask-image: var(--ic-send); }
.ic-stop  { -webkit-mask-image: var(--ic-stop);  mask-image: var(--ic-stop); }
.pin-btn.pinned .ic-pin { -webkit-mask-image: var(--ic-pin-filled); mask-image: var(--ic-pin-filled); }

/* A reply's meta row: the copy button and (on the newest reply) the context-size figure,
 * on one line under the bubble. The row owns a little breathing space above the actions
 * (margin-top) — the ~0.15rem negative left pulls the copy glyph's optical edge back onto
 * the prose column. When the step then runs tools, the row also keeps them from crowding
 * it (margin-bottom below). */
.reply-meta {
  display: flex;
  align-items: center;
  gap: 0.3rem;
  margin: 0.4rem 0 0 -0.15rem;
}
.reply-meta:not(:last-child) { margin-bottom: 0.6rem; }

/* "Copy raw markdown": a quiet icon button, no border or fill — the muted mark is the
 * whole affordance, darker on hover, the same restraint as the chat-header buttons (R007).
 * On a successful copy it swaps to a check in the accent for a beat (see .copied). */
.copy-raw {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.5rem;
  height: 1.5rem;
  padding: 0;
  border: none;
  background: none;
  color: var(--on-surface-muted);
  cursor: pointer;
}
.copy-raw:hover { color: var(--on-surface); }
.copy-raw .ic { width: 0.9rem; height: 0.9rem; }
.copy-raw.copied { color: var(--accent-text); }
.copy-raw.copied .ic-copy { -webkit-mask-image: var(--ic-check); mask-image: var(--ic-check); }

/* The context-size figure, in the meta row beside the copy button — a quiet muted count
 * ("12.3k tokens"), the same metadata tone as a timestamp, never competing with the
 * reply. Selectable (unlike the surrounding meta chrome) so the number can be copied by
 * hand — the one bit of the meta row worth grabbing. */
.ctx-tokens {
  font-size: var(--text-meta);
  color: var(--on-surface-muted);
  user-select: text;
}

/* The folded detail of a block (reasoning, or a tool result) — flush with the
 * summary's own left edge, not indented under it, muted and at the summary size. */
.think-body,
.tool-body {
  white-space: pre-wrap;
  word-break: break-word;
  margin: 0.3rem 0 0.5rem 0;
  color: var(--on-surface-muted);
  font-size: var(--text-ui);
  line-height: 1.6;
}
/* A failed tool swaps its leading bolt for a red warning triangle — front and centre
 * where the type-mark lives, so a failure is noticeable even folded, without repainting
 * the whole row red (a transient failure the agent often retries past shouldn't shout).
 * The label itself stays at the normal muted tone. */
.tool.errored > summary .ic {
  -webkit-mask-image: var(--ic-warn);
  mask-image: var(--ic-warn);
  background-color: var(--error);
}

/* A "● compacting" status notice: a plain flat activity line, no icon, no fold. */
.notice-line {
  white-space: pre-wrap;
  word-break: break-word;
  color: var(--on-surface-muted);
  font-size: var(--text-ui);
  line-height: 1.5;
}

/* ── composer ────────────────────────────────────────── */
/* One bordered box holds the textarea (borderless) and the Send button, so they
 * read as a single control instead of two unaligned boxes. */
.composer {
  padding: 1rem 2rem 1.5rem;
  max-width: 52rem;
  width: 100%;
  margin: 0 auto;
}

.composer-box {
  display: flex;
  align-items: flex-end;
  gap: 0.5rem;
  border: 1.5px solid var(--line);
  border-radius: var(--radius);
  background: var(--surface-alt);
  padding: 0.4rem 0.4rem 0.4rem 0.85rem;
}

/* The composer opts out of the global :focus-visible ring (the textarea sets outline: none in
 * its base rule above), so this border IS its focus indicator and owes the same >= 3:1 the
 * ring does — an element that opts out of the shared treatment does not opt out of the floor.
 * A second, dead .composer textarea:focus-visible rule used to restate that opt-out and set a
 * border-colour on a borderless element; it is gone. The gentle
 * neutral it used to wear — --line mixed 45% toward --on-surface-muted — measured 2.08–2.37
 * against the box's own fill: a change you can see only if you already know where to look,
 * on the one field the operator uses most. It now wears exactly what every other field in
 * the app wears on focus (R007 同類就走既有元件), which lands at 4.6–6.6. */
.composer-box:focus-within { border-color: var(--accent-text); }

.composer textarea {
  flex: 1;
  resize: none;
  /* one row by default; app.js grows it to fit content (cross-browser). */
  overflow-y: auto;
  max-height: 12rem;
  border: none;
  background: transparent;
  color: var(--on-surface);
  padding: 0.4rem 0;
  font: inherit;
  line-height: 1.5;
  outline: none;
}

.composer textarea::placeholder { color: var(--on-surface-muted); }

/* Past max-height the textarea scrolls; match the slim themed scrollbar the
 * transcript and picker use so it doesn't fall back to the chunky native bar. */
.composer textarea::-webkit-scrollbar { width: 6px; }
.composer textarea::-webkit-scrollbar-thumb { background: var(--line); border-radius: 3px; }

/* Send is deliberately quiet — Enter is the real send (the placeholder says so), so
 * the button is a muted arrow, not a loud accent-filled block. The accent shows only
 * on hover, as an interaction cue, keeping the resting composer restrained. */
/* The send button — scoped to .composer-box so it doesn't style the Approve/Reject buttons,
 * which now also live inside .composer (above the box). */
.composer-box button {
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 34px;
  height: 34px;
  border: 0;
  border-radius: var(--radius);
  background: transparent;
  color: var(--on-surface-muted);
  cursor: pointer;
  transition: background-color 0.15s ease, color 0.15s ease;
}

.composer-box button:hover:not(:disabled) { background: var(--surface); color: var(--accent-text); }
.composer-box button:disabled { opacity: 0.4; cursor: default; }

/* .ic's default 0.92em reads too small at the composer's font-size — size these two up to
   the 20px the send arrow used to be drawn at, rather than tuning size per-icon. */
.composer-box .ic { width: 20px; height: 20px; }

/* Where a failed Stop lands (see composerNote in app.js) — --error like every other refusal on
 * the page (the pairing .admin-field-status.alert makes), sized as footer metadata since it is
 * a receipt beside the button, not prose to read. Hidden (no box, no margin) whenever empty. */
.composer-note {
  font-size: var(--text-meta);
  color: var(--error);
}

/* Pictures waiting to be sent, in a row above the box. They sit OUTSIDE .composer-box on
 * purpose: inside, a strip of thumbnails would push the send button around and make the one
 * control the person aims at move every time they add a photo. The strip is what the model
 * will get — each chip says the size it was scaled to and what it now weighs, because the
 * browser re-encodes before uploading (see prepareImage in app.js) and a silent shrink is a
 * change made to someone's file without telling them. */
/* An author `display` outranks the UA sheet's `[hidden] { display: none }`, so the strip has
   to say so itself: without this the empty strip keeps its box and its margin above every
   composer on every page, and the `hidden` attribute renderAttachments toggles does nothing. */
.composer-files[hidden] {
  display: none;
}

.composer-files {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-bottom: 0.5rem;
}

.composer-file {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  align-items: center;
}

.composer-file img {
  width: 4.5rem;
  height: 4.5rem;
  object-fit: cover;
  border-radius: var(--radius);
  border: 1px solid var(--line);
  background: var(--surface-alt);
}

.composer-file-note {
  font-size: 0.72rem;
  color: var(--on-surface-muted);
  font-variant-numeric: tabular-nums;
}

/* Drop this one. Anchored to the thumbnail's corner and always visible — not hover-revealed,
 * which is no affordance at all on a phone. It carries its own opaque fill so the glyph keeps
 * its contrast over whatever the photograph happens to be underneath. */
.composer-file-drop {
  position: absolute;
  top: -0.35rem;
  right: -0.35rem;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 1.25rem;
  height: 1.25rem;
  padding: 0;
  border: 1px solid var(--line);
  border-radius: 50%;
  background: var(--surface-alt);
  color: var(--on-surface);
  cursor: pointer;
}
.composer-file-drop:hover { color: var(--accent-text); }
.composer-file-drop .ic { width: 0.7rem; height: 0.7rem; }

/* A refused attachment says so under the box, never over it: below keeps the send button
 * where the person left the pointer (form-feedback.html spells the reasoning out). */
.composer-error { margin-top: 0.5rem; }

/* Pictures inside a sent message. A modest grid rather than full-width: an attachment is
 * evidence attached to a sentence, not the message itself, and a phone photograph rendered
 * at bubble width would push the words it belongs to off the screen. Each one links to
 * itself, which is the whole "see it bigger" affordance — a native new tab, no lightbox. */
.msg-images {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-bottom: 0.4rem;
}

.msg-images img {
  display: block;
  max-width: 14rem;
  max-height: 14rem;
  border-radius: var(--radius);
  border: 1px solid var(--line);
}

/* ── focus: one restrained treatment ─────────────────── */
/* Accent at full strength — this is the one accent mark that cannot be quiet. It used to be
 * --primary at 50% alpha, which on Day put a 1.7:1 ring on white: the keyboard user could not
 * see where they were. Restraint is the right instinct for a mark that sits in the resting UI,
 * but this one only exists while a key is driving the page, and for the person driving it there
 * is no other cue at all.
 *
 * WHICH accent depends on the ground, which is the whole point of having two of them:
 * --accent-text (deepened) on paper, --accent-chrome on the sidebar panel. A single value here
 * cannot serve both — the deepened one measures 1.44–1.88 on dusk's taupe, i.e. no ring at all
 * on the default theme's entire left column, which is exactly the failure this rule replaced.
 *
 * The offset is INSET, not outward. Almost every focusable thing in this app sits inside a
 * clipping scroller (.nav-scroll, .md-panel, .admin-shell-single, .freq-tabs), and an outward
 * ring on a row that starts flush at its scroller's content edge is simply not painted on that
 * side — measured: the left segment of every sidebar row's ring was missing entirely. An inset
 * ring cannot be clipped by definition, so it is the treatment, not the exception.
 *
 * The width is --rule, the accent-indicator width, and it has to be: a nav row that is BOTH the
 * open one and the focused one wears two accent marks at its left edge — the "you are here" bar
 * (--rule wide, on the border box) and this ring (inset by exactly that much). Same colour,
 * touching, so they read as one continuous rectangle. At any other width they don't: this rule
 * asked for 1.5px, which browsers floor to an integer 1, leaving a 2px bar, a 2px gap and a 1px
 * line — three bands where the eye expects an edge, and the row's 2px radius rounding the bar's
 * corners but not the ring's, so the left side looked broken off and lopsided. */
:focus-visible {
  outline: var(--rule) solid var(--accent-text);
  outline-offset: -2px;
}
.sidebar :focus-visible { outline-color: var(--accent-chrome); }

/* ── the field ─────────────────────────────────────
 * THE APP HAS ONE FORM LANGUAGE — a --surface-alt box with a --line edge, at --text-content — and
 * it is declared HERE, once, for every field that speaks it. The list below is that language's
 * whole vocabulary; each field family adds only what is genuinely its own (its padding, and in
 * one case its ground) beside its own layout, further down.
 *
 * It used to be six near-identical copies, one per family, and the copies were how the app kept
 * being wrong in ways nobody could see coming: the accent retune had to be applied by hand to
 * each of them, a showcase's <select> rendered in raw browser chrome because it sat outside every
 * one of the six, and --text-content (the rung that stops iOS zooming the page on focus) was
 * restated with its reason six times. A field added by copying its neighbour now inherits the
 * solved values rather than whatever that neighbour happened to have.
 *
 * --text-content is load-bearing, not a size preference: iOS zooms the viewport when a field
 * under 16px takes focus. Any new field belongs in this list. */
.admin-label input,
.admin-label select,
.admin-label textarea,
.auth-label input,
.filter-bar input,
.repeat-row input:not([type='checkbox']),
.repeat-row select,
.freq-timeday input[type="time"],
.freq-timeday select,
.freq-hourly input,
.freq-custom input,
.task-assign select,
.task-note {
  font: inherit;
  font-size: var(--text-content);
  color: var(--on-surface);
  background: var(--surface-alt);
  border: 1px solid var(--line);
  border-radius: var(--radius);
}

/* …and one focus treatment, over the same list. They opt out of the shared ring because the box
 * already IS the outline: a second, inset ring 2px inside a 1px border reads as a doubled edge.
 * The border it swaps in owes the same 3:1 (--accent-text measures 4.6–7.0 on the field's own
 * --surface-alt ground, more on the task fields' lighter one). */
.admin-label input:focus,
.admin-label select:focus,
.admin-label textarea:focus,
.auth-label input:focus,
.filter-bar input:focus,
.repeat-row input:not([type='checkbox']):focus,
.repeat-row select:focus,
.freq-timeday input[type="time"]:focus,
.freq-timeday select:focus,
.freq-hourly input:focus,
.freq-custom input:focus,
.task-assign select:focus,
.task-note:focus {
  outline: none;
  border-color: var(--accent-text);
}

/* Honour a reduced-motion preference: our motion is only micro-feedback (hover
 * tints, the disclosure chevron) — none of it is load-bearing, so drop it all
 * rather than animate it. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
  }
}

/* ── nav utility rows ──────────────────────────────────────────────
 * Every non-conversation row in the sidebar: a project's Tasks / Schedules / New session /
 * Settings, the workspace-level Tables / Webhooks below the divider, the two items in the
 * account menu (Settings, Log out), and the category list while Settings mode is open. A
 * line-icon + label, styled like a conversation row but quieter — the icon, in currentColor,
 * carries it (no loud emoji), keeping the warm-editorial register.
 *
 * These were SEVEN near-identical blocks, one of which carried the comment "mirrors
 * .proj-schedules exactly" — which is the copy R007 rules out ("reuse is walking into the
 * component, not keeping a parallel half-copy of its look"). Copying is also how they drifted:
 * they had four paddings (0.32/0.35/0.4/0.6rem), two gaps and two font-sizes between them, none
 * of which any rule meant to say. One block, one set of values. The selector list rather than a
 * shared class in the markup is deliberate: it is the same dedup with the change confined to
 * this file, and the class each template already carries is what the nav JS keys .active off.
 *
 * The rows differ in which element they wrap — a link, a div, and (Log out) a form button — so
 * the block resets the three things a UA styles a <button> with and nothing else does: its own
 * font, its border, its background. .ws-account-item then adds the two properties only a button
 * inside a full-width menu needs; see it below.
 *
 * The .active rule below is written twice — .nav-scroll-prefixed to outrank the neutral
 * `.nav-scroll button:hover` fill for the rows inside the scroller, and bare for .ws-link,
 * which lives in .sidebar-workspace outside it. */
.proj-tasks,
.proj-schedules,
.proj-files,
.proj-new,
.proj-settings,
.ws-link,
.settings-nav-item,
.ws-account-item {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.35rem 0.55rem;
  border: none;
  /* Square, full stop, like the nav rows this one shares its shape with —
     see .nav-scroll .project.current-project > summary. */
  border-radius: 0;
  border-left: var(--rule) solid transparent;
  background: transparent;
  color: var(--on-secondary);
  text-decoration: none;
  cursor: pointer;
  font: inherit;
  /* One size for the whole nav — see the .project > summary note for why the column
     stopped spelling its hierarchy in font-size. */
  font-size: var(--text-ui);
  /* Where --nav-row-fade comes from. Fading a solved ink is fading its contrast: --on-secondary
     is tuned to clear 7:1 on the panel, and the 0.85 these rows started at composited it back
     down to 6.95 on dusk — under the floor, on the resting state, which is where a nav row
     spends its life. 0.92 keeps the rows a step quieter than the project names above
     them (that is all the fade was ever for) and lands at 7.80 (dusk) / 9.25 (day, whose
     near-white panel gives its ink more room to be faded). Shared by every faded
     row in the sidebar; hover and .active still go to 1. */
  opacity: var(--nav-row-fade);
  transition: background-color 0.15s ease, opacity 0.15s ease;
}
.proj-tasks:hover,
.proj-schedules:hover,
.proj-files:hover,
.proj-new:hover,
.proj-settings:hover,
.ws-link:hover,
.settings-nav-item:hover,
.ws-account-item:hover { background: var(--secondary-wash); opacity: 1; }

/* the page you're currently viewing — the same accent left bar over a neutral fill the active
 * conversation row uses, so the nav always shows what's open. (markActiveNav sets .active from
 * the URL, including /projects/<p>/new.) */
.nav-scroll .proj-tasks.active,
.nav-scroll .proj-schedules.active,
.nav-scroll .proj-files.active,
.nav-scroll .proj-new.active,
.nav-scroll .proj-settings.active,
.ws-link.active,
.settings-nav-item.active {
  background: var(--secondary-wash);
  border-left-color: var(--accent-chrome);
  opacity: 1;
}

/* …and what happens when you point AT the row you are already on. Every hover rule in this column
 * paints --secondary-wash, which is the colour these rows already rest on, so until this rule the
 * whole .active family answered the pointer with nothing at all — the row a reader points at most,
 * the one they are looking at, was the one row that did not move. R007's usable floor.
 *
 * A step further from the panel is what hover means everywhere else here (transparent → wash), so
 * this is that step taken again — but the wash cannot simply go darker or lighter. The direction
 * is not the same in all three themes (dusk's panel is mid-tone with a DARKER wash and LIGHT ink;
 * day's wash is darker than its near-white panel; night's is lighter than its near-black one), so
 * a lightness move needs a per-theme sign, and the two dark themes have no lightness room left:
 * --on-secondary-muted rests at 4.84 on night's wash, one step above the floor.
 *
 * So the step is toward the row's OWN accent instead. Hue, not lightness: one direction that is
 * the same sentence in every theme — "the row you are on, warmed toward the colour that marks it"
 * — and R007 lists the interaction cue as one of the three things accent is for. 15% is measured,
 * not chosen: it puts the fill ΔE .070–.073 from the resting wash (visible; the avatar ladder's
 * two near-identical light themes sit at .021), while the bar it tints toward keeps 4.17 (dusk) /
 * 3.50 (day) / 4.41 (night) against it and the row's ink keeps 8.84 / 7.42 / 8.76.
 *
 * This rule is the sidebar's seven rows and nothing else. The same collision exists on every
 * family that rests on --surface-alt, and each was answered where it lives rather than here:
 * .run-row.active gave the fill up and took a bar instead (see it below — it had a second, worse
 * half this rule could not have fixed anyway), and .theme-dot.active deepens its own accent wash.
 * The one still standing is .md-navitem.active: that fill has no room in any direction — its bar
 * is --primary, which measures 3.08 on --surface-alt in dusk, so tinting toward it lands at 2.68
 * and away from it costs the same 3:1 from the other side. Fixing it means a token, not a rule.
 * (.md-card.active shares --surface-alt with .md-card:hover too, but .md-card-link:hover repaints
 * the card's name, so it answers the pointer over its whole navigating area; what it does not
 * answer is a pointer resting on the card's padding or between its action chips.)
 *
 * Listed here rather than each site listing itself because the question a reader arrives with is
 * "why did this sweep stop where it did" — but the SELECTOR list above is not the only copy: the
 * same seven rows are enumerated again for the .active rule two blocks up, once more for the
 * conversation rows near .proj-sessions, and a fourth time in app.js's markActiveNav. A new kind
 * of active row has to be added to all four. */
.nav-scroll .proj-sessions button.active:hover,
.nav-scroll .proj-tasks.active:hover,
.nav-scroll .proj-schedules.active:hover,
.nav-scroll .proj-files.active:hover,
.nav-scroll .proj-new.active:hover,
.nav-scroll .proj-settings.active:hover,
.ws-link.active:hover,
.settings-nav-item.active:hover {
  background: color-mix(in srgb, var(--secondary-wash) 85%, var(--accent-chrome));
}

/* The leading mark: an inline SVG on some rows, a glyph span on others. Decorative beside a
 * text label that already says the same thing, so the 0.7 fade is exempt from 1.4.3. */
.proj-tasks svg,
.proj-schedules .cal,
.proj-files svg,
.proj-settings .cog,
.ws-link svg,
.ws-account-item svg,
.proj-new-icon { flex-shrink: 0; opacity: 0.7; }

/* ── proposal card (in-conversation schedule approval) ───
 * A turn suspended at propose_schedule: the proposed change, with Approve/Reject. The
 * accent border marks it as needing a decision; otherwise the same restrained palette —
 * colour blocks and whitespace, 2px radius, no shadows or gradients. */
/* Pinned above the composer box (not in the scrolling transcript), so it shares the
 * composer's width and left edge and stays in reach while the turn is suspended. */
.proposal-card {
  margin: 0 0 0.6rem;
  padding: 0.75rem 0.9rem;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--surface-alt);
}
/* The eyebrow is fixed boilerplate ("Schedule proposal"/"Schema proposal"/"Webhook proposal"
 * — one per capability) — quiet, not accented; the accent is reserved for the verb below, the
 * one word that actually changes and carries the decision. */
.proposal-head {
  font-size: var(--text-meta);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--on-surface-muted);
  margin-bottom: 0.4rem;
}
.proposal-body { font-size: var(--text-content); }

/* ── Schedule card ──: verb, id and name share one line, same size, neither bold — only the
 * verb carries colour (the restrained accent-as-cue the rest of the UI uses); id and name read
 * as plain content, same weight as everything else the person is deciding on. */
.proposal-action { color: var(--accent-text); text-transform: uppercase; margin-right: 0.3rem; }
/* ── Question card ──: who the question is waiting on, under the question itself. Meta tone,
 * because it is about the question rather than part of it; the NAME takes the accent, the one
 * thing on this card that is an identity (R007). Not uppercased the way the eyebrow above is —
 * a person's name is not a label. */
.proposal-for { font-size: var(--text-meta); color: var(--on-surface-muted); margin-top: 0.25rem; }
.proposal-person { color: var(--accent-text); }
.proposal-name { color: var(--on-surface); }
.proposal-ref { color: var(--on-surface); }
/* When gets its own line below, in the body's normal text colour (not muted) — it's content
 * the person is deciding on, not a caption. */
.proposal-when { display: block; margin-top: 0.15rem; }
/* The proposed prompt can be long; cap its height and scroll it with the slim themed scrollbar
 * rather than letting the whole card grow past the viewport. */
.proposal-prompt {
  color: var(--on-surface-muted);
  margin-top: 0.4rem;
  white-space: pre-wrap;
  word-break: break-word;
  max-height: 9rem;
  overflow-y: auto;
}
.proposal-prompt::-webkit-scrollbar { width: 6px; }
.proposal-prompt::-webkit-scrollbar-thumb { background: var(--line); border-radius: 3px; }

/* ── Schema / Webhook card ──: same head as a schedule (accented verb + the table/webhook name),
 * then one plain-language sentence below — not a field table. A new table's column list is that
 * same sentence, in the body's normal colour: the muted, capped .proposal-prompt is for a
 * schedule's long free-text PROMPT, not for short content the person is deciding on.
 * A destructive change (drop_field/drop_table, a webhook removal or a set that stops the
 * restoring) reddens THE VERB AND ONLY THE
 * VERB — the same one-word slot the accent uses on every other row, swapped to the danger colour.
 * "This can't be undone." is wording inside the sentence, not a second line in a second colour: a
 * red paragraph is volume, not protection, and the approval gate is what actually stops the change
 * (R003/R007 — quiet, accent spent only on the decision keyword).
 * A schema proposal is a BATCH: one .proposal-change per change (a batch of one reads like a single
 * card); rows are spaced apart and each destructive row's verb reddens on its own. */
.proposal-change + .proposal-change { margin-top: 0.5rem; }
.proposal-summary { display: block; margin-top: 0.15rem; color: var(--on-surface); white-space: pre-wrap; word-break: break-word; }
.proposal-body.destructive .proposal-action,
.proposal-change.destructive .proposal-action { color: var(--error); }

/* ── Reject reason ──: a line to write on, not a filled box, sharing the decision row so its place
 * beside Reject says which decision it is for. At the BODY's type size it is found by size and the
 * rule under it — the card is already a panel floating over a dead composer, so it never has to be
 * the brightest block on it. Brightness was the whole problem with a filled field: an ordinary form
 * field's fill (--surface-alt) is exactly this card's background, so keeping it meant lifting the
 * optional side path to --surface, louder than the change being decided. Hence no fill, no outline,
 * no radius — just the rule.
 * flex-wrap plus a flex-basis (rather than a bare flex:1) is what makes a narrow pane drop the field
 * onto its own full-width line under the buttons instead of crushing it to a few unreadable
 * characters. That is deliberately the ONLY responsive behaviour here: reversing the order when
 * stacked (field above the buttons) would take this stylesheet's first width breakpoint, and a
 * VIEWPORT breakpoint is the wrong ruler anyway — this card's width follows the chat pane, which
 * follows the nav. Flex wrapping reads the real width for free.
 * The rule is --on-surface-muted, NOT the lighter --line every other divider uses, and that is the
 * one thing here worth not "simplifying" later: at --line the field is legible while empty (a muted
 * hint over a hairline) but DISSOLVES once filled — the person's own words land at the same size,
 * colour and left edge as the proposal's body text, so the card reads as if they were part of what
 * is being proposed. The rule has to be definite enough to own the text sitting on it.
 * It is deliberately not the shared form-field atom: there a field IS the pane's content and earns
 * a box; here it is one optional line on a decision row. Focus goes accent the way every
 * other field's border does. The hint only says what this is and that it is optional — the worked
 * example belongs in the README, not on every card a person ever sees. With no visible label left,
 * the accessible name rides on aria-label, and the placeholder takes a muted colour rather than
 * the UA's washed-out grey, which sits under the 4.5:1 floor.
 * The placeholder blends --on-surface-muted 70/30 toward --on-surface rather than taking the bare
 * token. That started as a rescue — the token was then solved against --surface alone and fell to
 * 4.07:1 / 4.35:1 on this card's --surface-alt ground — and the token has since been solved on both
 * grounds (5.04 / 5.34), so the mix is no longer load-bearing for the floor. It stays because a
 * placeholder should sit a step under the muted text around it, which is what the mix now buys. */
.proposal-note {
  display: block;
  width: 100%;
  margin: 0.7rem 0 0;
  font: inherit;
  font-size: var(--text-content);
  color: var(--on-surface);
  background: transparent;
  border: 0;
  border-bottom: 1px solid var(--on-surface-muted);
  border-radius: 0;
  padding: 0.35rem 0;
}
.proposal-note:focus { outline: none; border-bottom-color: var(--accent-text); }
.proposal-note::placeholder { color: color-mix(in srgb, var(--on-surface-muted) 70%, var(--on-surface)); }

/* Right-aligned, with the DECLINING button first. This card sits in the composer's place and
 * disables it, and the composer's own Send — the button that moves the conversation on — is at
 * the bottom right; the one that does the same here belongs where the hand already goes. Putting
 * the exit first also means the button under a mis-aimed click is the one that costs nothing.
 * Same arrangement as .confirm-dialog-acts, for the same reason. */
.proposal-actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  align-items: center;
  gap: 0.4rem 0.5rem;
  margin-top: 0.6rem;
}
/* The resting edge is --control-edge, not --line. These used to take --line at 1.36–1.56, which
 * is a DIVIDER's value: on a card whose ground is only a shade off the paper, that read as two
 * words floating rather than two buttons, and the quiet one looked like it wasn't there. A button
 * is not a rule between things, it is a thing to press, and its shape is the cue — 3:1 (WCAG
 * 1.4.11). Both buttons take the SAME edge: the accent falls on the ink, and on the primary's
 * border, so the two are told apart by hue, not by whose frame is heavier. The confirm dialog's
 * plain button is the same shape and takes the same token — see .confirm-dialog .act-btn below. */
.proposal-btn {
  font: inherit;
  font-size: var(--text-ui);
  padding: 0.3rem 0.85rem;
  border: 1px solid var(--control-edge);
  border-radius: var(--radius);
  background: var(--surface);
  color: var(--on-surface);
  cursor: pointer;
  transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.proposal-btn:hover:not(:disabled) { border-color: var(--primary); color: var(--accent-text); }
.proposal-btn:disabled { opacity: 0.5; cursor: default; }
/* The button that goes ahead — Submit on a question, and nothing else today: Approve and Reject
 * stay equal-weight, because on a proposal neither is "more correct" and the card's accent verb
 * already marks the decision. An ask is not symmetric — answering is the point and skipping is
 * the exit — so the one that answers takes the accent, exactly as .act-btn--primary does
 * elsewhere (same declaration, deliberately: this IS that button, in this card's vocabulary).
 *
 * There is deliberately NO --danger variant here, though .act-btn has one. A destructive
 * proposal's Approve keeps the ordinary treatment: the warning belongs on the VERB and the plain
 * sentence saying what is lost, which the card already colours. Painting the button too would
 * spend the alarm colour twice for one fact — and on Dusk it puts --error within a step of the
 * accent, so "this destroys data" and "this is the main action" stop being distinguishable at a
 * glance, which is the exact trap this stylesheet's header already records falling into once. */
.proposal-btn--primary { color: var(--accent-text); border-color: var(--primary); }
.proposal-btn--primary:hover:not(:disabled) {
  /* 6%, the same wash .act-btn--primary:hover takes and for the reason recorded there — this
     card's ground (--surface-alt) is the tightest one it lands on, so it is the one that set the
     number. */
  background: color-mix(in srgb, var(--primary) 6%, transparent);
}

/* ── the ask_person card's own middle ────────────────────
 * Everything above is shared with the three proposal cards; what follows exists only on a
 * question. The options are native radios in rows a person clicks anywhere on — the row is the
 * target, not the 13px disc (R003: this is a small team on a laptop, not a form-filling expert).
 * The rows keep --line rather than --control-edge, and that is not an oversight: the radio inside
 * each one is itself a visible control at full strength, so the box around it is grouping, which
 * is exactly what --line is for. */
.proposal-question { margin: 0; font-size: var(--text-content); }
.proposal-options { display: flex; flex-direction: column; gap: 0.35rem; margin-top: 0.6rem; }
.proposal-option {
  display: grid;
  grid-template-columns: auto 1fr;
  column-gap: 0.55rem;
  align-items: start;
  padding: 0.45rem 0.7rem;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--surface);
  color: var(--on-surface);
  cursor: pointer;
  transition: border-color 0.15s ease, color 0.15s ease;
}
.proposal-option:hover { border-color: var(--primary); }
.proposal-option:hover .proposal-option-label { color: var(--accent-text); }
.proposal-option:focus-within { outline: 2px solid var(--accent-text); outline-offset: 2px; }
.proposal-option input[type="radio"] { margin: 0.32rem 0 0; accent-color: var(--primary); }
/* The chosen row is marked by BORDER AND INK, not by colour alone — the radio's own dot is the
 * primary cue and this only seconds it, but a row that said "picked" in hue only would be one
 * more thing a person cannot see (1.4.1). */
.proposal-option--chosen { border-color: var(--primary); }
.proposal-option--chosen .proposal-option-label { color: var(--accent-text); }
.proposal-option-label { grid-column: 2; display: block; font-size: var(--text-content); cursor: pointer; }
.proposal-option-detail {
  grid-column: 2;
  display: block;
  font-size: var(--text-meta);
  color: var(--on-surface-muted);
  margin-top: 0.1rem;
}
/* The always-present last row: the person's own answer. Dashed, because it is the one option the
 * agent did not write — the card adds it to every question and the model cannot take it away. */
.proposal-option--other { border-style: dashed; }
/* A line to write on, like .proposal-note above and for the same reason — a filled box would be
 * the loudest thing on a card whose job is the question. */
.proposal-other {
  grid-column: 2;
  display: block;
  width: 100%;
  margin-top: 0.3rem;
  font: inherit;
  font-size: var(--text-content);
  color: var(--on-surface);
  background: transparent;
  border: 0;
  border-bottom: 1px solid var(--on-surface-muted);
  border-radius: 0;
  padding: 0.25rem 0;
}
.proposal-other:focus { outline: none; border-bottom-color: var(--accent-text); }
.proposal-other::placeholder { color: color-mix(in srgb, var(--on-surface-muted) 70%, var(--on-surface)); }

/* ── master-detail shell ─────────────────────────────────
 * The two-pane list+detail layout shared by Schedules, Webhooks and the Models
 * settings pane: a master list on the left, one detail/form view on the right.
 * One vocabulary (md-*) so the three pages speak the same visual language —
 * change a token here and all three follow. Lives inside #chat (already 1fr). */
.md-shell {
  display: grid;
  grid-template-columns: 22rem 1fr;
  height: 100%;
  overflow: hidden;
}
/* A slimmer master column, for a list whose rows are a name and a number (Tables). The detail
 * there is a records GRID, which wants every pixel it can get — 22rem of mostly-empty column
 * beside a squeezed table is the wrong trade (R007 實用先於好看). One modifier, not a second
 * shell: everything else about the layout is the same. */
.md-shell--slim { grid-template-columns: 16rem 1fr; }
.md-panel {
  /* A clearly-visible divider — a mid tone between the hairline --line and the muted text — so the
   * master list reads as a distinct column from the detail pane, rather than one surface split by a
   * barely-there hairline. It's a decorative rule (no AA text-contrast floor to hit), and both
   * panes keep --surface so the muted card metadata stays ≥AA (R007) — a recessed panel background
   * would drop it below 4.5:1. Theme-aware via color-mix so it holds in Day/Dusk/Night. */
  border-right: 1px solid color-mix(in srgb, var(--line) 45%, var(--on-surface-muted));
  overflow-y: auto;
  display: flex;
  flex-direction: column;
}
/* A flex column (not just a scroll box) so an embedded chat.html's .chat-form
 * (flex: 1) fills the pane the same way it does in the top-level .chat — without
 * this, .chat-form's flex:1 is a no-op outside a flex parent and the transcript
 * stops short of the pane's height (the schedule runs page embeds a chat here). */
.md-view { overflow-y: auto; display: flex; flex-direction: column; }
/* ── a detail pane's geometry, declared once ─────
 * A detail face in this shell has exactly THREE widths, decided by what it holds:
 *
 *   清單／表格 → FULL BLEED (.sched-runs: the schedules' and webhooks' run lists). The rows need
 *                the width — time, name and preview sit side by side — and a row's hover fill has
 *                to span the whole pane or it doesn't read as a row.
 *   表單／散文 → THIS: 46rem, left, 1.75rem/2rem padding. Line length has a ceiling, and fields
 *                that stretch across a wide screen stop reading as a column.
 *   chat       → 52rem CENTRED (.chat-header-inner / .transcript-inner), its own family; the run
 *                page is the one detail face that wears it.
 *
 * Adding a detail face means deciding WHICH of the three it is and walking into that one. The
 * three names below are kept because each has its own job elsewhere (a form's field vocabulary, a
 * settings pane's stack, a focus page's density) — but the geometry is one thing and now says so.
 * It was three copies of these same numbers, which is how a showcase pane arrived carrying
 * .admin-pane's own scrollbar: a focus page scrolls itself because it IS the window; as a detail
 * face it is a 46rem column inside a .md-view that already scrolls. Copying half a component is
 * how you get the half that doesn't fit (R007 同類就走既有元件). */
.md-form,
.md-view--pad,
.md-view > .admin-pane { padding: 1.75rem 2rem; max-width: 46rem; width: 100%; }
/* The master list and detail pane wear the same slim themed scrollbar as the transcript and
 * the admin panes — without this they fall back to the native OS bar, which reads as a foreign
 * element beside the app's own (the visible mismatch on the master column). */
.md-panel::-webkit-scrollbar,
.md-view::-webkit-scrollbar { width: 6px; }
.md-panel::-webkit-scrollbar-thumb,
.md-view::-webkit-scrollbar-thumb { background: var(--line); border-radius: 3px; }

/* ── runs detail-pane header inset ──────────
 * The runs pane names itself (#6) with the project-settings pane header (.admin-head--page) —
 * the same atom every project-scoped pane wears, not a parallel one (R007).
 * Its padding matches .md-form's (1.75rem 2rem) so the header lands at the exact same x AND y
 * as the edit form's header — toggling Edit ↔ Runs, the title doesn't move. The rows below
 * carry their own 2rem inset (.sched-runs stays full-bleed for the rows' hover fill). */
.md-head { padding: 1.75rem 2rem 0; }

/* A settings-style pane used as a master-detail's DETAIL (the Tables and Showcases panes). Its
 * geometry comes from the shared rule above — the padding it takes there is the detail-face one,
 * not .admin-pane's own 3rem top, which is a FOCUS page's inset (the window edge is right there
 * with nothing above it). Beside a master column that edge is already drawn, and the pane header
 * has to line up with the other detail views' (.md-head, .md-form) or the title visibly drops
 * when you switch to it.
 *
 * What it gives up here is its OWN scrollbar. .admin-pane scrolls itself because as a focus page
 * it is the whole window; as a detail face it isn't — it is a 46rem column inside a wider
 * .md-view, which already scrolls (and wears the slim themed bar). Left as-is, the bar appeared
 * at the column's right edge, floating mid-pane, while every other detail view (.md-form,
 * .md-head, the embedded chat) puts it against the window edge. Two scroll boxes nested, one
 * visible in the wrong place. */
.md-view > .admin-pane { overflow-y: visible; }

/* A full-width row at the top of the panel: the ＋New create entry, or a fixed nav
 * item like the Models "Settings" row. A create action stays quiet (no accent text,
 * like New session, R007); every selectable row marks "you are here" the same way the
 * rest of the app's nav does — an accent left-bar over a neutral fill, scannable and
 * consistent (a fill alone was too faint to catch at a glance). */
.md-navitem {
  display: block;
  padding: 0.6rem 1rem;
  border-bottom: 1px solid var(--line);
  border-left: var(--rule) solid transparent;
  color: var(--on-surface); /* not accent: a create action stays quiet, like New session (R007) */
  text-decoration: none;
  font-size: var(--text-ui);
  transition: background-color 0.15s ease;
}
.md-navitem:hover { background: var(--surface-alt); }
/* --primary, not --accent-text: an accent LINE on paper owes 3:1, not a word's 4.5:1 — see the
   blockquote rule, which is the same idiom and now takes the same token.
   KNOWN, and deliberately left: this fill is the same --surface-alt :hover paints one line up, so
   pointing at the row you are already on changes nothing. Every other family with that collision
   was fixed; this is the one with no room — the arithmetic, and why it needs a token rather than
   a rule, is written once beside .active:hover in the nav section. Do not re-derive it here. */
.md-navitem.active { background: var(--surface-alt); border-left-color: var(--primary); }

/* ── cards (one item in the master list) ── */
.md-card {
  border-bottom: 1px solid var(--line);
  border-left: var(--rule) solid transparent;
  padding: 0.7rem 1rem;
  transition: background-color 0.15s ease; /* same hover fade as .md-navitem, so every row in the shell eases alike */
}
.md-card.active { background: var(--surface-alt); border-left-color: var(--primary); } /* see .md-navitem.active */
.md-card:hover { background: var(--surface-alt); }
/* The card's main link — an `<a class="md-card-link">` inside the `<div class="md-card">` on all
 * three master-detail pages: it navigates while the action chips beside it stay their own controls. */
.md-card-link {
  display: block;
  min-width: 0;
  text-decoration: none;
  color: var(--on-surface);
  cursor: pointer;
}
/* No font-weight: it is a row of a list, and every row would wear it (see the header's note on
   where the emphasis weight is spent). The name is already the only full-contrast text in the
   card — the schedule line, the tag and the state under it are all muted — and it is a rung
   above them in size. Weight would be the third signal saying the same thing. */
.md-card-name {
  display: block;
  min-width: 0; /* so the name (not the tag) is what ellipsizes inside .md-card-nrow */
  font-size: var(--text-content);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.md-card-link:hover .md-card-name { color: var(--accent-text); }
/* Name + a trailing tag on one line: the name ellipsizes, the tag stays whole. Only cards that
 * carry a tag (a model's "default") wrap in this; a tagless card keeps the name as a plain block. */
.md-card-nrow {
  display: flex;
  align-items: baseline;
  gap: 0.35rem;
  min-width: 0;
}
/* A small "default" tag inline after a card name (the workspace-default model). Identity, not an
 * action, so an accent OUTLINE marks it (R007) — and the text stays muted rather than accent
 * because a tag is a label, not a decision keyword; --accent-text would clear the floor here
 * (5.1–5.2) but would spend accent on something unremarkable. Quiet — no fill. */
/* A showcase's capability list is the same atom in a different place: a quiet, outlined label
 * naming what something IS, never a control. Walked into rather than copied (R007) — the one
 * rule below, two selectors, so the two can't drift apart. */
.md-card-tag,
.showcase-uses li {
  flex-shrink: 0; /* never clipped by the name's ellipsis — it's the whole point of the tag */
  font-size: var(--text-meta);
  color: var(--on-surface-muted);
  border: 1px solid color-mix(in srgb, var(--primary) 45%, transparent);
  border-radius: var(--radius);
  padding: 0 0.25rem;
  white-space: nowrap;
}
/* A secondary line under the name — a schedule's cadence / last run, or a model's id.
 * .mono renders an id in the mono face; .failed recolours a failed-run line. */
.md-card-meta {
  display: block;
  font-size: var(--text-meta);
  color: var(--on-surface-muted);
  margin-top: 0.15rem;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.md-card-meta.mono { font-family: var(--font-mono); }
.md-card-meta.failed { color: var(--error); }
/* …and .waiting recolours a run parked at a decision. Deliberately NOT --error: a schedule
 * waiting for an answer is not broken, and the one outcome colour has to keep meaning "this
 * went wrong". It takes --accent-text instead — the accent's AA-verified text value, spent on
 * the decision point R007 names as one of accent's three jobs. The line says "waiting for you"
 * in words either way, so nothing here rests on the colour alone. */
.md-card-meta.waiting { color: var(--accent-text); }
/* The action row on a card — Pause/Resume/Edit/Delete/Mapping on a Schedule/Webhook, and the
 * Delete form + set/not-set status on a Model/Secret card. Absent only where a card has no
 * actions. */
.md-card-acts {
  display: flex;
  align-items: center;
  gap: 0.25rem;
  margin-top: 0.45rem;
  flex-wrap: wrap;
}
.md-card-acts form { margin: 0; }
/* A small status pill — the paused / no-secret flag on a card, or a set/not-set badge inline in
 * a form label. Quiet by default (a glyph + word, no colour); .alert recolours it to flag an
 * attention state (an unsigned hook, a missing key). In a card's action row it's pushed to the
 * right edge (margin-left:auto); in a form label the label's own flex layout places it. */
.md-status {
  display: inline-flex;
  align-items: center;
  gap: 0.25rem;
  font-size: var(--text-meta);
  color: var(--on-surface-muted);
  white-space: nowrap;
}
.md-status svg { flex-shrink: 0; }
.md-status.alert { color: var(--error); }
.md-card-acts .md-status { margin-left: auto; }

/* ── action chips (Pause / Resume / Edit / Delete) ─── */
/* --text-ui, not the --text-meta the rest of a card's chrome uses: this is a CLICK TARGET
 * carrying a verb the operator has to read before acting, not meta they skim. Meta can be
 * the quietest thing on the card; the button that deletes a schedule cannot. */
.act-btn {
  font: inherit;
  font-size: var(--text-ui);
  padding: 0.18rem 0.5rem;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: transparent;
  color: var(--on-surface-muted);
  cursor: pointer;
  text-decoration: none;
  display: inline-block;
  line-height: 1.5;
}
/* hover recolours the border AND the label to the accent — the whole chip is the interaction
 * cue (R007 accent-for-interaction), while the resting button stays a quiet neutral hairline.
 * Every action hovers alike, destructive included: a red hover only appears once the pointer is
 * already on the button, so it warns too late to be the guard — every destructive action here
 * carries an hx-confirm that names the consequence in words, and THAT is the safety floor
 * (R007). One hover rule, no variants. */
.act-btn:hover { border-color: var(--primary); color: var(--accent-text); }
/* The two variants exist ONLY inside the confirm dialog, where one of the two buttons carries out
 * the action and the other backs out: there, "which one does the thing" has to be visible before
 * the pointer arrives, because the whole point of the dialog is that the next click is the last
 * chance. Everywhere else an action stays quiet (R007 action 安靜) — a page of coloured chips
 * spends the accent on things that are merely available. That ONLY is asserted, not merely
 * written down (TestTheColouredButtonVariantsStayInTheConfirmDialog): it had already stopped
 * being true once, on the Files pane, and the hover wash below is measured against this dialog's
 * ground alone. Colour on the ink and the hairline, not
 * a filled block: a fill would make one button the loudest thing on the screen, and the dialog is
 * already the only thing on it. */
.act-btn--primary { color: var(--accent-text); border-color: var(--primary); }
.act-btn--danger { color: var(--error); border-color: var(--error); }
/* Hover adds a wash instead of recolouring, because these two are ALREADY coloured — the shared
 * .act-btn:hover rule would drag the red one to the accent, i.e. hovering the delete button would
 * stop it looking like a delete button. Both variants sit after that rule for exactly this.
 *
 * 6%, and the number is not a taste call: the wash is made of the SAME hue as the label, so every
 * point of it is paid for out of the label's own contrast. At the 12% this started on, the red
 * button — the delete button, the one that must never be hard to read — measured 4.38 on dusk,
 * under the AA floor R007 locks. The tightest ground any of these three washes lands on is the
 * proposal card's --surface-alt, and 6% is what keeps THAT one (4.61 token-against-token, ~4.51
 * with the grain composited in) above 4.5. One value for all three because they are one button in
 * three vocabularies; the composited-ground rows in assets_contrast_test.go are what hold it. */
.act-btn--primary:hover { color: var(--accent-text); background: color-mix(in srgb, var(--primary) 6%, transparent); }
.act-btn--danger:hover { color: var(--error); border-color: var(--error); background: color-mix(in srgb, var(--error) 6%, transparent); }

/* ── confirm dialog (index.html + app.js) ─────── */
/* One fixed width, not shrink-to-fit: a one-line question ("Run 'daily' now?") collapsed the box
 * to a stub that read as broken, and every confirm came up a different size than the last. 26rem
 * is where the longest of them (the delete questions, which name what is lost) already sat, so
 * pinning it there changes nothing about those and gives the short ones a body to sit in. The
 * min() keeps a 1rem margin on a phone. */
.confirm-dialog {
  width: min(26rem, calc(100vw - 2rem));
  padding: 1.25rem;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--surface);
  color: var(--on-surface);
  font-size: var(--text-ui);
  line-height: 1.6;
}
/* Dim rather than tint: the backdrop's job is to take the page out of play, and a neutral scrim
 * does that without adding a fourth colour to a screen that is already one box on one surface.
 * A fixed dark wash works on all three themes because it is under the dialog's own opaque
 * surface, never under text. */
.confirm-dialog::backdrop { background: rgba(0, 0, 0, 0.45); }
.confirm-dialog p { margin: 0 0 1.25rem; white-space: pre-wrap; }
/* Both buttons together at the bottom right, cancel then confirm — the arrangement every desktop
 * and web dialog uses, so the hand already knows where the safe one is. They were tried at
 * opposite edges to make a mis-click harder; that reads as "these two are not a pair" (the split
 * layout belongs to a dialog whose left button is a third, different action) and leaves a lake of
 * dead space in the middle. What actually prevents the mis-click is here instead: a 1.5rem gap
 * (three times the usual chip spacing), a bigger target than a card's chip, a confirm button you
 * can tell apart before you aim, and the focus starting on Cancel. */
/* The dialog's plain button — the one that backs out — takes --control-edge, the same step up
 * .proposal-btn takes and for the same reason: here the resting border IS the button's shape,
 * and --line's 1.36–1.56 is a divider's value, not a control's (WCAG 1.4.11 asks 3:1). It is
 * scoped past the two coloured variants, which already carry a border strong enough to see; a
 * blanket .confirm-dialog .act-btn would out-specify them and repaint the delete button's red
 * edge grey. The chips OUTSIDE the dialog stay on --line deliberately — a page of them should
 * read as available, not as a row of framed buttons (R007 action 安靜). */
.confirm-dialog .act-btn:not(.act-btn--primary):not(.act-btn--danger) {
  border-color: var(--control-edge);
}
.confirm-dialog-acts { display: flex; justify-content: flex-end; gap: 1.5rem; }
/* Half a size up from a card's act-btn (29px → 36px), not the full 44px touch floor: this is a
 * place you aim at, but the buttons are still act-btns and a 44px pair would be the largest
 * control in the app. Pointers that actually need the 44px get it below. */
.confirm-dialog .act-btn { padding: 0.3rem 0.9rem; min-height: 2.25rem; }
@media (pointer: coarse) {
  .confirm-dialog .act-btn { min-height: 2.75rem; }
}

.md-empty {
  padding: 1rem;
  font-size: var(--text-ui);
  color: var(--on-surface-muted);
  line-height: 1.5;
}

/* ── right panel states ─────── */
.md-view-empty {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
  color: var(--on-surface-muted);
  font-size: var(--text-content);
  text-align: center;
  padding: 2rem;
}

/* ── runs panel (inside md-view or md-panel) ───── */
.sched-runs { padding: 0 0 0.25rem; }
/* The run-page sidebar's own title, naming the schedule whose runs it lists. It labels a MASTER
 * column, not a detail pane — every detail pane names itself with .md-head + .admin-head--page
 * instead (R007). Not uppercased: it carries a schedule name the operator
 * chose (see "Casing" in the header). --text-content at --weight-emph, full contrast: a rung
 * BELOW the runs pane's own --text-title head, which is the master/detail relation every other
 * pair in the app already has (a webhook's list rows against its --page-headed detail). Emphasis
 * via type and contrast, not a weight of its own.
 *
 * Its air is symmetric — 0.9rem above and below — because it sits between two drawn lines: the
 * back link's rule above and the date picker below. It was briefly 0.35rem on top, pulled up
 * against the back link during the revisions in which that rule was transparent; air alone was
 * the only thing left to say "these two are one header block", and a title floating in symmetric
 * nothing read as sitting between two strangers. The rule is back (see .run-page-back), so the
 * separation is drawn rather than implied and the reason to lean is gone. Air that leans is a
 * thing to justify; air that does not is just the section rhythm.
 * Side inset stays 1rem to line up with the filter and rows (the pane's 2rem is its own gutter).
 * No project name: the nav carries that on every screen, so this narrow column is spent on the
 * schedule alone. */
.runs-head {
  padding: 0.9rem 1rem;
  font-size: var(--text-content);
  font-weight: var(--weight-emph);
}
/* ── filter bar: the schedule runs window AND the webhook received filter ─────
 * One component, not two lookalikes (R007 同類就走既有元件): both narrow a history by a date
 * window, applied on change with no Filter button, so they wear the same row. Each field labels
 * its own input above it — a label beside a native date input has to fight the input's own
 * yyyy/mm/dd placeholder for the same reading line. The inputs speak the app's form language
 * (surface-alt fill, hairline border, accent on focus) rather than the browser's default, and
 * the rule underneath closes the header block off from the rows it filters. Wraps on a narrow
 * panel. Detail panes get the 2rem gutter by default; the run page's master column resets it to
 * its own 1rem below. */
.filter-bar {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 0.5rem 0.75rem;
  /* The air goes INSIDE the block, above the rule — not below it as a margin. Every other rule in
   * these lists (one per row) is followed immediately by its row, so a margin under this one made
   * the first row float away from the line while every later row hugged its own: the same
   * boundary, two different gaps. Padding keeps the runway where it belongs — the filter's own
   * breathing room — and leaves the rule reading like all the others. */
  padding: 0 2rem 1.5rem;
  border-bottom: 1px solid var(--line);
}
.filter-field { display: flex; flex-direction: column; gap: 0.3rem; }
.filter-field > span { font-size: var(--text-meta); color: var(--on-surface-muted); }
/* Dressed by the shared field rule ("the field"); a bar's field sits tighter than a form's. */
.filter-bar input { padding: 0.4rem 0.55rem; }
/* The search box (received view only) is a field, not a bar: a comfortable reading width for a
 * phrase, then it stops. Stretching it into the pane's leftover width made it three times its
 * neighbours and read as the row's subject, when it is one filter of three. max-width keeps it
 * inside a narrow pane, where the row wraps anyway. */
.filter-search input { width: 18rem; max-width: 100%; }
/* The run page's master column is a 1rem-inset list, not a detail pane — the filter lines up with
 * the column head and the run rows around it. */
.md-panel .filter-bar { padding-left: 1rem; padding-right: 1rem; }

/* The list's own breathing room, given ONCE at the top of the list rather than by whatever
 * happens to be the first thing in it. The empty state and a run row are two states of the same
 * slot, and they used to arrive with two different gaps under the filter's rule: 18px for the
 * empty state (a <p>, so the UA's 1em margin — a number nobody in this file chose) and 6.4px for
 * the row (its own padding). Zeroing the paragraph's margin here and paying the gap from the
 * container is what makes the two states interchangeable, which is what they are. */
.sched-runs-list,
#run-runs-list { padding: 0.35rem 0; }
.runs-empty {
  margin: 0;
  padding: 0.4rem 1rem;
  font-size: var(--text-ui);
  color: var(--on-surface-muted);
}
/* The day a group of runs happened, written once over them (sched-runs.html / run-page.html).
 * Metadata voice, so the runs themselves stay the thing you scan; it is a heading only in the
 * sense that it labels what follows. Its top padding is the air BETWEEN groups — the first one
 * sits under the list's own inset, so it does not need a rule to separate it from the filter. */
.run-day {
  padding: 0.7rem 1rem 0.15rem;
  font-size: var(--text-meta);
  color: var(--on-surface-muted);
}
.run-row {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
  /* The left slot every marked row in this app reserves, so selecting one does not shift its own
     text sideways by the width of the bar. Taken OUT of the inset rather than added to it: the
     clock keeps the exact left edge the .run-day heading above it starts on. */
  border-left: var(--rule) solid transparent;
  padding: 0.4rem 1rem 0.4rem calc(1rem - var(--rule));
  text-decoration: none;
  color: var(--on-surface);
  font-size: var(--text-ui);
}
.run-row:hover { background: var(--surface-alt); }
/* "Selected" is the bar; the fill is left to mean hover — the same trade .current-project made in
 * the nav, and for the same two reasons doubled here. The fill USED to be the whole of selected,
 * and --surface-alt is also what :hover paints, so (1) pointing at the open run changed nothing
 * at all, and (2) pointing at any OTHER run painted it the exact colour that means selected —
 * with no bar anywhere on this row to tell the two apart, the column briefly showed two selected
 * runs and no way to tell which one you were reading. That second half is why this row could not
 * take the .active:hover tint the sidebar rows took: a tint answers the pointer but leaves the
 * ambiguity, and the fills that clear AA against this row's muted clock (5.38 / 5.34 / 5.18 at
 * rest, and the clock is the row's smallest text) are the ones too close to see.
 *
 * --primary, not --accent-chrome: this is a master-column list like .md-card, whose mark it now
 * matches exactly, and chrome's accent is tuned for the sidebar's dark panel — it measures 1.69
 * on --surface-alt in dusk. --primary measures 3.32 at rest and 3.13 hovered there (5.79/5.41 day,
 * 7.00/6.57 night): the bar is the only cue that this row is the open one, so 1.4.11's 3:1 is the
 * number it owes on BOTH fills.
 *
 * No weight on top, then or now: changing weight rewraps the row's text, so selecting a run
 * nudged the preview's ellipsis — the one row you just pointed at was the one that moved. */
.run-row.active { border-left-color: var(--primary); }
/* The running indicator sits INLINE ahead of the clock, and a row with nothing to report writes
 * nothing at all — no reserved column. So a running row starts further in than its neighbours:
 * accepted, and the cheaper side of the trade. Holding a slot open on every row would indent
 * every title on every day to keep a place for a state most rows never enter.
 *
 * align-self: center rather than the row's baseline, because .msg-loading is a flex box with no
 * text in it: it has no real baseline, so the row falls back to its bottom margin edge and the
 * dots line up with the clock beside them by luck. Centring is the rule that says what was meant. */
.run-row .msg-loading { align-self: center; }
/* The clock opens the row at the list's own left edge — the same edge the day heading above it
 * starts on. It was briefly right-aligned inside a slot wide enough for the widest time, which
 * lines the PREVIEWS up but indents every clock shorter than "10:10 PM" by a character: a
 * one-character ragged right edge in exchange for every row sitting in from the heading. The
 * left edge is the one a reader scans down, so it is the one that stays straight. */
.run-when { flex-shrink: 0; color: var(--on-surface-muted); font-variant-numeric: tabular-nums; }
.run-preview { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; }
/* The running run row (run-sidebar-list / sched-runs-list) wears the session pending dots inline,
 * in place of the failed dot — a live turn's cue, cleared to plain by the 4s poll the beat it
 * ends. It IS a link, so it keeps its hover/pointer; only the atom's absolute overlay positioning
 * is overridden to sit inline. */
.run-row--running .msg-loading { display: flex; position: static; }
/* A run parked at a proposal or a question takes the same inline slot as the pending dots, and
 * says the state in a word rather than a mark — a run this list shows once and then leaves alone
 * for days has to be readable without a legend. Accent for the same reason the card's line takes
 * it (a decision point), muted-size so it sits with the clock instead of shouting over the
 * preview beside it. */
.run-waiting {
  flex-shrink: 0;
  color: var(--accent-text);
  font-size: var(--text-meta);
}
/* Inset the runs pane's rows / empty / truncation notice to 2rem so they line up with this
 * pane's header (.md-head) and the schedule form (.md-form's 2rem) — the header stays put when
 * you toggle Edit ↔ Runs. Scoped to .sched-runs: the shared .run-row/.runs-empty/.runs-more
 * atoms keep their 1rem default in the run-page sidebar. The row itself stays full-bleed for its
 * hover fill; only the text inset moves. */
.sched-runs .run-row,
.sched-runs .run-day,
.sched-runs .runs-empty,
.sched-runs .runs-more { padding-left: 2rem; padding-right: 2rem; }
/* …minus the bar slot, same as the 1rem default above: only .run-row reserves one, so only it
   pays for it out of the inset. Without this the rows in this pane sit 2px right of their own
   day headings. */
.sched-runs .run-row { padding-left: calc(2rem - var(--rule)); }

/* The runs pane's truncation note — "showing the newest N" (R006 self-describing bound). */
.runs-more { color: var(--on-surface-muted); font-size: var(--text-ui); padding: 0.6rem 1rem 0; }

/* ── webhooks page (received view + form extras) ─────── */
/* The save/delete banner reuses .admin-notice / .admin-error (a bare line of text, either ink
   clearing the AA 4.5:1 floor, R007); give it room in the UNpadded
   schedule/webhook view (which has no side padding of its own). Both twins, or a refusal would
   sit flush against the edge while the confirmation it replaces was inset. The padded Models view
   (.md-view--pad) keeps the default margins so its banner lines up with the form fields inside
   the 2rem padding. */
.md-view:not(.md-view--pad) > .admin-notice,
.md-view:not(.md-view--pad) > .admin-error { margin: 0.75rem 2rem 0; }
/* The master column's twin of the rule above, and for the same reason: .md-panel has no padding of
 * its own, so the pane-top slot's 0 side margin put the message flush against the panel's left
 * edge while every card beside it sat 1rem in. 1rem, not the 2rem above, because that is the
 * inset the cards in THIS column use — the message lines up with what it is about. This slot is
 * now the fallback (a card action's answer renders inside its card; see CardFeedback), so what
 * lands here is mostly "that card is gone". */
.md-panel > .admin-notice,
.md-panel > .admin-error { margin: 0.75rem 1rem; }
/* The received pane's rows sit at the same 2rem inset as the schedule runs pane's — one detail-pane
 * gutter across both, lining up under the .md-head that now heads them (it was 1rem here, which
 * also disagreed with .sched-runs .runs-empty's 2rem inside this very pane). */
/* The hook's endpoint needs no line of its own: it IS the received pane's title (webhooks.html),
 * with the hook's name as the crumb above it — the pane header atom, not a caption bolted under
 * it (R007 同類就走既有元件). Nothing here to style. */
.runs-empty code { font-family: var(--font-mono); font-size: 0.9em; }
/* One captured envelope: a header row (arrival time + signature) over the raw body. */
.wh-envelope { padding: 0.35rem 2rem 0.75rem; border-bottom: 1px solid var(--line); }
.wh-env-head {
  display: flex;
  align-items: baseline;
  gap: 0.6rem;
  margin-bottom: 0.35rem;
  font-size: var(--text-meta);
  color: var(--on-surface-muted);
}
.wh-env-when { flex-shrink: 0; font-variant-numeric: tabular-nums; }
.wh-env-sig {
  font-family: var(--font-mono);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}
/* A block of literal text on the page — a webhook's captured payload. It WRAPS rather than
 * scrolling sideways: text you have to read must not need a horizontal scrollbar
 * (R007 實用先於好看). A showcase's prompt used to share this atom; it does not any more —
 * it is a chat message, and now renders as one (see showcases.html). */
.wh-body {
  margin: 0;
  padding: 0.6rem 0.75rem;
  background: var(--surface-alt);
  border-radius: var(--radius);
  font-family: var(--font-mono);
  font-size: var(--text-ui);
  /* Reference data, not primary — soften off full contrast while staying ≥AA on --surface-alt. */
  color: color-mix(in srgb, var(--on-surface) 82%, var(--surface-alt));
  white-space: pre-wrap;
  word-break: break-word;
  overflow-x: auto;
}
/* The custom-provider signature envelope, folded away for the common preset case. */
.wh-custom { margin: 0.5rem 0; }
/* The signature envelope while it does not apply (the provider is a preset, so app.js has
 * disabled its three inputs). Dimmed so the section reads as "not for you right now" rather
 * than three separately-broken fields. Kept visible rather than hidden: a person choosing a
 * provider should be able to see what picking "custom" would ask of them.
 *
 * The dim is on the FIELDS, not the section. Disabled controls are the one thing WCAG's
 * contrast rules exempt, so fading them costs nothing; the heading and the note explaining
 * what "custom" would ask of you are neither disabled nor exempt, and the whole-section 0.55
 * this used to be put them at 2.30:1 — the least readable text in the app, on the very block
 * that was kept visible in order to be read. */
.wh-custom--off .admin-label { opacity: 0.55; }
.wh-custom--off input:disabled, .wh-custom--off select:disabled { cursor: not-allowed; }
/* The envelope section's own label — a quiet section heading now that it's a plain, always-shown
 * block rather than a fold (see webhooks.html). Same shape as .admin-label-head, which is the
 * head of a field group in the very same form: same rung, same muted tone, same job. So it takes
 * the same answer that rule already reached — "a 13px label does not need the weight to lead" —
 * rather than sitting beside it at a different weight for no reason a reader could name. */
.wh-custom-head { margin: 0 0 0.3rem; font-size: var(--text-ui); color: var(--on-surface-muted); }

/* The restore-mapping editor — the lower half of a hook's edit pane, below its definition form.
 * Stacked sections (head+note, the form, then the recent payloads) sharing the form's left edge:
 * a side-by-side form/payload split felt cramped on the panel's width. Reuses admin-form fields
 * rather than a parallel set (R001/R007). The gap above separates it from the definition's
 * actions; its .admin-head carries the same in-section rhythm as the form's other heads. */
.wh-mapping { margin-top: 2rem; padding-bottom: 0.5rem; }
/* The captured payloads you read while filling the mapping above. It carries the same section
 * head as "Mapping", so it takes the same 2rem of air a section is separated by — a peer that
 * looked like one but sat closer would read as a sub-part of the form. */
.wh-sample { margin-top: 2rem; }
/* REPEAT ROWS — a row a form can have several of, with a quiet ＋ under the stack and a quiet ×
 * on each row (app.js drives all of it). Born here as the mapping's column→path rows and named
 * for the mapping; it is now shared with the Tables page's "add fields" form, which repeats rows
 * for exactly the same reason, so the names carry no page in them. Only the COLUMN classes below
 * stay per-page — how wide a column is and whether it is monospaced is the one thing that cannot
 * be shared between a JSON path and a field type.
 *
 * The bottom margin is 0.9rem + 0.5rem — the same air every other form section leaves above its
 * actions (.admin-label's 0.9rem plus the section's own 0.5rem). It is spelled out here because
 * this block ends in the FEATHERWEIGHT "+ Add …" text button, not in a bordered input, so without
 * it the buttons crowd the last row. */
.repeat-rows { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: 1.4rem; }
/* wrap: the add-a-field row carries five controls once a description box is one of them, and on a
   narrow pane the fifth would otherwise squeeze the name box to nothing rather than move down a
   line. Nothing else wraps until it overflows, so the mapping editor's rows are unchanged. */
.repeat-row { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; }
/* Dressed by the shared field rule ("the field"), so a row's boxes read as part of the same form
 * as the fields above them; tight padding, like the filter bar's — these sit in a row of their
 * own, not in a form's field stack. */
.repeat-row input:not([type='checkbox']),
.repeat-row select { padding: 0.4rem 0.55rem; }
.wh-field-col { flex: 0 1 10rem; min-width: 0; }
.wh-field-path { flex: 1 1 12rem; min-width: 0; font-family: var(--font-mono); }
.wh-field-arrow { flex-shrink: 0; color: var(--on-surface-muted); }
/* The Tables page's own columns in the same repeat row: name, type, what it references, and the
 * unique tick. The reference box takes the slack because it is the one holding a path a person
 * reads back (orders.po_no), while a field name and a type from a fixed list are short and known.
 *
 * The tick IS an .admin-check (table-schema.html), the same checkbox every other form here wears
 * — walked into, not copied (R007). Only what the ROW context changes lives below: the component
 * stacks in a form, so it carries a bottom margin a row must not inherit, and it must not stretch.
 * The shared field rule above excludes checkboxes for the same reason it has to: a native
 * checkbox ignores an author's border and background, so the focus treatment that swaps the ring
 * for a border colour would leave a keyboard user with NO visible focus on it (R007's floor).
 *
 * The margin reset is keyed off the ROW, not off .tbl-field-unique, and that is the whole point:
 * as a bare class it TIED with .admin-check and lost on source order — .admin-check is declared
 * further down this sheet, so the margin it strips came straight back. Nothing looked broken
 * enough to name: align-items centres MARGIN boxes, so a 0.9rem margin that only exists below the
 * tick lifted it 7px above the row it shares with a name, a type and a ×, and a lone tick sitting
 * high next to three centred boxes reads as the × being wrong. Written against the row it also
 * says the durable thing — a stacking margin is what a FORM's checkbox needs and a row's never
 * does — so the next .admin-check dropped into a repeat row is right on arrival.
 *
 * This is the SECOND container to strip that margin (.task-wake .admin-check is the first), which
 * is the honest argument for moving it off the component and onto whatever stacks it. Not taken:
 * the margin is right for every OTHER .admin-check in the sheet, so moving it means giving the
 * form stack a rule and re-checking every form, to delete two lines. Two resets is the cheaper
 * shape until a third container appears (R001) — and a third is what should trigger the move,
 * not another copy of this note. */
.tbl-field-name { flex: 0 1 9rem; min-width: 0; }
.tbl-field-type { flex: 0 0 auto; }
.tbl-field-ref { flex: 1 1 10rem; min-width: 0; font-family: var(--font-mono); }
/* What the field holds, in words — the widest box on the row because it is the only one holding a
   sentence, and the first to be given a line of its own when the row wraps. */
.tbl-field-doc { flex: 1 1 14rem; min-width: 0; }
.repeat-row .admin-check { margin-bottom: 0; }
.tbl-field-unique { flex-shrink: 0; white-space: nowrap; cursor: pointer; }
/* The destination cards. A mapping delivers to one or more places, so each gets a card: a plain
 * bordered block with its own kind picker, target and field rows. No accent, no shadow, no
 * collapse — the layers are a border and the page's own alt surface, which is all the separation
 * a short stack of them needs (R007: 層次靠色塊與留白). The two kinds share one card and one
 * field-row component; only the middle part swaps. */
.wh-dests { display: flex; flex-direction: column; gap: 0.9rem; margin-bottom: 1.4rem; }
.wh-dest {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 0.8rem 0.9rem 0.2rem;
  background: var(--surface);
}
/* The kind picker and the card's own × on one line: the × belongs to the card, so it sits at the
 * card's top edge rather than trailing its last field row. The two columns are different heights
 * — a caption sits above the select and nothing sits above the × — so how they line up has to be
 * said, and what it has to say is "on the select", the only thing the × is beside.
 *
 * `last baseline`, because the two boxes have nothing else in common. flex-end was the first
 * answer and it aligned the wrong edges: it put the ×'s BOTTOM on the select's bottom border,
 * which left the glyph 12px below the select's own text — visibly low, and next to a field row
 * whose × is centred on its input it read as the one that was wrong. Baseline is the alignment
 * that survives the two boxes having different heights, paddings and line-heights, so it needs no
 * number kept in step with the select's padding (which is where the arithmetic answer — pad the
 * button until the heights match — goes stale the first time a field's padding is retuned).
 * `last`, not plain baseline: the label column's FIRST baseline is the caption's, which would
 * hang the × off the word "Destination" instead. Measured, not assumed — the ×'s box centre
 * lands on the select's to the pixel, which is also where the field row's × sits relative to its
 * input, so the two × now mean the same thing (R007 同類就走既有元件).
 *
 * flex-end stays underneath it as the fallback for a browser without `last baseline` (it drops
 * the whole declaration, and the bare default would stretch the button and float the glyph at the
 * card's top). Slightly low beats detached. The two verdicts on flex-end — "the one that was
 * wrong" above, "good enough" here — are comparisons against different things, not a change of
 * mind: it loses to baseline and it beats stretch. What it does not survive is the e2e assertion,
 * which pins the ×'s centre on the select's to within a pixel; a browser on the fallback fails
 * that test by design. The test states the intended rendering, not the degraded one.
 *
 * The row carries the field's bottom margin and the label inside it carries none. Under `last
 * baseline` that reset does not move the × at all — baseline alignment reads text baselines, not
 * margin boxes — it stops the label's empty 0.9rem from growing the flex line and pushing the
 * whole head down. It DOES move the × on the flex-end fallback, which lines margin boxes up: a
 * label keeping its 0.9rem would hang the × one gap below the select it is supposed to sit beside.
 * One reset, a different job on each branch, which is why it is written once and not twice.
 * Spacing to whatever follows is measured from the row instead of from the label inside it. */
.wh-dest-head {
  display: flex;
  align-items: flex-end;
  align-items: last baseline;
  gap: 0.5rem;
  margin-bottom: 0.9rem;
}
.wh-dest-head .admin-label { flex: 1 1 auto; margin-bottom: 0; }
/* Remove-this-row and remove-this-destination: the quiet twins of the add buttons, sitting at the
 * end of their row / the top of their card. Muted until hovered so a column of them doesn't read
 * as a column of buttons (R007: action 安靜). */
.repeat-del,
.wh-del-dest {
  flex-shrink: 0;
  font: inherit;
  line-height: 1;
  color: var(--on-surface-muted);
  background: transparent;
  border: none;
  padding: 0.1rem 0.35rem;
  cursor: pointer;
}
.repeat-del:hover,
.wh-del-dest:hover { color: var(--on-surface); }
/* A quiet "add another row / another destination" affordance — a plain text button, not a loud
 * act-btn. */
.repeat-add,
.wh-add-dest {
  align-self: flex-start;
  font: inherit;
  font-size: var(--text-ui);
  color: var(--on-surface-muted);
  background: transparent;
  border: none;
  padding: 0.1rem 0;
  cursor: pointer;
}
.repeat-add:hover,
.wh-add-dest:hover { color: var(--on-surface); text-decoration: underline; }

/* ── run-page: back link above the runs list ─────────── */
/* It IS an .md-navitem (see run-page.html) — full-contrast text, hover fill, the accent left-bar
 * slot — so the row that opens this master column is the same row that opens the schedules one,
 * not a lookalike. It used to be muted, borderless-left and its own thing entirely: a whole
 * horizontal band spent on the faintest text on screen, stacked above the schedule name, so the
 * column opened with two competing header rows.
 *
 * It takes the atom WHOLE — the rule under it included. Only flex-shrink is added, and that is
 * layout, not appearance: this row sits in a flex column that scrolls, and without it the row
 * gives up height to the list below.
 *
 * Its rule spent three revisions being argued about, so here is the argument and where it landed,
 * to save the fourth. The case for dropping it: on the schedules page a .md-navitem (＋ New
 * schedule) is followed by the LIST it heads, and the rule is the boundary between the two — but
 * here it is followed by .runs-head, the name of the very column this row returns from, which
 * reads as one thought with it ("back, and here is where you are"), so a rule between them draws
 * a boundary where there is none. That was tried twice: dropped outright, then with the line
 * painted only on hover. Both cost more than the seam they saved. Dropping it left the hover fill
 * ending in mid-air, since a fill has to stop on something and every other row in the shell stops
 * on a rule. Painting it on hover fixed the fill and introduced a line that appears under the
 * pointer — which, on the one pair in this column that is deliberately NOT separated, reads as
 * the header splitting rather than as the link answering.
 *
 * So: the same row, with the same rule, in both states. What it costs is that seam, and the seam
 * is worth less than three rules that each had to be explained. What it buys is that nothing here
 * is a special case — ＋ New schedule and ← Schedules are the same element with the same
 * behaviour, and this block no longer holds a reason that has to survive the next retune.
 *
 * A note on what is NOT the reason: the rule does not land on the same y as the chat header's
 * across the column divider, and it is not supposed to. That alignment was bought once with
 * padding: 1rem, which made this row 13px taller than the same row one click away on the
 * schedules page — the height jump is seen far more often than the alignment is (R007
 * 同類就走既有元件: same element, same size). The master column and the detail pane keep their
 * own rhythms; that was settled deliberately and is not drift.
 *
 * Pinned in schedules.spec.ts ("the Run button fires a schedule now") against ＋ New schedule's own
 * row: the total height — which folds padding, border, font-size and line-height into one number,
 * and that breadth is the point, every one of them is supposed to match — and the resting colour
 * of the rule, because a transparent slot is exactly what this row wore twice. */
.run-page-back { flex-shrink: 0; }

/* ── edit form (inside md-view) ──────────────────── */
/* The padded form pane shared by the schedule and webhook detail views. Their md-view hosts
 * mixed content — a runs list, a chat, a form — so the FORM carries its own padding rather than
 * the pane (the Models view is form-only and uses md-view--pad instead). Fields inside use the
 * shared admin-* form vocabulary, same as the webhook and model forms. */
/* Its geometry is the shared detail-pane one (see "a detail pane's geometry" above): same inset
 * and width as every other 46rem detail face, so the schedule form's header sits at the same x as
 * the project-settings header it reuses (R007), and the top stays a detail face's 1.75rem rather
 * than a focus page's 3rem — the two page families keep different densities on purpose; see
 * "Two page families" in the header. Nothing left to declare here; the class stays because it is
 * what the form's own vocabulary below hangs off. */
/* The frequency picker's group label — a label above the tab control (not wrapping an input, so
 * it can't be a plain admin-label); matches the admin field labels' quiet xs/muted text. */
.freq-label {
  display: block;
  font-size: var(--text-meta);
  color: var(--on-surface-muted);
  margin-bottom: 0.35rem;
}

/* ── frequency picker (friendly cron) ──────────────────
 * Hidden radios + label tabs; :checked drives the active tab and which sub-control
 * (time / day / custom) is visible — pure CSS so it survives htmx swaps with no wiring. */
.freq-field { margin-bottom: 0.9rem; }
.freq-controls > input[type="radio"] { position: absolute; opacity: 0; pointer-events: none; }
.freq-tabs {
  display: inline-flex;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  overflow: hidden;
  margin-bottom: 0.85rem;
}
.freq-tabs label {
  font-size: var(--text-meta);
  padding: 0.35rem 0.7rem;
  color: var(--on-surface-muted);
  cursor: pointer;
  border-right: 1px solid var(--line);
  /* a transparent underline slot on every tab, so marking the active one (below) adds an
     accent line without shifting height — the segmented-control cousin of the nav left bar. */
  border-bottom: var(--rule) solid transparent;
  user-select: none;
}
.freq-tabs label:last-child { border-right: none; }
.freq-tabs label:hover { background: var(--surface-alt); }
/* The radios are visually hidden, so their focus ring would be invisible; mirror keyboard
 * focus onto the matching tab instead. Spelled out rather than inherited: the ring belongs to the
 * hidden radio, so the global :focus-visible rule cannot reach the label that has to wear it. */
#freq-hourly:focus-visible   ~ .freq-tabs label[for="freq-hourly"],
#freq-daily:focus-visible    ~ .freq-tabs label[for="freq-daily"],
#freq-weekdays:focus-visible ~ .freq-tabs label[for="freq-weekdays"],
#freq-weekly:focus-visible   ~ .freq-tabs label[for="freq-weekly"],
#freq-custom:focus-visible   ~ .freq-tabs label[for="freq-custom"] {
  outline: var(--rule) solid var(--accent-text);
  outline-offset: -2px;
}
/* active tab — one rule per preset (the radio's id maps to its label[for]). */
#freq-hourly:checked   ~ .freq-tabs label[for="freq-hourly"],
#freq-daily:checked    ~ .freq-tabs label[for="freq-daily"],
#freq-weekdays:checked ~ .freq-tabs label[for="freq-weekdays"],
#freq-weekly:checked   ~ .freq-tabs label[for="freq-weekly"],
#freq-custom:checked   ~ .freq-tabs label[for="freq-custom"] {
  /* an accent underline marks the active tab (its bottom-border slot filled) — accent on a
   * line, the same "active = line" cue the nav's left bar gives, not a fill. */
  border-bottom-color: var(--accent-text);
  color: var(--on-surface);
}
/* sub-controls: hidden by default, revealed by the matching frequency. */
.freq-sub { display: none; align-items: center; gap: 0.5rem; font-size: var(--text-ui); color: var(--on-surface-muted); }
/* daily/weekdays/weekly share the time row; hourly and custom each get their own. */
#freq-daily:checked    ~ .freq-timeday,
#freq-weekdays:checked ~ .freq-timeday,
#freq-weekly:checked   ~ .freq-timeday { display: flex; }
#freq-hourly:checked   ~ .freq-hourly { display: flex; }
#freq-custom:checked   ~ .freq-custom { display: flex; flex-direction: column; align-items: stretch; gap: 0.3rem; }
/* "on <day>" rides on the same row as the time, only for weekly. */
.freq-on { display: none; }
#freq-weekly:checked ~ .freq-timeday .freq-on { display: inline-block; }
/* Dressed by the shared field rule ("the field"); the picker's own inline padding. */
.freq-timeday input[type="time"],
.freq-timeday select,
.freq-hourly input,
.freq-custom input { padding: 0.4rem 0.6rem; }
.freq-hourly input { width: 4rem; }
.freq-custom { max-width: 22rem; }
.freq-custom-hint, .freq-hourly-hint { font-size: var(--text-meta); color: var(--on-surface-muted); }


/* ── workspace nav (Tables / Webhooks) ───────────────────
 * The workspace-level CAPABILITIES — the things a team builds its business system out of —
 * under their own divider below the project list. Settings is deliberately NOT here; see
 * index.html, which draws that line on R011. Same quiet nav-row register as the per-project
 * rows: a line icon in currentColor carries each. */
.sidebar-workspace {
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
  padding: 0.5rem 0 0.35rem;
  margin-top: 0.35rem;
  border-top: 1px solid var(--secondary-edge);
}
/* .ws-link and the per-project .proj-settings row take the shared nav utility row
 * above — the workspace block only adds its own divider and stacking. */

/* ── settings mode nav (the left nav while Settings is open) ──
 * Settings is a mode: the project list steps aside for a Back row and the workspace
 * category list (nav-settings.html). Styled in the sidebar's palette, like the project
 * rows — a quiet Back, and the same accent-left-bar "you are here" mark on the active
 * category the project rows use. */
.nav-back {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.4rem 0.55rem;
  margin-bottom: 0.5rem;
  border-radius: var(--radius);
  color: var(--on-secondary-muted);
  text-decoration: none;
  font-size: var(--text-ui);
  transition: background-color 0.15s ease, color 0.15s ease;
}
.nav-back:hover { background: var(--secondary-wash); color: var(--on-secondary); }
.nav-back svg { flex-shrink: 0; opacity: 0.8; }

/* The left nav while a MODE is open — today that is the Settings categories
 * (nav-settings.html). Named for the act, not for Settings: picking which one of a list the
 * pane shows is the same row wherever a second mode needs it, so it walks into this rather
 * than growing a parallel look-alike (R007). */
.settings-nav { display: flex; flex-direction: column; gap: 0.1rem; }
/* .settings-nav-item takes the shared nav utility row above — it is the same row, carrying
 * a category name instead of a project's. It has no leading icon; nothing else differs. */

/* ── admin shell (Settings / Tables / project settings) ──
 * Settings' category pane, the Tables page and the project-settings page are all a single
 * scrolling pane (.admin-shell-single) — the Settings category list lives in the left nav
 * now (see nav-settings.html), not a second column here. */
.admin-shell { height: 100%; overflow: hidden; }
/* The single-pane layout: a full-width scrolling pane that centres itself (margin:0 auto,
 * capped at its max-width). Every admin page uses it now — Settings' category pane, Tables
 * and project settings — since Settings' old two-column tab layout moved to the left nav. */
.admin-shell-single { display: block; height: 100%; overflow-y: auto; }

/* The admin scroll containers wear the same slim themed scrollbar as the transcript,
 * composer and textareas — otherwise project settings / Tables show the chunky native bar
 * while the rest of the app shows the thin one (see the transcript's rule). */
.admin-shell-single::-webkit-scrollbar,
.admin-pane::-webkit-scrollbar { width: 6px; }
.admin-shell-single::-webkit-scrollbar-thumb,
.admin-pane::-webkit-scrollbar-thumb { background: var(--line); border-radius: 3px; }

/* A focus page (see "Two page families" in the header): 3rem above the header outranks the
 * 2.25rem between two .admin-sections, so the ladder reads 3rem page › 2.25rem section › 0.9rem
 * head-to-content — each step clearly below the last (every head sits the same 0.9rem above its
 * content, one in-section rhythm shared with the note and the fields; see .admin-head). At 1.75rem
 * that order was inverted and the title read as cramped against the window edge, with no toolbar
 * above the pane to stand in for the space. Bottom stays 1.75rem — scroll slack, no hierarchy to
 * carry. */
.admin-pane {
  overflow-y: auto;
  padding: 3rem 2rem 1.75rem;
  max-width: 46rem;
  width: 100%;
}
.admin-shell-single .admin-pane { margin: 0 auto; }

/* Neither head declares a weight: both classes only ever sit on an h2/h3, and the h1–h6 reset
 * above already puts every heading at --weight-emph. Declaring it again here would be a second
 * place to change it — and the kind of duplicate that quietly disagrees once one of them moves. */
.admin-head { font-size: var(--text-content); margin: 0 0 0.9rem; }
/* Every head here is a section head — there is no separate "pane title" tier that earns extra
 * air. All heads keep the same 0.9rem below (from .admin-head) — one in-section rhythm shared with
 * the note and the fields beneath, so nothing reads as cramped against its content. A pane's FIRST
 * head is only lightly emphasised, and never by spacing: it marks itself .admin-head--page for a
 * --text-title type bump — every pane, with no exceptions left. (Three panes used to name a
 * parent above themselves with a smaller crumb line instead; each of them was naming something
 * the marked card beside it, the URL or its own title already said.) Emphasis via type, never a
 * bigger gap — a first head sits the same distance from its content as any other. */
.admin-head--page { font-size: var(--text-title); }
.admin-subhead { font-size: var(--text-content); margin: 0 0 0.4rem; }
.admin-note,
.admin-help { /* a field's own explanation — same atom; its own note sits with .admin-label */
  font-size: var(--text-meta);
  color: var(--on-surface-muted);
  line-height: 1.5;
  margin: 0 0 0.9rem;
  max-width: 40rem;
}
.admin-meta { font-size: var(--text-meta); color: var(--on-surface-muted); margin: 0.2rem 0; }
.admin-empty { font-size: var(--text-ui); color: var(--on-surface-muted); padding: 0.5rem 0; }
.admin-section { margin-bottom: 2.25rem; }

/* A refusal (R005) — the same SHAPE as the confirmation below, deliberately: a line of text,
 * nothing else. They answer the same press, in the same slot (a form's feedback slot, or the pane
 * top), so wearing two different visual languages there made the success read as the weightier of
 * the two — backwards, when the refusal is the one that has to stop you. Same atom, same shape
 * (R007 同類就走既有元件); the words say which one it is, and only THIS one is coloured, because
 * only this one asks the reader to go do something about it.
 *
 * No rule, no fill, no box. This is how every other failure in the app is written — a failed run,
 * an errored bubble, an alert status, a destructive verb: --error on the words and nothing around
 * them. A left rule was tried here (borrowed from .auth-error, the one site that has one) and it
 * needed a fill under it not to read as hanging in space, and the fill then pushed --error below
 * R007's AA 4.5:1 floor on Dusk — a box's worth of complexity, arrived at by copying the outlier
 * instead of the convention. Colour on the words is also simply louder than colour on a 2px line,
 * which is what a write's answer needs to be.
 *
 * --error on --surface: 5.22 Dusk / 6.48 Day / 7.27 Night; on --surface-alt, where the same
 * message lands inside a card: 4.93 / 6.05 / 6.83. Clears the 4.5 floor on both grounds in all
 * three. Re-measure if --error, --surface or --surface-alt moves; Dusk on --surface-alt is the
 * tight one, and widening --error there widens all nine sites. */
/* Everything the twins share lives here, so "identical but for the colour" is enforced by the
 * stylesheet rather than asserted by a comment: the wrapping rules used to be on the error alone,
 * which meant a confirmation naming a long unbroken thing ("Password updated for …") overflowed
 * where the refusal beside it wrapped. Server messages carry newlines, hence pre-wrap.
 *
 * No scroll-margin: app.js scrolls a fresh message with block:"nearest", which is a no-op while
 * the message is already visible — and that no-op is the point, because a scroll here moves the
 * Save button out from under the pointer that just pressed it. A margin would have re-armed
 * exactly that for any message sitting within it of the pane's edge (found in review). */
.admin-notice, .admin-error {
  font-size: var(--text-meta);
  line-height: 1.5;
  margin: 0.5rem 0 1rem;
  white-space: pre-wrap;
  word-break: break-word;
}
.admin-error { color: var(--error); }
/* An error rendered AFTER the actions row keeps the atom's OWN top gap and drops the bottom one:
 * the default margin is tuned for a message that precedes what it is about, and --after is the
 * form's last element, so there is nothing below to be spaced from. The gap is the same 0.5rem
 * either way — a wider one here read as a separate paragraph rather than as the answer to the
 * press just above it. See form-feedback.html for why this position. Spacing only — the shape is
 * the atom's. It must sit BELOW .admin-error: same specificity, so only source order decides
 * which margin wins on an element carrying both. */
.admin-error--after { margin: 0.5rem 0 0; }
/* Save confirmation (R007: a write must give feedback) — the atom above, minus the colour.
 *
 * Three values so far, and the middle one is the instructive one. It first rode --primary: wrong
 * twice over, since R007 spends accent on identity, interaction hints and decision keywords and
 * "Saved." is none of those, and since on Dusk the accent is a terracotta a refusal's brick red is
 * indistinguishable from. The fix reached for the obvious opposite, a sage --ok facing --error —
 * which cured the collision but kept its premise: that a confirmation is the refusal's equal and
 * owes itself a colour. It is not, and it does not. A refusal sends you back to a field; "Saved."
 * asks nothing, and it is already saying it twice — it sits under the button just pressed, and it
 * was not there a moment ago. Colour was a third cue buying weight the message has no use for,
 * and on a palette this warm the sage was the one cold thing on the screen, which made the least
 * consequential line in the UI the one that pulled the eye.
 *
 * Muted ink, then: found when looked for, quiet when not, and still on both grounds like every
 * other ink here (the tier is asserted in assets_contrast_test.go, same as when it had a token of
 * its own). Not a loud toast; sits inline exactly where the refusal would. If some outcome later
 * genuinely needs a green, that is a token to reintroduce with a case of its own — not a symmetry
 * to put back. */
.admin-notice { color: var(--on-surface-muted); }
/* A notice rendered AFTER the actions row — same reasoning as .admin-error--after: feedback for a
 * button press belongs below that button, so pressing it never moves it. Below .admin-notice for
 * the same source-order reason. */
.admin-notice--after { margin: 0.5rem 0 0; }

/* Forms reuse the schedule-editor field language (a muted label above a bordered input),
 * so the whole admin reads as one family with the schedule edit form. */
.admin-form { display: flex; flex-direction: column; }
.admin-label {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  font-size: var(--text-meta);
  color: var(--on-surface-muted);
  margin-bottom: 0.9rem;
}
/* A field's header row: the friendly name on the left and, when a field has one, a small
 * status badge (set / not set for a write-only secret) on the right. Just the plain name for
 * a non-technical person (R003) — the config key it maps to lives in the CLI/README, not the
 * form. gap guards against the two colliding.
 *
 * The name takes FULL contrast while the wrapper's muted tone stays the default for everything
 * else in the field (the help line below the input, a checkbox's text). That is what separates
 * a field's name from its explanation: the name is the thing, the help is an annotation on it.
 * The separation has to come from the name going up, because the help cannot come down — muted
 * is already at the 4.5:1 floor and there is no dimmer step to spend (R007). Weight stays 400;
 * --weight-emph is spent sparingly and a 13px label does not need it to lead. The status badge sets its
 * own muted colour, so it is unaffected. */
.admin-label-head {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 1rem;
  color: var(--on-surface);
}
/* A field's own explanation, under its input (see intKnob.help) — same xs / muted voice as
 * .admin-note, so it is that atom with one difference: it belongs to the field above it rather
 * than to the section, so the label's bottom margin closes up and the help carries it instead.
 * It reads as the quieter of the two voices in a field because the LABEL goes up to full
 * contrast, not because the help comes down (see .admin-label-head): muted is already at the
 * 4.5:1 floor and there is no dimmer step to spend (R007).
 *
 * It sits OUTSIDE the <label>, tied to the input by aria-describedby. Inside the label its whole
 * sentence would be folded into the input's accessible NAME, so a screen reader would read the
 * paragraph out as the field's name every time focus landed. Described-by is the role it has. */
.admin-label:has(+ .admin-help) { margin-bottom: 0.35rem; }
/* Appearance and focus come from the shared field rule ("the field", above); this is what a form
 * field adds — the roomiest padding in the app, and a textarea a person can pull taller. */
.admin-label input,
.admin-label select,
.admin-label textarea {
  padding: 0.45rem 0.65rem;
  resize: vertical;
}
/* The Global-/project-instructions editor is a normal .admin-label textarea (so it inherits the
   field appearance, focus, scrollbar and the wrapper's rhythm like every other field — no
   bare-field exception that can drift out of them); this only adds the monospace document look.
   Scoped under .admin-label to outweigh `.admin-label textarea { font: inherit }` above, which
   would otherwise reset the family back to sans. */
.admin-label .admin-instructions-text {
  font-family: var(--font-mono);
  line-height: 1.6;
}
.admin-label textarea::-webkit-scrollbar { width: 6px; }
.admin-label textarea::-webkit-scrollbar-thumb { background: var(--line); border-radius: 3px; }

.admin-check {
  display: flex;
  align-items: center;
  gap: 0.45rem;
  font-size: var(--text-ui);
  color: var(--on-surface-muted); /* match the field labels (admin-label), not full contrast */
  margin-bottom: 0.9rem;
}
.admin-check input { width: auto; }

/* LEFT-aligned, and that disagreement with .proposal-actions / .confirm-dialog-acts (both
 * flex-end) is the decision, not an oversight — leave it alone, or write down why it changed.
 *
 * A form is a column of left-aligned fields, read top-down along that left edge, and its actions
 * row is the end of that column: the button lands where the eye already is. Pushed right it
 * detaches from the last field read — on a wide pane (the schedule editor's prompt box is the
 * full width) that is most of a screen away from anything it is about. It would also break the
 * one rule the feedback slot rests on: the message belongs to the button that produced it BY
 * SITTING UNDER IT (form-feedback.html), and a button on the right with its answer on the left
 * is no longer next to anything.
 *
 * A dialog is the other case and gets the other answer. It has no field column to end — it is a
 * sentence and a decision — so the eye leaves at the bottom-right, which is where every platform
 * puts those buttons. Different thing, different rule. */
.admin-actions { display: flex; gap: 0.5rem; align-items: center; }
/* Only the padding: .act-btn's own size is --text-ui now, so restating it here would be a no-op
   that reads like a deliberate override. */
.admin-actions .act-btn { padding: 0.32rem 0.85rem; }

/* Instructions version history: a quiet timeline of collapsible past versions. Each row is a
   native <details>; the summary is the save time, expanding to the text and a Restore action. */
/* The rule needs air on BOTH sides. This is the one .admin-section that follows a form as a
   sibling rather than after a section's 2.25rem, so with no top margin the rule was drawn flush
   against whatever the form ended with — 0px under the Save button, and 0px under "Saved." once
   the feedback slot spoke (--after deliberately carries no bottom margin, being the form's last
   element; the gap belongs to the divider, not to the message). More above than below so the
   rule binds to the section it introduces. */
.admin-history { border-top: 1px solid var(--line); margin-top: 1.5rem; padding-top: 1rem; }
.admin-version { border: 1px solid var(--line); border-radius: var(--radius); margin-bottom: 0.4rem; }
.admin-version-when {
  font-family: var(--font-mono);
  font-size: var(--text-ui);
  color: var(--on-surface);
  padding: 0.4rem 0.65rem;
  cursor: pointer;
}
.admin-version-text {
  margin: 0;
  padding: 0.5rem 0.65rem;
  border-top: 1px solid var(--line);
  background: var(--surface-alt);
  font-family: var(--font-mono);
  font-size: var(--text-ui);
  line-height: 1.6;
  white-space: pre-wrap;
  word-break: break-word;
}
/* Restore sits in the card's own padding, not flush against the text panel above it: the panel is
   a filled block with a hard edge, so a 0 top gap glued the button to it while leaving 0.6rem
   below — the same one-sided air as the section rule above. Equal top and bottom; the 0.65rem
   sides line the button up with the text it restores. */
.admin-version .admin-form { padding: 0.6rem 0.65rem; }

/* A field's set/not-set (or state) badge, sitting at the right of an admin-label-head: small
 * and quiet; .alert turns it red for a state the operator must act on (R005). Used by the model
 * and secret edit forms' write-only key/token labels. */
.admin-field-status {
  flex-shrink: 0;
  white-space: nowrap;
  font-size: var(--text-meta);
  color: var(--on-surface-muted);
}
.admin-field-status.alert { color: var(--error); }
/* A knob's config key, in that same right-hand slot: the identifier a tool's refusal, the config
 * file and the CLI all use, so the name someone was told to raise is findable on the page (a
 * browser's own find-in-page is the search this page needs — there is nothing folded for it to
 * miss). Monospace because it is a literal to be typed or matched, not prose; otherwise it wears
 * the badge's size and muted colour, so it stays a footnote to the label rather than competing
 * with it.
 *
 * It sits INSIDE the <label> (unlike .admin-help above, which is a whole sentence and would be
 * read out as the field's name): one short identifier belongs in the accessible name — it is
 * what the operator was told to type. Keep the whitespace before it in the markup, or the name
 * comes out concatenated ("Max file readread_max_bytes"). */
.admin-key {
  flex-shrink: 0;
  white-space: nowrap;
  font-family: var(--font-mono);
  font-size: var(--text-meta);
  color: var(--on-surface-muted);
}
.admin-card-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.75rem;
  margin-bottom: 0.75rem;
}
.admin-card-head form { margin: 0; }
/* The stacked-list twin of .md-card-name, and it drops the weight for the same reason and at the
   same time: the two families render the same thing (a row naming an item) on two densities, so
   a weight in one and not the other would make them read as different kinds of row. */
.admin-card-name { font-size: var(--text-content); }

/* A plain list row (projects) — name on the left, one action on the right. */
.admin-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  padding: 0.5rem 0;
  border-bottom: 1px solid var(--line);
}
.admin-row:first-of-type { border-top: 1px solid var(--line); }
.admin-row form { margin: 0; }
.admin-row-name {
  color: var(--on-surface);
  text-decoration: none;
  font-size: var(--text-content);
}
.admin-row-name:hover { color: var(--accent-text); }
/* A muted detail beside the name (an operator's linked Google address). The auto margin absorbs
 * the row's free space so it sits with the name it describes rather than adrift in the middle,
 * leaving the action on the right where every other row keeps it. */
.admin-row-meta {
  font-size: var(--text-meta);
  color: var(--on-surface-muted); /* ≥4.5:1 on --surface (AA) */
  margin-right: auto;
}

/* A name with its own sentence UNDER it (a table's or a field's description, Tables page). The
 * row's left side turns into a column for it: .admin-row-meta above is the beside-the-name form
 * and holds for a word or two, but a description runs to a couple of hundred characters and
 * beside the name would push the row's actions off a narrow screen (R007: usable before tidy).
 * min-width:0 is what lets the sentence wrap instead of stretching the flex row. */
.admin-row-stack {
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
  min-width: 0;
  margin-right: auto;
}
.admin-row-doc {
  font-size: var(--text-meta);
  color: var(--on-surface-muted); /* ≥4.5:1 on --surface (AA), same token as .admin-row-meta */
}

/* A row and the form it opened, read as ONE block: the rule between them is dropped and the
 * block's own rules close around the pair. A form that renames a field is about the row ABOVE
 * it, but rows here are separated by rules alone, so a form dropped between two of them sits
 * inside the next row's fence and reads as that row's. Grouping is the whole fix — no tint, no
 * accent stripe: the rules already say where the block starts and ends, and accent is spent on
 * identity and decision points (R007). The border moves to the wrapper rather than being
 * cancelled on the row, so the first-of-type top rule stays one rule and not two stacked. */
.admin-row-edit { border-bottom: 1px solid var(--line); }
.admin-row-edit:first-of-type { border-top: 1px solid var(--line); }
.admin-row-edit .admin-row { border: 0; }
.admin-row-edit .admin-form { padding-bottom: 0.9rem; }

/* A field's type, references and unique flag, beside its name on the Tables page — the
 * table's shape, said the same way wherever it appears (table-schema.html's field-shape). */
.tbl-type { color: var(--on-surface-muted); }

/* The forms in the chat-header row (compact, delete) carry no margin, so their icon buttons
 * sit tight in the flex row beside the pin icon. */
.chat-header-row form { margin: 0; }

/* ── Sign-in / first-run setup page (auth.html) ──────────────────────────────
 * A standalone page (no app shell): the person isn't signed in yet. One centred column of
 * fields on the main surface — no card, no panel; the same warm-editorial calm as the app,
 * accent kept to the focus line on a field and the submit button's border. */
.auth-page {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 2rem;
  background: var(--surface);
}
/* No border, no panel: the form sits on the page itself. The card it used to be was a box
   the same colour as the page with a line around it, so the line was doing all the work —
   decoration carrying the hierarchy, which is what R007 asks the layout not to do. Centring
   and the width cap are enough to say "this is the one thing here". No padding either: with
   the box gone there is no box to pad, and .auth-page already holds the fields off a narrow
   viewport's edges — a second 2rem inside the first only made the form narrower on the small
   screens that can least afford it. max-width is 18rem so the column reads the width it did
   inside the old card (22rem less its 2rem sides), not suddenly wider. */
.auth-card {
  display: flex;
  flex-direction: column;
  width: 100%;
  max-width: 18rem;
  margin: 0;
}
.auth-logo { font-size: 2rem; margin: 0 0 0.75rem; color: var(--on-surface); }
.auth-lead {
  font-size: var(--text-content);
  color: var(--on-surface-muted); /* ≥4.5:1 on --surface (AA) */
  margin: 0 0 1.25rem;
  line-height: 1.5;
}
/* A rejected attempt's message: accent lives on a line, so a left rule, not a fill (R007). */
.auth-error {
  font-size: var(--text-ui);
  color: var(--error);
  border-left: var(--rule) solid var(--error);
  padding: 0.35rem 0 0.35rem 0.6rem;
  margin: 0 0 1rem;
}
.auth-label {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  font-size: var(--text-meta);
  color: var(--on-surface-muted);
  margin-bottom: 0.9rem;
}
/* The shared field rule ("the field") dresses it; the sign-in page is a form page, so it takes the
 * form padding. */
.auth-label input { padding: 0.45rem 0.65rem; }
.auth-submit {
  font: inherit;
  font-size: var(--text-ui);
  color: var(--on-surface);
  background: transparent;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 0.5rem 0.85rem;
  margin-top: 0.5rem;
  cursor: pointer;
}
.auth-submit:hover { border-color: var(--primary); }
/* Google sign-in is the same KIND of control as the submit button — the one other way into the
 * workspace — so it wears the same component rather than a lookalike (R007). As an <a> it needs
 * only the two things a link doesn't inherit from a button. */
a.auth-submit { text-align: center; text-decoration: none; }
/* The separator between the two ways in. Muted words, no rule: one short label carries the
 * "either/or" without adding a line the page doesn't need. */
.auth-or {
  font-size: var(--text-meta);
  color: var(--on-surface-muted); /* ≥4.5:1 on --surface (AA) */
  text-align: center;
  margin: 0.9rem 0 0;
}

/* The signed-in operator row above the sidebar footer: name (muted) + a quiet Log out
 * link. On the dark sidebar, muted text uses --on-secondary-muted (AA on --secondary). */
/* The signed-in account: a trigger (avatar + name + caret) at the foot of the nav. Its menu opens
 * UPWARD IN-FLOW — a real block above the account bar that, when shown, pushes the nav up (the
 * flex-grow project list yields the space) rather than floating over it. Flat, layered by space +
 * a colour block, no shadow (R007). A plain button toggles it (app.js). */
.ws-account-trigger {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  flex: 1;
  min-width: 0;
  padding: 0.3rem 0.35rem;
  border: none;
  border-radius: var(--radius);
  background: transparent;
  font: inherit;
  text-align: left;
  cursor: pointer;
  color: var(--on-secondary-muted);
}
.ws-account-trigger:hover { background: var(--secondary-wash); }
.ws-avatar {
  flex-shrink: 0;
  width: 22px;
  height: 22px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  background: var(--secondary-wash);
  color: var(--on-secondary);
  font-size: var(--text-meta);
  font-weight: var(--weight-emph);
  text-transform: uppercase;
}
.ws-account-name {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: var(--text-ui);
  color: var(--on-secondary); /* identity reads at full contrast, not muted */
}
.ws-account-caret { flex-shrink: 0; opacity: 0.7; transition: transform 0.15s ease; }
.ws-account-trigger[aria-expanded="true"] .ws-account-caret { transform: rotate(180deg); }
/* The menu is quiet: Log out reads as just another nav row (like Tables/Webhooks/Settings), set
 * off from the workspace links only by a hairline — the same divider the workspace group uses
 * under the projects (R007 consistency). No box, no shadow; opening it reflows the nav up. */
/* It opens and shuts INSTANTLY, and that is the deliberate part — the caret's 0.15s rotate is the
 * only motion, the same one every other fold in the app rotates on (.project > summary, a chat
 * tool row).
 *
 * This block used to slide: the height animated as a one-row grid (0fr↔1fr) over a --menu-slide
 * token, with `display` in the transition under allow-discrete and an @starting-style to give the
 * opening one a from-state, plus an inner wrapper whose only job was to let the 0fr row collapse.
 * It looked fine on its own and wrong beside anything else. The app has four folds — a project in
 * the sidebar, a chat row's thinking and tools, the instructions history — and the other three
 * snap, because they are <details> and a native one cannot animate its content height without
 * either betting on ::details-content or bolting JS onto the element (R007 原生元素優先). This one
 * was the exception only because it is the one fold built from a JS [hidden] toggle, so the
 * animation was reachable — an artefact of HOW it is built, never a decision about how it should
 * feel. It sits directly under the project folds it disagreed with, which is where it showed.
 *
 * Snapping also means `hidden` alone hides it: nothing here declares `display`, so the UA sheet's
 * `display: none` applies unopposed, and the closed menu is out of the a11y tree and untabbable
 * for free. The old rule had to respell that, because the author `display: grid` outranked it.
 *
 * If folds ever animate here, they animate TOGETHER — and that starts with the three <details>,
 * not with this one. */
.ws-account-menu {
  margin-top: 0.35rem;
  padding-top: 0.35rem;
  border-top: 1px solid var(--secondary-edge);
}
.ws-account-menu form { margin: 0; }
/* Everything else about these two rows is the shared nav utility row above. What is left is
   what a <button> in a menu needs and a sidebar link does not: a button shrink-wraps its
   content and centres its text, so without these Log out would be a short centred row beside
   a full-width left-aligned Settings link. */
.ws-account-item { width: 100%; text-align: left; }

/* ── Task inbox ───────────────────────────────────────────────────────────────
 * The task list: the home page (every project's open tasks) and each project's own Tasks
 * page, filling the main pane. Warm Editorial — layered by space
 * and colour blocks, no shadow; accent (--primary) only on the Done decision key and the
 * links (source conversation, and the home inbox's group headers into a project's page). */
/* Outer full-width scroller / inner max-width column, the same split .transcript /
 * .transcript-inner and .chat-header / .chat-header-inner already use. It was ONE element doing
 * both jobs, which put the scrollbar at the right edge of the 52rem COLUMN — floating mid-pane on
 * a wide screen, the one place in this app a scrollbar never sits. That is the same half-copied
 * component the detail-pane geometry note records above ("a showcase pane arrived carrying
 * .admin-pane's own scrollbar"): a face that scrolls itself and a face that is a column inside
 * something else are two different things, and merging them gets you the half that doesn't fit
 * (R007 同類就走既有元件).
 *
 * scrollbar-gutter, for the reason spelled out at .transcript: reserved on BOTH edges so the
 * column stays on the pane's true centre. It earns its keep here in particular — closing a task
 * changes the list's height, so a classic scrollbar can appear or vanish under the operator's
 * hands, and without the reservation the whole page would step sideways as it did. */
.inbox-tasks {
  flex: 1;
  overflow-y: auto;
  scrollbar-gutter: stable both-edges;
}
.inbox-inner {
  max-width: 52rem;
  width: 100%;
  margin: 0 auto;
  padding: 1.5rem 2rem;
}
/* The same slim themed scrollbar every other scroll container here wears (the transcript, the
 * admin panes, the master/detail columns, a capped textarea) — a pane that scrolls in a
 * chrome-default bar is the one place the app stops looking like itself, and this is the home
 * page, the first thing anyone sees. */
.inbox-tasks::-webkit-scrollbar { width: 6px; }
.inbox-tasks::-webkit-scrollbar-thumb { background: var(--line); border-radius: 3px; }

.inbox-head { margin-bottom: 1.25rem; }
/* Same tier as .admin-head--page: the title of a page, not of a section inside one. */
.inbox-title { font-size: var(--text-title); margin: 0; }
.inbox-sub { color: var(--on-surface-muted); font-size: var(--text-ui); margin: 0.3rem 0 0; }
.inbox-empty { color: var(--on-surface-muted); padding: 2rem 0; }
.inbox-group { margin-bottom: 1.5rem; }
.inbox-group-name {
  font-size: var(--text-meta);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--on-surface-muted);
  margin: 0 0 0.55rem;
}
/* The home inbox's group header walks into that project's Tasks page — the same quiet
 * accent-link treatment as .task-card-link (never the UA default blue, which fails the
 * AA floor on the dark themes). */
.inbox-group-name a { color: var(--accent-text); text-decoration: none; }
.inbox-group-name a:hover { text-decoration: underline; }
.task-card {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--surface-alt);
  padding: 0.85rem 1rem;
  margin-bottom: 0.6rem;
}
.task-card-title { font-size: var(--text-content); }
.task-card-body {
  color: var(--on-surface-muted);
  font-size: var(--text-ui);
  margin-top: 0.3rem;
  white-space: pre-wrap;
}
.task-card-meta {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem 0.75rem;
  align-items: center;
  margin-top: 0.45rem;
  font-size: var(--text-meta);
  color: var(--on-surface-muted);
}
/* The task's target — which resource it is about. Monospace because it is an identifier,
   not prose, and elided because it can run to 200 characters — the full value is in the
   title attribute. It stays a plain
   .task-card-meta child otherwise (muted, same row): the only thing it needs of its own
   is not being allowed to push the rest of the row off the card. The width fits the targets
   people actually write ("table:orders:1042", "table:conversations:12") without eliding
   them — the ellipsis is for the pathological end of the 200-character allowance, not the
   normal case. */
.task-card-target {
  font-family: var(--font-mono);
  max-width: 32ch;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.task-card-link { color: var(--accent-text); text-decoration: none; }
.task-card-link:hover { text-decoration: underline; }
.task-card-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-top: 0.65rem;
}
/* Dressed by the shared field rule ("the field") — which is also where --text-content comes from,
   the rung that stops iOS zooming when the note takes focus (the note is the one place in the app
   a person types a sentence on a phone; the buttons beside it stay on --text-ui, since tapping a
   button does not focus a field). Two things are its own: the tightest padding in the app, and
   --surface rather than --surface-alt, because a task card's ground IS --surface-alt and a field
   the same colour as what it sits on has no edge left but its hairline. */
.task-assign select, .task-note {
  padding: 0.32rem 0.45rem;
  background: var(--surface);
}
.task-close { display: flex; gap: 0.4rem; flex: 1; min-width: 14rem; }
.task-note { flex: 1; min-width: 0; }
.task-done-btn {
  font: inherit;
  font-size: var(--text-ui);
  padding: 0.32rem 0.85rem;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: transparent;
  color: var(--accent-text);
  cursor: pointer;
}
/* Accent-border hover, no fill — the same quiet treatment every action button gets
 * (see the R007 anchor in the header: no button gets a resting or hover fill). */
.task-done-btn:hover { border-color: var(--primary); }
/* The wake checkbox: a decision, not a control to reach for, so it sits at the muted rung and
 * lets the assign select and the note keep the row's visual weight. Its label wraps the box so
 * the words are part of the target — a checkbox alone is a small tap on a phone. */
.task-wake { display: flex; align-items: center; flex-wrap: wrap; }
/* The wake checkbox IS an .admin-check (assets/inbox.html), the same checkbox settings,
   webhooks and table rows use — walked into, not copied (R007). Only what the card context
   changes lives here: the component stacks in a form, so it carries a bottom margin that a
   row of card actions must not inherit. */
.task-wake .admin-check { margin-bottom: 0; cursor: pointer; }
.task-wake input { accent-color: var(--primary); }

/* Each card carries THREE forms, so each carries its own feedback slot (form-feedback.html):
 * one message at the top of the inbox would blame a page of cards for one card's problem.
 * .task-close and .task-wake are flex rows, so their messages have to claim a full line rather
 * than squeeze in beside the input (a refusal wedged next to a checkbox squashes its label to a
 * few characters); and the actions row aligns to the top, so a message growing under one form
 * doesn't drag its sibling's control down the card with it. */
.task-card-actions { align-items: flex-start; }
.task-close { flex-wrap: wrap; }
.task-close .admin-feedback,
.task-wake .admin-feedback { flex-basis: 100%; }

/* SHOWCASES — a worked example's detail pane (assets/showcases.html). The pane itself is the
 * ordinary .admin-* stack; only these two pieces are new (the prompt is a .bubble, styled by the
 * chat's own rendered-markdown rules — showcases.html says why).
 *
 * The capability list wraps because it is read, not scanned in columns: three or five names in a
 * row, each one a .md-card-tag (the shared rule above). It carries the same 0.9rem below that
 * .admin-note does, so the pane keeps one rhythm all the way down. */
.showcase-uses {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem;
  list-style: none;
  padding: 0;
  margin: 0 0 0.9rem;
}

/* The prompt is a QUOTATION — the message Apply will send — sitting directly under the pane's own
 * prose explaining it. Without an edge there is nothing to say where flology stops speaking and
 * the quoted message starts; both are the same rendered prose in the same face, and the reader has
 * to infer the seam from the wording. So: a hairline and padding, the same edge every bounded
 * block in the app draws.
 *
 * It does NOT get a fill. The obvious "sheet of paper" is --surface-alt, but that is exactly the
 * background inline `code` chips use (.bubble.rendered code), and this prompt is full of them —
 * webhook names, provider ids, tool names. Fill the plate and every chip in it goes flat, losing
 * the one thing that marks the words a person must type exactly. An accent left-bar (the
 * blockquote treatment) was the other candidate; accent is spent on identity, interaction and
 * decision points (R007 accent 省用), and a block that is only being quoted is none of those. */
.showcase-prompt {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 0.9rem 1rem;
}

/* ── Files (a project's files from a browser) ─────────────
 * The master-detail shell again (see .md-shell): the folder down the left, one file's preview
 * on the right. Everything structural — the cards, the panel, the detail pane, the buttons —
 * is the shared vocabulary; what follows is only the parts a file list has and no other page
 * does: the breadcrumb, the upload form, and the numbered text preview.
 *
 * No new colours. Every rule below spends a token that already exists, which is also what
 * keeps assets_contrast_test.go's sweep meaningful — a page that invented its own greys would
 * be a page whose contrast nobody measured. */

/* Where you are in the project, and the way back up. Reads as a path (`sales / reports /`),
 * so the separator is a real slash rather than a chevron: a folder path is a thing this
 * audience already knows how to read, and it is what they will type to the agent. */
.files-crumbs {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.15rem 0.3rem;
  list-style: none;
  margin: 0;
  padding: 0.7rem 1rem;
  font-size: var(--text-meta);
  color: var(--on-surface-muted);
  border-bottom: 1px solid var(--line);
}
/* The separator is a character, not a rule: it is text, so it takes a text colour. --line is a
 * border colour and lands around 1.5:1 here — legible as a hairline, not as a glyph. */
.files-crumbs li + li::before { content: "/"; margin-right: 0.3rem; }
.files-crumbs a { color: var(--on-surface-muted); text-decoration: none; }
.files-crumbs a:hover { color: var(--accent-text); text-decoration: underline; }
/* The folder you are in is the one hop that is not a link, and it is the answer to "where am
 * I" — so it takes the full ink while its ancestors stay muted. */
.files-crumbs [aria-current] { color: var(--on-surface); }

/* The "add one" at the top of the master list, where Schedules and Webhooks keep theirs. It is
 * a form rather than a button, because the thing being added comes from the reader's own
 * machine and a file input is how a browser asks for it. */
.files-upload {
  padding: 0.7rem 1rem 0.9rem;
  border-bottom: 1px solid var(--line);
}
.files-upload input[type="file"] {
  display: block;
  width: 100%;
  margin-top: 0.3rem;
  font: inherit;
  font-size: var(--text-meta);
  color: var(--on-surface);
}
/* The limit, beside the button rather than under the field: it is only ever read after a file
 * has been chosen and refused, and that is where the eye already is. No bottom margin — the
 * .admin-actions row it sits in is the last thing in the form. */
.files-upload-note { margin: 0; }
/* Revealed by app.js only when the chosen name collides with something already here (see
 * files.html for why it is hidden until then). It sits ABOVE the field it qualifies because
 * that is where the collision is announced; a warning under the button would arrive after the
 * decision it is meant to inform. */
.files-replace {
  display: flex;
  align-items: baseline;
  gap: 0.4rem;
  margin-bottom: 0.5rem;
  font-size: var(--text-meta);
  color: var(--on-surface);
}
.files-replace[hidden] { display: none; }
.files-replace-text { line-height: 1.4; }
/* The name being overwritten, in the mono face the rest of the app spells filenames in — this
 * is the one word in the sentence that decides whether the tick is right. */
.files-replace-name { font-family: var(--font-mono); font-weight: var(--weight-emph); }

/* The folder marker before a subfolder's name. A glyph, not an icon: it is one character wide,
 * it never carries meaning alone (the card's meta line says "Folder" in words), and it keeps
 * the list to one text flow that ellipsizes correctly. */
.files-kind { margin-right: 0.35rem; color: var(--on-surface-muted); }
/* The listing stopped short. Sits under the last card with the panel's own padding, since it
 * is a note about the list rather than a row of it. */
.files-truncated { margin: 0.7rem 1rem; }

/* A file that turned out to be an image (sniffed, not guessed from its name — serveFileRaw).
 * Bounded to the pane, and on the dark theme given the same plate a reply's image gets — line
 * art drawn on transparency is invisible on a near-black surface, and it is the same rescue for
 * the same reason (walked into rather than copied: see [data-theme="night"] .bubble.rendered
 * img, which carries the full note). */
.files-image {
  display: block;
  max-width: 100%;
  height: auto;
  border: 1px solid var(--line);
  border-radius: var(--radius);
}
[data-theme="night"] .files-image {
  padding: 0.5rem;
  background: var(--image-plate);
  color: var(--image-plate-ink);
}

/* The text preview: a numbered listing, not a <pre> blob. The number is addressable — "line
 * 412 is wrong" is how a person hands a finding to the agent — and a table is what keeps the
 * numbers in their own column while the text beside them scrolls.
 *
 * The horizontal scroll lives HERE, on the box, not on the pane: a JSONL record is one long
 * line and the page must not slide sideways because of it (R007 實用先於好看). */
.files-lines {
  overflow-x: auto;
  border: 1px solid var(--line);
  border-radius: var(--radius);
}
.files-text {
  border-collapse: collapse;
  width: 100%;
  font-family: var(--font-mono);
  font-size: var(--text-meta);
  line-height: 1.55;
}
.files-lineno {
  /* Muted, right-aligned, and never selected with the text — a reader copying a preview wants
   * the file's lines, not the numbers we put beside them. */
  width: 1%;
  padding: 0 0.6rem;
  text-align: right;
  color: var(--on-surface-muted);
  background: var(--surface-alt);
  border-right: 1px solid var(--line);
  user-select: none;
  vertical-align: top;
  white-space: nowrap;
}
.files-linetext {
  padding: 0 0.7rem;
  color: var(--on-surface);
  white-space: pre;
  vertical-align: top;
}
/* The gap between the head and the tail of a long file. Muted and centred over the text
 * column, so it reads as a seam in the listing rather than as a line of the file. */
.files-gap .files-linetext {
  color: var(--on-surface-muted);
  white-space: normal;
  padding-top: 0.3rem;
  padding-bottom: 0.3rem;
  background: var(--surface-alt);
}
/* A line that was longer than the preview keeps. Says so in words at the point of the cut,
 * because a silently truncated line reads as a complete one. */
.files-cut { color: var(--on-surface-muted); font-family: var(--font-body); }

/* A file path in a tool line in the transcript ("read orders.jsonl") is a LINK to that file on
 * the Files page — the transcript is where a person most often meets a filename, and until now
 * it was the one place the name was a dead end. Rendered by chat.html on reload and by app.js
 * live; both produce this class.
 *
 * It inherits the summary row's muted ink rather than taking the accent: a tool line is
 * skimmed, and a row of terracotta filenames down the transcript would spend accent on the
 * quietest thing on the page (R007 accent 省用). The underline is what says it is a link, and
 * the accent arrives on hover, where the reader is asking. */
.tool-path { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
.tool-path:hover { color: var(--accent-text); }
