/* The card shell: tokens, the frame, the solo column, the layout modes,
   the label system and the detail. Everything not scoped to one form.
   Source order is unchanged from the single-file v22 stylesheet. */
:root{
  --ground:#000;
  --ink:#F5F5F7;
  /* .pr-lock/.pg-lock/.rv-lock -- the submit button on all three prompt-first
     guessing forms (price, pricegap, reveal) -- sit on var(--ink) and each
     printed this near-black text as its own literal. Not --ground: #0A0B0D is
     close to #000 but not equal, and a button's own text colour is a
     different decision from the card's base than a coincidence in hex should
     be allowed to hide (fe-011, frontend-2026-09-03). */
  --lock-ink:#0A0B0D;
  --muted:#9A9DA5;
  /* #5E6A54 measured 3.67:1 on the ground and 3.32:1 on --panel. --dim is not
     a decorative token: it carries the issuer name, the segment chip, the
     timestamp, the sub, the chart caption, the volume caption and every axis
     row, at 9px to 11.5px, so essentially every supporting word on a card sat
     under the 4.5:1 body floor -- and lower still on --panel, where the
     receipt rows, tape rows and cohort rows put it. Lifted to the minimum that
     clears 4.5 against the WORSE of the two grounds (5.19:1 on black, 4.70:1
     on --panel) at the same hue and saturation, so the palette stays dim
     rather than becoming a second --muted at 8.48:1. */
  --dim:#6E7179;
  /* The deck sits a step under the headline, not down with the labels:
     13.9:1 on black. --dim at 5.19 is a label colour and it buried the one
     line on the card that says what the move was worth. */
  --deck:#C9CBD1;
  --line:#1D1E21;
  /* --line is a divider hairline at 1.26:1 and stays there -- lifting it would
     draw a wireframe around all 22 archetypes. --edge is --line's own hue and
     saturation at 3:1, for the three borders that are the ONLY thing carrying
     a shape or an affordance: the outlined .seg chip, the Read more underline,
     the sourcepath token. An outline nobody can see is not an outline. */
  --edge:#3A3C42;
  /* --line is the chrome hairline and disappears inside a plot; the anchor rule
     has to be seen or the fixed band reads as a broken renderer rather than as
     a quiet day. One step up, still below the tick row's --dim. */
  --grid:#2A2C31;
  --panel:#0F1012;
  --down:#FF4D3D;
  --up:#33E08E;
  --rail:clamp(18px,5vw,28px);
  /* --rail's vertical uses (.cardface's own top/bottom padding) fed the fit-
     scale bug at 375x667: `vw` resolves against the REAL viewport, not the
     card's container, so it is not touched by `100cqh` the way every other
     vertical number here is -- and .cardface's bottom padding is what the
     closed solo action row is glued to (a flex-grow spacer fills the rest of
     the column, so the row sits at faceh minus this padding). At 393 width
     --rail is 19.65px; at 375 it is 18.75px, so the design-space action row
     read 852-18.75=833.25 instead of 852-19.65=832.35 -- the measured 0.89px,
     confirmed by re-running: 393x780 (same width, shorter) holds 832.36
     exactly, 375x667 (narrower) does not, and 360x780 (narrower still, same
     height as the 393 case) is 1.64px over. Every vertical number in this
     file is meant to be scaled ONLY by the height ratio; --railv pins the
     vertical contribution to what --rail evaluates to at the 393px design
     width, so it no longer drifts with the real device's width. `--rail`
     itself is untouched and keeps doing its horizontal (--railL/--railR) job,
     where scaling with the real width is the point (a notch's inset does not
     care what the design width was). */
  --railv:19.65px;
  /* The height of the visualisation panel on a solo card. Fixed rather than
     flexed, so the claim beneath it starts at the same y on every form. The
     frame is 393x852 and stays 393x852 (rule 8), so this is a number about a
     known frame, not a magic constant.

     Held at 569 when the chips became the two-line provenance block, which is
     12.12px shorter again. 569 is not a fact about the credit any more: y=607
     is the invariant, the panel is what sets it, and a credit that shrinks
     spends its pixels inside the lower third -- ten on the gaps around it,
     2.12 to the auto margin over the action row -- rather than buying the
     panel back. Reversing this number to reclaim the height would move the
     claim on every solo form to pay for a change in one row of the lower
     third, which is the trade this number exists to refuse.

     583 -> 569 when the source credit became chips. The credit went 37.1px to
     49.5px and --band could not pay for it: --band is the headline's fit
     budget, not reserved height, so on a card whose headline already fits --
     every static Move card -- lowering it frees nothing and the 12.4px lands
     on the action row instead, which measured 847.2px against its pinned
     832.4. The panel is the only fixed item in the column, so it is the only
     one that can give. The claim now starts at y=607 on every form rather
     than y=621, which is the same invariant at a new number.

     Named --panelh, not --panel. It was declared as --panel in the same
     :root as the panel COLOUR, one block below it, so the length won every
     cascade and `background:var(--panel)` -- twenty rules, every viz surface
     on the card -- was an invalid value that dropped to transparent. The
     panels had been rendering as bare #000 ground since v20, and the two
     contrast notes above that reason about ratios "on --panel" were arguing
     about a colour nothing was painting. The length has one reader; the
     colour has twenty, so the length is the one that moved. */
  --panelh:569px;
  /* prompt_first's panel. Shorter than --panelh because the question now sits
     above it rather than under it, and the column still has to close at 852.

     400 -> 427, measured on the frame. Once the headline holds its full 120px
     band, this mode's column is entirely fixed: 120 inset + 120 band + 14 +
     --panelq + a 134.3px foot, against a content bottom of 832.4. At 400 that
     left 44px with nowhere to be but above the action row, which is the same
     pooled remainder the band floor closed in media_first, arriving by a
     different route. 427 spends it on the picture and lands the gap at 17 --
     the same 17 every media_first card holds, which is what makes the two
     modes' feet agree rather than merely resemble each other.

     The panel is the right place for it: the mode's own contract already says
     the setup slot takes the panel's slack and nothing else grows
     (contracts/cards.v1.json, card_layouts). This gives that slot 27px more to
     take. It does not reopen the headline budget -- --band is still 120 and
     the claim can still be four lines, which is what 400 was chosen against. */
  --panelq: 427px;
  /* The card is full-bleed to the device edge, so in landscape on a notched
     phone the left or right rail runs under the cutout. These two are the rail
     for every HORIZONTAL use of it -- padding and the negative-margin bleeds
     both, which have to move together or the bleed stops short of the edge.
     --rail itself cannot just be redefined because three sites spend it
     vertically. max(), not +: a device reporting no inset resolves these to
     exactly var(--rail), so the stored batches render byte-identical. */
  --railL:max(var(--rail), env(safe-area-inset-left, 0px));
  --railR:max(var(--rail), env(safe-area-inset-right, 0px));
  /* .cardface's OWN horizontal padding (below) is --railL/--railR, and
     because `.hookline` takes no horizontal padding of its own, that padding
     IS the column `.hookline` wraps inside -- so it inherited the same
     double-scaling defect --fs-headline-lg had above: `vw` resolves against
     the true viewport while --facew stays fixed, so a wider phone's design
     column got NARROWER (a bigger rail eating a fixed-width box) at the same
     time its type got bigger. lane-headline-fixed, 2026-09-03: a face-scoped
     pair, fixed at the design width's own rail value (--rail resolves to
     19.65px at 393 -- see --railv above -- 20px chosen to match the
     reviewer's calibration, an 0.35px difference that changes no wrap
     decision) rather than vw. `max()` with env() is kept for the same reason
     --railL/--railR keep it (comment below, "the corollary for --rail"): a
     real notch inset has to track the real device edge regardless of the
     design width, and a device with no inset resolves this to exactly 20px,
     same as before. Used ONLY by `.cardface`'s own padding -- every other
     --railL/--railR consumer (.dbody, .thread, .dreply, .card.open .rail)
     is the open/detail state, outside this lane's scope, and is untouched. */
  --face-railL:max(20px, env(safe-area-inset-left, 0px));
  --face-railR:max(20px, env(safe-area-inset-right, 0px));
  --cardh:100dvh;
  /* lane-shell-flex-panel, 2026-09-03: the face used to be laid out at a
     literal 852 and every vertical number in this file (the 569px panel, the
     74px band, the action row landing at 832.36) was a pixel budget spent
     against that one number, so a window shorter than 852 (743 on the
     reviewer's own phone, measured through this batch) clipped 189px off the
     bottom of every card -- .card{overflow:hidden} hid the failure rather
     than reporting it. That is the bug this lane fixes for the CONTAINER;
     each form's own panel content still assumes it gets 569 (or --panelq)
     and is the next fan-out's job (NEXT.md "each form fills its panel").

     --faceh is now derived, not declared: the design height that, at the
     WIDTH-bound scale this file already computes for .cardface
     (100cqw / var(--facew), untouched by this lane), paints out to exactly
     the height the window actually gives the card. Read backwards: painted
     height = --faceh * scale, and we want painted height = the available
     viewport height, so --faceh = available-height / scale = available-height
     * (var(--facew) / 100cqw).

     "Available height" is `100svh`, not `100dvh` or `--cardh` (reviewer's
     course correction): `dvh` shrinks and grows as Safari's URL bar
     collapses and reappears mid-scroll, and --faceh feeds a LAID-OUT height
     (the panel's flex:1 remainder, the shell block's fixed reserve) rather
     than a one-time paint -- if it tracked `dvh` the panel would resize live
     while the reader is mid-scroll and the headline's fixed y would move
     under them. `svh` is the height with the chrome SHOWN, i.e. the worst
     case / smallest number Safari ever gives -- exactly the 743 the reviewer
     measured on the live phone -- so once the chrome hides, `--cardh`
     (still 100dvh, untouched) grows past 100svh and the extra is inert
     letterboxing below the face rather than a live resize of the layout
     above it. --cardh is deliberately NOT the source of this: --cardh is the
     card's own scroll-snap box (its real window), --faceh is the slide laid
     out inside it, and this lane keeps them two different numbers on
     purpose, the same way --faceh and --cardh were already two different
     numbers before this change.

     `100cqw` needs `.cardface` (or a descendant) to actually consume this
     variable inside `.card`'s size-contained box for the unit to resolve --
     true here exactly as it already is for the transform's own `100cqw`
     three lines down, so no new container dependency is introduced. */
  --faceh:calc(100svh * (var(--facew) / 100cqw));
  /* --faceh's horizontal twin. The face used to be laid out at whatever width
     the card itself happened to be (`.cardface{left:0;right:0}`, i.e. the
     device width, uncapped below 560px) and only --faceh's ratio drove the
     fit-scale; that was fine while the rule only ever scaled DOWN, because a
     phone narrower than 852-tall was, on every stored device, also narrower
     than 393 wide, so width was never the tighter constraint. Scaling UP
     breaks that: a phone taller AND wider than the frame (430x932) has to
     stop scaling on whichever axis fills first, and that needs a width to
     divide by the same way --faceh is one to divide by. Named beside it
     rather than left a bare 393 in the transform for the reason --faceh's own
     comment gives: one number, one name, read by .cardface below. */
  --facew:393px;
  /* --faceh-min, the per-form floor lane-face-fit-width added and
     lane-shell-owns-headline made inert, was removed in session 33: nothing
     read it, and a form that does not fit solves it inside its panel. */

  /* Type scale. Families and weights were already governed -- two faces,
     four weights, obeyed everywhere -- but every font-size and
     letter-spacing shell.css set was a literal with no name, so a form
     copying one had no shared source to inherit from and 28 form sheets
     each carry their own copies (session-23 audit; see
     docs/design-system.md). Named here as the numbers that already ship:
     nothing below is new, nothing is rounded to tidy a step against its
     neighbour, and no two names share a value unless the CSS already did.
     --beat-figure above is the one precedent -- a font-size already stored
     as a token and already consumed by three form files -- this extends
     that, it does not invent it. */
  --fs-9:9px;    --fs-9-5:9.5px;
  --fs-10:10px;  --fs-10-5:10.5px;
  --fs-11:11px;  --fs-11-5:11.5px;
  --fs-12:12px;  --fs-12-5:12.5px;
  --fs-13:13px;  --fs-13-5:13.5px;
  --fs-14:14px;  --fs-14-5:14.5px;
  --fs-15:15px;  --fs-15-5:15.5px;
  --fs-19:19px;
  --fs-22:22px;
  --fs-28:28px;
  --fs-29:29px;

  /* Headline-family clamps. --fs-headline is one token for two call sites
     that were already byte-identical (.hookline and .card.solo .hook .sub);
     --fs-headline-lg and --fs-metric-compact are the two habit.css /
     overlay.css duplicated verbatim -- named here, consumed there. Names
     follow the selector each clamp already lived at; this is not a strict
     ladder and two names that look adjacent (-metric vs -metric-compact)
     are not necessarily related decisions. */
  --fs-ticker:clamp(29px,7.4vw,38px);
  --fs-ticker-concept:clamp(22px,5.4vw,28px);
  --fs-headline:clamp(24px,6.2vw,31px);
  --fs-headline-compact:clamp(19px,5.1vw,24px);
  /* Fixed 30px design px, not a vw clamp -- lane-headline-fixed, 2026-09-03.
     `.cardface` lays out at a FIXED --facew:393px and is CSS-scaled to the
     real device width (`transform:scale(calc(100cqw / var(--facew)))`,
     below), so a `vw` clamp here resolved against the TRUE viewport and was
     scaled again by that transform: a 430-wide phone got a BIGGER font in a
     NARROWER design column (--facew is fixed, but the rail eating into it
     was also `vw`-scaled) than a 393 phone, so the same words wrapped to a
     different line count depending on the phone -- session/headline-fit.mjs
     documents the measured numbers. The reviewer's fix is to stop varying
     type size with the viewport at all: one fixed size, scaled only by the
     face transform like everything else in the column. 30px was chosen
     against three headlines that wrap to three lines at 30px and one of
     them (Habit's) to four at 32px -- see this lane's report for the table.
     session/headline-fit.mjs's FS_HEADLINE_LG mirrors this value exactly;
     pipeline/selftest.mjs greps this file to keep them in sync. */
  --fs-headline-lg:30px;
  --fs-metric:clamp(44px,13vw,70px);
  --fs-metric-compact:clamp(26px,7.4vw,36px);
  --fs-companion:clamp(22px,6vw,30px);
  /* .counterbox .metric is declared twice at equal specificity, a couple
     hundred lines apart (rule 17): --fs-counter-metric-unused is the first,
     --fs-counter-metric is the second and the one that actually renders
     because source order breaks the tie. Named, not fixed -- removing the
     shadowed rule is a behaviour change outside a naming pass, so both stay
     and both are flagged. See the finding at each call site below. */
  --fs-counter-metric-unused:clamp(50px,14vw,76px);
  --fs-counter-metric:clamp(40px,12.5vw,64px);
  --fs-counter-metric-open:clamp(24px,7vw,32px);
  /* Not one of the clamps above -- .card.open .insider-stat b and
     .overlayviz.overlaid .pollopt.booklead .bookpct carried this exact
     clamp verbatim in their own files, matching neither each other's
     source nor any --fs-* already named here (nearest neighbours are
     --fs-companion at 22px,6vw,30px and --fs-counter-metric-open at
     24px,7vw,32px -- both close, neither equal, so neither is reused).
     Added rather than left as two literals because the duplication is
     exact across two form files (session-23 form pass). */
  --fs-stat-open:clamp(22px,6.4vw,32px);
  /* .pr-guess/.pr-actual (price.css), .pg-guess (pricegap.css) and .rv-value
     (reveal.css) -- the big guess/reveal figure on the same three
     prompt-first guessing forms as --lock-ink above -- carried this exact
     clamp verbatim. Nearest neighbours are --fs-metric at 44px,13vw,70px and
     --fs-counter-metric at 40px,12.5vw,64px, both close, neither equal, so
     neither is reused (fe-011, frontend-2026-09-03). */
  --fs-guess:clamp(40px,13vw,54px);

  /* Letter-spacing. -.02em/-.015em/-.01em are negative tracking on large
     numerals; .11em-.18em is one cluster of label tracking with eight
     distinct steps and no comment anywhere naming why -- named, not
     collapsed. See the two-lists note in the design-system.md scale entry
     for which of these look like one decision and which look like two. */
  --ls-n02:-.02em;   --ls-n015:-.015em;  --ls-n01:-.01em;
  --ls-005:.005em;   --ls-01:.01em;      --ls-02:.02em;
  --ls-04:.04em;     --ls-06:.06em;      --ls-10:.1em;
  --ls-11:.11em;     --ls-12:.12em;      --ls-13:.13em;
  --ls-14:.14em;     --ls-15:.15em;      --ls-16:.16em;
  --ls-17:.17em;     --ls-22:.22em;
}

