/* TKATI Media — tkati.com
   ─────────────────────────────────────────────────────────────────────────────
   One stylesheet, no build step, plain CSS. It dresses TWO documents and both
   of them matter:

     1. index.html — a single-viewport chat application. The PAGE never scrolls;
        the message thread is the only scroll container on it.
     2. The pages netlify/functions/enquiry.mjs returns for a real enquiry. They
        link this file unversioned, they carry no script at all, and they are
        ordinary scrolling documents built from the legacy .cta / .wrap / .h1 /
        .p / .btn / .enq classes. Those rules are NOT dead — they are the only
        styling those two pages ever get, and the error page is where somebody
        who mistyped their address has to find the submit button again. Section
        12 keeps them, and section 2 keeps that document scrollable.

   READING ORDER
      0  tokens · the [hidden] guard · the visible-by-default fade rule
      1  reset
      2  the locked viewport, and the one document that must NOT be locked
      3  focus
      4  utilities — .vh .skip .ico
      5  the shell — .app .main .thread
      6  the top bar
      7  the rail
      8  the thread column and the turns
      9  the dock — composer, send, fine print, jump pill
     10  the enquiry form
     11  the CTA block the enquiry function's pages are built from
     12  keyframes, by name
     13  EVERY media query, grouped, reduced motion last

   Two things in here are enforced by tools/verify.mjs and fail the build when
   broken — each is marked THE CHECK where it appears.

   Media queries are ALL in section 13. Components declare no breakpoint of
   their own, so the answer to "what changes at 900px" is one place in the file
   rather than five. @supports blocks stay next to the rule they guard, because
   a feature query is about the engine, not about the window.
   ─────────────────────────────────────────────────────────────────────────── */


/* ═══ 0. DESIGN TOKENS ════════════════════════════════════════════════════════ */