*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
/* Mono is the default family, so anything that does not opt out inherits it --
   which is how full sentences ended up set as tracked uppercase mono. The
   opt-out is safe for figures: measured in this renderer, IBM Plex Sans and
   IBM Plex Mono share a 0.600em digit advance and Plex Sans figures are
   already tabular, so "1234567890" is 120px in either face at 20px. Nothing
   that moves to sans loses a column. tabular-nums below is belt and braces --
   it inherits everywhere, including into the sans rules. */
body{
  background:var(--ground);color:var(--ink);
  font-family:"IBM Plex Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;
  font-variant-numeric:tabular-nums;-webkit-font-smoothing:antialiased;
  overflow:hidden;
}

.feed{
  height:var(--cardh);overflow-y:auto;overflow-x:hidden;
  scroll-snap-type:y mandatory;scrollbar-width:none;-webkit-overflow-scrolling:touch;
  /* `contain`, not `none`. A mandatory-snap feed is unguarded without either --
     a drag that starts at the top of the first card can overscroll past this
     element and chain into the document (there is none, `body{overflow:hidden}`
     sees to that) or into the browser chrome itself: iOS rubber-banding the
     whole page and Android's pull-to-refresh both read as the feed's own
     gesture escaping its container. `contain` stops that chaining without
     removing the boundary effect the browser shows AT the feed's own edge --
     the bounce/glow when a reader drags past the first or last card, which
     here is arguably a feature (it says "that's the end") rather than a bug.
     `none` would suppress that too. This matches the only other user of the
     property in this file, `.card.open .cardface` below, so a scrollable
     region here fails the same way twice rather than two different ways. If
     the bounce at the feed's own edge turns out to read as unwanted chrome
     rather than useful feedback, that is a design call for whoever is looking
     at the rendered frame, not one this comment makes for them. */
  overscroll-behavior:contain;
}
.feed::-webkit-scrollbar,.card.open .cardface::-webkit-scrollbar{display:none}
/* Stands in for a card that is not currently built. It must be a snap target
   like the card it replaces: with scroll-snap-type mandatory the browser will
   not scroll past the content it has, so a single tall spacer pinned the feed
   to the rendered window and a jump of fifty cards moved three. One
   placeholder per card keeps every position reachable. */
.vcard{height:var(--cardh);max-width:560px;margin:0 auto;
  scroll-snap-align:start;scroll-snap-stop:always;pointer-events:none}

.card{
  --brand:#85BB65;--brand-dim:#47632F;--brand-wash:rgba(133,187,101,.08);--brand-halo:rgba(133,187,101,.18);
  --money:var(--brand);
  position:relative;height:var(--cardh);max-width:560px;margin:0 auto;
  scroll-snap-align:start;scroll-snap-stop:always;overflow:hidden;touch-action:pan-y;
  /* Makes the card the query container its own face measures itself against,
     so `100cqh` on .cardface is THE CARD'S height and not the viewport's.
     100dvh would be the same number on a phone and the wrong one in the
     desktop .device harness, where the card is min(852px, 100dvh - 66px) and
     a face sized to the full viewport would be scaled too little and clipped.
     The scale rule must not carry a second copy of the device's arithmetic.
     Size containment is safe here because the card's box already ignores its
     contents: the height is --cardh and the width comes from .feed. */
  container-type:size;
}
.brand-nvda{--brand:#76B900;--brand-dim:#3F6F1A;--brand-wash:rgba(118,185,0,.09);--brand-halo:rgba(118,185,0,.2)}
.brand-tsla{--brand:#E82127;--brand-dim:#731D21;--brand-wash:rgba(232,33,39,.085);--brand-halo:rgba(232,33,39,.18)}
.brand-aapl{--brand:#B8C0CA;--brand-dim:#5D6670;--brand-wash:rgba(184,192,202,.08);--brand-halo:rgba(184,192,202,.17)}
.brand-macro{--brand:#64D2FF;--brand-dim:#255E72;--brand-wash:rgba(100,210,255,.08);--brand-halo:rgba(100,210,255,.17)}
.brand-meta{--brand:#5F8CFF;--brand-dim:#2C457F;--brand-wash:rgba(95,140,255,.08);--brand-halo:rgba(95,140,255,.17)}
.brand-jpm{--brand:#B08A58;--brand-dim:#5B4329;--brand-wash:rgba(176,138,88,.08);--brand-halo:rgba(176,138,88,.17)}
.brand-wmt{--brand:#FFC220;--brand-dim:#695314;--brand-wash:rgba(255,194,32,.08);--brand-halo:rgba(255,194,32,.17)}
.brand-crwd{--brand:#FF365D;--brand-dim:#7A1C32;--brand-wash:rgba(255,54,93,.08);--brand-halo:rgba(255,54,93,.17)}
.brand-crypto{--brand:#F7931A;--brand-dim:#78450D;--brand-wash:rgba(247,147,26,.08);--brand-halo:rgba(247,147,26,.17)}

/* The card does not fit a phone, and the fix is scaling rather than reflowing.
   A phone whose hardware is taller than 852 still hands the page less than 852
   once Safari's bottom bar is up -- measured on 2026-09-01-feed-4, every card
   in the feed crosses the fold between 820 and 800, and by 740 the action row
   is 75px past it. Reflowing into that would mean re-deriving every pixel
   budget in this file and solving The Insider's zero-slack fit a second time.
   So the face is LAID OUT at 393 wide and DRAWN at whichever of the card's
   width or height fits it first: a fixed slide, centred, shown small in a
   small window and large in a large one.

   This used to stop at `min(1, ...)`: a phone taller than 852 (2026-09-03, a
   430x932-class iPhone on the live site) kept the face at its design size and
   left the slack under the action row black, because the rule only ever
   scaled DOWN. There is nothing left in this file that depends on the face
   never growing past 1 -- the layout is already fixed at 852 regardless, the
   snap geometry point below does not care which direction the scale goes --
   so the cap was never load-bearing, only untested past 1.

   That fixed `min(1,...)` cap for the tall phone and broke the short one: the
   fit rule is `min(width ratio, height ratio)`, and on a phone shorter than it
   is wide-relative-to-393 -- Safari's own chrome on the reviewer's 430x932
   phone leaves about 430x743 -- HEIGHT is the tighter ratio, so the face
   shrank to ~87% and ~45pt of black sat on both sides of the card.
   **lane-face-fit-width, 2026-09-03, on the reviewer's word: "it doesn't fill
   the width of the screen at all... fix that this doesn't happen."** The face
   is WIDTH-bound -- laid out to fill the card's width unconditionally -- and
   lets the card get SHORTER rather than narrower.

   **lane-shell-owns-headline, same day, further: "We have one scale for the
   entire experience... I don't want this to be a dynamic thing that looks at
   the cards in the current feed."** The width-bound lane above still let a
   form floor its own height (`--faceh-min`) and fed that floor back into the
   scale, which made the scale a function of WHICH CARD was on screen, not
   just of the viewport -- a category error once the feed is one continuous
   scroll rather than a deck of independently-sized slides. The floor is
   gone from this rule entirely: the face is laid out at a fixed 393x852,
   ALWAYS, and drawn at exactly `100cqw / var(--facew)`, full stop -- no
   `min()`, no `max()`, nothing that reads a card's own content or a form's
   own class. A form whose panel does not fit inside whatever height that
   scale leaves it is that form's defect to fix in its own panel (NEXT.md:
   "each form's job is to get under 679"), never the shell's to hide by
   picking a smaller scale. `--faceh-min` stays declared -- at :root and on
   the forms that set their own (`.card.close{--faceh-min:765px}`, etc.) --
   because ripping it out of every form file is out of scope here and it is
   inert now rather than wrong: nothing in this rule reads it any more.

   Four things this rule is careful about.

   `height`, not just a transform, and now unconditional. The face used to be
   `inset:0`, so it was already laid out at the window height and its
   contents overflowed BEFORE any transform ran -- scaling afterwards shrinks
   the overflow along with everything else and fixes nothing. `height:
   var(--faceh)` fixes the box at 852 regardless of viewport or form, the
   same way `width` below fixes it at 393 -- the box is the 393x852 slide,
   full stop, and the transform is the only thing that ever changes size.

   Width is `var(--facew)`, fixed, for the same reason: not `min()` against
   `100cqw` any more. Under the old height-bound-primary rule that clamp kept
   a below-393 phone from being double-shrunk (the box narrowed to the card's
   own width AND the height ratio scaled it again); now that the scale is the
   single width ratio at every size, a fixed 393-wide box scaled by
   `100cqw/393` paints at exactly `100cqw` on every viewport with no clamp
   needed -- at 375 wide: `393 * (375/393) = 375`, to the pixel, by
   construction rather than by a second `min()` fighting the first. This also
   collapses the two-regime centring case the old comment tracked (below 393
   the box tracked the card; at or above it, it was pinned): the box is
   always 393 pre-scale now, so centring is one case, described below.

   It sits on .cardface and not on .card, which is the scroll-snap target. A
   snap area is the element's TRANSFORMED border box, so scaling the card would
   shrink or grow its snap area against .vcard -- the placeholder for a card
   that is not built yet, which keeps the full untransformed pitch -- and the
   feed would have two pitches. The face is absolutely positioned inside the
   card and is not in the feed's scroll geometry at all.

   The divisor needs `calc()`, unlike the old rule's `min()`/`max()` forms.
   `min()`, `max()` and `calc()` are all CSS math functions and each permits
   a bare `+ - * /` among its own arguments; `scale()` is not one, so
   `scale(100cqw / var(--facew))` is a division sitting directly in a
   property value with no enclosing math function, which is invalid and
   drops the whole declaration -- silently, with no console error, leaving
   the PREVIOUS `transform` (none, .cardface has no other rule setting it)
   in effect. Measured: that exact line shipped once in this lane's history,
   `getComputedStyle(face).transform` read back `"none"` at every viewport,
   and the face rendered at 1:1 scale everywhere rather than filling the
   width -- caught by re-measuring after the edit rather than by any error.
   `scale(calc(100cqw / var(--facew)))` is the fix, and is exactly what
   `min()`/`max()` were doing for free by being math functions themselves.
   WebKit could not be driven at all in this sandbox to confirm the
   rendered pixels directly (`playwright-core` 1.62.1 against the frozen
   `webkit_mac14_arm64` build either threw `Protocol error
   (Page.overrideSetting): Unknown setting: PushAPIEnabled` or hung past a
   30s bound, on a bare page with none of this rule's CSS on it -- an
   environment gap, not a finding about the CSS), so this is Chromium-
   confirmed only: measured, 430x743 renders the face's transform as
   `matrix(1.09415,0,0,1.09415,0,0)`, i.e. exactly `430/393`, the single
   number this rule now promises at every size, and 375x667 as
   `matrix(0.954198,...)`, exactly `375/393`.

   And centring: `left:0;right:0;width:var(--facew);margin:0 auto` -- the
   classic absolute-centring pair, and what this rule used above 393 before
   this lane, when the box was never wider than its container -- turned out
   to NOT centre this box once it is unconditionally 393 wide and the
   container is narrower (every viewport below 393, now a normal case
   rather than an edge one). Measured in isolation: with `left:0;right:0`
   both pinned, Chromium resolves the two `auto` margins as `0` and
   `-18px` at 375 (`393-375`), not `-9px` each -- the box is left-anchored
   to the container's own left edge and overflows entirely to the right,
   not split evenly -- so scaling it around `transform-origin:top center`
   (the box's OWN centre, 9px right of the container's) lands the painted
   box 9px off-centre rather than filling edge to edge. `left:calc(50% -
   var(--facew) / 2)` replaces the auto-margin pair: it centres the box
   directly, in one property, independent of which way the width/container
   comparison goes, and does not depend on how a browser resolves
   over-constrained auto margins. Confirmed in isolation and against the
   live rule: 375x667 now paints the face at `left:8, width:375` -- filling
   the card edge to edge, not 9px off first.

   The corollary for --rail: below this rule, `.card.solo .cardface{padding-
   top:calc(var(--rail) + 16px)}` still reads `--rail`, which is
   `clamp(18px,5vw,28px)` -- `vw` against the REAL device width, not the
   fixed 393 the face always lays out at. That is unchanged and is left that
   way on purpose for every VERTICAL use (--railv pins the one number that
   feeds the fit-scale budget; see --railv's own comment above), and for
   every horizontal use OUTSIDE this box (.dbody, .thread, .dreply, the open
   state's rail row) -- a notch's inset has to track the real device edge
   regardless of what the design width was, the same argument --rail's own
   comment makes for the horizontal bleed.

   THIS box's own horizontal padding is the one exception, since
   lane-headline-fixed (2026-09-03): `.hookline` takes no horizontal padding
   of its own, so this padding IS the column it wraps inside, and a `vw`
   rail here meant a wider phone got a NARROWER design column (--facew fixed,
   rail growing) at the same instant its font grew -- the double-scaling bug
   session/headline-fit.mjs documents. `--face-railL`/`--face-railR` (defined
   beside --railL/--railR above) fix that column at the design width's own
   rail value instead, so it no longer drifts with the real device's width.
   `env(safe-area-inset-*)` is still read exactly once, here, inside the box
   this rule scales -- unchanged from before this lane, just against a fixed
   20px floor rather than a vw one. */
.cardface{
  position:absolute;top:0;left:calc(50% - var(--facew) / 2);width:var(--facew);
  height:var(--faceh);
  transform:scale(calc(100cqw / var(--facew)));
  transform-origin:top center;
  /* The box is fixed at 393x852 and the transform is the only thing that
     ever changes its size, so painted WIDTH always equals the card's own
     width exactly (393 * (cqw/393) = cqw, by construction, at every
     viewport) -- .thread, parked at translateX(100%), one FACE width to the
     right, never shows a sliver of its own top border the way a mismatched
     width used to. HEIGHT is the axis this rule no longer protects, on
     purpose (lane-shell-owns-headline: one scale, a function of the
     viewport only, never of a card): a card taller than 852*scale shows
     blank card background below the face; a card shorter than 852*scale has
     the face run past the card's own bottom edge, and `.card{overflow:
     hidden}` -- not this rule's own `clip`, which only catches a form's OWN
     content overflowing ITS box -- is what keeps that from spilling into
     the next card. At exactly 393x852 this changes nothing: scale is 1, the
     face fits the card exactly, and there was never anything to clip. */
  overflow:clip;
  display:flex;flex-direction:column;gap:15px;
  /* The action row is the last thing in this column and the card runs to the
     device edge, so it sat under the iPhone home indicator with nothing under
     it. env() is 0px on desktop and on any phone without one, so this does not
     move the harness. Top stays the 44px fake-island inset; the coarse-pointer
     block below swaps it for the real one. --railv, not --rail: this is the
     vertical budget the 852 design height is spent against, and --rail's
     `vw` would let a narrower phone quietly change that budget out from under
     the fit-scale rule (see --railv's own comment, above). */
  padding:calc(var(--railv) + 44px) var(--face-railR) calc(var(--railv) + env(safe-area-inset-bottom, 0px)) var(--face-railL);
  /* `transition:transform .5s` used to be here and is deliberately gone. It
     animated nothing: the open/close morph sets its transforms on .rail,
     .hookline and .stage, and the thread slide is on .thread -- nothing has
     ever written a transform to the face. Now that the fit-scale lives here it
     would animate that instead, and the thing it would animate is a resize:
     half a second of the card growing into place on first paint, and again
     every time Safari's URL bar retracts and 100cqh changes mid-scroll. */
}
/* min-height 53 was the masthead's height. With the ticker, the slot chip and
   the provenance gone, this row is one timestamp; reserving a masthead's worth
   of space under it only pushed the whole card down. */
.rail{
  order:1;display:flex;align-items:flex-start;gap:14px;min-height:0;flex:none;
}
.back{
  width:0;opacity:0;pointer-events:none;flex:none;position:relative;
  /* overflow:hidden moved to .backclip. It lived here to clip the 17px chevron
     while the button's own width animates 0 -> 19px, but it also clipped the
     ::after hit area back to that same 19px box -- which is why this control
     measured 19x19 against a 44px floor and could not be grown from CSS at
     all. The clip still happens, one node in. */
  margin:2px 0 0;background:none;border:none;color:var(--muted);cursor:pointer;
  transition:width .4s cubic-bezier(.16,1,.3,1),opacity .3s,margin .4s cubic-bezier(.16,1,.3,1);
}
/* `.detailin`, not `.open`: the chevron is the second beat of the sequence,
   and on the way out it has to leave before the headline drops. Non-solo
   cards have no sequencer, so they keep the original one-step reveal. */
.card.open .back{width:19px;opacity:1;pointer-events:auto;margin:2px 9px 0 -3px}
.card.solo.open:not(.detailin) .back{width:0;opacity:0;pointer-events:none;margin:2px 0 0}
.back svg{width:17px;height:17px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
.backclip{display:block;width:100%;overflow:hidden}
/* 48x48 centred on the chevron and sized independently of the button box, so
   the open/close width animation never resizes the target mid-transition.
   Inert while the card is closed: .back is still width:0 and
   pointer-events:none, so there is no phantom 48px hotspot over the ticker. */
.back::after{content:"";position:absolute;top:50%;left:50%;
  transform:translate(-50%,-50%);width:48px;height:48px}

.subject{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}
.sym{
  font-size:var(--fs-ticker);line-height:1;font-weight:700;color:var(--brand);
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
/* One identity, one family. `.sym.concept` is set in IBM Plex Sans and `.nm`
   sat directly beneath it in mono, so a single subject block rendered two
   typefaces. An issuer name is a proper noun, not a column of digits, and its
   capitalisation is part of the identity: uppercase turned "DeepSeek shock
   basket" into "DEEPSEEK SHOCK BASKET" and "Nvidia vs. Ford Motor" into a
   shout. Uppercase is for labels of three words or fewer; these run to five. */
.nm{
  font-family:"IBM Plex Sans",system-ui,sans-serif;
  font-size:var(--fs-11-5);letter-spacing:var(--ls-01);color:var(--dim);
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
.meta{
  flex:none;display:flex;flex-direction:column;align-items:flex-end;gap:5px;
  padding-top:4px;text-align:right;font-size:var(--fs-10);letter-spacing:var(--ls-15);text-transform:uppercase;
}
.dom{padding:3px 7px;background:var(--brand-dim);color:var(--ink);font-weight:700}
/* Outlined, not filled: the segment name is the most permanent thing on the
   card -- same slot, same words, every print -- so it should read as a
   standing mark rather than a status badge competing with the ticker. */
/* The border was --line at 1.26:1, which is to say invisible: the chip did not
   read as a chip and the 'outlined, not filled' intent above never reached the
   screen. --edge is the same hairline at 3:1. */
.seg{padding:3px 7px;border:1px solid var(--edge);color:var(--dim);font-weight:700;
     font-size:var(--fs-10);letter-spacing:var(--ls-15);text-transform:uppercase}
.brand-aapl .dom,.brand-wmt .dom{color:var(--ground)}
.when{color:var(--dim)}
/* C14 provenance and C11 shelf life are face-level, not open-only. */
/* One line, not three boxes: age and provenance are the same kind of fact and
   read as one sentence rather than as a stack of unrelated chips. */
/* The timestamp was 9px tracked dim, the same size and colour as the
   provenance beside it, on a card whose entire subject is one session. When it
   happened is the second most important fact on a Session card and it was set
   as a footnote. Promoted to 19px ink; the provenance drops beneath it, which
   also retires the middot that only made sense in a row. */
.metaline{display:flex;align-items:baseline;gap:6px;color:var(--dim);font-size:var(--fs-9);letter-spacing:var(--ls-14)}
.metaline .prov::before{content:"\00b7";margin-right:6px;opacity:.6}
.prov{color:var(--dim);font-size:var(--fs-9);letter-spacing:var(--ls-14)}
.prov.fixture{color:#B58B2F;font-weight:700}
/* Only drawn once the card is past its shelf life, so it always means something. */
.ttl.stale{color:#B58B2F;font-size:var(--fs-9);letter-spacing:var(--ls-14);font-weight:700}
/* C6: a macro concept is not a ticker. */
.sym.concept{font-family:"IBM Plex Sans",system-ui,sans-serif;font-weight:600;letter-spacing:var(--ls-n01);font-size:var(--fs-ticker-concept);color:var(--ink)}
.sym.concept::after{content:"";display:block;width:34px;height:2px;margin-top:5px;background:var(--brand)}

/* The issuer name follows the headline when the masthead ticker has been
   dropped as a duplicate. .hook is display:contents, so this takes an order
   between the hookline and the deck and DOM order breaks the tie. */
/* .nm carries white-space:nowrap, which was safe inside .subject (min-width:0)
   and is not safe here: as a direct child of the column .cardface its
   max-content width propagates up and widens the whole card past the frame.
   An issuer name is short and may wrap. */
.subject.slotonly{align-items:flex-start;justify-content:flex-start}
/* An identity surface, never a value-bearing one (C52). Not .tick — that is
   already the streak tally's animation class. */
.hooktick{color:var(--brand)}
.hook{display:contents}
.hookline{
  order:2;min-height:0;margin-bottom:6px;font-family:"IBM Plex Sans",system-ui,sans-serif;
  font-size:var(--fs-headline);line-height:1.15;font-weight:700;
  text-wrap:balance;opacity:0;transform:translateY(9px);
  transition:opacity .34s ease-out .06s,transform .34s ease-out .06s;
}
.card.in .hookline{opacity:1;transform:none}
/* C5/C13 - directional colour tracks the card's stance, not the issuer's brand.
   Brand keeps identity surfaces (ticker, domain chip, section headings). */
/* Identity and direction are different jobs and were sharing one variable.
   `--stance` existed and was consumed by exactly one selector while 51 data
   surfaces painted themselves `--brand` directly, which is why a card about a
   sale rendered as a field of cheerful Amazon orange and an up day at
   Microsoft rendered red.

   Brand keeps identity: ticker, company name, domain chip, section headings,
   hover affordances, the comment thread. Direction takes everything that
   carries a value: bars, fills, marks, rails, highlighted rows, big numbers.

   The fallback is the whole trick. A stanceless card -- a receipt, a poll --
   leaves `--stance` resolving to `--brand`, so brand colour stays free
   exactly where there is no direction to contradict. */
.card{--stance:var(--brand);--stance-dim:var(--brand-dim);--stance-wash:var(--brand-wash);
  --beat-figure:clamp(42px,12vw,62px)}
.card.stance-up{--stance:var(--up);--stance-dim:#1B6B45;--stance-wash:rgba(51,224,142,.09)}
.card.stance-down{--stance:var(--down);--stance-dim:#7A2419;--stance-wash:rgba(255,77,61,.09)}
.move{color:var(--stance)}
.stage,.rail,.hookline{transform-origin:top left}
.stage{
  order:4;flex:1;min-height:0;width:100%;display:flex;align-items:stretch;justify-content:center;
  padding:2px 0 2px;
}
/* The sub is a sentence, so it takes the sentence family. At 11.5px tracked
   uppercase mono the Move card's "The fund closed $8.53 under its three-month
   high." measured 336px of text in a 354px column and broke, orphaning "HIGH."
   onto a line 39px wide. The same string in 13px sans sets in one line at
   294px. All-caps also strips the ascender/descender profile a reader shapes
   words by, which is the cost that never shows up in a measurement.
   min-height follows the leading: two lines at 13px/1.45 is 38px, not 32, and
   the longest sub in the stored batches is 59 characters. */
/* The deck. It reads as the second half of the hook, so it sits directly under
   the headline and near its weight -- the way a news card's standfirst does --
   not as a dim footnote below the chart. It was order:4 (under the stage) at
   13px --dim, which put the one line carrying the money at the bottom of the
   card in the quietest ink on it. Reading order is now headline, deck,
   visualisation, read more, and that is the whole card. */
/* The deck is the second half of the claim, so it is set as a second beat in
   the headline's own face and size, and placed after the visualisation: the
   card reads headline, chart, payoff. Scoped to .hook so the detail thread's
   own .sub is not dragged to 31px with it. */
.card.solo .hook .sub{
  order:5;max-width:none;
  font-size:var(--fs-headline);line-height:1.15;font-weight:700;
  letter-spacing:var(--ls-n01);text-wrap:balance;
}
.card.solo .more{order:6}
.card.solo .actions{order:7}

/* ------------------------------------------------------------------ Session
   One spacing system, and it is about grouping rather than about a single gap
   being right. The card is four groups:

      the claim        timestamp + headline
      the reading      figure + caption + plot + volume + axis
      the payoff       what the move was worth
      the affordances  read more, actions

   4px inside a group, 20px between groups. Before this the column gap was
   uniform, so the headline sat closer to the figure below it than to the
   timestamp above it -- which put the boundary in the wrong place and made the
   timestamp read as a stray line rather than as the top of the claim. */
.card.solo .cardface{
  gap:4px;
  /* The 44px stood in for the Dynamic Island under a 29px masthead. This card
     has one timestamp there. Touch devices keep the real inset -- the
     coarse-pointer block overrides padding-top outright. */
  padding-top:calc(var(--rail) + 16px);
}
/* The headline leads and the timestamp is its credit line: set under it, hung
   to the right, in the other family at a size that cannot compete. Two tries
   got this wrong -- above the headline it read as a kicker the headline had to
   climb out of, and left-aligned under it the two lines fought for the same
   starting point. Hung right, it is unmistakably subordinate, and the ragged
   gap between the headline's last word and the time is what makes them one
   block instead of two lines.
   :not(.open) because in the open state this row carries the back button and
   has to stay at the top of the card. */
.card.solo:not(.open) .hookline{order:1}
.card.solo:not(.open) .rail{order:2}
.card.solo .subject.slotonly{display:none}
.card.solo .rail{gap:0;margin-bottom:0}
.card.solo .meta{margin-left:0;align-items:flex-start;text-align:left;padding-top:0}
.card.solo .metaline .when{font-size:var(--fs-15);line-height:1;font-weight:400;
  letter-spacing:var(--ls-04);color:var(--muted);text-transform:none}
/* Decided 2026-09-03, by looking at rendered frames (fe-001,
   docs/reviews/frontend-2026-09-03.md): the claim sits at the payoff's size,
   --fs-headline-lg -- see "3 - the payoff" further down, which already reads
   that token "to match the claim" -- rather than the shell's own smaller
   --fs-headline from the base .hookline rule above. All eight non-habit solo
   archetypes were judged at both sizes, closed feed state, and the larger one
   read better across the board. This used to be forms/habit.css's rule: it
   silently set every solo card's headline size while that file's own header
   comment claimed everything in it was scoped to .card.habit (rule 14) -- a
   system-wide solo-layout decision, not a habit-only one, so it moved here. */
.card.solo .hookline{
  font-size:var(--fs-headline-lg);line-height:1.12;letter-spacing:var(--ls-n015);
  margin:0;text-wrap:balance;
}
.card.solo .stage{margin-top:16px}
.card.solo .hook .sub{margin-top:16px}
.card.solo .more{margin-top:14px}
.sub{
  order:3;min-height:0;margin-bottom:20px;
  font-family:"IBM Plex Sans",system-ui,sans-serif;
  font-size:var(--fs-15-5);line-height:1.42;letter-spacing:0;max-width:34ch;
  color:var(--deck);opacity:0;transform:translateY(6px);
  transition:opacity .34s ease-out .2s,transform .34s ease-out .2s;
}
.card.in .sub{transform:none}
.card.in .sub{opacity:1}
.more{
  /* The underline is this control's only affordance and it was #24301E at
     1.52:1, so Read more rendered as a bare word. --edge at 3:1. */
  order:5;align-self:flex-start;background:none;border:none;border-bottom:1px solid var(--edge);
  color:var(--muted);font:inherit;font-size:var(--fs-11);letter-spacing:var(--ls-12);text-transform:uppercase;
  padding:0 0 4px;cursor:pointer;position:relative;
  transition:border-color .2s,color .2s;
}
/* Read more used to fade in .5s after the card entered. Reviewer, 2026-09-03:
   "This Read more thing always fades in. Let's remove that." It is an
   affordance, not content; it is there when the card is. */
.more:hover,.more:focus-visible{border-bottom-color:var(--brand);color:var(--brand);outline:none}
/* Pseudo, not padding: the 1px border-bottom IS this control's affordance, so
   padding-bottom would peel the underline off the text it underlines.
   Measured 71x19; this is 91x45. The 13px of vertical overhang lands inside
   .cardface's 15px gaps, so on a poll card it stops short of the first
   .pollopt sitting at the top of .stage. */
.more::after{content:"";position:absolute;inset:-13px -10px}
.actions{
  /* 24, not 18: the hit areas below are 44px wide around a 29px like icon and
     a 15px share icon, which needs 22px of clearance between them. At 18 the
     share's box overlapped the like's by 4px and, being later in the DOM, took
     those pixels. 24 leaves 2px. Only the like moves, 6px left. */
  order:6;flex:none;display:flex;align-items:center;gap:24px;
  padding-top:11px;border-top:1px solid var(--line);
}
/* The action row used to fade in .45s after the card entered, like Read more
   did. Reviewer, 2026-09-03 (session 34, on the live site): "kill the bottom
   bar fading in. Similar to read more." Same reasoning as .more above: an
   affordance, not content; it is there when the card is. */
.act{
  position:relative;
  background:none;border:none;color:var(--muted);cursor:pointer;font:inherit;
  font-size:var(--fs-10);letter-spacing:var(--ls-12);text-transform:uppercase;display:flex;align-items:center;gap:7px;
}
.act svg{width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}
/* Pseudo, not padding. This row's height feeds .stage{flex:1}, so 44px of real
   padding would take ~29px of chart off every one of the 22 archetypes -- a
   tap-target fix that silently reflows the Insider and the Receipt is not a
   fix. width:100% with min-width:44px grows the 29px comment and like boxes
   and the 15px share box all to 44 without a fixed width that would clip a
   longer count. Centred rather than left-aligned so the comment icon stays
   flush with the rail instead of sliding 7.5px inward off the hookline's edge.
   The 14.5px of vertical overhang lands in the row's own 11px padding-top and
   in the card's bottom rail -- both empty, neither interactive. */
.act::after{content:"";position:absolute;top:50%;left:50%;
  transform:translate(-50%,-50%);width:100%;min-width:44px;height:44px}
.act .ct{color:var(--dim)}
.act.push{margin-left:auto}
.act:hover,.act:focus-visible,.back:hover,.back:focus-visible{color:var(--brand);outline:none}
.act.liked{color:var(--down)}
.act.liked svg{fill:var(--down);stroke:var(--down)}

.thread{
  order:6;position:absolute;left:0;right:0;bottom:0;top:var(--threadTop,58%);
  background:var(--ground);border-top:1px solid var(--line);display:flex;flex-direction:column;
  transform:translateX(100%);transition:transform .5s cubic-bezier(.16,1,.3,1);z-index:12;
}
.card.threadin .thread{transform:translateX(0)}
.dbody{flex:1;min-height:0;overflow:auto;scrollbar-width:none;padding:20px var(--railR) 26px var(--railL)}
.dbody::-webkit-scrollbar{display:none}
.dsec{opacity:0;transform:translateY(10px);transition:opacity .4s ease-out,transform .44s cubic-bezier(.16,1,.3,1)}
.card.open .dsec{opacity:1;transform:none}
.card.solo.open:not(.detailin) .dsec{opacity:0;transform:translateY(10px);transition-delay:0s}
.dsec + .dsec{margin-top:30px;padding-top:24px;border-top:1px solid var(--line)}
/* cardHtml pipes `detail.title` into this slot, so the same 10px/.16em
   eyebrow carries "Comments" on one card and "MSTR filed a 8-K -- disclosed
   under Reg FD; reported another material event" on the next. A sentence
   cannot be set as an all-caps label. Brand colour is deliberate and load-
   bearing: selftest guards this selector as an identity surface, and now that
   the heading and the body are both sans it is colour and weight, not case,
   that separates them. */
.dsec h2{font-family:"IBM Plex Sans",system-ui,sans-serif;font-size:var(--fs-13);line-height:1.4;letter-spacing:var(--ls-005);font-weight:600;color:var(--brand);margin-bottom:14px}
.dsec p{font-family:"IBM Plex Sans",system-ui,sans-serif;font-size:var(--fs-15);line-height:1.62;color:var(--muted);max-width:60ch}
.dsec p + p{margin-top:12px}
.sources{display:flex;flex-direction:column;gap:9px}
.srcitem{display:grid;grid-template-columns:72px 1fr;gap:12px;padding:10px 0;border-bottom:1px solid var(--line)}
.srcitem:last-child{border-bottom:none}
.srctype{font-size:var(--fs-9-5);letter-spacing:var(--ls-14);text-transform:uppercase;color:var(--brand);font-weight:700}
.srcmain b{display:block;font-size:var(--fs-12-5);line-height:1.35;color:var(--ink);font-weight:700;margin-bottom:3px}
.srcmain a{color:inherit;text-decoration:none;border-bottom:1px solid var(--brand-dim)}
.srcmain span{display:block;font-size:var(--fs-11);line-height:1.45;letter-spacing:var(--ls-06);text-transform:uppercase;color:var(--dim)}
.cmt{display:flex;flex-direction:column;gap:20px}
.cmt-item{display:flex;gap:11px;align-items:flex-start}
.av{
  width:29px;height:29px;border-radius:50%;flex:none;background:var(--line);
  border:1px solid #24301E;display:flex;align-items:center;justify-content:center;
  font-size:var(--fs-9-5);font-weight:700;color:var(--muted);
}
.cmain{min-width:0;flex:1}
.chead{display:flex;gap:9px;align-items:baseline;margin-bottom:5px}
.chead b{font-size:var(--fs-11-5);color:var(--ink)}
.chead span{font-size:var(--fs-9-5);letter-spacing:var(--ls-10);text-transform:uppercase;color:var(--dim)}
.cmt p{font-family:"IBM Plex Sans",system-ui,sans-serif;font-size:var(--fs-14-5);line-height:1.5;color:var(--muted)}
.cact{display:flex;gap:16px;margin-top:8px}
.vote{background:none;border:none;color:var(--dim);font:inherit;font-size:var(--fs-10);letter-spacing:var(--ls-10);text-transform:uppercase}
.dreply{
  /* Sticky to the bottom of a full-bleed card in the open state, so this is
     the row the home indicator lands on -- it carries the bottom inset, since
     .card.open .cardface zeroes the face's own vertical padding. */
  flex:none;display:flex;align-items:center;gap:11px;
  padding:12px var(--railR) calc(var(--rail) + 2px + env(safe-area-inset-bottom, 0px)) var(--railL);
  border-top:1px solid var(--line);background:var(--panel);opacity:0;transform:translateY(10px);
  transition:opacity .4s ease-out .36s,transform .4s cubic-bezier(.16,1,.3,1) .36s;
}
.dreply .me{background:var(--brand-dim);border-color:var(--brand-dim);color:var(--ink)}
/* Placeholder copy is what a person is about to type, so it is set the way
   their comment will be -- `.cmt p` is already sans. */
.dreply .fake{flex:1;font-family:"IBM Plex Sans",system-ui,sans-serif;font-size:var(--fs-13-5);color:var(--dim)}
/* Post was #26311F at 1.54:1 on --panel -- functionally invisible. --dim would
   clear the floor but lands it on exactly the weight of the .fake placeholder
   beside it, so the action would read no louder than the prompt it answers.
   --muted is what the card's other read affordance (.more) already uses. */
.dreply .send{font-size:var(--fs-10);letter-spacing:var(--ls-13);text-transform:uppercase;color:var(--muted);font-weight:700}

.card.open .cardface{overflow-y:auto;overflow-x:hidden;overscroll-behavior:contain;padding-top:0;padding-bottom:0;gap:12px;scrollbar-width:none}
.card.open .rail{
  position:sticky;top:0;z-index:16;background:var(--ground);
  padding:calc(var(--rail) + 18px) var(--railR) 11px var(--railL);margin:0 calc(-1 * var(--railR)) 0 calc(-1 * var(--railL));
  border-bottom:1px solid var(--line);
}
.card.open .sym{font-size:var(--fs-22)}
/* Split out: 9px was set for tracked uppercase, which has a cap-height floor
   holding it up. Sentence-case sans at 9px loses the issuer name in the
   compressed rail. The meta column is still uppercase and still reads at 9. */
.card.open .nm{font-size:var(--fs-10)}
.card.open .meta{font-size:var(--fs-9)}
.card.open .arch{opacity:0}
.card.open .hookline{
  min-height:auto;height:auto;flex:none;display:block;
  font-size:var(--fs-headline-compact);line-height:1.18;margin:2px 0 4px;
  position:relative;z-index:2;
}
.card.open .stage{
  flex:0 0 auto;height:auto;min-height:0;padding:0;justify-content:flex-start;
  overflow:hidden;position:relative;z-index:1;
}
/* The detail is reading, not looking. The visual stayed on under the headline
   as "evidence", but the reader has just come from the closed card where that
   picture was the whole frame -- so it opened as a smaller copy of what they
   were already looking at, and pushed the words they tapped for below the
   fold. The picture belongs to the closed state.

   .vizsec covers the solo forms (it wraps the readout and the stage); .stage
   alone covers the rest. NOTE: the ~44 `.card.open .<form>` rules further
   down compress the visual for the open state and are now inert. They are
   left in place rather than swept up inside this change -- deleting rules
   across twenty-five forms is its own pass, with its own frames to check. */
.card.solo.open .vizsec,.card.open .vizsec,.card.open .stage{display:none}
/* .card.solo re-declares .more as display:inline-flex further down, at the
   same specificity and later in the sheet, so "Read more" survived into the
   detail it opens -- a link to the thing you are already reading. Named with
   .solo here so the open state wins wherever the rule sits. */
.card.open .sub,.card.open .more,.card.open .actions{display:none}
.card.open .thread{position:relative;top:auto;bottom:auto;margin:0 calc(-1 * var(--railR)) 0 calc(-1 * var(--railL));padding:0 var(--railR) 0 var(--railL);border-top:none;flex:0 0 auto}
.card.open .dbody{overflow:visible;flex:none;padding:20px 0 26px}
.card.open .dreply{position:sticky;bottom:0;z-index:14;margin:0 calc(-1 * var(--railR)) 0 calc(-1 * var(--railL));padding-left:var(--railL);padding-right:var(--railR);opacity:1;transform:none}
.feed.locked{overflow:hidden}

/* Open: the visual compresses so it is evidence under the headline, not a second card. */
.card.open .metric,.card.open .counterbox .metric{font-size:var(--fs-metric-compact)}
.card.open .counterviz{gap:8px}
.card.open .counterbox{padding:8px 0}
.card.open .counterbars{height:34px}
.card.open .spark{height:72px}
.card.open .track{height:52px;--railtop:21px}
/* C16 - this used to shrink to 52px on open while .insider-dots (top:31px)
   and .railends (bottom:9px) kept their offsets, so the prices rendered
   straight through the marks. The open state scrolls; it can afford the
   full band. */
/* Used for one frame while a card is reset, so its entrance replays from the
   start rather than resuming from wherever the fade-out had reached. */
.card.snap,.card.snap *{transition:none!important;animation:none!important}
.card.open .insider-plan{padding:39px 14px 0}
.card.open .pollviz{gap:8px}

/* A caption is prose, and the payloads prove it: the Cohort ships "Share of
   sales that go to China. The less you sell there, the better the rule read."
   through this selector -- seventeen words of tracked uppercase mono -- and
   the Gauge ships "Alexander C. Karp sold 7.1% of the position". Sans also
   buys the width back: the Move card's caption goes 259px to 185px. Neither
   the Receipt nor the Insider uses .vizcap, so this reaches neither. */
.vizcap{font-family:"IBM Plex Sans",system-ui,sans-serif;font-size:var(--fs-11);letter-spacing:var(--ls-01);color:var(--dim)}
.metric{font-size:var(--fs-metric);line-height:1.04;font-weight:700;color:var(--ink)}
.spark{height:150px;display:flex;align-items:flex-end;gap:6px;border-bottom:1px solid var(--line)}
.spark i{flex:1;height:var(--h);background:var(--brand-dim);opacity:.56;transform-origin:bottom;transform:scaleY(.05);transition:transform .58s cubic-bezier(.16,1,.3,1) var(--d),background .3s var(--d)}
.spark i:nth-last-child(-n+3){background:var(--down)}
.card.in .spark i{transform:scaleY(1)}
/* Finding 07, s10. This row and .volcap were the same 9px uppercase dim row
   set space-between, stacked 10px apart, and read as two rows of one table.
   They are not the same kind of thing: .volcap is a statistic, this is the
   plot's x-axis. So it looks like one -- a hairline the labels hang off, with
   a mark at each end of the drawn range. Ends only: space-between places
   interior labels approximately, and a mark under one would claim an x it does
   not have (C2). :empty because a portfolio card with no ticks still emits the
   div, and a bare hairline under six stored cards is not an axis. */
.tickrow{display:flex;justify-content:space-between;gap:8px;font-size:var(--fs-9);letter-spacing:var(--ls-13);text-transform:uppercase;color:var(--dim);border-top:1px solid var(--line)}
.tickrow:empty{display:none}
.tickrow span:first-child,.tickrow span:last-child{display:flex;flex-direction:column}
.tickrow span:last-child{align-items:flex-end}
.tickrow span:first-child::before,.tickrow span:last-child::before{
  content:"";width:1px;height:4px;background:var(--dim);margin-bottom:5px}

.counterviz{width:100%;display:flex;flex-direction:column;gap:16px}
.counterbox{padding:18px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}
.counterbox .metric{font-size:var(--fs-counter-metric-unused)}
.counterbars{height:68px;display:flex;align-items:flex-end;gap:9px}
.counterbars i{flex:1;height:var(--h);background:var(--stance);opacity:.78;transform-origin:bottom;transform:scaleY(.05);transition:transform .72s cubic-bezier(.16,1,.3,1) var(--d)}
.card.in .counterbars i{transform:scaleY(1)}
.counterfoot{display:flex;justify-content:space-between;gap:12px;color:var(--dim);font-size:var(--fs-10);letter-spacing:var(--ls-13);text-transform:uppercase}
.track{--railtop:36px;position:relative;height:82px;display:grid;grid-template-columns:repeat(var(--n),1fr);align-items:end}
.track::before{content:"";position:absolute;left:5%;right:5%;top:var(--railtop);height:2px;background:var(--brand-dim);transform:scaleX(.02);transform-origin:left;transition:transform .85s cubic-bezier(.16,1,.3,1) .2s}
.card.in .track::before{transform:scaleX(1)}
.titem{position:relative;text-align:center;font-size:var(--fs-9);color:var(--dim);letter-spacing:var(--ls-10);text-transform:uppercase;opacity:0;transform:translateY(8px);transition:opacity .3s ease-out var(--d),transform .38s cubic-bezier(.16,1,.3,1) var(--d)}
.card.in .titem{opacity:1;transform:none}
.dot{width:18px;height:18px;border-radius:50%;display:block;margin:0 auto 12px;background:var(--stance)}
.titem.down .dot{background:var(--down)}
.titem.open .dot{background:var(--ground);border:3px solid var(--ink)}
.titem strong{display:block;margin-top:8px;font-size:var(--fs-13);color:var(--up);letter-spacing:0;text-transform:none}
.titem.down strong{color:var(--down)}
.titem.open strong{color:var(--stance)}
/* Beat one fills the box solid: the whole position is the whole rectangle, which
   is what makes the 7.1% slice in beat two land as small. Border only was too
   quiet to carry that contrast. */
@keyframes pos-frame{
  0%,4%{border-color:var(--line);background-color:transparent}
  12%,34%{border-color:var(--ink);background-color:var(--ink)}
  46%,86%{border-color:var(--stance-dim);background-color:transparent}
  100%{border-color:var(--line);background-color:transparent}
}
@keyframes pos-whole{0%{opacity:0}12%,30%{opacity:1}42%,100%{opacity:0}}
@keyframes pos-slice{
  0%,44%{width:0}
  60%,80%{width:var(--w)}
  92%,100%{width:var(--w);opacity:0}
}
@keyframes pos-sold{0%,48%{opacity:0}62%,80%{opacity:1}92%,100%{opacity:0}}
@keyframes tally-tick{0%{transform:scale(.86)}100%{transform:scale(1)}}

/* The strip and its caption are one block so the caption groups with its own
   bars rather than with the tick row below it (see volumeStrip). */
.volblock{display:flex;flex-direction:column}
/* Volume belongs to the line, so it arrives with it: same wipe, same duration,
   same delay. It used to fade in as a block at .5s, which read as a second,
   unrelated object appearing while the stroke was still drawing. */
.volwrap{display:flex;align-items:flex-end;gap:1px;height:26px;margin-top:5px;
  clip-path:inset(0 100% 0 0);transition:clip-path 1.9s cubic-bezier(.22,.9,.3,1) .2s}
.card.in .volwrap{clip-path:inset(0 0 0 0)}
/* Volume is a magnitude with no sign, so it was borrowing a direction it does
   not have -- and --stance-dim under the .55 wrapper composited to 1.67:1 on
   an up day and 1.35:1 on a down day, which made the Move card's only
   secondary data series invisible. No alpha rescues it: guaranteeing 3:1 for
   the worst legal --stance (a brand sitting on the resolver's 4.5:1 floor)
   needs opacity above ~.85, at which point the opacity is doing nothing.
   --dim is 5.19:1 and still reads well under the full-strength price path. */
  /* --stance-dim, never --dim: these bars belong to the move, and a contrast
   pass briefly moved them to the neutral token, which drew a down day's
   volume in the up palette (C52 - value-bearing surfaces take stance). */
.volwrap i{flex:1;height:var(--h);min-height:1px;background:var(--stance-dim);display:block}
.volwrap .volpad{flex:1}
.volcap{display:flex;justify-content:space-between;align-items:baseline;margin-top:4px;font-size:var(--fs-9);letter-spacing:var(--ls-15);text-transform:uppercase;color:var(--dim)}
/* A statistic, not a label: the value drops the caps, gains a size and reads
   at --muted, so "32.2m shares" stops matching "16:00" stroke for stroke.
   .02em is already in the sheet (.pathends); 10.5px was not needed twice. */
.volcap strong{font-size:var(--fs-12);letter-spacing:var(--ls-02);text-transform:none;color:var(--muted);font-weight:400}
/* C56/C57 - the two-beat primitive, shared by every archetype with two claims
   to make. A label set beside a 62px figure has nowhere to go and reads as that
   figure's caption, so it goes above it on its own line. Beat two is the same
   font at the same size as beat one; what separates them is time, not weight.
   Sizing beat two down says the second claim matters less, when it is usually
   the one that reframes the first. */
.beat-label{font-size:var(--fs-10);letter-spacing:var(--ls-15);text-transform:uppercase;color:var(--dim)}
.beat-label em{font-style:normal;color:var(--ink);opacity:.82}
.beat-two{margin-top:auto;display:flex;flex-direction:column;gap:5px;
  padding-top:13px;border-top:1px solid var(--line)}
.beat-two b{font-size:var(--beat-figure);line-height:.9;color:var(--ink);font-weight:700}
.beat-in{opacity:0;transform:translateY(7px);
  transition:opacity .34s ease-out,transform .44s cubic-bezier(.16,1,.3,1)}
.card.in .beat-in{opacity:1;transform:none;transition-delay:1.15s}
.beat-note{font-size:var(--fs-9);letter-spacing:var(--ls-14);text-transform:uppercase;color:var(--dim)}
.card.open .beat-two b{font-size:var(--fs-28)}
.insider-plan{position:relative;height:88px;border:1px solid var(--line);background:var(--brand-wash);overflow:hidden;padding:39px 17px 0}
.insider-plan::before{content:attr(data-plan);position:absolute;left:13px;top:11px;font-size:var(--fs-9);letter-spacing:var(--ls-17);text-transform:uppercase;color:var(--dim)}
.insider-rail{position:relative;height:2px;background:var(--line)}
.insider-rail i{position:absolute;inset:0 auto 0 0;width:0;background:var(--stance);transition:width 1.05s cubic-bezier(.16,1,.3,1) .22s}
.card.in .insider-rail i{width:100%}
.insider-dots{position:absolute;left:17px;right:17px;top:31px;display:flex;justify-content:space-between}
/* Priced marks are placed, not distributed: left is the execution price. */
.insider-dots.priced{display:block}
.insider-dot.priced{position:absolute;top:50%;transform:translate(-50%,-50%) scale(.35)}
.card.in .insider-dot.priced{transform:translate(-50%,-50%) scale(1)}
.railends{position:absolute;left:17px;right:17px;bottom:9px;display:flex;justify-content:space-between;align-items:baseline;font-size:var(--fs-9);letter-spacing:var(--ls-13);text-transform:uppercase;color:var(--dim)}
.railends em{font-style:normal;opacity:.75;letter-spacing:var(--ls-11)}
.insider-dot{width:15px;height:15px;border-radius:50%;background:var(--ground);border:2px solid var(--stance);opacity:0;transform:scale(.35);transition:opacity .28s ease-out var(--d),transform .4s cubic-bezier(.34,1.56,.64,1) var(--d)}
.card.in .insider-dot{opacity:1;transform:scale(1)}
.insider-flat{display:flex;align-items:baseline;justify-content:space-between;gap:11px;
  padding:10px 12px;border:1px solid var(--line);background:var(--brand-wash);
  font-size:var(--fs-9);letter-spacing:var(--ls-15);text-transform:uppercase;color:var(--dim)}
.insider-flat strong{font-size:var(--fs-12-5);color:var(--ink);letter-spacing:var(--ls-02);
  text-transform:none;font-weight:700}
.stage:has(.pollviz){justify-content:flex-start;align-items:stretch}
/* .pollviz was the shared wrapper for both The Poll and The Overlay's own
   option markup -- one card form, not two colliding names -- before The
   Overlay was retired (C8). It was declared a second time near The Poll's
   own section, 1000+ lines down, at equal specificity: height:100% and
   min-height:0 there, gap and padding-top here. None of the three shared
   properties (display, width, flex-direction) disagreed, so this was
   pixel-identical rather than a live defect -- but it is the same shape as
   the --panel/--panelh collision above: one selector, two declarations, and
   an edit to either would have silently done nothing or silently done
   everything, depending on which property it touched. Merged into the one
   rule The Poll actually reads. */
.pollviz{width:100%;display:flex;flex-direction:column;gap:10px;padding-top:4px;height:100%;min-height:0}

.empty{height:100%;display:flex;align-items:center;justify-content:center;color:var(--dim);font-size:var(--fs-12);letter-spacing:var(--ls-16);text-transform:uppercase;text-align:center;padding:28px}
/* Real phone: don't add the 44px fake-island inset on top of Safari's status bar. */
@media (hover:none) and (pointer:coarse){
  .cardface{padding-top:calc(var(--rail) + env(safe-area-inset-top, 0px))}
  .card.open .rail{padding-top:calc(var(--rail) + env(safe-area-inset-top, 18px))}
}
.stamp{display:none}
.dsk{display:none}
@media (min-width:900px) and (hover:hover) and (pointer:fine){
  body{display:flex;align-items:center;justify-content:center;background:#08090A;padding-top:30px}
  .stamp{
    display:block;position:fixed;top:0;left:0;right:0;z-index:20;text-align:center;
    font-size:var(--fs-9);letter-spacing:var(--ls-22);text-transform:uppercase;color:var(--dim);
    padding:9px 0;background:var(--ground);border-bottom:1px solid var(--line);
  }
  .dsk{display:inline}
  .device{
    /* --faceh's own formula (100svh * facew/100cqw) reads the real browser
       window's svh, which is the WRONG number here for the same reason the
       .card{container-type:size} comment already gives for width: this box
       is a phone mockup smaller than the desktop viewport, not the viewport
       itself. cqw is 393 in here regardless (the device is 393 wide and the
       card fills it, so scale is always 1), so --faceh can skip the ratio
       and just be the device's own available height directly -- the same
       quantity `height`/`max-height` below already compute, named once
       rather than re-derived through cqw at a scale that is always 1. */
    --cardh:100%;--faceh:min(852px, calc(100dvh - 66px));
    width:393px;height:852px;max-height:calc(100dvh - 66px);
    border-radius:46px;overflow:hidden;position:relative;background:var(--ground);
    box-shadow:0 0 0 2px #242C1E,0 0 0 11px #17191B,0 0 0 12px #303436,0 34px 80px rgba(0,0,0,.85);
  }
  .device::before{content:"";position:absolute;top:11px;left:50%;transform:translateX(-50%);width:104px;height:29px;border-radius:16px;background:#000;z-index:30}
}

/* Focus was communicated by colour alone: every :focus-visible rule above sets
   outline:none and swaps --muted for --brand. That is invisible to anyone who
   cannot separate those two greens. .vote and .srcmain a had no rule at all
   and fell through to the UA outline, giving three different focus looks in
   one card.
   The ring is drawn on the hit-area pseudo wherever there is one, so what a
   keyboard user sees is the box that will take the click -- not the 15px icon
   sitting inside a 44px target. */
.act:focus-visible,.more:focus-visible,.back:focus-visible,
.vote:focus-visible,.srcmain a:focus-visible{outline:none}
.act:focus-visible::after,.more:focus-visible::after,.back:focus-visible::after,
.vote:focus-visible::after,.srcmain a:focus-visible::after{
  outline:2px solid var(--brand);outline-offset:0}
.pathviz{width:100%;height:100%;overflow:visible}
/* C53 - a path names the two values that bound it. The labels sit at each
   endpoint's own height and step away from the line's direction of travel, so
   a descending path gets its start above and its end below and neither label
   lands on the stroke. Ticks name the window; these name the move. Value-
   bearing, so they take --stance and never brand (C52). */
.pathends{position:absolute;inset:0;pointer-events:none}
/* Each label belongs to an end of the line, so it arrives when the line does:
   the opening price as the stroke leaves, the close as it lands. Both were
   painted at full opacity on load, which put the closing price on screen a
   full second before the line reached it and gave away the ending. */
.pathends span{position:absolute;font-size:var(--fs-11);letter-spacing:var(--ls-02);
  color:var(--stance);white-space:nowrap;transform:translateY(-50%);
  opacity:0;transition:opacity .34s ease-out}
.card.in .pathends .s{opacity:1;transition-delay:.30s}
/* .2s delay + 1.9s wipe: the close lands as the stroke reaches it, not before. */
.card.in .pathends .e{opacity:1;transition-delay:2.1s}
.pathends .s{left:0}
.pathends .e{right:0}
/* The reveal is a left-to-right wipe, not a dash offset.
   stroke-dasharray:1000 drew the line before, and `vector-effect:
   non-scaling-stroke` makes a browser measure dashes in SCREEN pixels rather
   than user units. SPY's session path renders 1234px long, so the last 234px
   sat in the dash gap and was never drawn -- the card dropped the final 19% of
   the trading day and read as a chart with a hole on the right. Backwards, at
   that: a choppier session makes a longer path, so the busier the day the more
   of it went missing. pathLength="100" does not rescue it, because the same
   non-scaling-stroke rule keeps the dash in screen units and it tiles.
   A wipe has no length units to get wrong, and it is the truer reveal anyway:
   this is a time series, so uncovering it left to right IS drawing it in the
   order the day happened. The dash swept along the path, which on a sharp
   drop runs downward while the day runs forward. */
.pathviz{clip-path:inset(0 100% 0 0);transition:clip-path 1.9s cubic-bezier(.22,.9,.3,1) .2s}
.card.in .pathviz{clip-path:inset(0 0 0 0)}
.pathline{fill:none;stroke:var(--up);stroke-width:1.6;vector-effect:non-scaling-stroke;
  stroke-linejoin:round;stroke-linecap:round}
.pathviz.dn .pathline{stroke:var(--down)}
.card.in .pathline{stroke-dashoffset:0}

/* ---- fill the canvas ------------------------------------------------
   Every visual owns the full stage height. Content that cannot grow is
   centred within it; content that can grow, grows. */
.stage > *{height:100%}
.counterviz{justify-content:center}

/* The Counter is one paired unit - the money and what it buys - not a hero
   number floating above unrelated footnotes. Type is sized to fit the widest
   value rather than to fill the width. */
.counterviz{gap:0}
.counterbox{flex:0 0 auto;padding:16px 0}
.counterbox .metric{font-size:var(--fs-counter-metric);letter-spacing:var(--ls-n01)}
.card.open .counterbox .metric{font-size:var(--fs-counter-metric-open)}
.companion{display:flex;align-items:baseline;gap:10px;padding:11px 0 0;margin-top:11px;
  border-top:1px solid var(--line)}
.companion b{font-size:var(--fs-companion);line-height:1;color:var(--ink);font-weight:700}
.companion span{display:flex;flex-direction:column;font-size:var(--fs-10);letter-spacing:var(--ls-13);
  text-transform:uppercase;color:var(--dim);line-height:1.35}
.companion em{font-style:normal;opacity:.62;letter-spacing:var(--ls-10)}
.card.open .companion{padding-top:7px;margin-top:7px}
.card.open .companion b{font-size:var(--fs-19)}
.counterfoot{padding-top:12px;opacity:.72}

/* ------------------------------------------------- v19: the label system
   v18 set the whole interface in IBM Plex Mono with 9px tracked uppercase
   micro-labels. Held next to X, Webull, Apple News, CNBC, Reddit and TikTok
   that is the one thing that separates this card from all of them: every one
   of those apps sets its interface in a system sans, in sentence case, at a
   size a person reads rather than decodes. Tracked caps at 9px is an
   enterprise-dashboard signal.

   Reset here rather than at the 56 sites that set it, so the change is one
   block to read and one block to revert.

   .flowrow, .flowtop span and .runzero came out of this list when The Flow and
   The Streak Board moved to their own sheets: they load after this block now,
   so naming them here would be a reset that silently stops resetting. Their
   computed values are written into forms/flow.css and forms/streak.css. */
.volcap,.tickrow,.metaline,.prov,.more,.act,.seg,.dom,.ttl,
.railends,.vizcap{
  text-transform:none;letter-spacing:0;
}
.volcap,.tickrow{font-size:var(--fs-12);color:var(--dim)}
.volcap strong{font-weight:600;color:var(--muted)}
.card.solo .metaline .when{font-size:var(--fs-15);letter-spacing:0;color:var(--muted)}
/* .priceref span left this pair when The Move moved to its own sheet; its
   computed values are written into forms/reaction.css. */
.pathends span{font-size:var(--fs-12-5);letter-spacing:0}

/* Read more was a text link with a hairline under it. Every reference app has
   an actual control here -- a filled pill, a segmented row, a button. */
.more{
  border:0;border-bottom:0;border-radius:999px;
  background:#1B1C1F;color:var(--ink);
  padding:11px 18px;font-size:var(--fs-14);font-weight:600;line-height:1;
}
.more:hover,.more:focus-visible{background:#26282C;color:var(--ink);border-bottom-color:transparent}
.more::after{inset:-6px -4px}
.act{font-size:var(--fs-12);gap:8px}

/* ============================================================== SESSION
   Three peers, in reading order:

       headline        the claim
       visualisation   one bounded section: price, change, plot, volume, axis
       payoff          the fact the claim pays off in

   The two text blocks are the same size deliberately -- they bracket the
   visualisation, so the eye goes claim, picture, consequence. Before this they
   were three different sizes competing with the price, and there was no
   sequence to follow. The price is no longer one of the three: it lives inside
   the section, which is what makes the section read as a section.

   Everything below is scoped to .card.solo and supersedes the earlier passes.
*/
/* :not(.open) on every solo rule that the open state also sets.
   These describe the closed card, but they are declared after the
   `.card.open ...` block at equal specificity, so they were silently winning
   in the open state. It had cost three separate bugs by the time this comment
   was written: the back chevron indented by a whole rail inset, `Read more`
   surviving into the page it opens, and -- the expensive one -- 36px of
   padding left on the scrollport, which is what `position:sticky; top:0`
   measures from, so the detail's sticky header parked 36px down and the
   headline scrolled up through the gap above it.
   Scoped at the source rather than answered with a fourth override. */
.card.solo:not(.open) .cardface{gap:0;padding-top:calc(var(--rail) + 16px)}
/* That padding reserved room for the back button, which only exists in the
   open state -- and the open state zeroes the face's padding anyway. With the
   panel leading the column it was 36px of black above the picture, paid for
   out of the plot. Sixteen, which is the inset the panel's own captions use. */
.card.solo:not(.open) .cardface{padding-top:16px}

/* 1 - the picture. The claim rests under it.

   The frame is 852 tall and the eye rests low on it, and the bottom third was
   holding a chart tail and two rows of chrome. This layout was built as claim
   -> picture -> payoff, but the payoff slot is empty on six of the seven solo
   forms (only The Move fills `sub`), so the strongest position on the card
   carried nothing while the sentence sat above the fold. That is the inverse
   of the vertical-feed grammar the rest of the design follows.

   It costs the picture nothing. .vizsec is flex:1, so the panel is whatever is
   left after the fixed rows, and the fixed rows are the same rows. Only the
   order changes.

   :not(.open) throughout, because the open state puts the back button in the
   rail and the rail has to lead the column again. */
/* lane-shell-flex-panel, 2026-09-03, reverses the call this comment
   describes -- kept because the timestamp-drift problem it names is real and
   the fix for it moved, not because the panel is fixed again.

   The panel WAS a fixed height (--panelh, 569, tuned against an 852 design
   height) rather than flex:1, so the credit line sat under the claim without
   drifting. That traded one fixed number (852, the window) for another
   (--panelh) and broke the moment the two stopped being the same thing: a
   window shorter than 852 (743 on the reviewer's phone) still laid the panel
   out at 569 and everything below it at its own fixed heights, so the column
   summed to more than the window had and the tail -- the claim's own credit
   line among it -- ran off the bottom, hidden by `.card{overflow:hidden}`
   rather than reported.

   The fix is the other fixed thing in the column, not this one: the shell
   block below (.hookline through .actions) is now ITSELF a fixed-height
   reserve, sized once and bottom-anchored (see the rules below), so the
   credit line's distance from the claim is fixed by THAT arithmetic instead
   of by the panel's height. With the reserve fixed, .vizsec is free to be
   flex:1 again -- exactly what plain `.card.solo .vizsec` above already
   specifies (flex:1, min-height:0, overflow:hidden) -- and this rule only
   needs to still change ORDER (panel first) and MARGIN (no top margin before
   it; the reserve owns its own spacing). Panel content still assumes 569
   (or --panelq) of room; fitting each form into whatever height a real
   window leaves is the next fan-out (NEXT.md "each form fills its panel"). */
.card.solo:not(.open) .vizsec{order:1;flex:1;margin-top:0}
/* Order only. This rule used to read `order:3;margin:7px 0 0` and the 7 was
   never applied: `.card.solo:not(.open) .rail` is declared a third time 110
   lines below, at equal specificity with `margin:6px 0 0`, and the last one
   wins. Anything reasoned from the 7 was reasoned from a number the frame
   never used -- the same shape as the --panel/--panelh collision above, one
   property declared twice under one name. The margin is set once now, in the
   lower rule, which is the one the browser was reading anyway. */
.card.solo:not(.open) .rail{order:3}
/* Was `margin-top:auto`, collecting whatever the headline's own min-height
   band did not spend so the action row still landed on its pinned bottom.
   With .hookline now a FIXED reserve (below) rather than a floor, and .vizsec
   flex:1 absorbing every pixel the shell block itself does not claim, there
   is no slack left in this column for an auto margin to collect -- the
   reserve already sums to a fixed number, so removing this changes nothing
   measured; left declared it would be a margin reasoned about that can never
   fire (rule 17's shape, caught before it was shipped rather than after). */

/* The claim sits in a fixed band, and starts at the top of it.

   Without one the title's *bottom* is pinned -- .vizsec is flex:1 and absorbs
   every spare pixel -- so the first line began at 635 on The Close, 653 on
   The Move and 695 on The Swings. Scrolling the feed, the headline jumped
   around by 60px from card to card while every other element held still.

   --band is the budget, not a suggestion: fitHeadline reads it and shrinks
   the type until the text fits inside, so a five-line headline cannot push
   the panel out of the frame. Three lines at the base size, which is the
   longest headline the seven forms currently produce; short ones leave black
   space under them, which is the same deliberate space the rest of the card
   is built from. */
/* --band is the fit budget, not a reserved box: fitHeadline shrinks the type
   until the text fits inside it, so a long headline can never push the action
   row off the frame. No min-height -- the panel above holds the position now,
   and the headline is free to be exactly as tall as its own text. */
/* --band is the headline's fit budget, and it is the only thing in this
   column that can give: the panel is fixed, the title starts at 621, and the
   action row is pinned to the bottom of the frame.

   86 -> 74, measured on the frame rather than reasoned about.

   The column has room for exactly one thing to grow, and everything in it is
   a hard number: 16 top pad + 583 panel + 22 + hookline + 7 + 21 rail + 4 +
   45.5 credit + 16 + 15 Read more + 28 action row (which carries its own 12px
   of padding-top) = 831.5 against a content bottom of 832.4. So the column
   holds a headline of 74.9px and no more. At --band 86 with the old 29.1px
   credit the auto margin above the action row measured 2.6px: 86 was inside
   the old capacity of ~87.3, but only just, and there was no slack to spend.
   (108 was never inside it. See the flex:none note below.) The rebuilt credit
   -- a label row over a chip row -- is 45.5px, so 16.4px had to come from
   somewhere, and --band is the only line item that can give it. 74 is that
   capacity, not a round number: at 78 The Habit finds a three-line fit the
   band accepts and the column cannot hold, and the action row lands 2.3px low.

   What it cost, measured on 2026-09-01-feed, before -> after:
     The Close      32.23px / 2 lines -> 32.06 / 2
     The Counter    29.33 / 2          -> unchanged
     The Reveal     31.18 / 2          -> unchanged
     The Swings     27.47 / 1          -> unchanged
     The Poll       32.23 / 2          -> 32.06 / 2
     The Move       32.23 / 2          -> 32.06 / 2
     The Price      25.22 / 3          -> 24.57 / 2
     The Habit      25.22 / 3          -> 22.80 / 2
   Three of the eight lose 0.17px, which is the band clipping a headline that
   was already at its natural size. The two that move properly drop a line --
   the fit search takes the largest size inside the band, and a step smaller
   is a step where the text sets on two lines instead of three. That is a
   smaller headline occupying fewer lines, not a headline that gained room.
   Nothing here grew, and the action row is back at 804.4 on every one of
   them, which is the thing the band exists to hold.

   The cost of a narrower band is robustness, and it is worth saying out loud:
   the fit search gives up at FIT_FLOOR (0.68 of base, ~23px), and 86px held
   three lines at that floor where 74 holds two. A headline long enough to
   need three lines at 23px now fails the search, keeps its full size and
   pushes the action row off the frame. The margin is thinner than it was.

   Measured across all 184 stored cards under this renderer, before and after:
   the same 26 closed solo cards miss the pinned bottom, and no new one joins
   them. They are forms the solo layout was never built for -- receipt, gauge,
   ripple, insider, cohort -- overflowing by 60 to 150px for reasons that have
   nothing to do with this row, plus the four static Move cards in
   2026-08-31-feed and -session, which were 0.7-2.4px over already. The credit
   is a net 12.4px taller than the one it replaces (45.5 + 4 against 29.1 + 8)
   and the band gives back what it can, so those cards land 12.4px lower and
   four of them, where the narrower band finds a smaller fit, land higher. */
/* The band is a floor as well as a ceiling, and that is what makes the bottom
   section a specification rather than a remainder.

   Measured on 2026-09-01-feed-4, media_first only, before this rule: headline
   height plus the gap above the action row is 91.0px on every card, and only
   the split between them moves.

     The Close     25.7 + 65.3     The Counter   65.7 + 25.3
     The Swings    30.8 + 60.3     The Move      71.8 + 19.2
     The Swings    31.3 + 59.7     The Habit     51.1 + 40.0

   One quantity, divided by how many lines the title happened to set on. The
   action row is pinned at 832.4 and `.actions{margin-top:auto}` collects
   whatever the headline did not spend, so a one-line title opens a 65px hole
   above the affordances and a two-line title opens 19px. Nothing chose either
   number.

   The panel cannot be the thing that absorbs it -- that was tried and is what
   the fixed band exists to undo (see the note above: the first line began at
   635 on The Close and 695 on The Swings, and the headline jumped 60px card to
   card while everything else held still). So the slack is taken out of
   circulation instead: min-height holds the claim at its full budget whether or
   not the text fills it, and the 17px above the action row is the same 17px on
   every card.

   `--band` stays 74px and stays the fit budget fitHeadline reads -- this adds a
   floor, it does not raise the ceiling, so the capacity argument above is
   untouched and no headline gains room. What changes is where a short one's
   spare pixels sit: under the claim, where the card is already built from
   deliberate black space, rather than pooled above the action row.

   The Counter is the reference frame, and it moves least: its headline grows
   8.3px and its gap tightens 25.3 -> 17. Six of the seven closed media_first
   cards in the day feed converge on it.

   prompt_first takes the same floor against its own 120px band, but the floor
   alone does not close its column, because it has a second source of slack
   media_first does not: its panel is a fixed --panelq rather than the hero
   height, and its foot measures 134.3px, so whatever the two do not spend
   pools above the action row exactly as it did here. Both had to be closed to
   make the two modes agree; see --panelq.

   `min-height` is declared in the rule below rather than in one of its own,
   which is rule 17's "merge the two blocks" and not a stylistic preference:
   this selector is one of DELIBERATE_OVERLAPS' entries -- a later pass that
   re-lays out .card.solo on purpose -- and a third declaration slid BETWEEN
   the two that collide stops them being consecutive, so the allowlist entry
   reads as stale and selftest fails on an overlap that is still there. Adding
   a rule can retire a guard as surely as deleting one.

   It reads `var(--band)` while the same rule sets --band, which resolves on
   the element rather than in source order -- so prompt_first, which overrides
   --band to 120px further down, floors at 120 without naming a number twice. */
/* lane-shell-flex-panel, 2026-09-03: 74 was sized to close an 852-design
   column against a fixed --panelh (the block of history above). The panel is
   flex:1 now (see .vizsec above) and the column closes on THIS number
   instead, so it has to be a fact about the headline, not a remainder of
   whatever the panel left over.

   "Reserve three lines, never shrink" (the reviewer, this session): the
   headline is never stepped down (fit.js), so the reserve has to be the
   height three lines actually render at, measured rather than guessed --
   129.6px at 430 wide (this renderer's own font-size clamp resolves against
   the real device width, not the 393 design width `cqw` scales everything
   else by, so it is not itself subject to that scale and has to be measured
   at the width it will actually render at), giving 118.46 design px once
   divided back out by the 430/393 transform this column is drawn through.
   A one- or two-line headline leaves the difference blank at the BOTTOM of
   this box -- min-height, not height, so a genuine fourth line still grows
   the box and pushes the action row visibly off the frame rather than
   clipping silently (the flex:none note below is exactly this failure mode,
   kept loud on purpose). 74 -> 118.5. */
/* No min-height on the band any more. With one, a two-line headline held a
   three-line box and the empty third line sat BETWEEN the headline and the
   timestamp (reviewer, session 33, on the Swings: "have less space between
   the headline and the 2 hours ago"). The headline is its own height; the
   invariant is that it ENDS at the same y on every card -- everything below
   it is fixed -- and grows upward, the panel (flex:1) giving up a line per
   extra line. --band stays declared for fit.js, which reads it as a floor. */
.card.solo:not(.open) .hookline{order:2;--band:118.5px;margin-top:16px;flex:none}
/* flex:none on the text stack. Without it these are shrinkable flex items:
   when the column overflowed, .hookline shrank BELOW its own content and the
   headline's last line printed straight through the timestamp -- while every
   bounding box still measured as if nothing were wrong. A layout that fails
   invisibly is worse than one that fails loudly, so they hold their height
   and an overflow now pushes the action row off the frame, where formcheck
   can see it.

   The source credit was missing from this list and was doing exactly what the
   list exists to prevent. Measured: with --band at 108 and the old credit, a
   three-line headline did NOT push the action row off 832 -- the credit
   silently squashed from 29.1px to 10.8px and clipped its own second line,
   and every box still measured as if the layout were fine. So the 108 that
   `--band` gave up 20px from was never a band the layout could actually
   hold; it only looked like one because the thing underneath was collapsing.
   Anything reasoned from that number is off by the difference. */
.card.solo:not(.open) .rail,
.card.solo:not(.open) .hook .sub,
.card.solo:not(.open) .src-credit,
.card.solo:not(.open) .more{flex:none}
/* Was: The Swings' headline hand-shrunk to clamp(25px,7vw,32px), because a
   five-word headline at the full size filled the frame corner to corner and
   outweighed the list it introduced. That was a fact about the headline
   sitting above the list. Under the picture it is a caption, and the band
   below fits it at the same size every other form uses -- so the one form set
   in a different size was the only thing making the sizes look chosen by
   hand. Dropped; fitHeadline shrinks a headline that genuinely needs it. */
.card.solo .subject.slotonly{display:none}
/* 6 -> 4. The timestamp dates the claim directly above it, and with the
   provenance block below now opening an 18px gap, it belongs to the headline
   rather than sitting halfway between the two. Two pixels, and the column had
   them: the provenance is 12.1px shorter than the chips it replaced. This is
   the only rule that sets the closed solo rail's margin -- see the order rule
   above for why there used to be two. */
.card.solo:not(.open) .rail{gap:0;margin:4px 0 0;min-height:0}
.card.solo .meta{margin-left:0;align-items:flex-start;text-align:left;padding-top:0}
.card.solo .metaline .when{font-size:var(--fs-15);line-height:1;font-weight:400;
  letter-spacing:0;color:var(--muted);text-transform:none}

/* 2 - the visualisation, as one surface. A panel rather than a run of loose
       rows: Apple News and Reddit both float content on a tonal card, and it
       is what tells you where the picture starts and stops. */
.card.solo .vizsec{
  order:3;flex:1;min-height:0;display:flex;flex-direction:column;
  margin:18px -6px 0;padding:16px 0 12px;
  background:#0B0C0E;border-radius:18px;overflow:hidden;
}
.card.solo .rdout{order:0;display:flex;flex-direction:column;gap:5px;margin:0;padding:0 15px}
.card.solo .rdprice{font-size:var(--fs-29);font-weight:700;line-height:1;letter-spacing:var(--ls-n02);color:var(--ink)}
.card.solo .rdchg{font-size:var(--fs-14);font-weight:600;display:flex;gap:7px;align-items:baseline}
.card.solo .rdchg.up{color:var(--up)}
.card.solo .rdchg.dn{color:var(--down)}
.card.solo .rdchg em{font-style:normal;font-weight:400;color:var(--dim)}
.card.solo .stage{order:1;flex:1;min-height:0;width:auto;margin:14px 0 0;padding:0}
.card.solo .volcap,
.card.solo .tickrow{padding-left:15px;padding-right:15px}
.card.solo .pathends .e{display:none}

/* 3 - the payoff, sized to match the claim so the two bracket the picture */
.card.solo .hook .sub{
  order:5;max-width:none;margin:18px 0 0;
  font-size:var(--fs-headline-lg);line-height:1.12;font-weight:700;
  letter-spacing:var(--ls-n015);color:var(--ink);text-wrap:pretty;
}

/* 16 -> 14 -> 10 (lane-shell-flex-panel). Read more and the provenance above
   it are the card's footer and read as one group; the gap over the
   provenance is what separates that group from the timestamp, and 10 is
   still visibly tighter than the 14 on either side of it (rail->credit,
   credit->more) -- these two rows are the closest pairing on the card and
   the gap between them is the one this shell reads as pure margin, not a
   group boundary that needs to be seen. */
.card.solo .more{
  order:6;background:none;border:0;border-radius:0;padding:0;margin:10px 0 0;
  color:var(--muted);font-size:var(--fs-15);font-weight:600;
}
/* It was set like a section heading, so it read as one. The arrow is what
   says it goes somewhere. */
.card.solo:not(.open) .more{display:inline-flex;align-items:center;gap:5px}
.morearrow{width:13px;height:13px;fill:none;stroke:currentColor;stroke-width:1.9;
  stroke-linecap:round;stroke-linejoin:round;flex:none}
.card.solo .more:hover,.card.solo .more:focus-visible{background:none;color:var(--ink)}
.card.solo .more::after{inset:-14px -10px}
/* margin-top 20 -> 8 (lane-shell-flex-panel). This rule used to be shadowed
   entirely by the higher-specificity `.card.solo:not(.open) .actions{margin-
   top:auto}` above (removed in this lane -- see its own note), so 20 was
   never the number the closed solo card actually rendered; auto was. Now
   that this declaration is the one that fires, it is retuned as part of the
   same gap pass as .src-credit and .more above rather than left at a number
   that was never live. padding-top:12 is untouched -- that is the visual
   rule above the action row, not spacing air, and this pass does not
   restyle. */
.card.solo .actions{order:7;margin-top:8px;padding-top:12px}

/* ----------------------------------------- 4 - the editorial provenance
   Every card carries its `sources`, and until v21 they were only legible
   after you opened it. The closed card has the room -- between the timestamp
   and `Read more` -- and where a claim came from is what a reader checks
   before deciding whether to believe it.

   Two lines of type, and no third thing:

       MARKET DATA
       US day gainers

   The eyebrow is every distinct KIND the card cites, de-duplicated in payload
   order and joined with " + ", so three sources over two kinds print two. The
   line under it is the sources' own `name` values. Nothing else: `detail` is a
   methodology string that runs from "Session 2026-08-31" to 137 characters of
   BLS series description, and it belongs to Read more, which already prints
   it. Every word here is `srcKind(type)` or `name`, verbatim.

   What this replaces. The credit was a <button> reading "Sources" over a
   full-width row of bordered chips with a chevron at the right, opening the
   detail at its Sources section. Three things were wrong at once: a bordered
   full-width row under a headline reads as a settings row or an empty input;
   its chevron competed with `Read more` fifteen pixels below for the single
   job of saying where the card goes; and a chip carries only the kind, so The
   Swings and The Close -- two cards off two different feeds -- both credited
   themselves "Market data" and the reader learned nothing they could use. The
   name is the specific fact, and it is the one the closed card lacked.

   It is not a control any more, and that is a removal rather than a
   restyling: no button, no data-open, no tabindex, no hit area, no hover and
   no focus state. `Read more` is the only affordance left in the lower third,
   which is the point of the change. The route the widget took --
   OPEN_TARGETS.sources -- is retired with it, and so is the selftest
   assertion that "sources" appears among the data-open values.

   Caps here are not the v18 genre coming back. v18 set chrome LABELS in 9px
   tracked uppercase, and leaving that is what v19 is for; the word "SOURCES"
   set that way would have been exactly the walk-back, which is why the widget
   this replaces set its label in sentence case. What is uppercase here is not
   a label but a value -- the kind is the card's own data -- drawn at the size
   the design system gives a data label (10-11px, .11-.15em).

   Height, measured rather than guessed, against the chips it replaces: a
   10.5px eyebrow at line-height 1, a 6px gap, and a 13.5px name line at 1.25
   leading (16.88) = 33.38px against 45.5. --panelh does not move, so the
   claim still starts at y=607 and the action row still ends at 832.36.

   Where the 12.12px went, because most of it did not go where it looks like
   it went. Ten of it is spent on the gaps around this block, not returned:
   the gap above it goes 4 -> 18, and 2px each come back off the rail's top
   margin (6 -> 4) and off `Read more`'s (16 -> 14). Only the remaining 2.12
   reaches the auto margin over the action row, which measures 19.17px on The
   Close where it measured 17.05. The lower third gets quieter, not shorter,
   and the two cards in 2026-08-31-session that sat 0.84px past the pinned
   832.36 now land on it.

   The gaps changed shape as well as size. The chips sat 4px under the
   timestamp and 16px above `Read more`, which read them as a second line of
   the timestamp's own row. 18 above and 14 below instead: the timestamp
   groups upward with the claim it dates, and the provenance groups downward
   with `Read more` as the card's footer -- related, and still two rows.

   One height for every card, not one per source count. The builder caps the
   name line at two names plus a count and the CSS truncates whatever is left,
   so a one-source card and a three-source card have the same lower third.

   Declared with the rest of the solo chrome rather than in a form's section:
   every solo form carries sources, so this is not one form's rule (rule 14).
   All three class names are new and used nowhere else, so the blast radius is
   a grep: `src-credit`, `src-kinds`, `src-names`.

   Hidden by default, shown only by a rule that carries :not(.open). Three
   bugs came from `.card.solo X` being declared after `.card.open X` at equal
   specificity and winning in the open state; here the open state does not
   need a rule at all, because the closed state is the exception. */
.src-credit{display:none}
/* 18 -> 14. lane-shell-flex-panel: with the panel flex:1 and the shell block
   below it a fixed reserve, every design px this block gives back is a
   design px the panel gets to draw in instead -- so the loose air between
   these rows is worth tightening rather than leaving as a remainder nobody
   was spending. This gap reads as a bigger group boundary (rail -> credit)
   than the ones on either side of it (4px above, 10px below after this
   change) without any visible change to what the rows themselves look like. */
.card.solo:not(.open) .src-credit{order:4;display:block;width:100%;margin:14px 0 0}
.src-kinds{font-size:var(--fs-10-5);line-height:1;letter-spacing:var(--ls-12);text-transform:uppercase;
  color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
/* One line, always, and the ellipsis is the second guard rather than the
   first: the builder already caps the list at two names, and this catches a
   single name long enough to overflow on its own -- The Habit stores one of
   44 characters. A line that wrapped would push the rail on some cards and
   not others, and a lower third whose height depends on how a source happens
   to be named is not a fixed composition. */
.src-names{margin-top:6px;font-size:var(--fs-13-5);line-height:1.25;letter-spacing:0;
  color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}

/* ------------------------------------------------ 5 - the layout modes
   Everything above this comment is the shared solo column: the panel at
   --panelh, the claim at y=607, the timestamp, the provenance, Read more and
   the action row ending at 832.36. A layout mode is a DELTA on that column,
   and the shell still composes the lower third exactly once -- a mode that
   needed its own lower third would be a second shell, which is the thing this
   section exists to prevent.

   Two modes today. The names are in `LAYOUTS` in the script below and in
   `card.enums.layout` in the contract; pipeline/selftest holds the three
   lists to each other.

   media_first  the panel leads -- a fixed-height picture at the top of the
                frame, the claim beneath it. It has NO RULES HERE, and that is
                the point: it is the column above, unaltered. Every solo card
                drew it before the modes were named, which is why it is what a
                payload with no `layout` field resolves to. The twenty-four
                frozen batches all take that path and none of them moves --
                183 stored cards DOM-diffed against the version before this
                block, zero geometry differences.

   prompt_first the card asks the reader to act, so the QUESTION leads.

   ------------------------------------------------------------- the stack

   A prompt-first panel is ONE SHARED REGION with six named slots, and the
   form drawing inside it fills the slots rather than composing a panel of its
   own. The order below is the reading order the card asks for, and it is
   declared once, here, for all four prompt-first forms:

       1 question   what the reader is being asked. Near the top, always.
       2 setup      the essential setup, or the known comparison value: the
                    photograph, the session that was run, the other city's
                    price. Still on the card; no longer what leads.
       3 answer     the current answer or guess -- and, after the lock, the
                    graded answer, in the same box, rewritten in place.
       4 control    the input: a slider, a pair of choices, a plus/minus.
       5 commit     the submit action, and the result that replaces it. Two
                    elements may share this slot precisely because they are
                    mutually exclusive -- the button before the lock, the
                    result after -- which is what "the input region becomes
                    the result in place" means in markup.
       6 note       the one essential footnote.

   A form fills a slot by putting `data-slot="<name>"` on one of the form
   root's own children. It does not choose the order, the type the question is
   set in, where the slack lands or where the lower third sits: those are this
   block's, once, for every prompt-first form. `PROMPT_SLOTS` in the script
   below is the same list in the same order, and review/formcheck holds the
   RENDERED FRAME to it -- every visible row's top coordinate, in slot order,
   on every prompt-first card in every batch. An `order` that stopped applying
   would leave the attributes exactly where they are and revert the frame,
   which is the shape of both duplicate-declaration bugs this file has already
   paid for.

   A slot may be empty. The Reveal has no `setup` element -- its comparison
   value is a mark on its own rail -- and The Poll has no `answer` one,
   because picking an option IS the answer. What no form may do is leave a row
   untagged: formcheck fails an untagged child of a prompt-first form root,
   because an untagged row sorts at order 0 and lands silently above the
   question.

   -------------------------------------------------------- the hero boundary

   --panelh does not move, and that is now a measured result rather than an
   assumption carried forward. The claim's top line is 607 on every solo form,
   media-first and prompt-first alike, and it is 607 BECAUSE the panel above
   it is a fixed height. A mode that bought its question's room out of --panelh
   would move the claim on four cards out of thirteen and break the one line
   the feed reads down.

   So the question's room comes out of `setup`, the only slot that flexes.
   That is the substantive change from the first version of this block, which
   grew the FIRST ROW of the sequence: while the context element led, "grow it"
   and "put it first" were one rule, and the moment the question leads they are
   two. The grower is therefore named rather than positional --

       [data-slot]         flex-grow:0; flex-basis:auto
       [data-slot="setup"] flex-grow:1; flex-basis:0%

   -- which is `flex:1` on setup and `flex:0 1 auto` on the rest, exactly what
   the four forms' own first rows carried before. `flex-basis` is set in both
   halves, never `flex-grow` alone: a row set with the `flex:1` SHORTHAND has
   a basis of 0%, so cancelling only the grow would collapse it to its content
   rather than to nothing, or to nothing rather than to its content, depending
   which way it was written. pipeline/selftest holds this rule to both halves,
   because the missing half fails silently -- the row is still there, still
   styled, and zero pixels tall.

   No `min-height` here, deliberately. The floor under a picture is the form's
   own judgement -- The Poll keeps its sparkline at 70px and The Price lets its
   photograph go to nothing rather than crop -- and a shared floor would be
   this block reaching a level down (rule 14).

   What the reordering cost, and it is much less than moving --panelh would
   have. Measured on the fourteen v21 batches, before -> after, on the setup
   row, which is where the whole bill lands:

     The Price      photograph   271.9 -> 265.4    -6.5
     The Poll       sparkline    259.0 -> 256.5    -2.5
     The Reveal     no setup row; the slack still lands above the note
     The Price Gap  setup slot   255.9 -> 265.4    +9.5

   The Price pays 6.5px and no more, because hoisting the question OUT of the
   ask returns most of what putting it above the photograph costs: the row is
   19.5px either way, and what actually changed is which gap sits under it,
   the ask's own 5 or the panel's 11. The Poll pays 2.5px, which is its
   question growing from a 13px deadline label to the 15px every question in
   this mode is set in. The Price Gap's SLOT grows, because it is two elements
   now -- a 233.0px photograph and a 23.4px anchor line with a 9px step
   between them -- and the anchor is paid for out of its own footnote, which
   drops from two lines to one.

   What it did not cost: 49 solo cards were measured on both sides of this
   change, thirty-eight of them media-first, and NOT ONE moved -- panel,
   claim, timestamp, provenance, Read more or action row. The eleven
   prompt-first cards are identical outside the panel too; only the rows
   inside it are in a different order. The claim's top line is 607 on all
   forty-nine, before and after.

   How much the stack can take, measured on The Poll by cloning its choices
   and reading the frame at each count. It is the setup row that pays, which
   is the whole point of the mode:

     2 options  sparkline 256.5   claim 607   action row 832.4   fits
     3 options  sparkline 181.5   claim 607   action row 832.4   fits
     4 options  sparkline 106.5   claim 607   action row 832.4   fits
     5 options  sparkline  70.0   claim 607   action row 832.4   note clips

   Four choices is the headroom at the current option height. At five the
   sparkline is on its own 70px floor, the sequence runs 38.5px past the
   panel, and the footnote clips against the panel's bottom edge -- and the
   claim and the action row still do not move, which is the failure mode this
   composition is meant to have: a prompt-first card that asks too much of the
   panel spoils its own panel and leaves the feed alone. review/formcheck
   fails a payload that reaches it ("the sequence does not fit the hero
   boundary"), so it cannot arrive unannounced.

   A form with no `setup` row would have nowhere to put the slack, so `note`
   carries `margin-top:auto` and it lands above the footnote instead. The two
   rules do not fight: flexible lengths are resolved before free space reaches
   an auto margin (CSS Flexbox 9.7, then 9.5), so wherever a `setup` row grows
   the auto margin is worth exactly zero. The Reveal's and The Poll's footnote
   rows used to set that margin themselves and no longer do -- one property,
   one declaration, because two rules setting it under two names is how
   `--panel` and the twice-declared `.rail` each stayed invisible for a
   fortnight (rule 17). pipeline/selftest asserts that no form's own footnote
   rule sets it again.

   ------------------------------------------- what the mode does NOT do

   It does not move the lower third, and it structurally cannot: the
   timestamp, the provenance, `Read more` and the action row are siblings of
   `.vizsec` in the column above, the panel is a fixed height, and every slot
   in this block is inside `.stage`. Revealing an answer rewrites rows INSIDE
   the panel and nothing below it moves by a pixel. Measured rather than
   reasoned -- formcheck locks a guess in and re-reads the four lower-third
   coordinates, because a DOM argument that two boxes cannot overlap has twice
   missed a headline printing straight through a timestamp.

   `> * > [data-slot]` reaches the form root's own children and stops there.
   It is a rule about the sequence, not about any form's insides: the widget's
   internal rhythm one level further down is the form's business (rule 14). No
   form's class name appears in this block.

   Names introduced here, every one grepped for first: the `data-layout`
   attribute, `data-layout-unsupported`, the two mode values, and `data-slot`
   with its six values. Nothing here overrides a property another rule sets
   under a different name -- `--panel`/`--panelh` and the twice-declared
   `.card.solo:not(.open) .rail` were both invisible for weeks because nobody
   ran that grep. */
.card.solo[data-layout="prompt_first"]:not(.open) .stage > * > [data-slot]{
  flex-grow:0;flex-basis:auto;
}
/* The question IS the headline, and it leads the card.

   prompt_first shipped with the claim still in the lower third and a quiet
   grey prompt added at the top of the panel, so a reader met "Guess the
   average price" in muted 15px and the actual question -- "What is the average
   price of a gallon of whole milk in the U.S.?" -- at full weight below the
   picture, after the control they were meant to answer with. Two voices asking
   one thing, the loud one second.

   So the hookline moves to the head of the column for this mode, at the weight
   it already has, and the in-panel prompt is not drawn at all. Everything
   under the panel -- timestamp, provenance, Read more, the engagement rail --
   keeps its order and stays at the foot. This is a third ordering of the same
   rows, not a second card: media_first is panel-then-claim, prompt_first is
   claim-then-panel, and the shell composes the foot once for both.

   `--panelq` is this mode's panel height, distinct from `--panelh` -- grep
   before adding a name (rule 17), and --panelh is the media_first hero that
   session 16 measured must not move. */
/* The island inset comes back when the headline leads.
   `.card.solo:not(.open) .cardface` drops to a flat 16px because with the
   PANEL at the head of the column the rail inset was 36px of black above the
   picture, paid for out of the plot -- and the panel's own empty top absorbs
   the island anyway. A headline has no such margin: at 16px its first line
   runs under the Dynamic Island and "NVDA reported revenue" reads as "NVDA
   repo…enue". So this mode takes the inset back, which is the media-first
   treatment flipped. */
.card.solo[data-layout="prompt_first"]:not(.open) .cardface{
  /* Mirrored from media_first rather than nudged.

     media_first runs panel[16,585] -> claim[607,673] -> rail -> actions[804,832].
     Flipped about the frame's midline the claim lands at [179,245], and that is
     the composition this mode is the reverse of. A literal mirror cannot ship --
     the action row stays at the foot in both modes, so the panel would run to
     836 and collide with it -- but the claim's inset is the half that carries
     the read, and at 16 or even 60 it sits jammed under the island rather than
     resting in the top third.

     So: the claim takes the mirrored inset and the panel gives up the
     difference. --panelq is 400 rather than 470 because the tallest headline
     here is four lines (The Reveal's claim plus its question) and the column
     still has to close above the action row. */
  padding-top:120px;
}
/* lane-shell-flex-panel, 2026-09-03: 120 was sized for this mode's OWN
   longest case (The Reveal's claim plus its question, four lines) against a
   fixed --panelq. The reviewer's "three lines is the max, reserve it always"
   is stated as one decision for the card, not a media_first-only one (rule:
   "cross-card things decided once"), so this mode gets the SAME reserve
   media_first does rather than a second number for the same kind of row --
   118.5, not 120, which is close enough that nothing currently drawing at
   120 was relying on the extra 1.5px, but IS a real cut for a headline that
   actually needs a fourth line: The Reveal is the one form this mode's own
   comment names as needing it, and confirming it still fits under three
   (or that it doesn't, and needs its own follow-up) is unverified by this
   lane -- the next fan-out's job, not fixed here. */
.card.solo[data-layout="prompt_first"]:not(.open) .hookline{
  order:0;margin-top:0;--band:118.5px;
}
/* height:var(--panelq) -> flex:1, matching the media_first .vizsec change
   above for the same reason: the panel is whatever is left once the fixed
   rows (this mode's own hookline at the head, the shared rail/credit/more/
   actions reserve at the foot) are spent, not a fixed number tuned against
   an 852 column that no longer exists. --panelq itself is left declared --
   it is still read by .revealviz's own layout notes elsewhere in this file
   and ripping it out is a forms-level change, out of this lane's paths. */
.card.solo[data-layout="prompt_first"]:not(.open) .vizsec{
  order:1;flex:1;margin-top:14px;
}
/* Not drawn: the headline above is the question now, and asking twice in two
   voices is worse than the void this replaced. Named by each form's own class
   rather than by a slot, because `question` stopped being a slot -- the thing
   it held leads the card from outside the panel. The elements stay in the
   markup: a payload can still ask a prompt form for media_first, and there the
   panel is the whole card and its question belongs inside it. */
.card.solo[data-layout="prompt_first"]:not(.open) [data-prompt-echo]{
  display:none;
}

.card.solo[data-layout="prompt_first"]:not(.open) .stage > * > [data-slot="setup"]{
  order:1;flex-grow:1;flex-basis:0%;
}
.card.solo[data-layout="prompt_first"]:not(.open) .stage > * > [data-slot="answer"]{order:2}
.card.solo[data-layout="prompt_first"]:not(.open) .stage > * > [data-slot="control"]{order:3}
.card.solo[data-layout="prompt_first"]:not(.open) .stage > * > [data-slot="commit"]{order:4}
.card.solo[data-layout="prompt_first"]:not(.open) .stage > * > [data-slot="note"]{
  order:5;margin-top:auto;
}
/* The question's type, once. It is the leading element of the shared region,
   so how it is set is the region's decision and not four forms' -- The Poll
   set it at 13px as a deadline label, The Price and The Reveal at 15px as a
   prompt, and nothing said which was right because nothing held them
   together. The Price's, The Price Gap's, The Reveal's and The Poll's
   question rows each gave up their size, weight, colour, tracking and case to
   this rule; what each keeps is its own box (The Poll's question carries an
   info button beside it, The Price's is centred under a photograph).

   Not scoped to the mode, unlike everything above it: a payload may put a
   prompt-first FORM into media_first, and a question that loses its type on
   that path would be a silent regression on a path nothing currently walks.
   Scoped to `.stage > * >` so it can only ever reach a form root's own
   children. */
.card.solo .stage > * > [data-slot="question"]{
  font-size:var(--fs-15);line-height:1.3;font-weight:600;letter-spacing:0;
  text-transform:none;color:var(--muted);
}

/* the close, marked on the line. The number lives in the readout, so the
   stroke does not carry it twice. */
.enddot{position:absolute;width:9px;height:9px;border-radius:50%;background:var(--stance);
  transform:translate(-50%,-50%);margin-left:-6px;opacity:0;transition:opacity .3s ease-out}
.card.in .enddot{opacity:1;transition-delay:2.1s}
/* Was `.card.swings .hook .sub:empty`. The payoff slot is empty on six of the
   seven solo forms, and its 18px margin was the last thing making the title
   band start at a different height on different cards -- 599 where the empty
   .sub still reserved its margin, 617 where it did not. Generalised, because
   it was never a fact about The Swings. */
.card.solo .hook .sub:empty{display:none;margin:0}
/* The closed solo column has no line item for a deck when it is NOT empty.
   The rule above covers the six forms that leave `sub` blank; this one covers
   the three that don't. The Move, The Habit and The Insider each hit the same
   wall independently -- an empty deck already lands the action row exactly on
   its pinned bottom (832.36, measured on 2026-09-01-feed-4's Insider with
   `.hook .sub` cleared), so a deck of any length overflows and there is no
   `--band`, no length bound and no font size that fits it, because both of
   those assume a slot with some height and this slot has none. Each form
   carried its own `display:none` for that reason; consolidated here because
   the finding was the column's every time, not any one form's -- three local
   fixes for one structural omission (see each form's stylesheet for its own
   measurement of what showing the deck would cost).

   This is a budget of ZERO, stated once, not a wider licence: it is scoped to
   the three classes that currently populate `sub` with real content, not to
   `.card.solo` in general, so it changes nothing for a form that has never
   tried to draw one. Whether any of these three should get a real budget for
   a deck -- reflowing the column to make room, rather than hiding it -- is a
   design call this rule does not make. */
.card.insider .hook .sub,
.card.habit .hook .sub,
.card.replay .hook .sub{display:none}
/* gap:16, and it is the one thing this form had to change to take
   prompt_first. The sequence -- question, figure, rail, commit -- runs on a
   single 16px step, which is the step the middle row was already using
   between its own three rows. It had none between the question and the
   figure, and that was invisible while that row was flex:1 and centred: 138px
   of absorbed slack sat there instead. Top-packed, the two touched. One step
   for the whole sequence rather than a second number for one join of it
   (rule 15, applied to spacing).

   Declared here rather than in the layout block: the block is a rule about
   where a prompt panel's slack goes, and this form's own rhythm is this
   form's business (rule 14). `.revealviz` is declared once in this file --
   grepped before this line was written. */
.revealviz{display:flex;flex-direction:column;width:100%;height:100%;min-height:0;
  gap:16px;padding:0 15px}
@keyframes ctrwake{
  0%{opacity:.35;text-shadow:0 0 0 transparent}
  45%{opacity:1;text-shadow:0 0 26px var(--stance-halo)}
  100%{opacity:1;text-shadow:0 0 0 transparent}
}
/* Read more, in three beats: the headline rises, the back button appears,
   the page comes in. Closing runs the same three backwards.

   The rise is not new code -- morph() already FLIPs .rail and .hookline from
   where they were to where they land, which is symmetric and so reverses for
   free. An earlier pass here added a `hookrise` keyframe on top of that, and
   a CSS animation outranks the inline transform morph() sets, so the keyframe
   was quietly cancelling the very thing that made the movement smooth. It is
   gone; this file now only sequences what was already there.

   `detailin` is that sequencer. It is added a beat after the card opens and
   removed a beat before it closes, so the back button and the body are gated
   on their own class rather than on `.open` -- which is what lets the exit
   run in the opposite order to the entrance instead of everything leaving at
   once. openCard/closeCard own the timing; nothing here fires on its own. */
.card.solo.open.detailin .back{width:19px;opacity:1;pointer-events:auto;margin:2px 9px 0 -5px}
.card.solo.open.detailin .dsec{opacity:1;transform:none}
/* The exit is the entrance read backwards, offset for offset -- which it was
   not: removing `detailin` fired the chevron and all three sections on the
   same frame, so opening was a sequence and closing was a cut.
      in   headline 0    chevron 220   s1 280   s2 360   s3 440
      out  s3 0    s2 50    s1 100    chevron 160   headline 220
   The last thing in is the first thing out. */
@media (prefers-reduced-motion:no-preference){
  .card.solo.open.detailin .dsec{transition-delay:.06s}
  .card.solo.open.detailin .dsec:nth-of-type(2){transition-delay:.14s}
  .card.solo.open.detailin .dsec:nth-of-type(3){transition-delay:.22s}
  .card.solo.open.detailin .dreply{transition-delay:.26s}
  .card.solo.open:not(.detailin) .dsec:nth-of-type(3){transition-delay:0s}
  .card.solo.open:not(.detailin) .dsec:nth-of-type(2){transition-delay:.05s}
  .card.solo.open:not(.detailin) .dsec:nth-of-type(1){transition-delay:.10s}
  .card.solo.open:not(.detailin) .back{transition-delay:.16s}
}