:root{
  /* ── SURFACES ─────────────────────────────────────────────────────────────
     Near-black canvas with a barely-perceptible violet lean. The rail, the top
     bar and the thread are ALL --bg; they are told apart by hairlines, not by
     fills. Only three raised levels exist, and each is a white alpha over the
     canvas so it composites identically on every surface it can land on. */
  --bg:#070707;                          /* page, rail, top bar, thread, dock */
  --bg-2:#0C0C0C;                        /* legacy: the enquiry function's pages */
  --bg-3:#131313;                        /* legacy: the enquiry function's pages */
  --surface-1:rgba(255,255,255,.035);    /* L1 raised: user bubble, chip at rest */
  --surface-2:rgba(255,255,255,.055);    /* L2 control: composer fill, row hover */
  --surface-3:rgba(255,255,255,.08);     /* L3: active rail row, chip hover */
  --hover:rgba(255,255,255,.05);         /* the universal hover step */

  /* ── HAIRLINES ────────────────────────────────────────────────────────────
     Two decorative strengths and one accessible strength. --hair-3 is a WCAG
     1.4.11 requirement (3:1 against the canvas), not a taste decision: it is the
     ONLY line permitted to be the sole boundary of an interactive control. */
  --hair-1:rgba(255,255,255,.06);        /* structural: rail edge, top bar underline */
  --hair-2:rgba(255,255,255,.10);        /* container edge: bubble, panel, code block */
  --hair-3:rgba(255,255,255,.36);        /* CONTROL boundary — 3.10:1. composer, chips */
  --hair-4:rgba(255,255,255,.55);        /* composer :focus-within, and the
                                            empty state's RESTING edge — so in
                                            the empty state the edge does not
                                            move on focus; the ring in 9.1 is
                                            the indicator there. */
  --line:rgb(var(--brand-rgb)/.2);       /* legacy accent hairline — .cta pages only,
                                            NEVER in the chat chrome */
  --line-2:rgba(255,255,255,.08);        /* legacy */
  --line-3:rgba(255,255,255,.055);       /* legacy */

  /* ── TEXT ─────────────────────────────────────────────────────────────────
     Five steps, all measured against #08070A with the WCAG 2.x relative
     luminance formula. Do not invent a sixth and do not go below --muted-3. */
  --text-hi:#FFFFFF;                     /* 20.09:1  emphasis, *strong*, enabled send glyph */
  --text:#E6E6E6;                        /* 16.14:1  body prose, bubble text, composer text */
  --text-2:#C8C8C8;                      /* 12.04:1  h3 inside an answer, active rail row */
  --muted:#A3A3A3;                       /*  7.99:1  rail rows at rest, lede, chip labels */
  --muted-2:#8B8B8B;                     /*  5.91:1  THE muted default: mono labels, dock note */
  --muted-3:#7E7E7E;                     /*  4.96:1  the floor. Nothing quieter is legal. */

  /* ── PURPLE ───────────────────────────────────────────────────────────────
     An accent, never a fill on a large area. Budget: at most FOUR purple objects
     visible at once — the focus ring, the active rail row's inset bar, the send
     disc's gradient, the jump pill's new-content dot. --brand-3 measures 3.15:1
     on the canvas and 2.88:1 on a raised surface: it is legal inside a gradient
     fill and illegal as text or as a focus ring, anywhere, ever. */
  /* ── THE ACCENT IS A SET, NOT THREE LITERALS ───────────────────────────────
     Green is the default and purple is kept whole, one attribute away. Every
     accent in this stylesheet reads these tokens and NOTHING reads a purple
     literal any more — that is what makes the toggle total rather than a swap
     that leaves violet fragments in the selection highlight and the legacy
     button shadows. --brand-rgb carries --brand-2's channels so the few places
     that need the accent at an alpha say rgb(var(--brand-rgb)/.32) instead of
     hard-coding a fourth copy of the hue.

     DARK AND DESATURATED. Two passes got this wrong in the same direction.
     #86EFAC read as spearmint; #4ADE80/#16A34A read as a highlighter — a pure,
     fully-saturated green that shouts, which is the colour of a discount badge
     rather than of a tool somebody trusts with their business.

     The palette is BLACK, WHITE, AND ONE QUIET GREEN. The canvas does the work,
     the type is near-white and carries the page, and the green appears only
     where something is live or focused. Every stop is pulled toward 45-55%
     saturation with a slight teal lean, which is what separates an instrument
     from a toy — the same move every serious tool makes with its accent.

     MEASURED ON THIS CANVAS: 8.31:1 / 4.77:1 / 1.66:1. --brand-1 keeps a wide
     margin over the 4.5:1 body-text floor while sitting visibly quieter than
     the two greens before it. --brand-3 is deliberately near-black — it is the
     far end of a gradient and nothing else, which is what the rule below
     enforces.

     THE GRADIENT IS DARKER THAN --brand-2 AT EVERY STOP, AND THAT IS A
     CONTRAST CONSTRAINT, NOT A MOOD. `.btn` sets 14px semibold #FFFFFF on
     var(--grad), so the LIGHTEST point of the ramp is what the label has to
     survive — normal-size text, so the floor is 4.5:1 and not 3:1. An earlier
     ramp opened on #4FA87C, which is 2.91:1 against white: the CTA label was
     failing WCAG 1.4.3 across the top third of the button and it is not
     something the eye reports. #2C7D5B is the lightest green that clears it
     with margin. Measured white ratios along the ramp: 5.01 / 7.12 / 12.09.
     RE-MEASURE IF YOU LIGHTEN ANY STOP. */
  /* ── NO HUE. BLACK, WHITE, AND THE DISTANCE BETWEEN THEM ───────────────────
     The accent is not a colour any more, so every job a colour was doing has
     to be done by luminance instead: emphasis is brighter, recession is
     darker, and a boundary is a hairline rather than a tint. That is a
     stricter discipline than having a hue, not a looser one — there is nothing
     to hide a weak hierarchy behind.

     The grey ramp above was cool-violet (#E7E6EA, #A3A2AA) because it was
     tuned to sit under purple. With the purple gone that cast reads as dirt,
     so every step is now a true neutral at the SAME measured ratio it had
     before: 16.14 / 12.04 / 7.99 / 5.91 / 4.96. Nothing regressed; the ink
     just stopped being slightly lilac.

     THE FILL IS WHITE AND ITS LABEL IS BLACK. `.btn` used to set white on a
     purple ramp and measured 4.82:1 at its lightest stop, which was the
     tightest margin on the page. Inverting it is both the more confident
     object and 19.80:1. */
  --brand-1:#FFFFFF;                     /* 20.14:1 focus rings, the active rail bar */
  --brand-2:#C8C8C8;                     /* 12.04:1 selection, and the rgb() alpha source */
  --brand-3:#8E8E8E;                     /*  6.15:1 the deep stop of the metallic ramp */
  --brand-rgb:255 255 255;               /* alpha uses read white now, not a hue */
  --grad:linear-gradient(135deg,#FFFFFF 0%,#EDEDED 55%,#DADADA 100%);
  /* FOR TYPE, NOT FOR FILL, and vertical rather than diagonal: light falling
     top-to-bottom across a letterform is what reads as machined metal. A
     diagonal reads as a swatch. Stops measure 20.14 / 12.16 / 6.15 on the
     canvas, so the darkest is still ink, not decoration. */
  /* A SHEEN, NOT A FADE. The first monochrome pass ran this to #8E8E8E and
     made "Dominate." the DIMMEST word in a headline whose other two words are
     pure white — the brand's punchline reading as the afterthought. With a hue
     a gradient can darken and still feel like emphasis; in greyscale darker is
     simply quieter. The range is now narrow enough to read as light falling
     across metal rather than as the word trailing off. */
  --grad-text:linear-gradient(180deg,#FFFFFF 0%,#F2F2F2 46%,#D2D2D2 100%);
  --grad-line:linear-gradient(90deg,transparent,rgb(var(--brand-rgb)/.35),transparent);
  --p0:#FFFFFF; --p1:#E6E6E6; --p2:#C8C8C8; --p3:#8E8E8E;   /* legacy .cta pages */

  /* The monogram is a violet raster (/assets/mark.png). Under the green accent
     it is rotated to match; under purple the filter is none, so the artwork
     shows exactly as drawn. Replace the PNG with a green original and this
     becomes `none` in both. */
  /* THE MARK IS A VIOLET RASTER and there is no violet left. brightness(0)
     crushes every non-transparent pixel to black and invert(1) lifts it to
     white, which keeps the alpha channel and the chiselled edge exactly as
     drawn — a hue-rotate cannot reach white from a colour, it can only move
     it. Replace the PNG with a white original and this becomes `none`. */
  --mark-filter:brightness(0) invert(1);
  /* AND THEN FADED. brightness(0) invert(1) lands the monogram on pure white,
     which made a small, densely filled glyph the brightest object in the top
     bar — brighter than the "TKATI Media" text beside it (20.14:1 against
     16.14:1) and reading as a glare rather than as a mark. It is decoration
     next to the word, not the brand statement, so it sits BELOW the text it
     accompanies. Composited over #070707 this lands on about #A3A3A3.
     Opacity rather than a darker filter, because it keeps the anti-aliased
     edge of the chiselled artwork correct. */
  --mark-opacity:.62;
  --grad-alt:linear-gradient(135deg,#2C7D5B 0%,#226349 45%,#143D2E 100%);

  /* ── TYPE ─────────────────────────────────────────────────────────────────
     Three families, loaded from Google Fonts in <head>. Inter Tight is the
     display face and appears ONLY on the h1 and on in-answer headings. JetBrains
     Mono is the machine-text face: labels, counters, keyboard hints, technical
     name lists. It never sets a sentence. */
  --display:"Inter Tight",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
  --body:"Inter",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
  --mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;

  /* ── RADII ────────────────────────────────────────────────────────────────
     Short scale. Nested radii step down, never repeat. Nothing is square except
     dividers. --r-chisel is the TKATI move: one tightened corner on the user
     bubble, the only place the brand's chiselled geometry appears in the app. */
  --r-xs:5px;      /* inline code */
  --r-sm:8px;      /* rail rows, small buttons, form fields */
  --r-md:12px;     /* panels, code blocks, the error box */
  --r-lg:18px;     /* user bubble */
  --r-xl:24px;     /* the composer */
  --r-chisel:6px;  /* the user bubble's bottom-right corner */
  --r-pill:999px;  /* chips, the send disc, the jump pill */

  /* ── SPACING — a 4px grid, an 8px rhythm ─────────────────────────────────
     Everything is a multiple of 4. The handful of values off the grid in this
     file are optical corrections and each is named where it appears. */
  --s-1:4px;  --s-2:8px;  --s-3:12px; --s-4:16px; --s-5:20px;
  --s-6:24px; --s-7:28px; --s-8:32px; --s-9:40px; --s-10:64px;

  /* ── SHADOW — there are exactly two on the page ─────────────────────────── */
  --shadow-1:0 2px 12px rgba(0,0,0,.28);            /* the composer */
  --shadow-2:0 16px 48px -24px rgba(0,0,0,.9);      /* the jump pill */

  /* ── MOTION ───────────────────────────────────────────────────────────────
     Entrances decelerate, exits accelerate and take ~0.65× the entrance.
     Nothing that responds to a click exceeds 200ms. Nothing on the page exceeds
     320ms. Nothing overshoots. Only transform and opacity animate, with the two
     named exceptions in section 13. */
  --t-micro:90ms;   /* icon swap, focus ring, press feedback */
  --t-fast:140ms;   /* hover, action-row reveal */
  --t-base:180ms;   /* message entrance, pill in */
  --t-mid:240ms;    /* rail collapse, drawer */
  --t-slow:320ms;   /* the one-shot post-send scroll */
  --t-exit:120ms;   /* the universal leave duration */
  --ease:cubic-bezier(.22,1,.36,1);        /* quart-out. The house entrance curve. */
  --e-std:cubic-bezier(.2,0,0,1);          /* movement between two states */
  --e-in:cubic-bezier(.4,0,1,1);           /* exits only */
  --e-panel:cubic-bezier(.32,.72,0,1);     /* rail and drawer. Zero overshoot. */
  --ease-io:cubic-bezier(.65,.05,.36,1);   /* legacy */

  /* ── LAYOUT ───────────────────────────────────────────────────────────── */
  --rail-w:16.5rem;      /* 264px */
  --topbar-h:52px;
  --col:47rem;           /* 752px — thread AND composer share this exactly */
  --gut:1rem;            /* raised at the breakpoints in section 13 */
  --pad-b:.75rem;
  --msg-gap:28px;        /* 22px on mobile */
  --w:1120px;            /* legacy .wrap on the enquiry function's pages */
  --pad:112px;           /* legacy */

  /* ── SAFE AREAS — always with the 0px fallback, never bare env() ────────── */
  --safe-t:env(safe-area-inset-top,0px);
  --safe-r:env(safe-area-inset-right,0px);
  --safe-b:env(safe-area-inset-bottom,0px);
  --safe-l:env(safe-area-inset-left,0px);

  /* ── Z-INDEX — the whole scale, no other value exists ──────────────────── */
  --z-base:0; --z-topbar:2; --z-composer:2; --z-jump:3;
  --z-scrim:4; --z-rail:5; --z-skip:50;

  /* ── WRITTEN BY main.js. Every rule that reads one has a working fallback,
     so the page is never waiting on a script. ────────────────────────────── */
  --app-h:100dvh;
  --vv-top:0px;
  --kb:0px;
  --composer-h:64px;

  /* legacy, decoration only */
  --sp:0;
  /* The dock's fade height. Read by BOTH the scrim that paints it and the
     padding that keeps content out from under it — they were two literals and
     they had already drifted: 56px of fade over 24px of clearance meant the
     last 32px of any thread could be read through a gradient. */
  --scrim-h:56px; --mx:50%; --my:50%;

  /* ── THE TWO SIZES THAT WERE USED AND NEVER DECLARED ──────────────────────
     Five declarations in this file read --fs-sm or --fs-md and neither name
     existed anywhere. An undefined var() makes the declaration invalid at
     computed-value time, and for an INHERITED property that means `inherit` —
     not a fall back to the component's own earlier rule. So .msg__via,
     .msg__lead-p, .intro__lead and .rail__accent all quietly rendered at the
     inherited 16px, and the empty state's lede had two authored sizes and
     rendered at neither of them. This is a bug fix, not a new scale: only the
     two names already in use are declared, and nothing joins them without a
     consumer. */
  --fs-sm:.875rem;       /* 14px — .msg__via, .msg__lead-p, .intro__lead, .rail__accent */
  --fs-md:1.0625rem;     /* 17px — the empty state's lede, and its composer text */
}

html.kb-open{ --safe-b:0px }   /* the home indicator is under the keyboard */

/* The dev-only client-login link is revealed by main.js on a development host
   and MUST stay invisible everywhere else. [hidden]{display:none} comes from the
   user-agent sheet, and ANY author rule that sets display on the element beats
   it regardless of specificity. This app sets display on everything. */
[hidden]{display:none!important}

/* THE CHECK (1 of 2) — tools/verify.mjs, section "marketing page".
   Content is never JS-dependent: visible by default, and the fade is opt-in,
   reachable only from the class the script adds once it has confirmed it can
   undo it. The verifier takes the FIRST rule in this file whose selector is
   that class alone and requires opacity:1 in it, reading the raw bytes with
   comments included — so nothing may precede the rule on its own line, and the
   selector must never appear followed by an opening brace in any comment above
   it. Nothing on the chat page carries the class; the rule is kept because the
   check requires it and because the enquiry function's own response pages link
   this stylesheet and do carry it. */
.reveal{opacity:1}
.js .reveal{opacity:0;transform:translateY(10px);transition:opacity .45s var(--ease),transform .5s var(--ease)}
.js .reveal.is-in{opacity:1;transform:none}


/* ═══ 1. RESET ════════════════════════════════════════════════════════════════
   Small and modern. It normalises boxes, kills the margins the components then
   own outright, and stops the four ways a mobile browser undoes a fixed layout.
   It deliberately does NOT flatten list-style: an answer's <ul> keeps its disc
   and the rail's <ul> turns it off itself. */

*,*::before,*::after{box-sizing:border-box}

body,h1,h2,h3,h4,h5,h6,p,figure,blockquote,dl,dd,ul,ol,pre{margin:0}
ul,ol{padding:0}

/* Headings are levels, not sizes. The document's outline is h1 → h2 (a visitor's
   question) → h3 (a heading inside an answer), and .msg__q is an <h2> set at
   16px/400 on purpose — a heading does not have to look like one. Neutralising
   the UA sizes here is what lets every component state its own size once, and it
   is why .h1 on the enquiry function's pages carries a full font shorthand. */
h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit;line-height:inherit}

img,svg,video,canvas{display:block;max-width:100%}
img{height:auto}

/* Controls inherit the page's face. The 16px floor below re-states font-size
   after this shorthand, so declaration order between these two rules matters. */
button,input,select,textarea{font:inherit;color:inherit;background:none;border:0;margin:0}
button{padding:0;cursor:pointer;text-align:inherit;-webkit-appearance:none;appearance:none}
button[aria-disabled="true"],button:disabled{cursor:default}
textarea{resize:none}

/* THE iOS ZOOM FLOOR. Safari zooms the whole page when a focused field computes
   below 16px and never zooms back: the layout viewport ends up narrower than the
   visual viewport, a page with no horizontal scroll gains one, and the visitor
   has to pinch out by hand. On a chat app whose input is focused constantly it
   is fatal. An explicit px value, NOT 1rem — if anything ever sets a root type
   scale, 1rem stops being 16px and the zoom comes back. Components may go below
   it only inside @media (pointer:fine), which is where no zoom exists. */
input,textarea,select,button{font-size:16px}

::placeholder{color:var(--muted-2);opacity:1}   /* Firefox dims it otherwise */

a{color:inherit;text-decoration:none}           /* prose links opt back in */

hr{border:0;border-top:1px solid var(--hair-1);margin:0}

/* ── THE GREEN ACCENT, ONE ATTRIBUTE AWAY ────────────────────────────────────
   Purple is the default and lives on :root above. assets/theme.js stamps
   data-accent="green" on <html> from localStorage BEFORE first paint; the
   control that writes it sits in the rail footer. Every accent in this file
   reads the tokens, so the swap is total — nothing keeps a purple fragment in
   the selection highlight or the legacy button shadows. */
:root[data-accent="green"]{
  --brand-1:#63B78C;                     /*  8.31:1 */
  --brand-2:#2E8B63;                     /*  4.77:1 */
  --brand-3:#143D2E;                     /*  1.66:1 GRADIENT AND FILL ONLY */
  --brand-rgb:46 139 99;
  --grad:linear-gradient(135deg,#2C7D5B 0%,#226349 45%,#143D2E 100%);
  --grad-text:linear-gradient(100deg,#9FE8BE 0%,#5FBE90 52%,#3E9A6E 100%);
  --p0:#A8D7BF; --p1:#63B78C; --p2:#2E8B63; --p3:#143D2E;
  /* The monogram is drawn in violet, so the DEFAULT needs no filter and this
     one does. */
  --mark-filter:hue-rotate(-118deg) saturate(.62) brightness(1.04);
  /* the swatch previews what pressing the control gives you — from green,
     that is the monochrome default. */
  --grad-alt:linear-gradient(135deg,#FFFFFF 0%,#EDEDED 55%,#DADADA 100%);
}

::selection{background:rgb(var(--brand-rgb)/.32);color:var(--text-hi)}


/* ═══ 2. THE LOCKED VIEWPORT ══════════════════════════════════════════════════
   The page is an app: the document itself is not a scroll container, and the
   only scroller is .thread. Everything here has a named failure mode. */

html{
  height:100%;
  /* overflow propagates from <body> to the viewport only while <html> is
     visible, so THIS is the declaration that counts. Both are set anyway, as
     insurance against a later rule changing one of them. */
  overflow:hidden;
  /* On <html>, not <body>: overscroll-behavior does NOT propagate. Without it a
     downward drag at the top of the thread triggers Chrome Android's
     pull-to-refresh — the page reloads and the conversation is gone — and on iOS
     the whole plate rubber-bands and shows the browser's own background through
     the gap, which reads as a rendering bug. Left unscoped: on the enquiry
     function's pages it only costs the overscroll glow. */
  overscroll-behavior:none;
  /* README: color-scheme AND an explicit background, or the browser paints
     un-rastered regions white — a white flash on every rotate and resize. */
  color-scheme:dark;
  background-color:var(--bg);
  /* Never smooth, globally: a smooth root would make every scrollTop assignment
     in main.js animate, and the stream pin would lag the reveal by ~300ms. */
  scroll-behavior:auto;
  /* iOS inflates text in some landscape layouts, which changes the composer's
     line-height, which changes the shell's row heights, which un-pins the
     thread. */
  -webkit-text-size-adjust:100%;
  text-size-adjust:100%;
}

body{
  height:100%;
  overflow:hidden;
  overflow-x:hidden;          /* never let a stray element widen the page */
  overscroll-behavior:none;
  background-color:var(--bg);
  color:var(--text);
  font:400 16px/1.5 var(--body);
  -webkit-font-smoothing:antialiased;
  -moz-osx-font-smoothing:grayscale;
  font-variant-ligatures:common-ligatures;
}

/* THE SECOND DOCUMENT IS NOT AN APP, AND THE LOCK ABOVE WOULD TRAP IT.
   netlify/functions/enquiry.mjs answers with <body><main><section class="cta">,
   and its error page is a heading, a message and the whole enquiry form again
   with the visitor's typed values in it — reliably taller than a phone screen.
   With html and body locked and nothing else scrollable, the submit button on
   that page is unreachable and the enquiry is lost at the last step.

   The chat page's own <main> is .main and lives INSIDE .app, so `body > main`
   matches on that document and only on that document; :not(.main) states the
   distinction rather than relying on it. The lock stays exactly as SPEC 2.2
   writes it — element specificity (0,0,1) — because the <noscript> block in
   index.html unlocks with `html,body{height:auto;overflow:auto}` and a scoped
   selector here would silently outrank it and break the no-script page. */
body > main:not(.main){
  height:100%;
  overflow-y:auto;
  overscroll-behavior:contain;
}


/* ═══ 3. FOCUS ════════════════════════════════════════════════════════════════
   Measured on #08070A: a 10% white border is 1.24:1 and a 22% white border is
   1.88:1, against the 3:1 WCAG 1.4.11 asks for. A brightening hairline is not a
   focus indicator on this palette; a real ring is.

   outline, never box-shadow: Windows High Contrast drops box-shadow entirely and
   keeps outline. :focus-visible, not :focus, so a mouse click paints nothing.
   :where() so every component overrides it with a plain class selector.
   --brand-3 is 2.9:1 on a raised surface and is FORBIDDEN as a focus colour. */
:where(a,button,input,textarea,select,summary,[tabindex]):focus-visible{
  outline:2px solid var(--brand-1);   /* the accent's lightest stop — 8.31:1 */
  outline-offset:2px;
  border-radius:inherit;
}


/* ═══ 4. UTILITIES ════════════════════════════════════════════════════════════ */

/* Visually hidden, and STILL IN THE ACCESSIBILITY TREE — the whole point.
   clip-path:inset(50%) rather than the legacy clip:rect(); white-space:nowrap so
   a long hidden string is not collapsed into a 1px column some engines truncate.
   It must never be display:none or visibility:hidden: both remove the text from
   the tree, which is the opposite of what is wanted. */
.vh{
  position:absolute;
  width:1px;height:1px;
  margin:-1px;padding:0;
  overflow:hidden;
  clip-path:inset(50%);
  white-space:nowrap;
  border:0;
}

/* The first two tab stops. `top` is one of the two properties in this file
   allowed to animate (a 100px travel on a control that appears once per session
   and cannot collide with anything). The left inset is the notch: in landscape
   these land exactly where it is. */
.skip{
  position:absolute;
  left:calc(var(--s-3) + var(--safe-l));
  top:-100px;
  z-index:var(--z-skip);
  padding:10px 16px;
  border-radius:var(--r-sm);
  background:#FFFFFF;
  color:var(--bg);
  font:500 14px/1 var(--body);
  transition:top var(--t-exit) var(--e-std);
}
.skip:focus{top:12px;outline:2px solid var(--brand-1);outline-offset:2px}

/* Every icon on the page is an inline <svg> using currentColor, carrying its own
   fill/stroke attributes. A background-image icon, a mask or an icon font is
   invisible under forced-colors and unstylable by the system palette. This rule
   is the default box; each control states its own size. */
.ico{flex:0 0 auto;width:20px;height:20px}


/* ═══ 5. THE APP SHELL ════════════════════════════════════════════════════════
   .app  ── grid ──  .rail | .main
                             .main ── grid rows ── .topbar / .thread / .dock   */

.app{
  position:fixed; left:0; right:0; top:0;

  /* THE FALLBACK CHAIN, and it must stay three separate declarations.
     100vh on iOS means the LARGE viewport — the window with the URL bar
     collapsed. On a page that never scrolls the bar never collapses, so a
     100vh shell is permanently taller than the screen and the composer is off
     the bottom with no way to reach it. svh and dvh shipped in the same
     releases everywhere; a browser that knows neither keeps 100vh for one frame
     until main.js writes --app-h.
     NEVER write height:var(--app-h,100dvh): with --app-h unset in a browser
     without dvh that declaration is invalid at computed-value time, height
     falls back to auto, and it wipes out both fallbacks above it. */
  height:100vh;
  height:100svh;
  height:100dvh;

  /* iOS pushes the VISUAL viewport down inside the layout viewport when the
     keyboard opens; a fixed shell does not follow it on its own. main.js
     publishes the offset and this transform follows it.
     CONSEQUENCE: a non-none transform makes .app the containing block for any
     fixed descendant, so NOTHING inside the shell may be position:fixed. The
     drawer, the scrim and the jump pill are position:absolute — which is what
     they wanted to be anyway. */
  transform:translate3d(0,var(--vv-top,0px),0);

  /* ONE TRACK. The rail is out of flow at every width — a drawer below 900px,
     an overlay above it — so there is no rail column to reserve. It was
     `auto minmax(0,1fr)`, and the moment the rail stopped being an in-flow grid
     item, <main> auto-placed itself into that now-zero-width first track and
     collapsed to nothing. A two-track grid with one item is a trap: the item
     silently takes the wrong track. */
  display:grid;
  grid-template-columns:minmax(0,1fr);
  overflow:hidden;
  /* Nothing inside the shell can change anything outside it, so a revealing
     message never triggers a document-level relayout. Legal because the height
     above is explicit. */
  contain:layout size;
  isolation:isolate;
  z-index:var(--z-base);

  /* NO TRANSITION ON THE TRACKS ANY MORE. The rail is out of flow at every
     width now — a drawer below 900px, an overlay above it — so track 1 is
     always 0 and there is nothing here to animate. The declaration that used to
     smooth the push is what made the push look deliberate; removing the push
     removed the need for it. */

  /* Kills double-tap-to-zoom — and therefore the 300ms click delay — while
     keeping pan and pinch. NEVER touch-action:none here: that would disable
     scrolling inside the thread and pinch-zoom with it, a WCAG 1.4.4 failure. */
  touch-action:manipulation;
}

/* Once main.js has measured the visual viewport, its number is the authority.
   A SEPARATE rule gated on html.vv, never a var() fallback inside the height
   above — see the note in .app. main.js adds .vv only after writing a real px. */
html.vv .app{height:var(--app-h)}

a,button,label,summary,input,textarea{touch-action:manipulation}

.main{
  display:grid;
  grid-template-rows:auto minmax(0,1fr) auto;   /* topbar | thread | dock */
  /* THE TWO MOST IMPORTANT DECLARATIONS IN THE FILE. A grid item's automatic
     minimum size is min-content, not zero: without them the 1fr row refuses to
     shrink below the height of every message, the grid outgrows the shell,
     overflow:hidden clips the bottom, and THE COMPOSER IS CUT OFF THE BOTTOM OF
     THE SCREEN. It looks perfect with three messages and breaks the moment the
     conversation is longer than one screen — which the short seeded thread will
     not reveal during development. min-width:0 is the horizontal twin: it stops
     a pasted 300-character URL from widening the whole app. */
  min-width:0;
  min-height:0;
  position:relative;      /* containing block for the drawer and its scrim */
}

/* ── THE ONLY SCROLLER ON THE PAGE ─────────────────────────────────────────── */
.thread{
  grid-row:2;
  min-height:0;
  overflow-y:auto;
  overflow-x:hidden;
  /* The gesture stops at the ends instead of being handed to the document. */
  overscroll-behavior:contain;
  /* Reserve the scrollbar's width whether or not the thread currently overflows.
     Without it the content box narrows by ~15px the instant the thread becomes
     scrollable — mid-reveal — and the whole centred column reflows sideways
     while the reader is reading it. Invisible on macOS overlay scrollbars,
     glaring on Windows and Linux. */
  scrollbar-gutter:stable;
  /* Chrome's scroll anchoring silently adjusts scrollTop when content above the
     viewport changes size. It is a good feature, and it fights the explicit pin
     non-deterministically, only when a web font finishes loading. */
  overflow-anchor:none;
  /* Smooth is passed per call by the jump pill and by the post-send scroll,
     never declared here: a smooth container animates every scrollTop assignment
     and the pin would lag the reveal. */
  scroll-behavior:auto;
  /* A rail row is a real anchor to a seeded turn and main.js never intercepts
     it, so the browser does the scrolling and needs to be told where the usable
     band is. The top bar is its own grid row, so the start value is breathing
     room rather than clearance; the end value is the 56px composer scrim plus
     8px of air, written as the scrim's own number so the two move together. */
  scroll-padding-block:var(--s-2) calc(56px + var(--s-2));
  /* Momentum has been the default since iOS 13, and the legacy value still
     creates a separate scrolling layer that breaks sticky children and clips
     border-radius. */
  -webkit-overflow-scrolling:auto;
  background:var(--bg);
  position:relative;
  z-index:var(--z-base);
  /* ONE SCROLLBAR EXISTS ON THIS PAGE. Quiet at rest, firmer while the pointer
     is in the thread, and never coloured: a purple scrollbar would be a fifth
     purple object and the budget is four. */
  scrollbar-width:thin;
  scrollbar-color:rgba(255,255,255,.16) transparent;
}

/* The ring is drawn INSIDE the scroller. .thread fills its grid row edge to
   edge inside .app{overflow:hidden}, so a 2px outset ring is drawn under the top
   bar and clipped at the left edge. This is the one control whose ring may not
   sit outside its box — and it is not optional: Firefox makes overflow
   containers focusable and Chrome and Safari do not, so with <body> locked an
   unfocusable scroller would make the whole transcript keyboard-unreachable. */
.thread:focus-visible{
  outline:2px solid var(--brand-1);
  outline-offset:-3px;
}

/* The WebKit half of the same scrollbar, painted through a transparent border so
   the visible thumb is 5px with clearance either side — a hairline, not a bar. */
.thread::-webkit-scrollbar{width:11px;height:11px}
.thread::-webkit-scrollbar-track{background:transparent}
.thread::-webkit-scrollbar-thumb{
  background:rgba(255,255,255,.14);
  background-clip:content-box;
  border:3px solid transparent;
  border-radius:var(--r-pill);
}
.thread:hover::-webkit-scrollbar-thumb{background:rgba(255,255,255,.24);background-clip:content-box}
.thread::-webkit-scrollbar-thumb:active{background:rgba(255,255,255,.34);background-clip:content-box}


/* ═══ 6. THE TOP BAR ══════════════════════════════════════════════════════════
   52px, opaque, one hairline under it. NO backdrop-filter and no scroll fade:
   .app carries a transform and contain:layout size, and a blurred translucent
   bar inside a transformed contained subtree is where Safari's compositor bugs
   live — it also costs a layer on every scroll frame on mobile. One scrim,
   above the composer, is enough.

   Contents, left to right: the toggle, the title, a flex spacer, the engine
   line, the one CTA. */
.topbar{
  grid-row:1;
  display:flex;
  align-items:center;
  gap:var(--s-2);
  min-width:0;
  /* 52px of bar plus whatever the status bar takes in standalone mode. The
     safe-area inset is padding on top of the bar's own height, not a slice out
     of it, or the row shrinks under the notch. */
  height:calc(var(--topbar-h) + var(--safe-t));
  padding-top:var(--safe-t);
  /* 12px matches the rail's own horizontal padding, so the toggle keeps the
     same distance from the viewport edge in both rail states. The left inset is
     the one people forget: in landscape on a notched iPhone one side is ~44px
     and the toggle would sit under the notch. */
  padding-left:calc(var(--s-3) + var(--safe-l));
  padding-right:calc(var(--s-3) + var(--safe-r));
  background:var(--bg);
  border-bottom:1px solid var(--hair-1);
  position:relative;
  z-index:var(--z-topbar);
}

/* ONE BUTTON, ONE DOM NODE, both rail states, every width — and at the same
   distance from the viewport's left edge in both. Its accessible name is the
   constant word "Sidebar"; only aria-expanded changes, because a name that
   changes makes the control unfindable by voice control the moment it is used
   once. It lives in the top bar despite the rail__ prefix. */
.rail__toggle{
  flex:0 0 auto;
  display:grid;
  place-items:center;
  width:32px;
  height:32px;
  border-radius:var(--r-sm);
  background:transparent;
  color:var(--muted);
  transition:background-color var(--t-fast) var(--ease),
             color var(--t-fast) var(--ease);
}
.rail__toggle .ico{width:18px;height:18px}

/* "TKATI Media". A plain, non-interactive label — no chevron, no popover, no
   model picker. A dead chevron is worse than no chevron, and a model picker on
   an engine that is not a model is a lie. margin-right:auto IS the flex spacer,
   which is why removing the engine readout beside it changed no geometry: a
   spacer ELEMENT, or an auto margin on that readout, would have taken the top
   bar's layout with it. */
.topbar__title{
  font:500 15px/1.2 var(--body);
  color:var(--text);
  margin-right:auto;
  min-width:0;
  white-space:nowrap;
  /* NO overflow:hidden AND NO ELLIPSIS ANY MORE. Both were here for a text
     title that could outgrow the bar. The title is a fixed-width drawn wordmark
     now, so there is nothing to truncate — and the clip would have cut the
     monogram off at the bar's edges, which is the one thing the treatment below
     is for. */
  position:relative;
  display:flex;
  align-items:center;
  padding-left:26px;          /* the room the monogram sits in */
}

/* A LABEL, NOT A BLOCK. This is the only white fill on the page — --grad on
   #070707, 20.1:1 — and at 131x42 with a drop shadow under it the eye landed
   top-right, on the button, rather than on the question the page is asking.
   Nothing about it changes but the type metrics and the air around them: 13px/1
   in the display face with POSITIVE tracking, which is what a small caps-height
   label wants and what makes it read as deliberate rather than as shrunken.
   117x29 is 39% less white on the screen for the same fill, the same label and
   the same entry in the CTA allowlist. 13.7 gives the height back to a thumb. */
.topbar .btn--sm{
  font-size:13px;
  line-height:1;
  letter-spacing:.006em;
  padding:8px 14px;
}



/* ═══ 7. THE RAIL ═════════════════════════════════════════════════════════════
   NOT a tone block. It is the same #08070A plane as the thread, separated from
   the main column by exactly one 6%-white hairline. Colour appears on precisely
   one thing in here: the 2px inset --brand-1 bar on the active row.

   overflow:hidden on the box (not overflow-y:auto) because the collapse animates
   it to zero width and its automatic minimum size must be able to reach zero;
   the scroller is .rail__nav, one level in, which is also what lets the head and
   the foot stay put while a long topic list moves under them.

   --rail-inner is the width the CONTENT is laid out at, and it never changes:
   the labels must not reflow into a two-line wrap on the way out of a 240ms
   collapse, so the box shrinks and the content inside it is simply clipped. */
.rail{
  --rail-inner:16.5rem;                    /* == the open value of --rail-w */

  display:flex;
  flex-direction:column;
  width:var(--rail-w,16.5rem);
  min-width:0;
  min-height:0;
  /* ONE SCROLLER, NOT FIVE, and that is the whole bug fix.
     This was overflow:hidden while each .rail__nav below was `flex:1 1 auto`
     with its own overflow-y:auto. Under any height pressure — which is every
     laptop shorter than 900px — the five groups each shrank and scrolled
     internally, so each clipped its own last row through the middle of the
     glyphs and the next group's heading painted over the gap. The rail scrolls
     as one column now and the groups keep their natural height. */
  overflow-y:auto;
  overscroll-behavior:contain;
  background:var(--bg);
  border-right:1px solid var(--hair-1);
  padding-left:var(--safe-l);
  padding-bottom:calc(var(--s-4) + var(--safe-b));
  contain:layout paint;
}

/* 52px, the same band as the top bar. Deliberately NO border-bottom: the top
   bar's underline stops at the rail's edge and the rail reads as one
   uninterrupted column. */
.rail__head{
  flex:0 0 auto;
  width:var(--rail-inner);
  height:calc(var(--topbar-h) + var(--safe-t));
  padding:var(--safe-t) var(--s-3) 0;
  display:flex;
  align-items:center;
}

/* THE SAME PLATE, sized for the rail. .rail carries contain:layout paint, which
   clips to its border box, so this one cannot hang off the left edge the way the
   top bar's does — it sits inside and the word is padded clear of it. */
.rail__mark img{
  position:absolute;
  left:-2px;
  top:50%;
  z-index:0;
  width:66px;
  height:auto;
  transform:translateY(-50%);
}

.rail__mark{
  display:inline-flex;
  align-items:center;
  position:relative;                       /* the plate below is positioned to it */
  /* Left padding clears the 66px plate. It is written INTO the shorthand on
     purpose: a `padding-left` longhand above this block is silently discarded
     by this line, which is how the word ended up sitting on the monogram. */
  padding:var(--s-2) 10px var(--s-2) 24px;
  margin-left:-10px;                       /* optical: the mark's ink aligns
                                              with the row labels below it */
  border-radius:var(--r-sm);
  text-decoration:none;
  transition:background-color var(--t-fast) var(--ease);
}

/* Image replacement, the established TKATI pattern: the word "TKATI" stays a
   real text node, so it survives with CSS off and reads correctly to a screen
   reader, while the drawn letterforms come from the only file allowed to carry
   them. /assets/wordmark.png exists; do not point this at anything else. */
.rail__word{
  display:block;
  position:relative;z-index:1;             /* above the plate */
  /* 104 / 6.11 = 17. The artwork's aspect is measured, not guessed: the old
     file was 6.93:1 and this box was cut for it, so leaving 15px here would
     have letterboxed the new one to 92px wide and left a gap nobody could
     explain. background-size:contain never distorts — it just quietly shrinks,
     which is the kind of wrong that survives review. */
  width:104px;
  height:17px;
  background:url("/assets/wordmark.png") left center/contain no-repeat;
  text-indent:-9999px;
  overflow:hidden;
  white-space:nowrap;
}

/* The rail's only scroller. Zero top padding so a sticky group label parks flush
   against the scrollport edge with nothing showing above it.
   overscroll-behavior:contain stops a flick at the end of the topic list from
   chaining into the thread. */
.rail__nav{
  /* Natural height. The scrolling moved up to .rail — see the note there. */
  flex:0 0 auto;
  min-height:auto;
  width:var(--rail-inner);
  padding:0 var(--s-3) var(--s-4);
  overflow:visible;
  overflow-x:hidden;
  overscroll-behavior:contain;
  scroll-padding-block:var(--s-2);
  scrollbar-width:thin;
  scrollbar-color:rgba(255,255,255,.14) transparent;
}
/* The thread's scrollbar is 11px; that is far too heavy inside a 264px column. */
.rail__nav::-webkit-scrollbar{width:8px}
.rail__nav::-webkit-scrollbar-track{background:transparent}
.rail__nav::-webkit-scrollbar-thumb{
  background:rgba(255,255,255,.12);
  border:2px solid transparent;
  background-clip:padding-box;
  border-radius:var(--r-pill);
}
.rail__nav:hover::-webkit-scrollbar-thumb{
  background:rgba(255,255,255,.2);
  background-clip:padding-box;
}

/* Machine text: mono, tracked out, uppercase, --muted-2 (5.89:1). Sticky on an
   opaque canvas ground, so the group a row belongs to is still named when the
   list is scrolled. z-index because the rows below create stacking contexts of
   their own (mask-image) and would otherwise paint over a sticky sibling that
   came earlier in the DOM. Horizontal padding is 10px, not 8px: an optical
   correction that puts the label's first glyph on the same x as every row label
   under it. */
.rail__heading{
  position:sticky;
  top:0;
  z-index:1;
  margin:0;
  padding:var(--s-2) 10px var(--s-1);
  background:var(--bg);
  font:500 11px/1.2 var(--mono);
  letter-spacing:.08em;
  text-transform:uppercase;
  font-variant-ligatures:none;
  color:var(--muted-2);
}
/* A second and any later group in the same nav. 16px of air, no divider rule:
   the label itself is the division. */
.rail__list + .rail__heading{margin-top:var(--s-4)}

/* 2px between rows, never 0 — at 0 the hover states fuse into a bar. */
.rail__list{
  list-style:none;
  margin:0;
  padding:0;
  display:flex;
  flex-direction:column;
  gap:2px;
}
.rail__list > li{min-width:0}

/* 36px, 8px radius, 13px body, --muted (7.95:1) at rest. Truncation is a
   right-edge fade rather than an ellipsis. Two notes on the mask, both
   deliberate:
   · It masks the row's OWN painting, so a hovered row's 5%-white fill fades out
     over the last 24px too. The step from #08070A to #141316 is 1.09:1, which
     is why that is invisible in practice.
   · It is dropped on :focus-visible. A mask clips everything the element paints,
     the outline included, and a focus ring that dissolves at its right edge is a
     WCAG 2.4.7 / 1.4.11 failure. The focused row truncates hard instead, for
     exactly as long as it is focused. */
.rail__item,
.rail__login{
  display:flex;
  align-items:center;
  height:36px;
  padding:0 10px;
  border-radius:var(--r-sm);
  font:400 13px/1.2 var(--body);
  color:var(--muted);
  text-decoration:none;
  white-space:nowrap;
  overflow:hidden;
  -webkit-mask-image:linear-gradient(to right,#000 calc(100% - 24px),transparent);
          mask-image:linear-gradient(to right,#000 calc(100% - 24px),transparent);
  transition:background-color var(--t-fast) var(--ease),
             color var(--t-fast) var(--ease);
}
.rail__item:focus-visible,
.rail__login:focus-visible{
  outline:2px solid var(--brand-1);
  outline-offset:2px;
  border-radius:inherit;
  -webkit-mask-image:none;
          mask-image:none;
}

/* The active topic — the seeded turn nearest the top of the thread, set by
   main.js from scroll position. Honest session state, not invented history.
   THREE cues, because colour is never the only signal: a raised surface, a
   brighter label, a heavier weight — and then the 2px purple bar on top of
   those. This bar is one of the four purple objects the page is allowed. */
.rail__item.is-here{
  background:var(--surface-3);
  color:var(--text-2);
  font-weight:500;
  box-shadow:inset 2px 0 0 var(--brand-1);
}

/* Pinned to the bottom (the nav takes the free space), separated by the same
   6% hairline that runs down the rail's right edge. */
.rail__foot{
  flex:0 0 auto;
  width:var(--rail-inner);
  padding:var(--s-2) var(--s-3) 0;
  border-top:1px solid var(--hair-1);
}

/* ACCENT TOGGLE. Sized and coloured as a rail row rather than as a control,
   because it is a preference and not a destination. The swatch carries
   --grad-alt, which each accent block defines as THE OTHER ONE — so the dot
   always previews what pressing it gives you. */
.rail__accent{
  display:flex;
  align-items:center;
  gap:10px;
  width:100%;
  padding:var(--s-2) 10px;
  margin-left:-10px;
  border:0;
  background:none;
  border-radius:var(--r-sm);
  color:var(--muted-2);
  font:inherit;
  font-size:var(--fs-sm);
  text-align:left;
  cursor:pointer;
  transition:background-color var(--t-fast) var(--ease),color var(--t-fast) var(--ease);
}
.rail__accent:hover{color:var(--text);background:var(--hover)}
.rail__accent:focus-visible{outline:2px solid var(--brand-1);outline-offset:2px}
.rail__swatch{
  width:12px;
  height:12px;
  flex:none;
  border-radius:50%;
  background:var(--grad-alt);
  /* A ring, so the swatch still reads as a swatch against a dark rail when the
     alternate accent happens to be dark at its edges. */
  box-shadow:0 0 0 1px rgba(255,255,255,.16);
}

/* The development-only client door. Quieter than a topic row, mono, at the
   contrast floor — it is a tool, not navigation.

   It ships with the bare `hidden` attribute and main.js only ever REVEALS it, on
   a loopback or private host. [hidden]{display:none!important} at the top of
   this file is what stops the display:flex above from publishing it on
   tkati.com; the rule below is the same guarantee written a second time, at a
   specificity that beats this section's own display declaration even if that one
   is edited. Do not remove either. */
.rail__login{
  font:400 11.5px/1.2 var(--mono);
  letter-spacing:.04em;
  color:var(--muted-3);
}
.rail__login[hidden]{display:none}

/* The same door in the top bar. Quieter than the CTA beside it on purpose: a
   returning client is looking for it, and a visitor who is not a client should
   not read it as the thing to do next. Mono and muted, matching the rail copy,
   so the two never look like different features.

   The [hidden] rule is repeated here for the same reason it is repeated on the
   rail link: .topbar is display:flex, and a future `.topbar > a{display:flex}`
   would beat the user-agent [hidden] and publish the admin door in production.
   styles.css:286 already guards this globally; this is the local belt. */
.topbar__login{
  flex:0 0 auto;
  font:400 11.5px/1.2 var(--mono);
  letter-spacing:.04em;
  color:var(--muted-3);
  text-decoration:none;
  padding:0 8px;
  height:28px;
  display:flex;
  align-items:center;
  border-radius:var(--r-sm);
  white-space:nowrap;
}
.topbar__login:hover{color:var(--text)}
.topbar__login:focus-visible{
  outline:2px solid var(--brand-1);
  outline-offset:2px;
}
.topbar__login[hidden]{display:none}
/* ON A PHONE THE TOP BAR HOLDS THE TITLE AND THE ONE CTA; the login is the rail's there (2026-09-29, when it
   went public: beside it, "Start your build" wrapped to two lines at 390px and three at 320px). */
@media (max-width:560px){ .topbar__login{display:none} }

/* The drawer scrim. display:none above 900px so it is never a grid item in the
   two-column shell; position:absolute inside .app, never fixed. */
.rail__scrim{display:none}


/* ═══ 8. THE THREAD ═══════════════════════════════════════════════════════════
   .thread__in and .composer carry the SAME max-width and the SAME gutters, so
   the composer's caret lands on the exact x of the first character of every
   assistant paragraph. That single alignment is most of what reads as
   engineered — check it at 1440 / 1024 / 768 with the rail open and shut.

   The column is centred in .main, not in the viewport. When the rail opens the
   47rem column shifts right, and that is deliberate: it keeps the composer
   directly under the text it answers. */
.thread__in{
  width:100%;
  max-width:var(--col);                    /* 47rem / 752px — the composer matches */
  margin-inline:auto;
  /* The bottom clears the dock's fade in full. At 24px against a 56px scrim
     the tail of a conversation sat inside the gradient — worst on a lead step,
     which arrives short and lands exactly there. */
  padding-block:var(--s-6) calc(var(--s-6) + var(--scrim-h));
  padding-inline:calc(var(--gut) + var(--safe-l)) calc(var(--gut) + var(--safe-r));
  display:flex;
  flex-direction:column;
  gap:var(--msg-gap);
  min-width:0;
}

/* THE RESERVE-SPACE SPACER, and the behaviour that most separates an engineered
   chat from a clone. On send the visitor's message scrolls to the top of the
   thread viewport and the answer reveals into the empty space below it; that
   scroll position only exists if there is somewhere to scroll to. It ships at
   height 0 in the static HTML so the no-script page is correct with no JS, and
   main.js only ever SHRINKS it during a turn. The negative margin cancels the
   flex gap, so a collapsed spacer contributes nothing at all. */
.thread__spacer{
  flex:0 0 auto;
  height:0;
  margin-top:calc(var(--msg-gap) * -1);
}

/* THE INTRO, and the type scale the rest of the page follows:
     h1  .thread__h1   clamp(28-36)px / 1.08   Inter Tight 600   -0.03em
     h2  .msg__q       16px / 1.6              Inter 400          0
     h3  in-answer     15px / 1.4              Inter Tight 600   -0.01em
     body p           16px / 1.65              Inter 400          0
     lede             16.5px / 1.6             Inter 400          0
     note             14.5px / 1.6             Inter 400          0
     route            15px / 1.6               Inter 400          0
     chip             13.5px / 1               Inter 400          0
     mono label       12px / 1.5               JetBrains 400      .02em
     think label      11px                     JetBrains 500      .08em
   47rem at 16px is ~90 characters, the reference measure for this genre. The
   density comes from the 1.65 leading and the 13px paragraph gap, never from a
   narrower column — a narrower one would break the composer alignment. */
.thread__intro{
  margin-bottom:var(--s-1);
  min-width:0;
}
.thread__h1{
  margin:0;
  font-family:var(--display);
  font-size:clamp(28px,3.4vw,36px);
  font-weight:600;
  line-height:1.08;
  letter-spacing:-.03em;
  color:var(--text-hi);
  text-wrap:balance;
}
.thread__lede{
  margin:var(--s-3) 0 0;
  font:400 16.5px/1.6 var(--body);
  color:var(--muted);
  text-wrap:pretty;
}
.thread__lede strong{color:var(--text);font-weight:600}

/* One gap, turn to turn. The 28px action row reserved under every assistant
   message gives the question-answer pairing its rhythm for free: user → answer
   is one gap, answer → next question is a gap plus the action row. */
.log{
  display:flex;
  flex-direction:column;
  gap:var(--msg-gap);
  min-width:0;
}

.msg{
  min-width:0;
  overflow-wrap:anywhere;
  word-break:break-word;           /* legacy spelling, kept for older engines */
  contain:layout style;            /* NOT content-visibility: a skipped subtree
                                      reports contain-intrinsic-size instead of
                                      its real height and the pin walks */
}
.msg:focus-visible{outline:2px solid var(--brand-1);outline-offset:4px}

/* Nothing in a message may widen the app. */
.msg pre,
.msg table{overflow-x:auto;max-width:100%}


/* ── 8.1 THE USER TURN ──────────────────────────────────────────────────────
   Right-aligned bubble, one tightened corner. Identity is carried by three cues
   — alignment, surface, and the visually hidden "You asked:" — never by
   alignment alone. No avatar, no name, no timestamp, no edit, no branch: there
   is nothing to branch against a fixed corpus, and a visitor who mistypes asks
   again, which costs one line of scrollback and zero code. */
.msg--user{
  width:fit-content;               /* also disables flex cross-axis stretch */
  max-width:74%;
  margin-left:auto;
  padding:10px 16px;
  background:var(--surface-1);
  border:1px solid var(--hair-2);
  /* The chiselled corner: bottom-right tightened to 6px. The only place the
     brand's geometry appears in the app chrome. */
  border-radius:var(--r-lg) var(--r-lg) var(--r-chisel) var(--r-lg);
}
/* The shared focus rule sets border-radius:inherit, which would square the
   chisel off. Restate it at higher specificity. */
.msg--user:focus-visible{
  border-radius:var(--r-lg) var(--r-lg) var(--r-chisel) var(--r-lg);
  outline-offset:3px;
}

/* An h2 that does not look like one: it buys the transcript a real outline and a
   screen-reader table of contents, at zero visual cost. */
.msg__q{
  margin:0;
  font:400 16px/1.6 var(--body);
  letter-spacing:0;
  color:var(--text);
  white-space:pre-wrap;            /* a typed line break is shown as typed */
}


/* ── 8.2 THE ASSISTANT TURN — prose on the canvas ───────────────────────────
   No bubble, no fill, no border, no padding, no avatar. Full column width. The
   assistant text IS the marketing copy and it has to read as a document. */
.msg--bot{
  max-width:100%;
  margin-right:auto;
}

.msg__body{
  font:400 16px/1.65 var(--body);
  color:var(--text);
}
.msg__body > *{margin:0}
.msg__body > * + *{margin-top:13px}            /* the paragraph rhythm */
.msg__body > :first-child{margin-top:0}
/* Blocks nested inside a list item — an <h3> and its paragraphs in ol.steps —
   run tighter than top-level paragraphs. */
.msg__body li > * + *{margin-top:var(--s-2)}

/* Headings inside an answer are h3. Never h2 — that level belongs to the
   questions, and mixing them wrecks the transcript's table of contents. */
.msg__body h3{
  margin-top:var(--s-5);                       /* 20px above */
  margin-bottom:var(--s-2);                    /*  8px below */
  font-family:var(--display);
  font-size:15px;
  font-weight:600;
  line-height:1.4;
  letter-spacing:-.01em;
  color:var(--text-2);
}
.msg__body h3 + *{margin-top:var(--s-2)}

.msg__body strong,
.msg__body b{font-weight:600;color:var(--text-hi)}
.msg__body em,
.msg__body i{font-style:italic;color:var(--text)}

/* Lists. list-style is kept (Safari drops the list role from a list-style:none
   ul), and the marker is tinted through ::marker rather than rebuilt out of
   pseudo-elements. */
.msg__body ul,
.msg__body ol{padding-left:var(--s-5)}
.msg__body ul{list-style:disc}
.msg__body li + li{margin-top:7px}
.msg__body ul li::marker{color:rgba(255,255,255,.35);font-size:.85em}
.msg__body ol li::marker{
  font-family:var(--mono);
  font-size:13px;
  font-weight:500;
  color:var(--muted-3);
}
.msg__body ol > li > h3:first-child{margin-top:0}

/* ol.steps — the three named process steps. Each item is a titled section, not
   a sentence, so the numeral is machine text set beside the block rather than a
   marker hanging off a line of prose. */
.msg__body ol.steps{
  list-style:none;
  padding-left:0;
  counter-reset:tk-step;
}
.msg__body ol.steps > li{
  counter-increment:tk-step;
  position:relative;
  padding-left:var(--s-7);
}
.msg__body ol.steps > li + li{margin-top:var(--s-5)}
.msg__body ol.steps > li::before{
  content:counter(tk-step);
  position:absolute;
  left:0;
  top:.15em;
  font:500 12px/1.4 var(--mono);
  font-variant-numeric:tabular-nums;
  color:var(--muted-3);
}

/* Key/value runs. Two columns that collapse to one on a narrow viewport. */
.msg__body dl{
  display:grid;
  grid-template-columns:minmax(0,12.5rem) minmax(0,1fr);
  column-gap:var(--s-4);
  row-gap:var(--s-2);
}
.msg__body dt{
  font-weight:500;
  font-size:15px;
  line-height:1.5;
  color:var(--text-2);
}
.msg__body dd{
  margin:0;
  font-size:15px;
  line-height:1.55;
  color:var(--muted);
}

/* Inline code. No border — the fill is the affordance. */
.msg__body code{
  font-family:var(--mono);
  font-size:13px;
  line-height:1;
  color:var(--text);
  background:var(--surface-2);
  padding:2px 5px;
  border-radius:var(--r-xs);
  font-variant-ligatures:none;
}
.msg__body pre{
  font-family:var(--mono);
  font-size:13px;
  line-height:1.6;
  color:var(--text);
  background:var(--surface-1);
  border:1px solid var(--hair-2);
  border-radius:var(--r-md);
  padding:var(--s-3) var(--s-4);
  overflow-x:auto;
}
.msg__body pre code{background:none;padding:0;border-radius:0;font-size:inherit}

/* Links: colour is never the only cue, so the underline is permanent.

   :not(.btn) IS LOAD-BEARING, NOT TIDINESS. This rule is about PROSE links, but
   `.msg__body a` scores (0,1,1) and `.btn` scores (0,1,0), so without the
   exclusion it also repaints the lead card's button — which lives inside
   .msg__body — and repaints it with --brand-1. That token was an accent colour
   when this was written; under the monochrome palette it is #FFFFFF, and the
   button's fill is a white gradient, so "Start your build" was rendering white
   on white at 1.00:1 and underlined besides. The button keeps .btn's #0A0A0A,
   which is 14.17:1 against the gradient's darkest stop. */
.msg__body a:not(.btn){
  color:var(--brand-1);
  text-decoration:underline;
  text-decoration-thickness:1px;
  text-underline-offset:2px;
}
.msg__body a:not(.btn):hover{text-decoration-thickness:2px}

/* The three answer-part classes. They are addressed as descendants, not as
   direct children: an .msg__note can sit inside a step in ol.steps. */
.msg__body .msg__note{
  margin-top:13px;
  padding-left:var(--s-3);
  border-left:2px solid var(--hair-2);
  font-size:14.5px;
  line-height:1.6;
  color:var(--muted-2);
}
.msg__body .msg__mono{
  margin-top:13px;
  font-family:var(--mono);
  font-size:12px;
  line-height:1.5;
  letter-spacing:.02em;
  color:var(--muted-2);
  font-variant-ligatures:none;
  font-variant-numeric:tabular-nums;
}
/* At most one per answer, always last: the line that points at the form. */
.msg__body .msg__route{
  margin-top:var(--s-4);
  font-size:15px;
  line-height:1.6;
  color:var(--muted);
}

/* The enquiry form sits inside the last answer. This is the gap it sits at in
   the answer's flow, and the reset of the centring margins the form carries for
   the enquiry function's own pages. */
.msg__body > .enq{margin:var(--s-5) 0 0;max-width:none}


/* ── 8.3 THE REVEAL — per-word opacity, never text appending ────────────────
   The complete answer text is inserted in ONE DOM mutation and revealed by
   animating opacity on per-word spans. The live region sees one addition and
   announces the answer once, in full; the accessible tree, the clipboard,
   find-in-page and translation have it from the first frame; nothing reflows,
   because the spans are already laid out and only opacity animates.

   The base state is VISIBLE. Dimming is opt-in and reachable only from the class
   the renderer adds — the same discipline as the fade rule at the top of this
   file. If the renderer throws halfway through, the remaining words are on
   screen, not invisible. The dimming rule itself is in section 13, inside
   prefers-reduced-motion:no-preference. */
.w{opacity:1}

/* THE CARET. One element for the whole app, re-appended after the last revealed
   word each frame — never one per block. Neutral white, not purple: this would
   be a fifth purple object on every answer. */
.msg__caret{
  display:inline-block;
  width:2px;
  height:1.05em;
  margin-left:.14em;
  vertical-align:-.17em;
  border-radius:1px;
  background:var(--text-hi);
  opacity:1;
}
/* .is-idle is added when no word has landed for >200ms — a punctuation hold long
   enough to notice — and removed on the next word. */
.msg__caret.is-idle{animation:tk-caret 900ms steps(1,end) infinite}
/* At completion .is-revealing comes off and the caret fades before the script
   removes it; taking it away instantly is a visible pop on every answer. */
.msg:not(.is-revealing) .msg__caret{
  opacity:0;
  transition:opacity var(--t-exit) var(--e-in);
}


/* ── 8.4 THE THINKING INDICATOR ─────────────────────────────────────────────
   One object, not three dots. The label is MATCHING because it is a matcher:
   never "Thinking", never a latency, never a token count, never a step log,
   never a model name — all of those are fabricated telemetry and several are a
   claim to be a language model.

   It sits exactly where the first word of the answer will appear — same left
   edge, same baseline, same line box — so its replacement produces zero shift.
   That is why .think carries the body paragraph's font and the label is an
   inline span sharing the parent's baseline. */
.think{
  font:400 16px/1.65 var(--body);   /* the paragraph strut, so nothing moves */
  min-height:calc(16px * 1.65);
}
.think__label{
  font-family:var(--mono);
  font-size:11px;
  font-weight:500;
  line-height:inherit;
  letter-spacing:.08em;
  text-transform:uppercase;
  color:var(--muted-2);
  font-variant-ligatures:none;
}


/* ── 8.5 THE MESSAGE FOOT AND THE CHIPS ─────────────────────────────────────
   Two sets, styled identically: .chips--lead (six, seeded into the first
   assistant turn, and they persist) and .chips--follow (at most three, built by
   chat.js into a completed answer and removed the moment the visitor sends
   anything). They sit inside .msg__foot, so they scroll with their message
   rather than floating.

   A real <button>, and NEVER a CTA class: tools/verify.mjs would reject every
   one of these labels and fail the build. The visible label is short and
   data-ask carries the full sentence that is submitted as the user's turn. */
.msg__foot{margin-top:var(--s-4)}

.chips{
  display:flex;
  flex-wrap:wrap;
  gap:var(--s-2);
  margin:0;
  padding:0;
  list-style:none;                  /* the reset keeps markers; a chip row is not
                                       a bulleted list. role="list" in the markup
                                       restores the semantics Safari drops. */
  min-width:0;
}
.chips > li{display:flex;min-width:0}

.chip{
  display:inline-flex;
  align-items:center;
  justify-content:center;
  height:34px;
  padding:0 14px;
  /* Below the 16px floor on purpose and entirely safe: Safari's zoom-on-focus
     applies to editable form fields, not to buttons. */
  font:400 13.5px/1 var(--body);
  color:var(--muted);
  text-align:left;
  background:transparent;
  /* 36% white. A chip's outline is the only thing identifying it as a control,
     so it gets a WCAG 1.4.11 boundary, not a 10% whisper. */
  border:1px solid var(--hair-3);
  border-radius:var(--r-pill);
  cursor:pointer;
  white-space:nowrap;
  -webkit-appearance:none;
  appearance:none;
  transition:background-color var(--t-fast) var(--ease),
             border-color var(--t-fast) var(--ease),
             color var(--t-fast) var(--ease),
             transform var(--t-fast) var(--ease);
}
/* The chip is about to disappear — a follow-up row is removed the instant it is
   used — so without press feedback the click has no acknowledgement at all. */
.chip:active{transform:scale(.97);transition-duration:var(--t-micro)}
/* The shared ring's border-radius:inherit would square the pill off. */
.chip:focus-visible{
  outline:2px solid var(--brand-1);
  outline-offset:2px;
  border-radius:var(--r-pill);
}


/* ── 8.6 THE ACTION ROW ─────────────────────────────────────────────────────
   28px reserved under every assistant message at all times, so a hover never
   reflows the thread. Copy is the only action: no thumbs (nothing is
   transmitted, and a control that discards the click is a lie told to the one
   visitor who bothered), no regenerate (a deterministic matcher reprints the
   same string), no share (there is no permalink).

   The 80ms exit delay lets the pointer travel diagonally from the message text
   down to the row without it vanishing underneath. Never display:none — that
   takes the control out of the tab order. */
.msg__acts{
  display:flex;
  align-items:center;
  gap:var(--s-1);
  min-height:28px;
  margin-top:var(--s-2);
  opacity:0;
  transition:opacity var(--t-fast) var(--ease) 80ms;
}
.msg.is-last .msg__acts{opacity:1;transition-delay:220ms}
.msg:hover .msg__acts,
.msg:focus-within .msg__acts{opacity:1;transition-delay:0ms}
/* Highest specificity of the three, so it also beats the coarse-pointer and
   forced-colors rules in section 13: no action row while the answer is still
   arriving. */
.msg[aria-busy="true"] .msg__acts{opacity:0}

.msg__act{
  display:grid;
  place-items:center;
  width:28px;
  height:28px;
  padding:0;
  border:0;
  border-radius:var(--r-sm);
  background:transparent;
  color:var(--muted-2);
  cursor:pointer;
  -webkit-appearance:none;
  appearance:none;
  transition:background-color var(--t-fast) var(--ease),
             color var(--t-fast) var(--ease);
}
.msg__act:hover{background:var(--hover);color:var(--text)}
.msg__act:focus-visible{outline:2px solid var(--brand-1);outline-offset:2px;border-radius:var(--r-sm)}
.msg__act .ico{width:16px;height:16px;grid-area:1/1}

/* Copy → check → copy, 90ms each way, 1600ms hold, held by the script. Both
   glyphs stack in the one grid cell so the button never resizes and focus
   survives the swap. Where the markup ships a single unmodified <svg> — the
   seeded turns do — this block is simply inert. */
.msg__act .ico--copy,
.msg__act .ico--check{transition:opacity var(--t-micro) linear}
.msg__act .ico--check{opacity:0}
.msg__act.is-copied .ico--copy{opacity:0}
.msg__act.is-copied .ico--check{opacity:1}


/* ── 8.7 THE ERROR BOX ──────────────────────────────────────────────────────
   Replaces a thinking indicator when the engine throws. Neutral, not red:
   nothing here is destructive, and the honest move is to point at the form. */
.msg__err{
  display:flex;
  align-items:flex-start;
  gap:var(--s-3);
  padding:var(--s-3) var(--s-4);
  background:var(--surface-1);
  border:1px solid var(--hair-2);
  border-radius:var(--r-md);
}
.msg__err p{
  margin:0;
  font:400 15px/1.6 var(--body);
  color:var(--muted);
}
.msg__act--retry{
  flex:0 0 auto;
  border:1px solid var(--hair-3);   /* the only control in the box, so it gets a
                                       real 3:1 boundary of its own */
  color:var(--muted);
}
.msg__act--retry:hover{border-color:var(--hair-4)}


/* ═══ 9. THE DOCK ═════════════════════════════════════════════════════════════
   The third grid row of .main. NEVER position:fixed and never sticky: the
   shell's height already equals the visible area, so an in-flow bottom row sits
   above the on-screen keyboard with no positioning at all. Every keyboard bug in
   every hand-rolled mobile chat UI comes from a fixed composer.

   position:relative for two reasons that are the same reason: it is the
   containing block for .jump (which may not be fixed — .app carries a transform)
   and for the ::before scrim. .dock spans the full width of .main, so a pill
   centred on the dock lands on the same pixel as one centred on .main. */
.dock{
  grid-row:3;
  position:relative;
  z-index:var(--z-composer);
  min-width:0;

  /* The same gutters as .thread__in, so the composer's box and the thread's
     column share one pair of edges. The left and right safe-area insets are the
     ones people forget: in landscape on a notched iPhone one of them is ~44px
     and the send disc would otherwise sit under the notch. */
  padding-top:var(--s-2);
  padding-bottom:calc(var(--pad-b) + var(--safe-b));
  padding-inline:calc(var(--gut) + var(--safe-l)) calc(var(--gut) + var(--safe-r));

  /* Opaque. The thread scrolls UNDER this row; without a solid ground here the
     last message would show through the composer's translucent fill. */
  background:var(--bg);
}

/* THE SCRIM — a pseudo-element, never a real node. A <div> between the thread
   and the composer becomes a fourth grid row, eats 56px out of the thread, and
   needs pointer-events bookkeeping this gets for free. It sits above the dock's
   padding box so it is drawn over the tail of the thread, and it is the only
   fade on the page: there is deliberately no matching one under the top bar, for
   the reason section 6 gives. The thread's bottom scroll-padding is written from
   this same 56px, so the two move together. */
.dock::before{
  content:"";
  position:absolute;
  left:0;
  right:0;
  bottom:100%;
  height:var(--scrim-h);
  background:linear-gradient(to top,var(--bg),transparent);
  pointer-events:none;
}

/* NOT ON THE EMPTY SCREEN. `bottom:100%` puts this fade ABOVE the dock, and its
   opaque end is the edge it shares with it — which is correct over the tail of a
   scrolling thread and wrong over the only thing on a screen that has no thread
   yet. The intro sits flush on that edge, so the fade was painting --bg over the
   lede at every viewport height, and over the bottom of the headline too once
   the window got shorter than about 560px. Measured before the fix at 1440x860:
   the scrim spanned 330-386 and the lede spanned 348-386, i.e. all of it.

   There is nothing to fade here — the thread is empty — so it simply does not
   paint. It comes back with the first answer, which is the moment it starts
   having a job. */
.app.is-empty .dock::before{content:none}


/* ── 9.1 THE COMPOSER SHELL ─────────────────────────────────────────────────
   A <div>, not a <form>: a form with no action submits to the current URL on
   Enter — with scripting off, or in the window before main.js runs — which
   navigates the page and destroys the conversation.

   align-items:flex-end keeps the send disc pinned to the bottom line as the
   field grows, rather than drifting up the middle of a five-line message.

   THE 36% BORDER IS A WCAG 1.4.11 REQUIREMENT, NOT A TASTE DECISION. A 10% white
   hairline measures 1.24:1 on this canvas and the fill step alone is 1.09:1, so
   a whisper-hairline composer has NO perceivable boundary at all. This is the
   one control on the page whose edge is the only thing identifying it, and it
   gets a real line. A deliberate, stated departure from the visual reference. */
.composer{
  /* The per-component cap for the growing field, declared here so the textarea
     and its measuring ghost read one number. Section 13 lowers it at the narrow
     and short breakpoints and while the keyboard is up; it is never set on the
     two boxes separately, because the two boxes must always agree. */
  --composer-cap:9.5rem;              /* 152px ≈ 6 lines */

  width:100%;
  /* THE TEXT COLUMN, NOT THE THREAD BOX. .dock carries the gutters OUTSIDE this
     element, so max-width:var(--col) made the composer exactly two gutters wider
     than the paragraphs above it — it overhung the prose on both sides at every
     width. Subtracting them lands its edges on the text column. */
  max-width:calc(var(--col) - 2 * var(--gut));
  margin-inline:auto;

  display:flex;
  align-items:flex-end;
  gap:var(--s-2);
  min-height:52px;                    /* a floor; the field's own metrics make the
                                         resting box ~64px, which is what
                                         --composer-h defaults to */
  padding:8px 8px 8px 16px;

  background:var(--surface-2);
  border:1px solid var(--hair-3);
  border-radius:var(--r-xl);
  box-shadow:var(--shadow-1);

  transition:border-color var(--t-fast) var(--ease);
}

/* FOCUS: the border brightens, and that is all. No glow, no coloured ring on the
   shell — a control that brightens its own edge feels like hardware; a control
   that grows a purple halo feels like a template. The caret is the real focus
   indicator inside the field, and the send button still gets a true
   :focus-visible ring of its own below. */
.composer:focus-within{border-color:var(--hair-4)}

/* AND A REAL RING FOR THE KEYBOARD, because the brightening edge above is not
   one. Measured, the empty state's 4px bloom composites to rgb(32,32,32) over
   the page and scores 1.24:1 against it — WCAG 2.4.11 wants 3:1, and every
   other control on this page already answers with the 2px white outline that
   :where(...):focus-visible sets in section 3. The composer opted out of that
   with outline:none on the field and a comment pointing at :focus-within, so it
   was the one control a keyboard user could not see.

   It is on the SHELL, not the field: the field is a rectangle inside a 16px
   pill, and an outline on it would trace the wrong shape. :has() is used in five
   other places in this file. :focus-visible, so a mouse click still gets only
   the edge and the bloom — which is what section 9.1 asks for. */
.composer:has(.composer__input:focus-visible){
  outline:2px solid var(--brand-1);
  outline-offset:0;                   /* FLUSH. At an offset it reads as a second
                                         ring outside the border, with a dark gap
                                         between the two; flush, the border and
                                         the ring are one 3px edge, which is the
                                         "brightens its own edge" this section
                                         asks for and still 20.14:1 against the
                                         page. */
}


/* ── 9.2 THE AUTO-GROWING FIELD ─────────────────────────────────────────────
   The replicated-content grid: zero layout reads, zero forced layout. The
   obvious implementation (style.height='auto'; style.height=scrollHeight+'px')
   does write-read-write and forces a synchronous layout of the whole document on
   every keystroke — with a long thread in the same document that is a measurable
   stall, and it is the classic "typing feels laggy" bug. Here main.js does
   exactly one thing per keystroke: grow.dataset.value = input.value.

   THE CAP. The wrapper clips at --composer-cap and the textarea is capped at the
   same number, so the TEXTAREA is the box that scrolls: the field stops growing
   at six lines, the caret stays reachable, and the ghost's overshoot is clipped
   rather than pushing the composer off screen. Both caps are required — the
   wrapper's alone would clip live text, the textarea's alone would leave the
   wrapper tracking the ghost for ever. */
.composer__grow{
  display:grid;
  flex:1 1 auto;
  min-width:0;
  max-height:var(--composer-cap);
  overflow:hidden;                    /* clips the ghost, never the textarea */
}

/* THE TWO BOXES MUST MEASURE IDENTICALLY. Every property that affects line
   breaking or box height is declared once, for both. Change one, change both.
   Their failure modes are all silent: different padding clips the last line;
   white-space other than pre-wrap collapses runs of spaces so long input
   under-measures; a border on the textarea and not on the ghost offsets every
   height. THE BORDER LIVES ON .composer, NEVER ON THE TEXTAREA. */
.composer__grow > textarea,
.composer__grow::after{
  grid-area:1/1/2/2;
  font:inherit;
  /* 16px IS A HARD FLOOR, and an explicit px value rather than 1rem. It is
     dropped to 15px in section 13 and ONLY inside (pointer:fine), where iOS
     zoom-on-focus does not exist — and there it is set on the ghost too, or
     auto-grow measures desktop text against phone metrics. */
  font-size:16px;
  line-height:1.45;
  letter-spacing:inherit;
  padding:.7rem .25rem;
  border:0;
  margin:0;
  white-space:pre-wrap;
  overflow-wrap:anywhere;
  word-break:break-word;
  min-width:0;
}

.composer__grow > textarea{
  max-height:var(--composer-cap);     /* caps the stretched grid item, so THIS is
                                         the element that scrolls at the cap */
  resize:none;
  overflow-y:auto;
  overscroll-behavior:contain;        /* a flick at the end of a six-line message
                                         does not hand the gesture to the thread */
  background:transparent;
  color:var(--text);
  caret-color:var(--text-hi);         /* white, not purple: the caret is the focus
                                         indicator inside the field and the purple
                                         budget is spent elsewhere */
  outline:none;                       /* replaced by the shell's :focus-within
                                         border — never removed without one */
  appearance:none;
  -webkit-appearance:none;
  border-radius:0;                    /* iOS rounds textareas by default */
}

/* THE GHOST. The trailing " " keeps a line's height for a trailing newline,
   which attr() alone loses — without it, pressing Return on the last line does
   not grow the box until the next character is typed. */
.composer__grow::after{
  content:attr(data-value) " ";
  visibility:hidden;
  pointer-events:none;
}

/* Where the platform can size the field itself, it does it better than a ghost
   can: no second box to keep in step. The ghost is switched off rather than left
   to measure nothing. */
@supports (field-sizing:content){
  .composer__grow::after{content:none}
  .composer__grow > textarea{field-sizing:content;max-height:var(--composer-cap)}
}

/* A hint, never a label — the field's accessible name is the visually hidden
   <label>. --muted-2 is 5.89:1 and reads as a suggestion rather than as typed
   text, which --muted-3 at 4.94:1 begins to fail at. */
.composer__input::placeholder{color:var(--muted-2);opacity:1}

/* THE SCROLLBAR AT THE CAP. It exists for perhaps two messages in a session, so
   it is a hairline, not a bar, and never coloured. scrollbar-gutter is
   deliberately NOT set: the ghost has no gutter, so reserving one inside the
   textarea would make the two boxes measure different widths and the field would
   clip a line early. */
.composer__input{
  scrollbar-width:thin;
  scrollbar-color:rgba(255,255,255,.16) transparent;
}
.composer__input::-webkit-scrollbar{width:8px}
.composer__input::-webkit-scrollbar-track{background:transparent}
.composer__input::-webkit-scrollbar-thumb{
  background:rgba(255,255,255,.16);
  background-clip:content-box;
  border:2px solid transparent;
  border-radius:var(--r-pill);
}
.composer__input:hover::-webkit-scrollbar-thumb{
  background:rgba(255,255,255,.26);
  background-clip:content-box;
}


/* ── 9.3 THE SEND CONTROL ───────────────────────────────────────────────────
   One disc, three states, and it is ALWAYS THE SAME ELEMENT — never re-created,
   never innerHTML-rewritten — which is what makes keyboard focus survive a send.
   The corner never has two buttons and never has none.

   The empty state is expressed as [aria-disabled="true"] and the enabled state
   is the base, not the other way round: main.js may signal "enabled" by writing
   aria-disabled="false" OR by removing the attribute, and both land on the same
   rule. The markup ships with the attribute set, so first paint is the empty
   state with no script.

   NEVER the `disabled` attribute. Disabling a button the keyboard user has just
   activated destroys focus, it falls to <body>, and the next Tab restarts from
   the top of the page. The click handler guards on the attribute instead.

   SECONDARY CONTROLS: THERE ARE NONE, AND THAT IS THE DESIGN. No attach, no
   microphone, no model picker, no mode chip, no token counter, no quota banner.
   Every one of them implies a backend this page does not have and three of them
   are a claim to be a language model. If a rule for a second composer control
   ever appears here, the control was invented rather than specified. */
.composer__send{
  flex:0 0 auto;
  align-self:flex-end;
  position:relative;
  display:grid;                       /* both glyphs share one cell */
  place-items:center;
  width:34px;
  height:34px;
  border-radius:var(--r-pill);

  /* The well. Always painted, at every state, so the disc never changes size and
     the gradient has something to fade in over. */
  background:rgba(255,255,255,.10);
  color:#0A0A0A;                      /* the enabled glyph: >=3:1 against every
                                         stop of the gradient */
  transition:color var(--t-fast) var(--ease),
             transform var(--t-fast) var(--ease);
}

/* THE ONE FILLED CONTROL ON THE SCREEN, and it is conditional. The gradient is a
   pseudo-element so the disc's own background-color never animates: a colour
   transition on a small shape reads as sluggish, an opacity crossfade does not.
   It also means the enabled/empty change is transform and opacity only. */
.composer__send::before{
  content:"";
  position:absolute;
  inset:0;
  border-radius:inherit;
  background:var(--grad);
  opacity:1;
  transform:scale(1);
  transition:opacity var(--t-fast) var(--ease),
             transform var(--t-fast) var(--ease);
}

/* The glyphs must paint above the absolutely positioned gradient: an out-of-flow
   pseudo-element is painted after in-flow content in the same stacking context,
   so without this the arrow disappears under the fill the moment it lights up. */
.composer__send .ico{
  grid-area:1/1;
  position:relative;
  z-index:1;
  width:18px;
  height:18px;
  transition:opacity var(--t-micro) linear,
             transform var(--t-micro) linear;
}

/* EMPTY. cursor:default comes from the reset's button[aria-disabled] rule. */
.composer__send[aria-disabled="true"]{color:rgba(255,255,255,.30)}
.composer__send[aria-disabled="true"]::before{opacity:0;transform:scale(.92)}

/* SEND ↔ STOP. A crossfade in one cell: the outgoing glyph leaves at scale(.8),
   the incoming arrives from scale(1.2). The circle itself never resizes. The .vh
   label text swaps Send → Stop — the visible hidden text, not aria-label, so
   there is one naming mechanism rather than two competing ones. */
.composer__send .ico--stop{opacity:0;transform:scale(1.2)}
.composer__send.is-stop .ico--send{opacity:0;transform:scale(.8)}
.composer__send.is-stop .ico--stop{opacity:1;transform:scale(1)}

/* Press. Never skipped: the disc is the end of the interaction and it has to
   acknowledge the click before anything else moves. */
.composer__send:not([aria-disabled="true"]):active{
  transform:scale(.96);
  transition-duration:var(--t-micro);
}

/* A purple ring on a light gradient fill measures 2.95:1 and fails WCAG 1.4.11.
   White, plus a canvas-coloured halo that separates the ring from the fill,
   passes. outline is the ring — Windows High Contrast drops box-shadow and keeps
   outline — and the box-shadow is only the halo. */
.composer__send:focus-visible{
  outline:2px solid #FFFFFF;
  outline-offset:3px;
  box-shadow:0 0 0 3px var(--bg);
}


/* ── 9.4 THE FINE PRINT ─────────────────────────────────────────────────────
   The one line on the page that may not be dressed up. It is the composer's
   aria-describedby target, so it is read on focus, and it states the mechanism
   rather than hedging an output — nothing here is generated, so there is nothing
   to hedge. Quiet, but never below --muted-2 (5.89:1), never truncated. */
.dock__note{
  max-width:var(--col);
  margin:var(--s-2) auto 0;
  padding-inline:var(--s-3);
  font:400 11.5px/1.5 var(--body);
  color:var(--muted-2);
  text-align:center;
  text-wrap:pretty;
}

/* The two human routes. NOT purple: two permanently visible brand links in the
   dock would blow the four-object accent budget on every frame. The underline is
   the non-colour cue and it is not optional. */
.dock__link{
  color:var(--muted-2);
  text-decoration:underline;
  text-decoration-thickness:1px;
  text-underline-offset:2px;
  text-decoration-color:currentColor;
  transition:color var(--t-fast) var(--ease),
             text-decoration-color var(--t-fast) var(--ease);
}
/* aria-hidden in the markup, so it never reaches the accessible name of either
   link or the field's description. */
.dock__sep{
  color:var(--muted-3);
  margin-inline:.35em;
}

/* THE NO-SCRIPT LINE. display:none here; the <noscript><style> in <head> flips
   it to block. Both rules are author-level with one class of specificity, so THE
   <noscript> BLOCK MUST COME AFTER THE STYLESHEET LINK IN <head> or this rule
   wins and the no-script visitor is told nothing. Do not use the hidden
   attribute instead — mixing an attribute default with a CSS override works, and
   it is the kind of interaction that gets tidied into a bug six months later. */
.dock__nojs{
  display:none;
  max-width:var(--col);
  margin:var(--s-3) auto 0;
  font:400 14px/1.6 var(--body);
  color:var(--muted);
  text-align:center;
  text-wrap:pretty;
}


/* ── 9.5 THE SCROLL-TO-BOTTOM PILL ──────────────────────────────────────────
   position:absolute, never fixed: .app carries a transform, which makes it the
   containing block for fixed descendants anyway. Centred on .dock, which spans
   .main, so it follows the thread column when the rail opens or closes.

   VISIBILITY IS THE `hidden` PROPERTY, never opacity:0 + pointer-events:none —
   that is a phantom tab stop the keyboard user hits repeatedly and cannot see.
   [hidden]{display:none!important} at the top of this file is what stops the
   display:grid below from publishing a pill main.js believes is hidden; this
   component is the reason that rule carries !important.

   The centring is margin-left:-17px rather than translateX(-50%) ON PURPOSE: it
   leaves `transform` entirely free for the enter, exit, hover and press states.
   Half of 34px.

   THE VERTICAL OFFSET IS calc(100% + 16px), NOT calc(var(--composer-h) + 16px).
   Read it as "16px above the top of the dock", which is where the pill has to
   be. The token formula only works if main.js writes the whole DOCK's height
   into --composer-h; a percentage of the containing block is the same number
   with no dependency — correct on the first frame rather than after the first
   measurement, and it tracks the field frame for frame as it grows. */
.jump{
  position:absolute;
  left:50%;
  margin-left:-17px;
  bottom:calc(100% + var(--s-4));
  z-index:var(--z-jump);

  display:grid;
  place-items:center;
  width:34px;
  height:34px;
  border-radius:var(--r-pill);

  background:rgba(20,19,24,.86);
  border:1px solid var(--hair-3);
  box-shadow:var(--shadow-2);
  color:var(--muted);

  transition:opacity var(--t-exit) var(--e-in),
             transform var(--t-fast) var(--ease),
             border-color var(--t-fast) var(--ease),
             color var(--t-fast) var(--ease);
}
/* Kept behind @supports so the fallback stays a flat, legible 86% plate rather
   than a transparent one on an engine that ignores the filter. */
@supports ((-webkit-backdrop-filter:blur(10px)) or (backdrop-filter:blur(10px))){
  .jump{
    -webkit-backdrop-filter:blur(10px);
    backdrop-filter:blur(10px);
  }
}
.jump .ico{width:15px;height:15px}
.jump:active{transform:scale(.96);transition-duration:var(--t-micro)}

/* THE EXIT. main.js adds .is-out, then sets hidden on transitionend BEHIND A
   STATE CHECK, so a re-show mid-exit cancels the hide. Removing .is-out on that
   path returns the pill over the same 120ms rather than snapping. */
.jump.is-out{
  opacity:0;
  transform:translateY(6px) scale(.94);
  transition-duration:var(--t-exit);
  transition-timing-function:var(--e-in);
}

/* NEW CONTENT WHILE THE READER IS ELSEWHERE. A pseudo-element on a class, not a
   child node: a child would either land inside the button's accessible name or
   need its own aria-hidden, and it would inherit the pill's press scale. This is
   the fourth and last purple object the page is allowed to show at once, and it
   is transient — main.js removes .has-new on click and at completion. The
   canvas-coloured ring is what separates the dot from the pill's own border. */
.jump.has-new::after{
  content:"";
  position:absolute;
  top:-1px;
  right:-1px;
  width:6px;
  height:6px;
  border-radius:var(--r-pill);
  background:var(--brand-2);
  box-shadow:0 0 0 2px var(--bg);
}


/* ═══ 10. THE ENQUIRY FORM ════════════════════════════════════════════════════
   THE ONLY <form> IN index.html, and the same class set is used by the pages
   netlify/functions/enquiry.mjs returns. It posts natively — no fetch, no JSON,
   no preventDefault, no client-side validation: native constraint-validation
   bubbles are the browser's own accessible error mechanism and are better than
   anything we would build, and the function re-renders with the typed values on
   error.

   THE TWO DOCUMENTS SHAPE IT DIFFERENTLY, AND BOTH HAVE TO WORK.
   · index.html ships a FLAT form — <label for>, then its <input>, then the next
     pair — because an explicit for/id association survives a grid reflow and is
     what voice control uses to target a field by name. Its layout is stated by
     name in the grid template under #enq, which reads as a diagram of the form.
   · The function's own pages ship WRAPPING labels — <label class="enq__f"><span>
     …</span><input></label> — which auto-place into the same two columns with no
     template at all. That is why the base rule below stays a plain two-column
     grid and why .enq__f branches on [for]: a flex column would stack the label
     text and its required marker on separate lines on the chat page. */
.enq{
  position:relative;                 /* the honeypot's containing block */
  display:grid;
  grid-template-columns:repeat(2,minmax(0,1fr));
  column-gap:var(--s-5);
  row-gap:var(--s-2);
  max-width:680px;
  margin:var(--s-9) auto 0;
  text-align:left;
}

/* The label. 13px, muted, and never a placeholder: a placeholder disappears on
   the first keystroke, which is when it is needed. */
.enq__f{
  display:block;
  margin-top:10px;
  font:400 13px/1.4 var(--body);
  color:var(--muted);
  letter-spacing:.01em;
}
.enq__f:not([for]){display:flex;flex-direction:column;gap:var(--s-2)}
.enq__f--wide{grid-column:1/-1}

/* required is the mechanism and the marker is aria-hidden decoration — do not
   add aria-required on top of it. Not purple: three of these plus the focus ring
   would spend the whole accent budget on punctuation. */
.enq__r{color:var(--text-2)}
/* "optional" is visible text inside the label, so the accessible name really is
   "Phone optional". <i> is the spelling the function's own pages use. */
.enq__opt,
.enq__f i{font-style:normal;color:var(--muted-2)}

.enq__req{
  grid-column:1/-1;
  margin:0;
  font:400 12.5px/1.5 var(--body);
  color:var(--muted-2);
}
.enq__hint{
  grid-column:1/-1;
  margin:var(--s-1) 0 0;
  font:400 12.5px/1.5 var(--body);
  color:var(--muted-2);
}

/* 44px fields, 16px text — they are inside the thread, on a phone, and they zoom
   exactly the way the composer would. The 36% border is the same WCAG 1.4.11
   obligation the composer and the chips carry. */
.enq input,
.enq textarea{
  width:100%;
  min-height:44px;
  padding:11px 13px;
  font:inherit;
  font-size:16px;
  line-height:1.45;
  color:var(--text);
  background:var(--surface-1);
  border:1px solid var(--hair-3);
  border-radius:var(--r-sm);
  transition:border-color var(--t-fast) var(--ease),
             background-color var(--t-fast) var(--ease);
}
.enq textarea{resize:vertical;min-height:120px;line-height:1.55}
/* :focus brightens the edge, and that is ALL it does — no outline:none anywhere
   near it. A rule here that removed the outline would outrank the shared
   :focus-visible ring in section 3 and leave keyboard users with a 1px colour
   change on a dark ground. */
.enq input:focus,
.enq textarea:focus{
  border-color:var(--hair-4);
  background:var(--surface-2);
}

.enq__go{
  grid-column:1/-1;
  justify-self:start;
  margin-top:var(--s-2);
}

/* THE TRAP. Not display:none and not visibility:hidden — a bot worth worrying
   about skips both, and the cheap ones read the computed style. This is
   off-canvas and 1px, which looks like a laid-out field to anything that is not
   actually painting the page. The markup carries aria-hidden on the wrapper and
   tabindex="-1" on the input, which is the half that protects a person rather
   than the half that catches a bot. Never leave it unstyled: then a human sees
   it. It is out of flow, so it occupies no grid cell. */
.enq__pot{
  position:absolute;
  left:-9999px;
  width:1px;
  height:1px;
  overflow:hidden;
}

/* THE CHAT PAGE'S FLAT FORM, placed by name. Nine rows: the required note, then
   two pairs of short fields side by side, then the brief, its hint and the
   submit across the full width. Section 13 restates this as one column below
   560px. Anything the markup adds that is not named here auto-places into an
   implicit row underneath, which is a visible, fixable failure rather than a
   silent overlap. */
#enq{
  max-width:none;
  grid-template-areas:
    "req    req"
    "lname  lemail"
    "iname  iemail"
    "lphone ldomain"
    "iphone idomain"
    "lbrief lbrief"
    "ibrief ibrief"
    "hint   hint"
    "go     go";
}
#enq .enq__req{grid-area:req}
#enq label[for="enq-name"]{grid-area:lname}
#enq label[for="enq-email"]{grid-area:lemail}
#enq label[for="enq-phone"]{grid-area:lphone}
#enq label[for="enq-domain"]{grid-area:ldomain}
#enq label[for="enq-brief"]{grid-area:lbrief}
#enq-name{grid-area:iname}
#enq-email{grid-area:iemail}
#enq-phone{grid-area:iphone}
#enq-domain{grid-area:idomain}
#enq-brief{grid-area:ibrief}
#enq .enq__hint{grid-area:hint}
#enq .enq__go{grid-area:go}


/* ═══ 11. THE CTA BLOCK, AND THE ONE CTA VERB ═════════════════════════════════
   THE CHAT PAGE USES NONE OF THIS EXCEPT .btn. Everything else in this section
   is the entire styling of the two pages netlify/functions/enquiry.mjs returns
   for a real enquiry — a thank-you and an error page, both built from
   <section class="cta"><div class="wrap cta__in">, both linking this stylesheet
   unversioned. They are the last thing a paying enquiry sees. Do not delete
   them as dead code; open /api/enquiry in a browser if you restructure this.

   THE CHECK (2 of 2) — tools/verify.mjs matches every <a> and <button> in
   index.html whose class contains the word btn and requires its text to be
   exactly one of "Start your build", "What we build", "DM us on Instagram" or
   "Email us". \b is a word boundary and - is a non-word character, so btn--sm,
   chat-btn and c-btn-send are all caught. Nothing else in this file is allowed
   to be given a class containing those three letters. */
.btn{
  display:inline-flex;
  align-items:center;
  justify-content:center;
  font-family:var(--display);
  font-weight:600;
  font-size:.88rem;
  letter-spacing:-.005em;
  padding:12px 24px;
  color:#0A0A0A;                       /* the fill is white now — see --grad */
  background:var(--grad);
  border:1px solid transparent;
  /* THE WHITE BLOOM IS GONE. box-shadow:0 6px 20px -14px rgb(var(--brand-rgb)/.9)
     was written when --brand-rgb was a hue; with the accent at 255 255 255 it is
     a white glow under a white pill on a black page, and it reads as fog rather
     than as light. The chisel and the fill carry the object, and the transition
     loses box-shadow with it — easing a property that never changes again is
     dead weight. The hover in 13.7 lost its own copy at the same time. */
  /* The chiselled corner pair, the brand's geometry. */
  clip-path:polygon(7px 0,100% 0,100% calc(100% - 7px),calc(100% - 7px) 100%,0 100%,0 7px);
  transition:transform var(--t-fast) var(--ease),
             filter var(--t-fast) var(--ease);
}
.btn--lg{padding:15px 30px;font-size:.95rem}
.btn--sm{padding:10px 20px;font-size:.82rem}
/* clip-path clips the outline with everything else, so the ring is pushed clear
   of the shape rather than drawn on top of it. */
.btn:focus-visible{outline:2px solid var(--brand-1);outline-offset:3px}

.wrap{width:100%;max-width:var(--w);margin:0 auto;padding:0 var(--s-7)}

.cta{
  position:relative;
  padding:var(--pad) 0;
  text-align:center;
  overflow:hidden;
  border-top:1px solid var(--line-2);
}
/* One soft radial behind the block. It is decoration on a page that has no
   other chrome at all, and it is the only glow anywhere in this file — the chat
   page has none, by design. */
.cta__glow{
  position:absolute;
  bottom:-54%;
  left:50%;
  transform:translateX(-50%);
  width:min(900px,120vw);
  height:min(900px,120vw);
  background:radial-gradient(circle,rgb(var(--brand-rgb)/.3),transparent 62%);
  pointer-events:none;
}
.cta__in{position:relative}
.cta__row{display:flex;gap:14px;justify-content:center;flex-wrap:wrap;margin-top:var(--s-7)}
.cta__fine{margin-top:var(--s-6);font-size:.82rem;color:var(--muted-2)}

.h1{
  font:600 clamp(1.85rem,3.7vw,2.6rem)/1.1 var(--display);
  letter-spacing:-.025em;
  color:var(--text-hi);
}
.p{
  max-width:62ch;
  font-size:.99rem;
  line-height:1.66;
  color:var(--muted);
}
.p--lead{margin-top:18px;font-size:1.06rem;color:var(--text-2)}
.p strong{color:var(--text);font-weight:600}
.cta .p{margin-left:auto;margin-right:auto}
.p a,
.cta__fine a{
  color:var(--brand-1);
  text-decoration:underline;
  text-decoration-thickness:1px;
  text-underline-offset:2px;
}


/* ═══ 12. KEYFRAMES ═══════════════════════════════════════════════════════════
   Every animation in this file, and there are six. Each is named tk-* so it can
   never collide with a name from anywhere else, each is declared once, and each
   is switched off by name in the reduced-motion block that closes the file:

     tk-caret     the streaming caret's blink when a reveal stalls  .msg__caret.is-idle
     tk-shimmer   the MATCHING label's sweep                        .think__label
     tk-chip-in   a follow-up chip arriving                         .chips--follow > li
     tk-msg-in    the user bubble's entrance                        .log > .msg--user
     tk-jump-in   the jump pill appearing                           .jump
     tk-dot-in    the pill's new-content dot                        .jump.has-new::after

   All six animate opacity and transform only. Nothing else on the page
   animates; the two permitted layout animations are transitions, not
   keyframes — the rail's grid track and the skip link's `top`. */
@keyframes tk-caret{
  0%,50%      {opacity:1}
  50.01%,100% {opacity:0}
}
@keyframes tk-shimmer{
  from{background-position:110% 0}
  to  {background-position:-10% 0}
}
@keyframes tk-chip-in{
  from{opacity:0;transform:translateY(4px)}
  to  {opacity:1;transform:none}
}
@keyframes tk-msg-in{
  from{opacity:0;transform:translateY(6px)}
  to  {opacity:1;transform:none}
}
@keyframes tk-jump-in{
  from{opacity:0;transform:translateY(6px) scale(.94)}
  to  {opacity:1;transform:none}
}
@keyframes tk-dot-in{
  from{opacity:0}
  to  {opacity:1}
}


/* ═══ 13. EVERY MEDIA QUERY, IN ONE PLACE ═════════════════════════════════════
   Five widths, two heights, three input queries, forced-colors, print and the
   two motion queries — and that is the whole set. Components above declare no
   breakpoint of their own, so this section is the complete answer to "what
   changes, and where".

   width          what changes
   ---------------------------------------------------------------------------
   >=1280px       --gut 32px
   901-1279px     --gut 24px
   <=900px        --gut 16px, --msg-gap 22px; the rail becomes an off-canvas
                  drawer with a scrim; the user bubble goes to 85%; the chip row
                  scrolls sideways instead of wrapping; the composer's growth cap
                  becomes viewport-relative; Enter inserts a newline (main.js);
                  the headline's entrance stops animating its tracking
   <=700px        the top bar's engine line goes — no room, least load-bearing
   <=560px        the enquiry grid and an answer's <dl> drop to one column; the
                  status bar stacks, and its readout is first-screen-only
   <=400px        the chrome tightens; READING TYPE DOES NOT CHANGE

   height         what changes
   ---------------------------------------------------------------------------
   <=560px        the chrome gets shorter, the composer's cap tighter and the
                  empty state's headline capped at 36px. This is the
                  landscape-phone case and it is also what a desktop at 400%
                  zoom hits on the way down, which is why it is not gated on a
                  pointer.
   >=700px        the empty state takes its optical lift (13.8a)
   <=380px        THE APP MODEL IS ABANDONED — ordinary document flow.

   ONE THING TO KNOW BEFORE WRITING A RULE IN HERE. Sections 15 to 18 were
   appended AFTER this section, so a rule written here at the same specificity
   as one of theirs loses on source order and does nothing at all — silently,
   and it looks correct in the file. Three fixes are used below and each says so
   where it appears: a custom property their own declaration reads (13.3, 13.8),
   one extra class (13.5, 13.13, 13.15), or !important where the failure would
   be total (13.14).

   The reading type scale is deliberately absent from that table. 16px/1.65
   prose, a 16px question and a 15px in-answer heading are the same at 320px as
   at 1920px: the column is already capped at 47rem, so a narrow window shortens
   the measure rather than crowding it, and shrinking body text on a phone is how
   a document gets harder to read exactly where it is read most. */


/* ── 13.1 THE GUTTER AND THE TURN GAP ─────────────────────────────────────── */
@media (min-width:1280px){
  :root{--gut:2rem}                       /* 32px */
}
@media (min-width:901px) and (max-width:1279px){
  :root{--gut:1.5rem}                     /* 24px */
}
@media (max-width:900px){
  :root{--gut:1rem;--msg-gap:22px}        /* 16px gutters, tighter turns */
}


/* ── 13.2 >=901px — THE RAIL IS A GRID COLUMN ──────────────────────────────
   The box goes to zero and the auto track follows it. There is no icon rail.
   Animating width is one of the two layout animations the motion table permits,
   mitigated by contain:layout paint on .rail, and nothing else on the page
   animates during those 240ms.

   border-right-color rather than border-right-width, so the divider fades out
   with the panel instead of the panel snapping 1px narrower at frame one. A
   border-box element cannot compute below its border, so the collapsed rail is a
   1px transparent column; that 1px is constant, so it never produces a pop.

   main.js sets `inert` on the collapsed rail: width:0 and opacity:0 both leave
   the links in the tab order and in the accessibility tree, and only inert
   removes them. */
@media (min-width:901px){
  /* IT OVERLAYS. IT DOES NOT PUSH.
     This rail used to animate its own `width` from 264px to 0, and .app's first
     grid track is `auto`, so the track followed it — which moved the entire
     column with it. Opening the sidebar slid the headline, the lede, the quote
     button and the composer 131px to the right, measured, every time. A panel
     appearing is not a reason for the thing somebody is reading to move.

     Taking it out of flow is the whole fix: with no in-flow item in track 1 the
     `auto` track is 0, so <main> is full width at every moment and nothing in it
     has any reason to reflow. The panel slides over the top instead, which is
     what it does below the breakpoint already — the two bands finally agree
     about what this control is.

     TRANSFORM, NOT WIDTH, for the same reason the drawer uses it: width is a
     layout property and animating it costs a reflow of everything inside the
     rail on every frame.

     OPEN IS THE ABSENCE OF `rail-collapsed` UP HERE. main.js only adds
     `rail-open` below the breakpoint, so a rule keyed on it would leave the
     desktop rail permanently hidden. */
  .rail{
    position:absolute;                    /* never fixed: .app has a transform */
    inset:0 auto 0 0;
    z-index:var(--z-rail);
    width:var(--rail-w,16.5rem);
    transform:none;
    visibility:visible;
    transition:transform var(--t-mid) var(--e-panel),
               visibility 0s;
  }
  html.rail-collapsed .rail{
    transform:translateX(-100%);
    visibility:hidden;                    /* inert is set by main.js as well */
    transition:transform var(--t-mid) var(--e-panel),
               visibility 0s var(--t-mid);
  }

  /* A stale `rail-open` above the breakpoint — rotate a tablet, drag a window
     wider with the drawer open, and main.js is a frame late dropping the class —
     must not leave the rail off screen. */
  html.rail-open .rail{transform:none;visibility:visible}
  html.rail-open .rail__scrim{display:none}
}


/* ── 13.3 <=900px — THE DRAWER BAND ────────────────────────────────────────
   One breakpoint, 900px, shared with main.js. A 264px rail plus a 752px column
   plus two 24px gutters needs 1064px to be comfortable; at 900px the column is
   already squeezed and the rail should be gone.

   visibility:hidden rather than display:none takes the closed drawer's links out
   of the tab order and out of the accessibility tree natively — no inert
   polyfill, no tabindex bookkeeping — while still allowing the slide. The
   delayed visibility transition is what keeps it painted for the 240ms it takes
   to leave; without the delay it vanishes on frame one and the slide is never
   seen.

   No scroll lock on open: the document was never scrollable. The body{position:
   fixed;top:-Npx} dance and its scroll-restoration bug do not exist here. */
@media (max-width:900px){
  .app{grid-template-columns:minmax(0,1fr)}

  .rail{
    --rail-inner:min(85vw,20rem);
    position:absolute;                    /* never fixed: .app has a transform */
    inset:0 auto 0 0;
    z-index:var(--z-rail);
    width:var(--rail-inner);
    transform:translateX(-100%);
    visibility:hidden;
    transition:transform var(--t-mid) var(--e-panel),
               visibility 0s var(--t-mid);
  }
  html.rail-open .rail{
    transform:none;
    visibility:visible;
    transition:transform var(--t-mid) var(--e-panel),
               visibility 0s 0s;
  }

  .rail__scrim{
    display:block;
    position:absolute;
    inset:0;
    z-index:var(--z-scrim);
    background:rgba(0,0,0,.55);
    opacity:0;
    pointer-events:none;
    transition:opacity var(--t-mid) var(--e-panel);
  }
  html.rail-open .rail__scrim{opacity:1;pointer-events:auto}

  .msg--user{max-width:85%}

  /* A chip row that wraps to four lines on a phone costs more of the screen than
     the answer it belongs to. One line, scrolled sideways, bled out to the
     column's edges so the scroll reads as intentional — .thread is
     overflow-x:hidden, so this can never widen the app. The block padding and
     its negative margin are not decoration: overflow-x:auto computes overflow-y
     to auto as well, which would clip a focused chip's 2px ring. */
  .chips{
    flex-wrap:nowrap;
    overflow-x:auto;
    overflow-y:hidden;
    scroll-snap-type:x proximity;
    scrollbar-width:none;
    -webkit-overflow-scrolling:touch;
    padding-block:4px;
    margin-block:-4px;
    margin-inline:calc(var(--gut) * -1);
    padding-inline:var(--gut);
    scroll-padding-inline:var(--gut);
  }
  .chips::-webkit-scrollbar{display:none}
  .chips > li{flex:0 0 auto;scroll-snap-align:start}

  /* The keyboard must not be able to squeeze the thread to nothing: on a 640px
     phone in landscape a six-line composer plus a keyboard leaves no
     conversation at all. The absolute cap is declared first so a browser that
     does not understand the viewport-relative one still has a cap — a custom
     property whose value is unparsable makes every declaration that reads it
     invalid at computed-value time, and max-height would fall back to `none`,
     which is no cap at all rather than the previous one. */
  .composer{--composer-cap:9.5rem}
  @supports (height:100dvh){
    /* dvh, not vh: the phone's URL bar is part of the viewport this page is
       locked to, and 30vh would over-measure by the height of the bar. */
    .composer{--composer-cap:min(9.5rem,30dvh)}
  }

  /* THE HEADLINE'S TRACKING DOES NOT ANIMATE IN THIS BAND. h1-set opens the
     line about a hundredth of an em loose and closes it, which costs width
     while it runs: 9.0px of travel against 16px of slack at 390, and that slack
     is one font swap thick. Above 900px it is 15.8px against 31px and safe, so
     here the line simply fades and rises like everything else.

     A TOKEN RATHER THAN AN OVERRIDE, and that is not style. Sections 15 to 18
     were appended AFTER section 13, so a rule here carrying their specificity
     loses to them on source order and does nothing at all; a custom property
     their own declaration reads cannot be outranked. The same reasoning gives
     13.5 its .app prefixes and 13.8 its two caps. */
  :root{--h1-entrance:empty-in}
}


/* ── 13.4 <=700px ─────────────────────────────────────────────────────────── */
@media (max-width:700px){
}


/* ── 13.5 <=560px — ONE COLUMN ────────────────────────────────────────────── */
@media (max-width:560px){
  .enq{grid-template-columns:minmax(0,1fr)}
  .enq__f--wide{grid-column:auto}
  /* The chat page's form is placed by name, so its template has to be restated
     rather than overridden a cell at a time. */
  #enq{
    grid-template-areas:
      "req"
      "lname"  "iname"
      "lemail" "iemail"
      "lphone" "iphone"
      "ldomain" "idomain"
      "lbrief" "ibrief"
      "hint"
      "go";
  }

  .msg__body dl{grid-template-columns:minmax(0,1fr);row-gap:var(--s-1)}
  .msg__body dd{margin-bottom:var(--s-2)}
  .msg__body dd:last-child{margin-bottom:0}

  /* THE STATUS BAR STACKS. 358px cannot hold a 245px readout and a 227px
     control on one line, and shrinking either is how a status bar turns into
     decoration. And in a CONVERSATION at this width the readout is chrome
     competing with the thread for height the thread cannot spare, so it renders
     on the first screen only — where there is nothing above or below it but
     free space. The stack is the only place on the page where these two are not
     on one line, which is why it is here and not in 18.2.

     THE .app PREFIXES ARE LOAD-BEARING. 18.2 is further down the file, so a
     rule here written at its specificity loses on source order and silently
     does nothing; one extra class is the whole difference. */
  .app .dock__bar{flex-direction:column;align-items:flex-start;gap:var(--s-2)}
  .app .dock__note{margin-inline:0;text-align:left}
  /* One more .app than the show-rules in 18.2 carry. They are later in the file
     and tie with this at (0,3,0), so without the extra class this silently stops
     working the moment the engine starts and the readout reappears on exactly
     the screen this rule exists to keep it off. */
  .app.app:not(.is-empty) .dock__state{display:none}
}


/* ── 13.6 <=400px ─────────────────────────────────────────────────────────── */
@media (max-width:400px){
  /* 8px of horizontal padding instead of 12px buys the top bar 8px, which is the
     difference between the CTA fitting and the title being clipped to four
     characters. The safe-area insets stay: on a notched phone in landscape one
     of them is ~44px and the toggle would sit under the notch. */
  .topbar{
    gap:var(--s-1);
    padding-left:calc(var(--s-2) + var(--safe-l));
    padding-right:calc(var(--s-2) + var(--safe-r));
  }
  /* The lede is chrome, not prose: it repeats what the first answer says at
     length, so it is the one string on the page allowed to give up half a point.
     Body copy is untouched. */
  .thread__lede{font-size:16px}
}


/* ── 13.7 POINTER AND HOVER ────────────────────────────────────────────────
   Gated on the input device, never on width: a 1100px tablet in landscape is
   still a touch device, and a 900px desktop window is not.

   EVERY HOVER STATE THAT LEAVES A MARK LIVES IN THE (hover:hover) BLOCK. :hover
   sticks on touch — a tapped lead chip would keep the raised, brightened state
   until the visitor tapped something else, which reads as a selection that is
   not one, and the lead chips are seeded content, so they stay on screen to show
   it. Colour-only hovers (.msg__act, prose links, the scrollbars) are harmless
   and stay with their components. */
@media (hover:hover){
  .rail__toggle:hover{background:var(--hover);color:var(--text)}
  .rail__mark:hover{background:var(--hover)}
  .rail__item:hover,
  .rail__login:hover{background:var(--hover);color:var(--text-2)}

  /* :not(:active) IS LOAD-BEARING ON EVERY HOVER THAT MOVES SOMETHING. A pointer
     that is pressing is also hovering, and .chip:hover and .chip:active tie on
     specificity — so with the hover states gathered here, after their
     components, hover would win the tie and the press would never be seen. The
     guard makes hover simply not match while the control is held, which leaves
     the press rule to apply unopposed and keeps each component's own states in
     one place. The same reasoning gives the pill :not(.is-out): a pill fading
     out from under the pointer must not be held at hover scale. */

  /* NEVER a purple fill. The lift is 1px — enough to register, not enough to
     look like a card picking itself up. */
  .chip:not(:active):hover{
    background:var(--surface-1);
    border-color:rgba(255,255,255,.5);
    color:var(--text-2);
    transform:translateY(-1px);
  }

  /* A 4% pop, shared by the two round controls in the dock. */
  .composer__send:not([aria-disabled="true"]):not(:active):hover{transform:scale(1.04)}
  .jump:not(.is-out):not(:active):hover{transform:scale(1.06);border-color:var(--hair-4);color:var(--text-2)}

  .dock__link:hover{color:var(--text-2);text-decoration-color:currentColor}
  .btn:hover{transform:translateY(-1px);filter:brightness(1.08)}
}

/* THE ACTION ROW IS NOT HOVER-REVEALED ON A DEVICE WITH NO HOVER. Without this
   the copy button under every answer but the last is permanently at opacity 0 on
   a phone — present in the tab order, invisible on the screen. The aria-busy
   rule in section 8.6 is (0,3,0) and still outranks it, so a row still does not
   appear while its answer is arriving. */
@media (hover:none){
  .msg__acts{opacity:1}
}

@media (pointer:coarse){
  .rail__item,
  .rail__login{height:44px}

  /* 32px passes WCAG 2.5.8 (24px minimum), but it is a thumb target that sits in
     the corner nearest the notch and next to nothing else. 40px costs no layout:
     the bar is 52px tall and the button is centred in it. */
  .rail__toggle{width:40px;height:40px}

  /* Section 6 takes 13px of height off the top bar's CTA, which is right under
     a mouse and wrong under a thumb. 35px clears WCAG 2.5.8 (24px) with room
     and is still 17% less white on the screen than the 42px block it replaced. */
  .topbar .btn--sm{padding-block:11px}

  .chip{height:40px;padding:0 16px}
  .msg__act{width:44px;height:44px}
  .composer__send{width:36px;height:36px}

  /* Two links inside an 11.5px paragraph are a ~14px-tall tap target. Vertical
     padding on an inline box does not change the line box, so the sentence they
     sit in is unmoved — but it IS part of the hit area. */
  .dock__link{padding-block:6px}

  /* The pill stays a 34px circle — it is a floating object over the reader's own
     text and growing it would cover a line — but its TARGET grows to 44px. An
     inset pseudo-element rather than padding, so the disc's geometry, its border
     and its two transforms are all untouched. ::after is the new-content dot. */
  .jump::before{content:"";position:absolute;inset:-5px;border-radius:inherit}
}

/* Desktop may drop the composer to 15px, and it MUST be set on the ghost too or
   auto-grow measures desktop text against 16px metrics and the field grows a
   line early. Legal only inside (pointer:fine), which is where Safari's
   zoom-on-focus does not exist. */
@media (pointer:fine){
  .composer__grow > textarea,
  .composer__grow::after{font-size:15px}
}


/* ── 13.8 SHORT VIEWPORTS: 381-560px TALL ──────────────────────────────────
   The landscape phone, and the 400%-zoom path — a 1280x800 desktop at 400% is a
   320x200 CSS-pixel viewport, so WCAG 1.4.10 reflow is satisfied by the same
   rules a phone in landscape gets, which is why neither band is gated on a
   pointer.

   Every number here comes off chrome. Nothing is hidden, nothing is truncated,
   and no reading type shrinks: a short window is a reason to spend less on the
   frame, never a reason to make the content harder to read or to quietly drop
   the honesty line under the composer. */
@media (max-height:560px){
  :root{
    --topbar-h:44px;      /* the rail head reads this token, so the two stay one
                             band across the divider */
    --msg-gap:20px;
    --pad-b:.5rem;

    /* THE ONE PLACE ON THIS PAGE WHERE A SIZE IS CHOSEN BY THE WINDOW INSTEAD
       OF DERIVED FROM THE MEASURE. Section 16 sets the empty state's headline
       from the composer's width; at 1280x480 that width asks for 66px inside
       480px of window and the answer is no. A CAP rather than a flat size, so a
       window that is short AND narrow keeps the smaller number it already had
       — a flat 36px would make the headline BIGGER on the tightest screen there
       is. Tokens rather than overrides, for the source-order reason 13.3 gives:
       sections 15 to 18 come after this one, and only an inherited custom
       property can reach into a rule that sits below it in the file. */
    --h1-cap:36px;
    --lede-measure:30rem;
  }

  .thread__in{padding-block:var(--s-4) var(--s-4)}   /* 24px -> 16px */
  .thread__intro{margin-bottom:0}

  /* The one type change in the file, and it is the display face on a line that
     is 36px tall in a 480px-tall window. clamp()'s floor is 28px, so it has to
     be overridden rather than re-clamped. */
  .thread__h1{font-size:26px}

  /* Tightened, never clipped. This paragraph is the composer's
     aria-describedby target and it states what the engine actually is; a
     line-clamp or an ellipsis on it would be the one dishonest thing on the
     page. */
  .dock__note{font-size:11px;line-height:1.45}

  .composer{--composer-cap:7rem}
  @supports (height:100dvh){
    .composer{--composer-cap:min(7rem,26dvh)}
  }

  /* 56px of gradient over a thread that is ~250px tall puts a fifth of the
     reading area under a wash. The scrim's job is to stop text touching the
     composer, and 32px does that. The thread's bottom scroll padding is the
     scrim's height plus air, so it moves with it. */
  .dock::before{height:32px}
  .thread{scroll-padding-block:var(--s-1) calc(32px + var(--s-2))}
}


/* ── 13.8a THE OPTICAL LIFT ────────────────────────────────────────────────
   margin:auto splits the free space in half, which puts the BOX's centre on the
   screen's centre — and the empty state's mass is all in its bottom half
   (composer, prompts, a status row, under one line of headline), so the group
   reads low. Taking a slice off the bottom of the free space lifts the whole
   group by half of it: about 30px on an 844px screen. A percentage, because a
   taller screen needs a bigger correction, and svh rather than vh so a mobile
   URL bar collapsing cannot make the composition jump.

   Only where there is free space to take it from. Below 700px the free space is
   nearly gone and this would push the status row off the bottom instead, which
   is the one thing the locked viewport may never do. */
@supports (height:100svh){
  @media (min-height:700px){
    .app.is-empty .main{padding-bottom:clamp(16px,3.5svh,48px)}
  }
}


/* ── 13.9 <=380px TALL — THE APP MODEL IS ABANDONED ────────────────────────
   A single-viewport app that cannot fit in the viewport is not an app: a 52px
   bar, a 52px composer and one line of thread do not make a usable chat, and
   locking the page at that size hides content, which the README forbids
   outright. Degrade to an ordinary scrolling document — the same shape the
   no-script page takes.

   html.vv .app is (0,2,0) and .app{height:auto} is (0,1,0), so with scripting ON
   the shell would otherwise keep a fixed pixel height here and everything below
   the fold would be unreachable — the exact failure this band exists to prevent,
   visible only at 400% zoom with JavaScript enabled. The rule below ties on
   specificity with main.js's own and wins on source order, which is one of the
   reasons every media query is at the end of this file. */
@media (max-height:380px){
  html,body{height:auto;overflow:auto;overscroll-behavior:auto}

  .app,
  html.vv .app{
    position:static;
    height:auto;
    transform:none;
    display:block;
    contain:none;
    overflow:visible;
  }
  .main{display:block}
  .thread{overflow:visible;min-height:0}

  /* The rail becomes a static block of links at the top of the document. */
  .rail{
    position:static;
    width:auto;
    height:auto;
    max-height:none;
    transform:none;
    visibility:visible;
    overflow:visible;
    contain:none;
    border-right:0;
    border-bottom:1px solid var(--hair-1);
    padding-bottom:var(--s-2);
  }
  .rail__head,
  .rail__nav,
  .rail__foot{width:auto}
  .rail__head{height:auto;padding-top:calc(var(--s-2) + var(--safe-t));padding-bottom:var(--s-2)}
  .rail__nav{overflow:visible}
  .rail__heading{position:static}
  .rail__scrim{display:none}

  .dock{position:static;padding-bottom:calc(var(--s-3) + var(--safe-b))}
  /* A gradient scrim belongs to a fixed dock over a scrolling thread. In
     document flow it is a smear across the middle of the page. */
  .dock::before{display:none}
  /* Nothing to jump to the bottom of. main.js may still un-hide the pill; this
     outranks it without touching the hidden property the script owns. */
  .jump{display:none}
  /* main.js writes an inline pixel height on the spacer to reserve room for an
     answer. In document flow that is several hundred pixels of nothing between
     the last message and the composer. display beats an inline height without
     needing !important. */
  .thread__spacer{display:none}

  .composer{--composer-cap:6rem}
}


/* ── 13.10 THE ON-SCREEN KEYBOARD ──────────────────────────────────────────
   main.js publishes --app-h, --vv-top, --kb and --composer-h onto <html> and
   adds `vv` once they are real. Two of them do the keyboard work:

     --app-h   the height the shell must be right now. On iOS the LAYOUT viewport
               does not shrink for the keyboard — the VISUAL one does — so 100dvh
               keeps reporting the full screen and a shell sized from it puts the
               composer behind the keys. `html.vv .app{height:var(--app-h)}` in
               section 5 is the whole mechanism: the dock is an in-flow grid row
               of a shorter grid, so it lands on top of the keyboard with no
               positioning at all, and .thread's clientHeight shrinks with it so
               "scrolled to the bottom" still means the newest message is visible.
     --vv-top  visualViewport.offsetTop, applied as the shell's transform,
               because iOS also scrolls the visual viewport down inside the layout
               viewport to reveal the focused field.

   No rule anywhere positions the composer. These two are all that read the
   keyboard, and html.kb-open{--safe-b:0px} at the top of the file is the third:
   the home indicator is under the keyboard, so its inset is dead space while the
   keyboard is up. */

/* 30dvh does not shrink for the keyboard on iOS, so under kb-open the cap comes
   off --app-h, which does. A third of the visible area is roughly two lines of
   composer on a phone with the keyboard up, and it leaves the answer being read
   on screen. --app-h's own initial value is 100dvh, so this is valid before
   main.js runs; the two-class selector outranks every width and height band
   above, whatever order they resolve in. */
@supports (height:100dvh){
  html.kb-open .composer{--composer-cap:min(9.5rem,calc(var(--app-h) * .34))}
}

/* THE BELT. If a browser reports a keyboard through visualViewport but the shell
   has not been resized to match — the window between the first resize event and
   the first frame main.js writes — the shell is still full height and the dock
   is behind the keys. Taking --kb off the height chain costs nothing when --kb
   is 0px, which is its value at rest. Gated on :not(.vv) so it can never fight
   the authoritative number, and on @supports so the declaration is never invalid
   at computed-value time: an invalid height here would collapse the shell to
   auto and take the composer off the screen entirely. */
@supports (height:100dvh){
  /* min-height:381px because 13.9 has already given the app model up below it —
     a landscape phone really can be 375px tall — and this rule is (0,3,0), so
     without the gate it would re-impose a fixed height on a shell that is
     deliberately in document flow. */
  @media (min-height:381px){
    html.kb-open:not(.vv) .app{height:calc(100dvh - var(--kb,0px))}
  }
}


/* ── 13.11 MOTION IS OPT-IN ────────────────────────────────────────────────
   Every animation in the file is declared inside this block, so a user who has
   asked for less motion never has one applied and then shortened. The blanket in
   13.15 is the safety net under it, not the mechanism. */
@media (prefers-reduced-motion:no-preference){

  /* Rows 12 and 13 — the word reveal. The base state is visible; this is the
     only rule that dims a word, and it is reachable only from the class the
     renderer adds. */
  /* 220ms AND EASED, NOT 120ms LINEAR. The cadence spends its budget in ticks,
     so roughly one or two words light at a time; with a fade shorter than the
     gap between ticks each word finished before the next began and the reveal
     read as popcorn — discrete, hard-edged, and the single most amateur thing
     on the page. A fade LONGER than the tick makes neighbouring words overlap,
     which is what turns a sequence of pops into one travelling wave. The curve
     is the file's own ease-out, so a word arrives quickly and settles. */
  .msg.is-revealing .w{opacity:0;transition:opacity 220ms cubic-bezier(.22,1,.36,1)}
  .msg.is-revealing .w.is-on{opacity:1}

  /* Row 10 — the thinking shimmer. A gradient travelling left to right between
     --muted-2 and --text-2, painted through the text. It never touches purple. */
  .think__label{
    background-image:linear-gradient(90deg,
      var(--muted-2) 0%, var(--muted-2) 36%,
      var(--text-2) 50%,
      var(--muted-2) 64%, var(--muted-2) 100%);
    background-size:220% 100%;
    background-repeat:no-repeat;
    -webkit-background-clip:text;
    background-clip:text;
    color:transparent;
    -webkit-text-fill-color:transparent;
    animation:tk-shimmer 1800ms linear infinite;
  }

  /* Row 6 — the user bubble's entrance, and ONLY on a turn the script just
     added. The two selectors resolve to the identical shorthand, so the
     animation is not restarted when the assistant turn lands ~90ms later and the
     first selector stops matching. Seeded turns never match either, so nothing
     fades in at load — the tell this page is specifically not allowed to have.
     TWO RULES, NOT ONE LIST: an unsupported pseudo-class invalidates the whole
     selector list it appears in, so pairing :has() with the plain selector would
     drop both in a browser without :has(). */
  .log > .msg--user:last-child{animation:tk-msg-in var(--t-base) var(--ease)}
  .log > .msg--user:has(+ .msg--bot[aria-busy="true"]){animation:tk-msg-in var(--t-base) var(--ease)}

  /* Row 18 — the follow-up chips arrive after their answer has finished, not
     with it: a 280ms beat, then 160ms each on a 45ms stagger. The seeded lead
     chips are static content and are deliberately NOT animated. The <li> carries
     the animation, not the button, so a fill mode can never fight the chip's own
     hover and press transforms. `backwards`, never `both`. */
  .chips--follow > li{animation:tk-chip-in 160ms var(--ease) 280ms backwards}
  .chips--follow > li:nth-child(2){animation-delay:325ms}
  .chips--follow > li:nth-child(3){animation-delay:370ms}

  /* Rows 20 and 22 — the pill enters by LOSING `hidden`, so it goes from
     display:none to displayed and a transition has no start value to run from.
     animation-fill-mode is `backwards`, never `forwards`: a forwards fill would
     hold transform:none after the animation ended and beat every hover and press
     rule, and the pill would stop responding to the pointer the moment it had
     appeared once. */
  .jump{animation:tk-jump-in var(--t-base) var(--ease) backwards}
  .jump.has-new::after{animation:tk-dot-in var(--t-fast) var(--ease)}
}


/* ── 13.12 update:slow ─────────────────────────────────────────────────────
   E-ink, some in-car displays and a few low-power panels repaint in hundreds of
   milliseconds. A per-word opacity reveal on one of those is not a reveal, it is
   a page that appears to be corrupting itself, and a 1.8s shimmer loop is a
   panel refreshing for ever. Neither device necessarily reports a motion
   preference, so it is asked separately. */
@media (update:slow){
  .msg.is-revealing .w,
  .msg.is-revealing .w.is-on{opacity:1;transition:none}
  .msg__caret{display:none}
  .think__label{
    animation:none;
    background-image:none;
    color:var(--muted-2);
    -webkit-text-fill-color:currentColor;
  }
  .chips--follow > li{animation:none;opacity:1;transform:none}
  .jump{animation:none}
}


/* ── 13.13 FORCED COLOURS ──────────────────────────────────────────────────
   A design built from 3-36% white alphas, hairlines and a box-shadow bar
   disappears completely in Windows High Contrast: box-shadow is dropped outright
   and background images with it. Every boundary that carries meaning is restated
   in system colours, the shimmer's transparent text gets its colour back, and
   the wordmark falls back to the real text node it was always hiding. */
@media (forced-colors:active){
  :focus-visible{outline:2px solid Highlight;outline-offset:2px}

  .rail{border-right:1px solid CanvasText}
  .rail__foot{border-top:1px solid CanvasText}
  .rail__item,
  .rail__login{
    -webkit-mask-image:none;
            mask-image:none;
  }
  .rail__item.is-here{border-left:3px solid Highlight}
  .rail__word{
    width:auto;
    height:auto;
    background:none;
    overflow:visible;
    text-indent:0;
    font:600 15px/1 var(--display);
    letter-spacing:.16em;
  }

  .msg--user,
  .msg__err,
  .chip,
  .app .intro__quote,
  .composer,
  .composer__send,
  .jump,
  .msg__act--retry,
  .enq input,
  .enq textarea{border:1px solid CanvasText}
  .msg__note{border-left-color:CanvasText}
  .msg__caret{background:CanvasText}
  .msg__acts{opacity:1}
  .msg__body code,
  .msg__body pre{border:1px solid CanvasText}
  .think__label{
    background-image:none;
    color:CanvasText;
    -webkit-text-fill-color:currentColor;
    animation:none;
  }
  /* A DRAWN SQUARE RATHER THAN A FILLED ONE. Forced colours override
     background-color, so the lamp's fill is Canvas in every state and 18.2's
     lit rule is out of reach from up here anyway — sections 15 to 18 come after
     this one in the file. A border survives both, so the lamp stays a visible
     5px square throughout; which state it is in is carried by the word beside
     it, and that word is real text in all four. */
  .app .dock__lamp{background:Canvas;border:1px solid CanvasText}
  .composer__send::before{background:none}
  .composer__send[aria-disabled="true"]{color:GrayText}
  .jump.has-new::after{background:Highlight;box-shadow:none}
  .btn{border:1px solid CanvasText}
  .ico{forced-color-adjust:auto}
}


/* ── 13.14 PRINT ───────────────────────────────────────────────────────────
   A chat application that prints as one blank screen is a broken document. With
   scripting on, the shell is a fixed box of exactly --app-h pixels with
   overflow:hidden and contain:layout size — a printer gets one page showing one
   screenful, and the transcript underneath it does not exist.

   So print undoes the app: static flow, no containment, no clipping, ink on
   white, and the chrome that only means something on a screen removed. What is
   left is what the page always was underneath — a heading, a lede, a
   question-and-answer transcript, the enquiry form and two ways to reach a
   person. It is the same document a visitor with JavaScript off reads.

   !important is used on the structural resets on purpose: they have to beat
   html.vv .app, whatever inline styles main.js has written by then, and any
   later-loading rule. Nowhere else. */
@media print{

  @page{margin:14mm}

  html{color-scheme:light}

  html,body{
    height:auto!important;
    overflow:visible!important;
    background:#fff!important;
    color:#000!important;
    font-size:11pt;
  }
  /* The enquiry function's pages scroll inside their own <main> on screen. */
  body > main:not(.main){height:auto!important;overflow:visible!important}

  /* The whole shell, undone. contain:size on a printed element makes it
     monolithic and unfragmentable: leave it in and a long answer is clipped at
     the bottom of page one instead of continuing onto page two. */
  .app,
  html.vv .app{
    position:static!important;
    height:auto!important;
    min-height:0!important;
    transform:none!important;
    display:block!important;
    overflow:visible!important;
    contain:none!important;
    isolation:auto;
  }
  /* container-type:inline-size (section 16) implies contain:layout style
     inline-size. The note above about contain:size applies to anything that
     could make a long transcript unfragmentable, and paper has no use for a
     query container in the first place, so it does not get one. */
  .main{display:block!important;container-type:normal!important}
  .thread{
    overflow:visible!important;
    height:auto!important;
    min-height:0!important;
    contain:none!important;
  }
  /* Flexbox fragmentation across pages is the least reliable corner of every
     print engine. Block flow with margins is not. */
  .thread__in{display:block;max-width:none;margin:0;padding:0}
  .log{display:block}
  .thread__intro{margin:0 0 16pt}
  .msg{
    contain:none!important;
    overflow:visible!important;
    margin:0 0 16pt;
  }

  /* Screen-only chrome. The composer, the jump pill, the sidebar toggle, the
     scrim, the chips, the action row, the thinking indicator, the caret and the
     reserve spacer all describe an interaction that paper does not have.
     .dock__nojs is already display:none on a scripted page and stays that way —
     the printed transcript is complete without it. */
  .skip,
  .topbar,
  .rail__scrim,
  .rail__nav,
  .rail__toggle,
  .dock,
  .dock::before,
  .jump,
  .composer,
  .dock__note,
  .dock__nojs,
  .chips,
  .msg__foot,
  .msg__acts,
  .think,
  .msg__caret,
  .msg__err,
  .thread__spacer,
  .enq__pot,
  .enq__go,
  .cta__glow{display:none!important}

  /* A print that starts mid-answer must not print the un-revealed half of it in
     white on white. The renderer's dimming class is undone outright. */
  .w,
  .msg.is-revealing .w{opacity:1!important}

  /* THE SAME TRAP, ON THE OTHER DOCUMENT THIS FILE DRESSES. The pages the
     enquiry function returns do carry the fade class, whose opt-in state is
     opacity:0 until a script adds .is-in. Print kills every transition below, so
     anything not scrolled into view would print as a blank sheet — on the two
     pages a real enquiry actually ends on. Forced visible here, `.js`-prefixed
     so the bare rule at the top of the file stays the first one the verifier
     finds. */
  .js .reveal,
  .js .reveal.is-in{opacity:1!important;transform:none!important}

  /* THE MASTHEAD. The rail's topic anchors are meaningless on paper and are
     hidden above; its head and its contact links are not. The wordmark is a
     background image and printers drop those by default, so it goes back to
     being the text node it was always hiding. */
  .rail{
    position:static!important;
    width:auto!important;
    height:auto!important;
    max-height:none!important;
    transform:none!important;
    visibility:visible!important;
    overflow:visible!important;
    contain:none!important;
    display:block;
    border:0;
    border-bottom:1pt solid #999;
    padding:0 0 8pt;
    margin:0 0 16pt;
  }
  .rail__head{height:auto;padding:0;margin:0 0 6pt}
  .rail__word{
    width:auto;
    height:auto;
    background:none;
    overflow:visible;
    text-indent:0;
    font:600 14pt/1 var(--display);
    letter-spacing:.2em;
    color:#000;
  }
  .rail__foot{border:0;padding:0;margin:0}
  .rail__list{display:block;list-style:none;margin:0;padding:0}
  .rail__foot li{display:inline;margin-right:12pt}
  /* .rail__login is named here only so a development host's printed page is not
     missing a line it can see on screen. In production it carries the `hidden`
     attribute and [hidden]{display:none!important} outranks this, on paper
     exactly as on screen: the admin door does not print. */
  .rail__item,
  .rail__login{
    display:inline;
    height:auto;
    padding:0;
    color:#000;
    font-size:9.5pt;
    /* A right-edge gradient mask in a print engine is a coin flip between
       "fades out" and "is not there". */
    -webkit-mask-image:none;
            mask-image:none;
    box-shadow:none;
    background:none;
  }

  /* THE TRANSCRIPT. Both speakers' visually hidden labels come back as real
     text: on screen the user turn is identified by alignment, surface and a
     hidden string, and paper keeps only the string. Scoped to .msg so the empty
     live regions and the buttons' hidden names stay out of it. */
  .msg .vh{
    position:static;
    width:auto;
    height:auto;
    margin:0;
    overflow:visible;
    clip-path:none;
    white-space:normal;
    border:0;
  }
  .msg--bot > .vh{
    font:500 8pt/1.4 var(--mono);
    letter-spacing:.08em;
    text-transform:uppercase;
    color:#555;
    margin:0 0 4pt;
  }

  /* The bubble becomes a quoted question: a right-aligned pill at 74% of the
     column reads as a layout accident once the fill is gone. And a question must
     never sit alone at the foot of a page. */
  .msg--user{
    max-width:none;
    margin:0;
    padding:0 0 0 10pt;
    background:none;
    border:0;
    border-left:2pt solid #000;
    border-radius:0;
    box-shadow:none;
    break-inside:avoid;
    break-after:avoid;
  }

  /* The type comes down to a printed scale. 16px is 12pt at the standard 96dpi
     mapping, which is a large book on paper; 11pt at 1.5 is a document. The
     RATIOS are unchanged, so the printed page reads as the same design. */
  .thread__h1{font-size:22pt;color:#000}
  .thread__lede{font-size:12pt;color:#333}
  /* WHITE INK ON WHITE PAPER. Section 16 sets the empty state's headline to
     var(--text-hi) and sizes it from a container query, both at (0,3,0), and
     this block is earlier in the file — so the two rules above never reached
     the first screen and it printed as a blank sheet with a pale grey lede
     under it. !important here is the same structural reset the shell above
     uses, for the same reason: the failure is total, not cosmetic. */
  .app.is-empty .thread__h1{font-size:22pt!important;color:#000!important}
  .app.is-empty .thread__lede{font-size:12pt!important;color:#333!important}
  .msg__q{font-size:12pt;font-weight:600}
  .msg__body{color:#000}
  /* orphans/widows: a paragraph must not leave one line behind. */
  .msg__body p,
  .msg__body li{font-size:11pt;line-height:1.5;orphans:3;widows:3}
  .msg__body h3{font-size:11.5pt;color:#000;break-after:avoid;break-inside:avoid}
  .msg__note{font-size:10pt;border-left:2pt solid #bbb;color:#444}
  .msg__mono{font-size:9pt}
  .msg__mono,
  .msg__route{color:#333}
  .msg__body code{background:#f1f1f1;border:1pt solid #ddd;color:#000}
  .msg__body strong{color:#000}
  .msg__body ol.steps > li::before{color:#555}

  .enq{break-inside:avoid}

  /* The form prints as a form: it is the only thing on the page that asks the
     reader for something, and a printed copy of what we ask is useful. The
     submit button is hidden above — a button on paper is a lie. */
  .enq input,
  .enq textarea{
    background:#fff;
    border:1pt solid #999;
    color:#000;
    min-height:26pt;
  }
  .enq__f,
  .enq__req,
  .enq__hint,
  .enq__opt,
  .enq__r{color:#333}

  /* Links carry their destination, once, and only where the text does not
     already say it: a mailto's label IS the address on this page. */
  a{color:#000;text-decoration:underline}
  .msg__body a[href^="http"]::after,
  .rail__foot a[href^="http"]::after{
    content:" <" attr(href) ">";
    font-size:8.5pt;
    color:#555;
    word-break:break-all;
  }

  /* The legacy pages the enquiry function returns print through this block too,
     and they are the last thing a real enquiry sees. */
  .cta,
  .wrap,
  .cta__in{background:none;color:#000;padding:0;max-width:none}
  .h1{color:#000}
  .p,
  .p--lead,
  .cta__fine{color:#333}
  .btn{
    color:#000;
    background:none;
    border:1pt solid #666;
    box-shadow:none;
    clip-path:none;
  }

  *,
  *::before,
  *::after{
    box-shadow:none!important;
    text-shadow:none!important;
    animation:none!important;
    transition:none!important;
  }
}


/* ── 13.15 PREFERS-REDUCED-MOTION: REDUCE — AND IT IS LAST ON PURPOSE ──────
   Reduced motion is a BEHAVIOUR change, not a duration change. It removes
   motion, never function: answers still arrive in full, the rail still
   collapses, the drawer still opens, the jump pill still appears and still
   works, the copy button still confirms. A build that disables the reveal and
   also stops appending the message is broken, not accessible.

   The blanket comes first — it catches an inline style, a third-party rule or a
   component that forgot. It is a safety net and not a mechanism: it shortens
   durations, and it cannot decide that the caret should not be rendered at all,
   that the shimmer's transparent text must get its colour back, or that a press
   must not scale. Everything after it is the mechanism, one entry per animation
   and per motion-carrying transition in the file, addressed by the selector that
   owns it — so switching an animation off never depends on remembering a
   keyframe name.

   main.js and chat.js read the query live in JS as well, because no stylesheet
   can reach element.animate() or scrollTo({behavior:'smooth'}), and a user who
   turns the setting on mid-session gets the new behaviour on the next answer
   without a reload. */
@media (prefers-reduced-motion:reduce){
  *,*::before,*::after{
    animation-duration:.001ms!important;
    animation-iteration-count:1!important;
    transition-duration:.001ms!important;
    scroll-behavior:auto!important;
  }

  /* Rows 1, 3, 4, 5, 16, 17, 19, 20, 21, 22, 26, 27 — everything whose motion is
     a transition. Every state still changes; it changes instantly. */
  .rail,
  .rail__scrim,
  .rail__mark,
  .rail__item,
  .rail__login,
  .rail__toggle,
  .topbar,
  .btn,
  .chip,
  .msg__act,
  .msg__acts,
  .msg__caret,
  .jump,
  .composer,
  .composer__input,
  .composer__send,
  .composer__send::before,
  .composer__send .ico,
  .msg__act .ico--copy,
  .msg__act .ico--check,
  .ico,
  .dock__link,
  /* 18.2's own rules are further down the file, so these two need one class
     more than their components carry or the entry is dead and only the blanket
     above is doing the work — and the blanket is a net, not the mechanism. */
  .app .intro__quote,
  .app .dock__lamp,
  .skip,
  .enq input,
  .enq textarea,
  .enq__go{transition:none}

  /* Row 2 — press feedback, on the three controls the motion table names. The
     scale is removed rather than shortened: a 4% jump with no travel is a flash,
     which is worse than nothing. */
  .chip:active,
  .composer__send:active,
  .jump:active{transform:none}

  /* Row 23 — the rail collapse animates a grid track, one of the two layout
     animations the motion table permits. The rail still collapses; it collapses
     in one frame. */
  .app{transition:none}

  /* tk-msg-in — row 6. Two rules, not one list: pairing :has() with the plain
     selector would drop BOTH in a browser without :has(), and dropping a rule
     that removes motion fails in the unsafe direction. */
  .log > .msg--user{animation:none}
  .log > .msg--user:has(+ .msg--bot[aria-busy="true"]){animation:none}

  /* Rows 12 and 13 — the word reveal. The answer appears complete in one frame.
     The text was in the DOM, the accessible tree and the clipboard from frame
     one either way; this only decides whether it was painted. */
  .msg.is-revealing .w,
  .msg.is-revealing .w.is-on{opacity:1;transition:none}

  /* tk-caret — rows 14 and 15. Under reduced motion the caret is not rendered at
     all: one that does not blink and does not advance is a stray 2px bar parked
     in the text. */
  .msg__caret,
  .msg__caret.is-idle{animation:none}
  .msg__caret{display:none}

  /* tk-shimmer — row 10. The label is painted with background-clip:text and a
     transparent fill, so stopping the animation alone would leave MATCHING
     invisible. The colour has to come back with it. The controller still holds
     the label a flat 320ms, so the sequence reads question -> considering ->
     answer. */
  .think__label{
    animation:none;
    background-image:none;
    color:var(--muted-2);
    -webkit-text-fill-color:currentColor;
  }

  /* tk-chip-in — row 18. No stagger, no lift; they are simply there. Removing an
     animation with a fill mode must be paired with the resting values, or a
     browser mid-animation could keep the `from` frame. */
  .chips--follow > li,
  .chips--follow > li:nth-child(2),
  .chips--follow > li:nth-child(3){animation:none;opacity:1;transform:none}

  /* tk-jump-in and tk-dot-in — rows 20 to 22. Visibility is the hidden property,
     so the pill still appears and disappears; only the fade and the 6px lift go.
     .jump.is-out keeps its opacity:0 — that is state, not motion — and the
     transition above makes it instant. */
  .jump,
  .jump.has-new::after{animation:none}

  /* The opt-in fade on the enquiry function's pages. The `.js` prefix is
     mandatory: a bare rule for that class here could become the first match the
     verifier finds and would fail the build, or hide those two pages outright. */
  .js .reveal{opacity:1;transform:none;transition:none}
  .js .reveal.is-in{opacity:1;transform:none}
}


/* ═══ 15. THE EMPTY STATE ══════════════════════════════════════════════════
   A visitor's first screen is a wordmark, a line, a composer and six prompts —
   not eleven answers to questions they have not asked yet. The seeded
   conversation is still in the document at first byte; it is simply not the
   thing they are handed.

   THIS IS A JAVASCRIPT-ONLY VIEW, AND THAT IS THE SAFE DIRECTION. main.js adds
   .is-empty on boot, so with no script the page renders the full seeded
   document exactly as before — a crawler and a reader with scripting off both
   get the complete Q&A. The script can only ever take the page from "shows
   everything" to "shows less", never the reverse, which is the same fail-open
   discipline the reveal and the dev-host door use.

   IT IS NOT HIDDEN CONTENT IN THE CLOAKING SENSE EITHER: every seeded turn is
   reachable in one click from the rail, and the state drops the moment anyone
   asks anything.

   The centring is margin:auto on both sides of the pair, not place-items on a
   grid: the thread and the dock must stay in DOM order and keep their own
   widths, and `margin-top:auto` on the first with `margin-bottom:auto` on the
   last centres the GROUP while leaving the top bar pinned. */
.app.is-empty .main{
  display:flex;
  flex-direction:column;
}
.app.is-empty .topbar{flex:0 0 auto}
/* NEVER overflow:visible HERE. The narrow-width chip row is flex-wrap:nowrap
   with overflow-x:auto, and section 13 states the invariant it relies on in
   its own comment: ".thread is overflow-x:hidden, so this can never widen the
   app". Setting it visible removed that guard — the row's min-content width
   propagated out through the flex line's cross size and every element on the
   page, top bar and composer included, was laid out wider than the phone. The
   thread keeps its own overflow in both states; there is nothing to scroll
   here anyway, because the content is one short block. */
.app.is-empty .thread{
  flex:0 0 auto;
  margin-top:auto;
  min-width:0;
}
.app.is-empty .dock{
  flex:0 0 auto;
  margin-bottom:auto;
  min-width:0;
}

/* THE SEEDED TURNS ARE A SEPARATE CONCERN FROM THE EMPTY LAYOUT, and conflating
   the two was a real bug. Hiding them used to be keyed on .is-empty, so the
   moment anybody asked anything the class came off and twenty-one pre-written
   turns dropped in ABOVE their question — the exact wall this page was
   redesigned to get rid of, delivered at the worst possible moment, and it
   pushed the answer they were waiting for off the screen.

   So `hide-seed` is its own state. Asking a question leaves it on: you see your
   question and its answer, and nothing else. It comes off only when a link
   points INTO a seeded turn — the rail, or the CTA reaching the enquiry form —
   because that is the one case where the reader asked for that content.

   All twenty-one carry data-seed; a turn the engine appends does not, so live
   answers are never touched by this rule. */
.app.hide-seed .log > .msg[data-seed]{display:none}
.app.is-empty .thread__spacer{display:none}

/* Centred, because a single block in the middle of a screen that is otherwise
   empty has no left edge to hang from. This is the ONE place the page centres
   body copy; everything after the first question is left-aligned prose. */
.app.is-empty .thread__intro{text-align:center;margin-bottom:var(--s-5)}
.app.is-empty .thread__lede{margin-inline:auto;max-width:34rem}
/* THE PRESET QUESTIONS WRAP HERE, THEY DO NOT SCROLL.
   Section 13 turns .chips into a one-line horizontal scroller below 760px,
   bled out to the column edges with a negative margin so the scroll reads as
   intentional. That is right INSIDE a conversation, where the row sits under
   an answer and the edge-bleed signals "there is more sideways".

   It is wrong here. On the empty state the row sits under a centred headline
   with nothing else on the screen, so a chip clipped by the right edge reads
   as a broken layout rather than as a scroller — which is exactly how it was
   reported. Wrapped and centred, six prompts are obviously six prompts.
   Everything section 13 set for the scroller has to be undone explicitly: the
   nowrap, the overflow, the negative margin AND the padding that compensated
   for it. */
.app.is-empty .chips{
  flex-wrap:wrap;
  justify-content:center;
  overflow:visible;
  margin-inline:0;
  padding-inline:0;
  scroll-snap-type:none;
}
.app.is-empty .chips > li{scroll-snap-align:none}

@media (prefers-reduced-motion:no-preference){
  .app.is-empty .thread__intro,
  .app.is-empty .log > .msg:first-of-type .msg__foot{
    animation:empty-in .5s var(--ease) backwards;
  }
  .app.is-empty .log > .msg:first-of-type .msg__foot{animation-delay:.08s}
}
@keyframes empty-in{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}


/* The mark in the top bar. It reads at 20px because it is a single chiselled
   glyph, and it takes the accent's hue rotation like every other copy of it. */
/* THE BRAND PLATE. An oversized monogram set BEHIND the wordmark and bleeding
   past the bar, rather than a 20px icon sitting politely beside it.

   Out of flow on purpose: at this size it would otherwise push the word across
   the bar and its height would fight the 52px row. Absolute means the layout is
   decided by the word alone and the monogram is free to be any size.

   THE MASK IS THE WHOLE EFFECT. A large mark at a flat opacity is a smudge; a
   large mark that DISSOLVES reads as depth. Two gradients composited: one
   carrying it away to the right so the wordmark stays legible over it, one
   fading the descent so the part that bleeds below the bar has gone before it
   reaches the conversation — which it is painted over, and must not smear.

   pointer-events:none because it overlaps the sidebar toggle's hit area. */
/* SMALLER AND QUIETER SINCE THE HEADLINE WAS SET. The plate was tuned against
   a 41.6px h1; the headline below it is 65px now and three times the size of
   anything else on the screen, and at 86px/.20 the monogram was still competing
   with it for the top-left corner. Same artwork, same mask, same dissolve —
   less of it. */
.topbar__mark{
  position:absolute;
  left:-10px;
  top:50%;
  z-index:0;
  width:64px;
  height:auto;                /* mark.png is 512x444; let it keep its aspect */
  transform:translateY(-50%);
}

/* Above the plate. */
.topbar__word{position:relative;z-index:1}

/* ONE DEFINITION FOR BOTH LOCKUPS. How faded it is, how it dissolves and how it
   takes the accent are identical in the top bar and the rail, so they are
   written once; only the offsets differ, because the rail clips and the top bar
   does not. A second copy is how two things that must match quietly stop
   matching after somebody tunes one of them.

   The vertical gradient dissolves at BOTH ends. In the top bar the plate
   exceeds the header upward and is cut by the window itself — a flat horizontal
   slice across the glyph, which is the opposite of cinematic. Fading in from
   the top reads as a mark emerging from the dark rather than one somebody
   trimmed. */
.topbar__mark,
.rail__mark img{
  pointer-events:none;
  filter:var(--mark-filter);
  opacity:.13;
  -webkit-mask-image:linear-gradient(104deg,#000 24%,rgba(0,0,0,.42) 66%,transparent 96%),
                     linear-gradient(180deg,transparent 2%,#000 30%,#000 58%,transparent 95%);
  mask-image:linear-gradient(104deg,#000 24%,rgba(0,0,0,.42) 66%,transparent 96%),
             linear-gradient(180deg,transparent 2%,#000 30%,#000 58%,transparent 95%);
  -webkit-mask-composite:source-in;
  mask-composite:intersect;
}
.topbar__title{display:flex;align-items:center;min-width:0}


/* The model-answer disclosure. Quiet, but never hidden: it is the sentence that
   makes the tiering mean something to a reader. Sits at the contrast floor for
   body text, not below it. */
/* THE SMALLEST TYPE ON THE PAGE, and it is meant to be. It sits under the lead
   card as a footnote to it: the card is for somebody who wants to be walked
   through three questions, this is for somebody who would rather type all three
   at once and be done. Louder than a footnote and there are two calls to action
   stacked under every answer, which is how a clean page turns into a pitch. */
.msg__share{
  margin-top:var(--s-3);
  color:var(--muted-3);
  font-size:12px;
  line-height:1.5;
  max-width:52ch;
}

.msg__via{
  margin-top:var(--s-3);
  padding-top:var(--s-2);
  border-top:1px solid var(--hair-1);
  color:var(--muted-2);
  font-size:var(--fs-sm);
  line-height:1.5;
}


/* ═══ 16. THE EMPTY STATE, COMPOSED ═══════════════════════════════════════════
   Five objects on an otherwise black screen, so each one has to earn its place.
   Everything here is scoped to .is-empty and none of it touches the page once a
   conversation has started — in a transcript the composer is a tool at the
   bottom of a document, not the subject of a composition. */

/* ═══ THE MEASURE IS THE COMPOSER, AND THE TYPE IS SET TO IT ═════════════════
   One number drives the whole first screen. --measure is the content width of
   .thread__in, which is also the rendered width of .composer — the two have
   been pixel-identical at every width since section 9.1 subtracted the gutters,
   and nothing on the screen was using that fact. 688px at 1440, 358px at 390.

   The headline is sized from it, the lede is measured against it, the prompt row
   is clamped to it and all of them start on its left edge. The page stops being
   five centred objects at five different widths and becomes one column that was
   obviously measured by somebody.

   100cqi, NOT 100vw, because the rail is a grid column at >=901px: with it open
   between 901 and 1016px the main column is up to 264px narrower than the
   window, and a vw-sized headline would be set for space it does not have and
   would overflow its own column.

   THE SAFE-AREA INSETS COME OFF INSIDE THE min(), NOT AFTER IT. The composer is
   min(main - 2*gut - insets, col - 2*gut); subtracting the insets outside the
   min() gives a different number under a notch in landscape, and the prompt row
   would stop sharing the composer's left edge on exactly the device where a
   misaligned left edge is most visible.

   .main IS THE QUERY CONTAINER, SO .main ITSELF MAY NEVER READ --measure OR
   --h1-size. cqi resolves against the using element's NEAREST ANCESTOR
   container, so on .main those units would resolve against the viewport and be
   wrong. Descendants only.

   container-type:inline-size implies `contain: layout style inline-size`, which
   makes .main a stacking context and the containing block for its absolutely
   positioned descendants. Audited, because this file has been bitten by
   containment before: .jump is positioned against .dock and the lamp against
   .main, and both of those were already position:relative, so neither moves;
   the rail, its scrim, the skip links, the grain and the vignette all live
   outside .main; and the enquiry form's off-canvas honeypot sits inside a
   [data-seed] turn, which is display:none in every state where .is-empty
   exists. There is no size containment and no paint containment, so nothing is
   clipped and no height is constrained — and 13.14 switches the container off
   for print anyway, because a containment mode is not a thing to leave
   guessing on a fragmented medium.

   The flex column this rule used to restate is section 15's and is not repeated
   here. Two rules for one behaviour is how the next person changes the wrong
   one. */
.app.is-empty .main{
  container-type:inline-size;

  --measure:calc(min(100cqi - var(--safe-l) - var(--safe-r), var(--col)) - 2 * var(--gut));

  /* THE HEADLINE FILLS 95.5% OF THE MEASURE, ON ONE LINE, AT EVERY WIDTH. Not a
     taste clamp — a solved equation, and this is how to re-solve it if the <h1>
     string ever changes, which is the one maintenance obligation this design
     adds:

       1. const c = document.createElement('canvas').getContext('2d');
          c.font = '600 100px "Inter Tight"';
          c.measureText('What do you need built?').width / 100   ->  W = 10.8119em
       2. tracking is calc(1.2px - .05em), so at size S the set line is
          (W - n*.05)*S + n*1.2  =  9.6619*S + 27.6 px    (n = 23 characters)
       3. solve 9.6619*S + 27.6 = 0.955*M   ->   S = M*0.0988 - 2.86px

     The 4.5% reserve absorbs the font swap: system-ui and SF set this string
     1.7% and 2.9% NARROWER than Inter Tight, so the fallback frame is safe on
     macOS and on Windows. Arial and Helvetica set it 6.4% wider and will wrap to
     two balanced lines for the length of the swap on a machine with neither —
     measured, accepted, and the reason text-wrap:balance stays on below.

     --h1-cap is the ceiling and 13.8 lowers it on a short window. That is the
     only place on this page where a size is chosen by the window rather than
     derived from the measure, and it is stated there. */
  --h1-size:clamp(1.75rem, calc(var(--measure) * .0988 - 2.86px), var(--h1-cap,4.25rem));
}

/* THE HEADLINE.
   The rule this replaces was clamp(1.75rem,3.4vw,2.6rem): 41.6px from 1224px of
   window all the way out to 1920, which is where "a small dialog stranded in
   black" came from. This is 65.1px at 1440 and 68px at 768 — BIGGER on the
   tablet, because the tablet's gutters are smaller and therefore its measure is
   wider. The type is a function of the measure, and the measure is the object
   the visitor types into. That is the whole idea.

   TRACKING IS OPTICAL, NOT CONSTANT. calc(1.2px - .05em) is a fixed positive
   offset minus a proportional term, so the line tightens as it grows and opens
   as it shrinks — the job an opsz axis would do, which these static Google
   Fonts instances do not carry:
       32.5px -> -0.0131em     47px -> -0.0244em     65.1px -> -0.0316em
   The single -0.028em that shipped is 2.1x too tight at 390px and 11% too loose
   at 1440.

   text-box trims the half-leading and the descender off the block, so the
   margins below are literal optical space, cap to cap. Chrome 133+ and Safari
   18.4+; the @supports block at the end of this section carries the untrimmed
   numbers for everything else. */
.app.is-empty .thread__h1{
  font-family:var(--display);
  font-size:var(--h1-size);
  font-weight:600;
  line-height:1.05;
  letter-spacing:calc(1.2px - .05em);
  color:var(--text-hi);
  text-wrap:balance;
  font-synthesis-weight:none;    /* never let a fallback fake 600 and re-measure */
  text-box:trim-both cap alphabetic;
  margin:0;
}
/* Kept verbatim. No element on this page carries .grad today; the rule is the
   hook for one emphasised word if the headline ever wants one. */
.app.is-empty .thread__h1 .grad{
  background:var(--grad-text);
  -webkit-background-clip:text;background-clip:text;
  color:transparent;
  -webkit-text-fill-color:transparent;
}

/* THE LEDE. 16px on a phone, 17px on a desktop, and nothing in between —
   reading type does not chase the viewport, which is section 13's own rule.
   What moves is the RATIO to the headline: 2.03x at 390, 3.83x at 1440. That is
   the contrast growing with the canvas rather than drifting with it.

   33rem is a break, not a round number. The string sets 54.79em; at 17px the
   only sane two-line split puts "…logins and dashboards" (508px) over "that run
   the day behind it. Yours to own, live in days." (424px). A measure between 508
   and 546px holds that break, and 33rem = 528px sits in the middle of it.

   --muted (7.99:1), not --muted-2 (5.91:1): this is one of two sentences on the
   whole screen, and a 5.91 grey under a 20.1 white reads as one bright thing
   and some fog. 7.99 against 20.1 is a hierarchy, and it leaves the <strong>
   somewhere to go at 16.14:1. */
.app.is-empty .thread__lede{
  margin:calc(var(--h1-size) * .44) 0 0;   /* 28.6px at 1440, 14.3px at 390 */
  max-width:var(--lede-measure,33rem);
  font:400 clamp(1rem, calc(var(--h1-size) * .265), var(--fs-md))/1.5 var(--body);
  color:var(--muted);
  text-wrap:balance;
  text-box:trim-both cap alphabetic;
}
/* text-wrap:balance evens line LENGTHS and does not know that "live in days" is
   a unit — it split it after "in", which reads as a typo. */
.app.is-empty .thread__lede strong{color:var(--text);font-weight:600;white-space:nowrap}

/* ONE LEFT EDGE.
   Section 15 argues for centring because "a single block in the middle of a
   screen that is otherwise empty has no left edge to hang from". It has one:
   the composer's border is a 1px 36%-white line 688px long, the most definite
   edge on the page, and .thread__in's content box already starts on the same
   pixel. Everything hangs from it now — which also settles the contradiction
   that the placeholder, the only text inside the hero object, was left-aligned
   inside a centred composition.

   The COLUMN stays centred in the window. Only its contents stop being. */
.app.is-empty .thread__intro{text-align:left;margin-bottom:0}

/* THE PHANTOM 28px. Section 16 used to claim the intro-to-composer gap was
   "declared once, on the dock". It was 20px of dock padding PLUS a 28px
   --msg-gap that .thread__in was paying to #log — a zero-height flex item whose
   every child is display:none under .hide-seed. The single most important
   interval in the composition was 58% phantom. It is one number now, and that
   number is one cap height of the headline. */
.app.is-empty .thread__in{padding-block:0;gap:0}
.app.is-empty .dock{padding-top:calc(var(--h1-size) * .72)}   /* 46.9px @1440, 23.4px @390 */

/* ── 18.6 THE MARK, AND THE LIGHT THAT MOVES OVER IT ─────────────────────────
   The logo at the height of the whole window, off the left edge, behind the
   opening statement, with a soft light crossing it. The one piece of decoration
   on this page, and it earns its place by being the brand rather than a shape.

   THE ARTWORK IS THE <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 495 425'><path d='M0%2C0 L494%2C0 L282%2C214 L494%2C424 L380%2C424 L233%2C273 L233%2C156 L307%2C82 L306%2C80 L209%2C81 L209%2C283 L134%2C358 L134%2C81 L0%2C80 Z' fill='none' stroke='white' stroke-width='3' stroke-linejoin='round'/></svg>, NOT THE PICTURE, and that inversion is what lets it
   carry light at all. A picture is a fixed set of pixels and nothing can travel
   over it. As a mask the file decides the SHAPE and whatever is painted behind
   it decides the COLOUR, so a moving gradient becomes a moving glint confined
   to the strokes of the mark.

   THE <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 495 425'><path d='M0%2C0 L494%2C0 L282%2C214 L494%2C424 L380%2C424 L233%2C273 L233%2C156 L307%2C82 L306%2C80 L209%2C81 L209%2C283 L134%2C358 L134%2C81 L0%2C80 Z' fill='none' stroke='white' stroke-width='3' stroke-linejoin='round'/></svg> IS THE TRACED PATH, INLINE. assets/mark.png's alpha boundary was
   followed and reduced to the fifteen points the logo actually has — it is a
   polygon and always was — so the geometry cannot drift from the artwork. 290
   bytes in this file, vector at any size, and no second request. It replaces a
   23KB PNG of the same outline, which is deleted.

   WHAT THIS IS NOT, AFTER TRYING BOTH. It was briefly a dash travelling the
   path: a lit segment with a head and a tail, running the outline like a
   current in a wire. That is a more literal answer to "light moving round the
   mark" and it looked worse — a dash of one colour with round caps is a
   capsule, and a capsule sliding round a shape reads as a segment crawling,
   whatever is done to its ends. Centring the falloff fixed the direction and
   not the fact that it was an object. A broad soft gradient lighting whatever
   it happens to cross has no edges, no head and nothing to recognise as a
   thing, which is why it reads as light and the dash never did.

   EMPTY STATE ONLY. It sits where the headline is; once there is a conversation
   the thread scrolls over that space, and moving light under moving text is the
   kind of thing that makes long copy unreadable.

   IT HANGS OFF THE LEFT EDGE, CROPPED. A logo fully in frame reads as a
   watermark somebody dropped in; one that runs out of the composition reads as
   printing. Two more mask layers fade it at top and bottom so it never collides
   with the top bar's monogram, and toward the reading column so the headline
   never sits on a hard line of it. Three mask layers, intersected.

   THOSE FADES ARE GENTLE ON PURPOSE, AND THE PULSE IS WHY. Opacity is
   MULTIPLICATIVE, so the visible size of a pulse at any point is proportional
   to how much mask is left there. The first version faded to fully transparent
   across the width, and measured, the mark swung 8.8 levels of 255 at its left
   edge and 1.5 at its right — a six-to-one range. The left of the TK visibly
   pulsed and the right barely moved, so it did not read as one object
   brightening; it read as parts of the logo appearing and disappearing in a
   pattern. The floor is .72 across and the vertical fade is pushed out to the
   last 11%, which keeps the whole mark within a narrow band of one alpha. The
   wash came down from 8.5% to 7% to pay for the extra presence. */
.app.is-empty .main::after{
  content:"";
  position:absolute;
  left:-7vh;                    /* cropped by the edge, not sitting against it */
  top:50%;
  transform:translateY(-50%);
  height:104vh;                 /* taller than the screen: no top or bottom end */
  width:auto;
  aspect-ratio:495 / 425;       /* the traced viewBox, so it cannot distort */
  z-index:0;
  pointer-events:none;

  -webkit-mask-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 495 425'><path d='M0%2C0 L494%2C0 L282%2C214 L494%2C424 L380%2C424 L233%2C273 L233%2C156 L307%2C82 L306%2C80 L209%2C81 L209%2C283 L134%2C358 L134%2C81 L0%2C80 Z' fill='none' stroke='white' stroke-width='3' stroke-linejoin='round'/></svg>"),
                     linear-gradient(180deg,transparent 0%,#000 7%,#000 93%,transparent 100%),
                     linear-gradient(90deg,#000 0%,#000 62%,rgba(0,0,0,.82) 100%);
  mask-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 495 425'><path d='M0%2C0 L494%2C0 L282%2C214 L494%2C424 L380%2C424 L233%2C273 L233%2C156 L307%2C82 L306%2C80 L209%2C81 L209%2C283 L134%2C358 L134%2C81 L0%2C80 Z' fill='none' stroke='white' stroke-width='3' stroke-linejoin='round'/></svg>"),
             linear-gradient(180deg,transparent 0%,#000 7%,#000 93%,transparent 100%),
             linear-gradient(90deg,#000 0%,#000 62%,rgba(0,0,0,.82) 100%);
  -webkit-mask-size:contain,100% 100%,100% 100%;
  mask-size:contain,100% 100%,100% 100%;
  -webkit-mask-position:left center,center,center;
  mask-position:left center,center,center;
  -webkit-mask-repeat:no-repeat;
  mask-repeat:no-repeat;
  -webkit-mask-composite:source-in,source-in;
  mask-composite:intersect,intersect;

  /* ONE LAYER, AND NOTHING IN IT MOVES.
     Three versions of this had a bright thing travelling over the mark: a dash
     running the traced path, an ellipse orbiting it, then a full-height band
     crossing sideways. Each was an honest attempt at "light moving over the
     logo" and each was reported the same way — that a bright form working its
     way over this particular shape reads as something other than light. It is
     not a rendering problem and it never was. A travelling highlight has a
     direction and a rhythm, and on a tall glyph with a long vertical stem those
     two things are the whole of the problem. Softer, slower, symmetric,
     reversed — all of those were fixes to the wrong thing.

     So there is no highlight. The mark is one flat wash at 8.5% white and it
     does not have a bright part, which means there is nothing to travel and
     nothing with a direction. */
  background-image:linear-gradient(rgba(255,255,255,.07),rgba(255,255,255,.07));
  background-repeat:no-repeat;
  background-size:100% 100%;

  /* WHAT IS LEFT IS A PULSE, AND IT HAS NO POSITION. The whole outline rises
     and falls together — every stroke at exactly the same moment by exactly the
     same amount — so the change has no direction and no leading edge. There is
     nothing on screen that can be followed with the eye, which is the property
     every earlier version lacked.

     THE CURVE IS AN LED, NOT A SINE. A symmetric fade in and out reads as
     something breathing; an indicator LED does not do that. It sits dark, rises,
     holds ON for a moment, falls, and then STAYS DARK for a while before it
     comes again — and it is that dwell at the bottom that makes it read as a
     light being driven rather than a shape fading. The proportions below are
     roughly 35% rise, 8% held at full, 35% fall, 22% dark. */
  animation:tk-pulse 24s ease-in-out infinite;
}

/* No keyframe here names a position, a size or a transform — only opacity, and
   the same opacity for the whole element. That is what makes this
   non-directional by construction rather than by restraint. */
@keyframes tk-pulse{
  0%   { opacity:.42 }   /* dark */
  35%  { opacity:1 }     /* up */
  43%  { opacity:1 }     /* on */
  78%  { opacity:.42 }   /* down */
  100% { opacity:.42 }   /* and dark again, for a fifth of the cycle */
}

/* The breath stops; the mark stays, at its brightest. Section 17's blanket reset
   drops animations to .001ms, which would freeze this at whatever opacity it
   happened to hold — possibly the dim end of the range, for good. Stopping it
   explicitly is the difference between "still" and "stuck". */
@media (prefers-reduced-motion:reduce){
  .app.is-empty .main::after{animation:none;opacity:1}
}

/* IT IS BEHIND EVERYTHING ALREADY. .main is a containing block because 16's
   container-type:inline-size implies contain:layout, and the thread and dock
   are given z-index:1 by the rule beside the composer glow.

   BUT BEHIND IS NOT THE SAME AS VISIBLE. .thread and .dock both carry an OPAQUE
   --bg and both sit at z-index:1 across the full width, so between them they
   painted a black band from the top of the thread to the bottom of the dock —
   measured at 1440x900, y 272 to y 648 — which is the exact middle of the
   screen and the exact place this mark runs through. Nothing looked broken,
   because the band is the same colour as the page: the mark simply had a
   rectangular bite out of it.

   Those backgrounds exist so a SCROLLING thread cannot show through the dock,
   and so the dock reads as a solid floor under it. On the empty screen there is
   no thread to hide and no floor to be. */
.app.is-empty .thread,
.app.is-empty .dock{background:transparent}

/* NOT ON A PHONE. At 390px the column IS the screen, so a full-height mark
   behind it is not a backdrop, it is a texture under every word. */
@media (max-width:900px){
  .app.is-empty .main::after{display:none}
}

/* No such thing as an 8% wash under forced colours. */
@media (forced-colors:active){
  .app.is-empty .main::after{display:none}
}

/* THE COMPOSER IS THE HERO OBJECT, and it was reading as a disabled search
   field: same height as a chip row, hairline the same weight as everything
   else, and a send control that looked switched off. Given presence — taller, a
   heavier resting border, and a focus state that blooms rather than blinks. */
.app.is-empty .composer{
  min-height:62px;
  border-radius:16px;
  border-color:var(--hair-4);
  background:linear-gradient(180deg,rgba(255,255,255,.045),rgba(255,255,255,.018));
  box-shadow:0 1px 0 rgba(255,255,255,.05) inset,
             0 18px 44px -28px rgba(0,0,0,.9);
  transition:border-color var(--t-mid) var(--ease),
             box-shadow var(--t-mid) var(--ease);
}
/* NO BORDER CHANGE HERE, DELIBERATELY. The rule above gives the hero composer a
   heavier resting edge by setting it to --hair-4 — the token whose entire job
   was to be the FOCUS colour — so this rule's `rgb(var(--brand-rgb)/.55)` was
   the identical rgba(255,255,255,.55) and moved nothing. Measured: .55 focused
   and unfocused alike.

   The first fix brightened it to .92, which worked and looked wrong: a bright
   edge INSIDE the ring 9.1 now draws reads as two rings with a gap between
   them. One indicator. The edge holds and the ring does the work. The bloom
   stays because it is depth, not a focus signal — at 1.24:1 against the page it
   was never capable of being one. */
.app.is-empty .composer:focus-within{
  box-shadow:0 1px 0 rgba(255,255,255,.06) inset,
             0 0 0 4px rgb(var(--brand-rgb)/.10),
             0 18px 44px -28px rgba(0,0,0,.9);
}
/* BOTH BOXES, ONE SIZE, AND THAT SIZE IS THE LEDE'S. This replaces
   .app.is-empty .composer__input{font-size:1.03rem}, which was (0,2,1) and beat
   13.7's (pointer:fine) 15px rule for the TEXTAREA only — while the ::after
   ghost stayed at 15px. Section 9.2 states the invariant and states that its
   failure modes are all silent; this one under-measured about 2.1px of leading
   per line, so the field stopped growing a line late and the caret dropped under
   the fold on a long first message. Declared on both here, at (0,3,1).

   17px also clears the 16px iOS zoom-on-focus floor with room to spare. What
   you type is set as large as what we say, which is this page's proposition
   stated as a number. */
.app.is-empty .composer__grow > textarea,
.app.is-empty .composer__grow::after{
  font-size:var(--fs-md);
  line-height:1.5;
}
/* The instruction inside the hero object is the second-largest text on the
   screen, so it is read at --muted (7.99:1) rather than at --muted-2. */
.app.is-empty .composer__input::placeholder{color:var(--muted);opacity:1}

/* THE PROMPTS, AND BOTH CHANGES ARE ALIGNMENT. The row is clamped to the
   measure so it can never be wider than the object it is subordinate to — it
   was 1375px against a 688px composer, overhanging the hero by 46px on each
   side — and it starts on the same left edge as everything else. Three centred
   rows of unequal widths is the shape that reads as a template; rows that share
   the composer's left edge read as a list.

   THE CHIP ITSELF IS NOT TOUCHED HERE, AND THAT IS DELIBERATE. Its type, its
   34px height, its 40px coarse-pointer target and its 36% boundary are section
   8.5's and 13.7's. The rule that used to sit here restated the type and the
   padding at (0,4,0), which quietly took the thumb target back to 34px, and it
   dropped the border to --hair-2 — a 1.24:1 line, on the one control whose
   outline is the only thing identifying it as a control. Section 8.5 calls that
   boundary a WCAG 1.4.11 requirement rather than a taste decision, and this
   design does not spend it to look quieter. */
/* THE LEAD PROMPTS BELONG TO THE EMPTY STATE AND NOWHERE ELSE.
   They were left in the dock once a conversation started, where two things
   went wrong at once. Visually: the empty-state margin below stopped applying,
   so they fell back to the narrow-width scroller rules — which carry a
   NEGATIVE margin-block — and the row closed to a 0px gap against the composer
   on desktop and a 4px overlap on a phone.

   And they should not be there anyway. Six fixed prompts pinned under the
   composer for the rest of the session is a menu competing with the answer
   the reader just got, and every answer already renders its own follow-up
   chips drawn from what was actually asked. Gone at the moment they stop
   being an instruction, which reclaims the height for the conversation. */
.app:not(.is-empty) .chips--dock{display:none}

.app.is-empty .chips{justify-content:flex-start}
.app.is-empty .chips--dock{
  max-width:var(--measure);
  margin:var(--s-3) auto 0;
  padding-block:4px;
}

/* THE UNTRIMMED FALLBACK (Firefox, and anything before Chrome 133 / Safari
   18.4). Without text-box the boxes carry their half-leading, so the same
   optical space needs smaller margins. Derived from Inter's real metrics, which
   are the numbers Chrome's own line box uses: cap .7275em, ascender .969em,
   descender .2412em.
     h1   (line-height 1.05): air above cap .1614em, below baseline .1611em
     lede (line-height 1.5):  air above cap .3864em, below baseline .3861em
   h1 -> lede loses 10.5 + 6.6 = 17.1px of it at 1440; lede -> composer loses
   6.6px. */
@supports not (text-box:trim-both cap alphabetic){
  .app.is-empty .thread__lede{margin-top:calc(var(--h1-size) * .177)}   /* 11.5px @1440 */
  .app.is-empty .dock{padding-top:calc(var(--h1-size) * .616)}          /* 40.1px @1440 */
}

/* ONE SOFT SOURCE OF LIGHT, and it is the difference between a dark room and a
   black swatch. Sits behind everything, centred on the composer rather than the
   page, so the object the visitor is meant to use is the thing that is lit.
   Purely decorative and pointer-transparent. */
.app.is-empty .main::before{
  content:"";
  position:absolute;
  left:50%;
  top:52%;
  width:min(1180px,120%);
  aspect-ratio:2/1;
  transform:translate(-50%,-50%);
  pointer-events:none;
  z-index:0;
  background:radial-gradient(50% 50% at 50% 50%,
    /* Halved from the tinted version. A coloured glow reads as light; the
       same alpha in white reads as grey fog sitting on the black. */
    rgb(var(--brand-rgb)/.055) 0%,
    rgb(var(--brand-rgb)/.022) 42%,
    transparent 72%);
  filter:blur(12px);
}
.app.is-empty .thread,
.app.is-empty .dock{position:relative;z-index:1}

/* The entrance. Staggered so the page assembles in the order it reads, and
   keyed BACKWARDS off the finished state so reduced motion and a failed script
   both land on the composed page rather than on an empty one. The whole block
   is inside (no-preference), so under `reduce` these animations do not exist at
   all and 13.15's blanket is the second net under them. It ends at 840ms. */
@media (prefers-reduced-motion:no-preference){
  .app.is-empty .thread__h1  {animation:var(--h1-entrance,h1-set) .56s var(--ease) .04s backwards}
  .app.is-empty .thread__lede{animation:empty-in .56s var(--ease) .15s backwards}
  .app.is-empty .composer    {animation:empty-in .52s var(--ease) .26s backwards}
  .app.is-empty .chips--dock {animation:empty-in .40s var(--ease) .36s backwards}
  .app.is-empty .dock__bar   {animation:empty-in .40s var(--ease) .44s backwards}
  .app.is-empty .main::before{animation:lamp     1.4s var(--ease) .10s backwards}
}
/* THE LINE SETS ITSELF. It arrives about a hundredth of an em loose and closes
   to its final tracking. The amplitude is deliberately below the threshold at
   which anyone could name what moved — it reads as the type settling, not as an
   effect, which is the only version of this worth shipping. letter-spacing is a
   layout property and relays out one line box per frame, which is cheap, but it
   also changes the line's width: the name is read from --h1-entrance so 13.3 can
   swap it for the plain fade in the band where the line has less slack than the
   tracking costs to animate. */
@keyframes h1-set{
  from{opacity:0;transform:translateY(10px);letter-spacing:calc(1.2px - .038em)}
  to  {opacity:1;transform:none;letter-spacing:calc(1.2px - .05em)}
}
@keyframes lamp{from{opacity:0}to{opacity:1}}


/* ═══ 17. GRAIN ═══════════════════════════════════════════════════════════════
   THE ONE THING FLAT BLACK NEEDS. A large area of #070707 with nothing on it is
   the single cheapest-looking surface in interface design — it reads as an
   unrendered region rather than as a considered ground, and every screenshot of
   it bands on an 8-bit display. A film grain at two and a half percent fixes
   both: it gives the black a texture to sit in, and it dithers the gradients so
   the lamp above stops stepping.

   Fixed, so it does not travel with the thread and become visible as motion.
   pointer-events:none and aria-hidden by construction — it is a pseudo-element,
   so it is not in the accessibility tree at all. The SVG is inline as a data
   URI: one 180px tile, no request, and nothing to fail to load. */
body::after{
  content:"";
  position:fixed;
  inset:0;
  z-index:9999;
  pointer-events:none;
  opacity:.025;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)'/%3E%3C/svg%3E");
}

/* A vignette so the screen has edges. Black to black, four percent — invisible
   as an effect, and the reason the centre of the page reads as lit rather than
   as the same flat value corner to corner. */
body::before{
  content:"";
  position:fixed;
  inset:0;
  z-index:9998;
  pointer-events:none;
  /* PUSHED OUT AND SOFTENED, because the corners are where a vignette is
     strongest and the top-right corner is where the primary CTA lives — the
     first version was quietly greying out the one white object on the page
     that has to look switched on. Transparent to 68% keeps the whole content
     column untouched and only shades the true edges. */
  background:radial-gradient(135% 105% at 50% 45%,transparent 68%,rgba(0,0,0,.30) 100%);
}

@media (prefers-reduced-motion:reduce){
  body::after{opacity:.02}
}


/* ═══ 18. THE ASK ═════════════════════════════════════════════════════════════
   The lead card that closes every answer, and the route to it from an empty
   screen. Both exist because the entire funnel used to be one small button in
   the top-right corner. */

.msg__lead{
  margin-top:var(--s-4);
  padding:var(--s-4);
  border:1px solid var(--hair-2);
  border-radius:var(--r-md);
  background:var(--surface-1);
}
.msg__lead-h{
  font:600 15px/1.3 var(--display);
  color:var(--text-hi);
  letter-spacing:-.012em;
}
.msg__lead-p{
  margin-top:6px;
  font-size:var(--fs-sm);
  line-height:1.55;
  color:var(--muted);
  max-width:46ch;
}
.msg__lead-go{margin-top:var(--s-3)}

/* ── 18.2 THE STATUS BAR ────────────────────────────────────────────────────
   One row under the composer, in BOTH states, because a status line that only
   exists on the splash screen is decoration and one that persists is an
   instrument. Two cells: what the engine is doing on the left, and on the right
   whichever of the two lines this section already chose between — the quote
   control on an empty screen, the keyboard hint once there is a conversation.

   IT COSTS THE THREAD NO HEIGHT. .dock__note was already one 11.5px line in
   exactly this position; the bar is that same line with a second cell in it. */
.dock__bar{
  display:flex;
  align-items:center;
  gap:var(--s-4);
  width:100%;
  /* THE COMPOSER'S BOX, SPELLED OUT RATHER THAN READ FROM --measure. The bar
     exists in both states and --measure is declared only under .is-empty, where
     .main is a query container; an undefined var() here would be invalid at
     computed-value time, max-width would fall back to `none`, and the keyboard
     hint would sit at the right edge of a 1920px window the moment a
     conversation started. This is .composer's own expression, so the two boxes
     are identical at every width by construction rather than by coincidence. */
  max-width:calc(var(--col) - 2 * var(--gut));
  margin:var(--s-3) auto 0;
  min-height:22px;
}

/* MONO, BECAUSE IT IS MACHINE TEXT. Section 0 states the rule: JetBrains sets
   labels, counters and technical name lists, and never a sentence. This is a
   readout. 11px matches .think__label, which is the other readout on the page,
   and the two should not disagree about what a machine's voice looks like. */
.dock__state{
  /* NOT PAINTED AT REST — see the note on .dock__s below. The three working
     states turn it back on. */
  display:none;
  align-items:center;
  gap:var(--s-2);
  margin:0;
  min-width:0;
  font:500 11px/1.4 var(--mono);
  letter-spacing:.06em;
  text-transform:uppercase;
  font-variant-ligatures:none;
  color:var(--muted-2);          /* 5.91:1 — the mono-label default */
}

/* A SQUARE, NOT A DOT. A circle is consumer; a 5px square is the state lamp on
   a piece of equipment. It changes colour and it never pulses — section 0's
   contract is that nothing on this page exceeds 320ms, and a solid white square
   while the engine works reads more like hardware than a blinking one does. */
.dock__lamp{
  flex:0 0 auto;
  width:5px;
  height:5px;
  background:var(--muted-3);
  transition:background-color var(--t-fast) var(--ease);
}

/* THREE STATES, AND NOTHING WHEN THERE IS NO STATE. There was a fourth, the
   resting line, and it was the default precisely so the bar could never be
   blank. Blank is now the correct resting appearance: the line said "Ready",
   which tells a visitor nothing they cannot see, and before that it quoted the
   size of the corpus, which told them something they should not have to care
   about. An unexpected or missing data-state therefore paints nothing at all —
   which is the safe direction, and still cannot strand a work state on screen,
   because every one of the three is keyed to its own attribute value.

   NOTHING IN HERE IS aria-live. #log already announces the answer and #sr-status
   is the page's only status announcer; a second live region under the composer
   would read every turn twice. And nothing in here is written by script either —
   the three sentences are static markup, which is what lets the readout exist at
   all on a page whose contract is that content is never JS-dependent. */
.dock__s{display:none;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.app[data-state="thinking"]  .dock__state,
.app[data-state="streaming"] .dock__state,
.app[data-state="error"]     .dock__state{display:flex}
.app[data-state="thinking"]  .dock__s--work{display:block}
.app[data-state="streaming"] .dock__s--live{display:block}
.app[data-state="error"]     .dock__s--err {display:block}

.app[data-state="thinking"]  .dock__lamp,
.app[data-state="streaming"] .dock__lamp{background:var(--text-hi)}

/* THE RIGHT-HAND CELL — the same swap as before, re-seated. These two only stop
   being centred blocks of their own and become cells of the bar. NEITHER
   RESTATES ITS TYPE: 9.4 sets the hint's and 13.8 tightens it on a short
   screen, and a second `font` declaration down here would be later in the file
   and would silently kill that. */
/* MOVED OUT OF THE DOCK. It was the bar's right-hand cell; it is the last line
   of the opening statement now, so it hangs from the lede on the same left edge
   as everything else in the intro and takes its space from a margin rather than
   from `margin-left:auto`. */
.intro__lead{
  display:flex;
  margin:var(--s-4) 0 0;
}
.dock__note{
  flex:0 0 auto;
  margin:0 0 0 auto;
  max-width:none;
  padding-inline:0;
  text-align:right;
}

/* A REAL CONTROL, NOT A SECOND CTA. The first version of this was "Or leave
   your name and number and a person will come back to you" as underlined text —
   an apology for not having a lead path rather than a lead path. This is a
   button, it names the cost up front, and it starts the flow in the thread
   instead of throwing the visitor at a five-field form.

   It carries no `btn` class and it must not: "Get a quote in three questions"
   is not on the CTA allowlist in tools/verify.mjs, and it should not be — the
   one CTA verb is still "Start your build", which is what the top bar and every
   lead card say. Section 6 shrinks that CTA by 39% on purpose, which is exactly
   why this route stays: re-seated as the right cell of a utility row it is no
   longer a second call to action competing on the page's centre line. */
.intro__quote{
  appearance:none;
  border:1px solid var(--hair-3);
  background:transparent;
  color:var(--text);
  font:500 13px/1 var(--body);
  padding:9px 14px;
  border-radius:var(--r-pill);
  cursor:pointer;
  white-space:nowrap;
  transition:background-color var(--t-fast) var(--ease),
             border-color var(--t-fast) var(--ease),
             color var(--t-fast) var(--ease);
}
.intro__quote:hover{background:var(--surface-1);border-color:var(--hair-4);color:var(--text-hi)}
.intro__quote:focus-visible{outline:2px solid var(--brand-1);outline-offset:3px}

/* ONE LINE UNDER THE COMPOSER AT A TIME. On an empty screen the useful line is
   the other way to reach a person; once there is a conversation every answer
   carries its own ask, and the keyboard hint becomes the more useful one. */
.app:not(.is-empty) .intro__lead{display:none}

/* #dock-note IS THE COMPOSER'S aria-describedby TARGET, and display:none takes
   a node out of the accessibility tree — so on the empty state, the state every
   single visitor lands in, the field had no accessible description at all. This
   is what used to be `display:none` here. Visually hidden instead of gone, and
   it costs zero pixels: it is out of flow, so the bar reads the same either way.
   (The quote route used to be the bar's other cell; it lives in the intro now.) */
.app.is-empty .dock__note{
  position:absolute;
  width:1px;height:1px;
  margin:-1px;padding:0;
  overflow:hidden;
  clip-path:inset(50%);
  white-space:nowrap;
  border:0;
}


/* NO COPY BUTTON ON A QUESTION. Every assistant turn gets a copy action, which
   is right for an answer and absurd on "What is your name?" — the lead flow
   was offering to put its own prompts on the visitor's clipboard, once per
   step. Keyed on the intent id the renderer already writes to the element, so
   it covers every step, the retry messages and the confirmation without a
   second marker to keep in sync. display:none rather than opacity, so it
   leaves the accessibility tree and the tab order too. */
.msg[data-intent^="lead."] .msg__acts{display:none}


/* ═══ THE TOP BAR LOCKUP ══════════════════════════════════════════════════════
   Monogram, then the drawn word. The <span> holds the real text and the
   stylesheet paints over it — image replacement, so the name survives with CSS
   off and reads correctly to a screen reader. */
.topbar__word{
  display:block;
  width:104px;
  height:17px;                      /* 104 / 6.11, the artwork's real aspect */
  background:url("/assets/wordmark.png") left center/contain no-repeat;
  text-indent:-9999px;
  overflow:hidden;
  white-space:nowrap;
  /* Composites to about #E6E6E6 over the canvas — exactly the --text the words
     "TKATI Media" were set in before the drawn mark replaced them, so the
     lockup did not quietly get brighter than the type it stands in for. The
     monogram beside it stays quieter still at --mark-opacity: it is decoration
     next to the name, not the name. */
  opacity:.9;
}

@media (max-width:420px){
  /* The word goes before the monogram does: the mark alone still identifies the
     company, a cropped wordmark identifies nothing. */
  .topbar__word{width:88px;height:14px}
}
