/* Dehydrate — direction K, "Plain confident".
   Near-white canvas, near-black ink, DM Sans throughout, pill actions.
   The colour budget is spent almost entirely on the forest cards: the check
   in the hero and the price. Everything else stays monochrome so those land.

   Scale: root is back to 100%. Forcing 125% made the type big but froze the
   proportions that worked, and on a 2560 screen the result used 48% of the width
   with 300px of dead air top and bottom. Size now comes from fluid clamps keyed
   to the viewport instead, so a large screen gets genuinely larger type rather
   than the same layout inflated. */

/* Type is served from this origin, not fonts.googleapis.com.
   The CDN version cost two extra DNS + TLS handshakes before display type could
   paint: the stylesheet came from googleapis, and only once it parsed did the
   woff2 request go out to gstatic. Measured on Fast 3G that put the last font
   byte at 1371ms and FCP at 1844ms. Six files live in fonts/ now; see OFL.txt.

   Only the latin subsets ever download for English copy — unicode-range keeps
   latin-ext on the shelf until a character needs it, which matters because the
   panel renders page titles from whatever site the visitor exported.

   DM Sans is a variable font (fvar/gvar/avar), so 400 through 700 are one file
   per subset rather than four. DM Mono is not, hence the separate 400 and 500.

   Those two DM Sans files are not the ones Google serves. Google's carry the
   whole 100-1000 weight axis; this page uses 400, 500, 600 and 700 and nothing
   else, so the axis is instanced down to exactly that range and the files drop
   42% (63KB -> 36KB latin, 31KB -> 18KB latin-ext). The opsz axis is left live,
   so optical sizing still applies at display sizes.

   Narrowing an axis is not quite lossless: the deltas get re-encoded, which is
   exact at 400 but rounds by up to 1.2 font units at 500-700, about 0.08px on
   the 68px headline. Measured across 138 text runs at 1440/900/390, the largest
   box drifted 0.281px and no line count anywhere changed, so nothing rewraps.
   Regenerate with fonts/build-fonts.py, not by re-downloading from Google. */

@font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;
  src:url(fonts/dm-sans-latin.woff2) format('woff2');
  unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;
  src:url(fonts/dm-sans-latin-ext.woff2) format('woff2');
  unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}
@font-face{font-family:'DM Mono';font-style:normal;font-weight:400;font-display:swap;
  src:url(fonts/dm-mono-400-latin.woff2) format('woff2');
  unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'DM Mono';font-style:normal;font-weight:400;font-display:swap;
  src:url(fonts/dm-mono-400-latin-ext.woff2) format('woff2');
  unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}
@font-face{font-family:'DM Mono';font-style:normal;font-weight:500;font-display:swap;
  src:url(fonts/dm-mono-500-latin.woff2) format('woff2');
  unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'DM Mono';font-style:normal;font-weight:500;font-display:swap;
  src:url(fonts/dm-mono-500-latin-ext.woff2) format('woff2');
  unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}

/* font-display:swap means the page paints in a fallback and then re-paints in
   DM Sans, and the fallback used to be system-ui, which measures 12.4% wider
   with an ascent of 107 against DM Sans's 99. Every line therefore moved when
   the real font landed: CLS 0.075, most of it in the headline.

   This is the same idea as reserving space for an image: scale Arial until it
   occupies the box DM Sans is about to occupy, and restate its ascent and
   descent as DM Sans's, so the swap changes the glyphs and nothing else.

   Arial is named first because Liberation Sans was built to match its advance
   widths exactly, so one ratio holds on Windows, macOS and Linux alike. If a
   machine has none of the three the rule simply does not load and the stack
   falls through to system-ui, which is where it started.

   There are four faces because one is not enough: a single ratio calibrated on
   body copy at 400 left the wordmark, set at 700, growing 81px -> 91px on swap.
   Per weight it now measures 91px either way. The ratios drift a long way
   between weights, which is the browser's synthetic bold at work.

   What the whole block buys, measured: the intermittent shift in the page body
   goes, and CLS becomes the same 0.0145 on every run rather than 0.033 then
   0.014 then 0.014. It does NOT clear that remaining 0.0145, which is the nav,
   and which is still there with this block deleted. That one is the CTA pill:
   it is set at 600 with default tracking while the 600 ratio is dominated by
   headings at -.035em, and one scalar per weight cannot match every glyph mix.
   It is a 5px change in a single pill, a seventh of the "good" threshold, and
   the fix would be pinning a px width that the font-size clamp and any
   translation would immediately invalidate. Left alone on purpose.

   Do not try to fix that by naming a bold face. local('Arial Bold') resolves to
   nothing on any of the three platforms, because local() matches a full font
   name, and a face whose src resolves to nothing is skipped without a warning:
   the earlier version of this block did exactly that, quietly landed on a
   regular face, and scaled it by a ratio computed for a bold one.

   size-adjust is above 100% because the fallback comes out narrower, which is
   the opposite of what a canvas measurement of Arial says. Both are true of
   different things: asking a canvas for Arial at weight 700 gets the real bold
   cut, while a face pinned to local('Arial') is the regular cut with the
   browser's synthetic bold on top, and that is 11% narrower. The face as
   declared is the only thing worth measuring.

   These numbers are converged, not derived. .impeccable/tools/font-fallback.mjs
   registers the face, measures it against DM Sans on the page's own elements at
   each weight, corrects, and repeats until it stops moving; it settles inside
   0.05% in two passes. Re-run it after any change to the type. */
@font-face{font-family:'DM Sans fallback';font-style:normal;font-weight:400;
  src:local('Arial'),local('Liberation Sans'),local('Helvetica');
  size-adjust:103.50%;ascent-override:95.84%;descent-override:29.95%;line-gap-override:0%}
@font-face{font-family:'DM Sans fallback';font-style:normal;font-weight:500;
  src:local('Arial'),local('Liberation Sans'),local('Helvetica');
  size-adjust:105.65%;ascent-override:93.89%;descent-override:29.34%;line-gap-override:0%}
@font-face{font-family:'DM Sans fallback';font-style:normal;font-weight:600;
  src:local('Arial'),local('Liberation Sans'),local('Helvetica');
  size-adjust:108.00%;ascent-override:91.86%;descent-override:28.70%;line-gap-override:0%}
@font-face{font-family:'DM Sans fallback';font-style:normal;font-weight:700;
  src:local('Arial'),local('Liberation Sans'),local('Helvetica');
  size-adjust:111.28%;ascent-override:89.15%;descent-override:27.86%;line-gap-override:0%}

/* And a second family for display sizes, because the ratio is not one number.
   DM Sans has an optical-size axis; Arial does not. As opsz opens up DM Sans
   tightens by 10.4% (measured: -1.4% at 14px, -6.2% at 32px, saturated by 40px)
   while the fallback holds still, so the gap between them swings with size. At
   weight 600 the text band wants 108.00% and the display band 98.65%, and the
   single value that used to split the difference was wrong for both: it left
   the hero headline wrapping to three fallback lines where DM Sans takes two,
   dropping everything below it by 75px. That was 0.067 of the CLS on its own.

   Only 600 exists here because that is the only weight the page sets above
   32px. Display text at another weight would fall through to system-ui and
   shift; add the face rather than reusing one of these. */
@font-face{font-family:'DM Sans display fallback';font-style:normal;font-weight:600;
  src:local('Arial'),local('Liberation Sans'),local('Helvetica');
  size-adjust:98.65%;ascent-override:100.56%;descent-override:31.42%;line-gap-override:0%}

html{font-size:100%}

:root{
  --canvas:#fdfcfa;
  /* The alternating band tone. #f6f4f0 was 1.07:1 against the canvas, a 7%
     step that measured as an alternation and read as one flat white page.
     This is 1.19:1, which is the first value you actually see. It cannot go
     much further: at #e6e1d5 (1.27:1) --muted text on an alt band falls to
     4.39:1 and fails AA, so the ceiling here is contrast law, not taste. */
  --surface:#ece8df;
  --white:#fff;

  --ink:#0a0a0a;
  --body:#45403a;
  --muted:#6b655d;

  --line:#e8e5e0;
  --line-soft:#f1efeb;
  /* The hairline for `--surface` grounds. `--line` is mixed for white and
     disappears on beige — 1.03:1 measured. This is the same ink as the body
     text at low alpha, so it carries the page's warmth rather than a grey. */
  --surface-line:rgba(69,64,58,.2);

  --forest:#0e3b2f;
  --forest-soft:#16513f;
  --forest-line:rgba(255,255,255,.12);
  /* A recess on forest — the counterpart to `--ink-well` on ink, added
     2026-08-16. Ink had a token for this and forest did not, so the export
     panel had hand-mixed its own four times over: `rgba(0,0,0,.22)` on the stat
     tiles, `.22` again on the picker list, `.28` on the log and `.3` on the
     preview's address pill. Four alphas doing one job, and the two that were
     meant to match were the two that were written twice rather than shared.
     One token, one recess. Sampled composited over `--forest` it is #0b2e25. */
  --forest-well:rgba(0,0,0,.24);
  --mint:#7ef0c0;
  --amber:#fbd88a;
  --on-forest:#c3d6ce;

  /* The only hue outside the forest/mint/amber budget, and it is a signal
     rather than colour: a field that will not submit, and a page that did not
     render. Two values because the two grounds are opposite — 7.5:1 on canvas,
     7.3:1 on forest. Both were loose hex literals until they were named. */
  --error:#9a2b1c;
  --error-on-forest:#ff9c8a;

  /* The ink band. Not colour — a ground, the same one the footer stands on —
     so the two forest surfaces keep the whole colour budget. Card lifts off
     the band, well recesses back toward it. All three text tints clear AA on
     the well, the darker of the two surfaces. */
  --ink-card:#171717;
  --ink-well:#0e0e0e;
  --ink-line:rgba(255,255,255,.10);
  --on-ink:#c9c5c0;      /* 10.5:1 on card */
  --on-ink-soft:#a5a09a; /* 7.4:1 on well */
  --on-ink-dim:#85807a;  /* 4.9:1 on well */

  --r-sm:.5rem;
  --r-md:.75rem;
  --r-lg:1rem;
  --r-hero:2rem;
  --pill:9999px;

  --nav-h:4.25rem;
  /* Band padding and the gap under a band's heading. Both were tighter and,
     worse, the heading gap was five different values across five sections. */
  --band:clamp(4.5rem,9.5vw,10rem);
  --gap-head:clamp(2.25rem,4vw,3.5rem);
  --pad:clamp(1.5rem,4vw,4.5rem);

  --ease:cubic-bezier(.16,1,.3,1);

  /* One stack, because the metric-matched fallback only prevents the layout
     shift on the elements that actually name it, and this was declared in four
     separate places. --mono has no matched fallback: it sets small labels well
     below the fold, so its swap costs nothing measurable. */
  --sans:'DM Sans','DM Sans fallback',system-ui,-apple-system,sans-serif;
  /* Same font, differently sized stand-in. Belongs on anything set at 32px or
     more; the list of those is h1, h2, .score, .shift-cost strong and the step
     counter. If a new rule crosses 32px it needs this too, and
     .impeccable/tools/font-fallback.mjs will name it in the display bucket. */
  --sans-display:'DM Sans','DM Sans display fallback',system-ui,-apple-system,sans-serif;
  --mono:'DM Mono',ui-monospace,monospace;

  /* DM Sans's "0" advance, frozen. A measure written in `ch` sounds font-proof
     and is the opposite: `ch` is the width of the current font's zero, so while
     DM Sans loads, a `calc(17 * var(--ch))` headline is measured in Arial's zero, which is 17%
     narrower. The container shrinks at the same moment the fallback text gets
     wider, and no size-adjust can rescue it -- scaling the font scales its zero
     too, so the text-to-container ratio is invariant. That is why the hero
     headline took three fallback lines at every value from 94% to 108%.
     Written in em the cap stops depending on the font while still tracking
     font-size, and the DM Sans rendering is unchanged to within 0.2px. */
  --ch:0.6858em;
}

*{box-sizing:border-box}

/* the nav is sticky, so without this every anchor in it parks its own section
   heading underneath the bar it was clicked from */
html{scroll-behavior:smooth;scroll-padding-top:calc(var(--nav-h) + 1.5rem)}
body{
  margin:0;
  background:var(--canvas);
  color:var(--ink);
  font-family:var(--sans);
  font-size:1rem;
  line-height:1.5;
  -webkit-font-smoothing:antialiased;
}

.shell{width:min(100% - 3rem,76rem);margin-inline:auto}
.shell.narrow{width:min(100% - 3rem,48rem)}
@media (max-width:700px){
  .shell,.shell.narrow{width:min(100% - 2rem,76rem)}
}

h1,h2,h3{margin:0;letter-spacing:-.035em;text-wrap:balance}

/* Every block of running text gets a wrap rule, not just the ones somebody
   remembered. A sweep of all twelve supported viewports found 68 paragraphs
   ending on a line under a fifth of the width of the longest — the fault Roman
   reported in the hero, present in the FAQ answers, the limits list, the price
   caption and the footer. `pretty` is the right default for a paragraph: it
   holds the last line back from collapsing without re-ragging the whole block,
   which is what a five-line answer wants. Short centred blocks take `balance`
   instead, individually, where an even two lines is the better shape. */
p,dd,li,figcaption,blockquote{text-wrap:pretty}
h1{font-family:var(--sans-display);font-size:clamp(2.2rem,5.75vw,3.7rem);font-weight:600;line-height:1.1;letter-spacing:-.04em}
h2{font-family:var(--sans-display);font-size:clamp(1.75rem,3vw,2.5rem);font-weight:600;line-height:1.15}
h3{font-size:1.125rem;font-weight:600;line-height:1.35;letter-spacing:-.02em}
p{margin:0}
a{color:inherit}

.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;
  clip:rect(0 0 0 0);white-space:nowrap;border:0}

.skip{position:absolute;left:-9999px;top:0;background:var(--ink);color:#fff;
  padding:.75rem 1.25rem;border-radius:0 0 var(--r-sm) 0;z-index:99;font-weight:500}
.skip:focus{left:0}

:where(a,button,summary,input):focus-visible{
  outline:2px solid var(--ink);outline-offset:3px;border-radius:4px}

.mono{font-family:var(--mono);font-size:.9em}

/* ---------- nav ---------- */

/* Opaque, not frosted. At rest the nav is in flow above the hero and overlaps
   nothing, so the translucency bought nothing there; once stuck it overlapped
   four ink bands and the white body text under it stayed legible straight
   through the bar. A flat fill plus the hairline below is also what the rest of
   the system does, and it drops a 10px backdrop blur off a sticky element. */
.nav-wrap{position:sticky;top:0;z-index:20;background:var(--canvas);
  border-bottom:1px solid transparent;
  transition:border-color .25s var(--ease)}
.nav-wrap.stuck{border-bottom-color:var(--line)}
.nav{display:flex;align-items:center;gap:2rem;height:var(--nav-h)}
/* Wordmark only. The mark is a licensed stock glyph, which is the right call
   for a 16px browser tab and the wrong one next to a wordmark this
   distinctive: at 26px it added a generic element to an identity whose
   strength is its lettering. It also made the nav overflow at 320px. The
   favicon keeps it, where being an identifier matters more than being owned. */
.mark{font-size:1.1875rem;font-weight:700;letter-spacing:-.04em;text-decoration:none}
.nav-links{margin-left:auto;display:flex;gap:1.75rem;font-size:.875rem;font-weight:500;
  color:var(--body)}
.nav-links a{text-decoration:none;transition:color .15s var(--ease)}
.nav-links a:hover{color:var(--ink)}
/* The links used to vanish outright below 1075px, which left a phone and even
   a 768px tablet with no way to reach any section but the top. Now they only
   tighten there, and become a panel on a phone. */
/* `-.6rem` was tuned when this sat in the old icon-plus-wordmark lockup and it
   left the glyph 3px inside the shell's right edge, which every other element
   on the bar honours exactly. The extra 3px puts the bars' own right edge on
   that line; the 44px target still overhangs it, which is what a target is
   supposed to do. */
.nav-toggle{display:none;align-items:center;justify-content:center;width:2.75rem;
  height:2.75rem;margin-left:.25rem;margin-right:calc(-.6rem - 3px);border:0;
  background:none;color:var(--ink);cursor:pointer;border-radius:var(--r-sm)}

/* The bars fold into a close mark. A menu button that looks identical open and
   shut makes the panel the only evidence of its own state, and on a phone the
   panel is below the thumb that just pressed it.

   `view-box` units equal CSS pixels here (18x12 viewBox at 18x12), so the
   origin is the glyph's centre. Read right to left: each outer bar is moved
   onto the centre line, shortened so the rotated arms stay inside the box, and
   then turned. `overflow:visible` is belt and braces for the round caps. */
.burger{overflow:visible}
.burger path{transform-box:view-box;transform-origin:9px 6px;
  transition:transform .22s var(--ease),opacity .12s var(--ease)}
#nav-toggle[aria-expanded="true"] .burger-top{transform:rotate(45deg) scale(.86) translateY(5px)}
#nav-toggle[aria-expanded="true"] .burger-mid{opacity:0}
#nav-toggle[aria-expanded="true"] .burger-bot{transform:rotate(-45deg) scale(.86) translateY(-5px)}

@media (max-width:1075px){
  .nav-links{gap:1.15rem;font-size:.8125rem}
}
@media (max-width:820px){
  .nav-links{gap:.9rem}
  .nav{gap:1rem}
}

/* Kept from the lockup era, when the icon pushed the bar 16px past a 320px
   viewport. The wordmark alone fits without it, but the bar is still the
   tightest row on the page there, so the margin stays. */
@media (max-width:380px){
  .nav{gap:.5rem}
  .mark{font-size:1.0625rem}
  .nav .pill{padding:0 1rem;font-size:.8125rem}
}

@media (max-width:699px){
  .nav-toggle{display:inline-flex}
  .nav .pill{margin-left:auto}
  /* .nav-wrap is sticky, so it is already the containing block for this.

     Padding was `.35rem 0 .6rem`: 5px of dead strip above the first row and
     11px below the last, asymmetric for no reason and unreachable by a thumb.
     It is zero now, so the rows run edge to edge and every pixel of the panel
     is a target. The panel's own bottom hairline closes the list; the rows'
     `--line-soft` separators sit between them, so nothing doubles up. */
  .nav-links{position:absolute;top:100%;left:0;right:0;margin:0;padding:0;
    flex-direction:column;gap:0;font-size:1rem;
    background:var(--canvas);border-bottom:1px solid var(--line);
    display:none;opacity:0;transform:translateY(-.375rem);
    /* the same discrete-display pattern `.field-error` uses, so the panel
       animates both ways with no JS: app.js only ever toggles `.open` */
    transition:opacity .2s var(--ease),transform .2s var(--ease),
               display .2s allow-discrete}
  .nav-links.open{display:flex;opacity:1;transform:none}
  @starting-style{.nav-links.open{opacity:0;transform:translateY(-.375rem)}}
  /* Rows measure 51px on the first and 52px on the rest. That is the 1px
     separator inside the border-box, not a rhythm error, and it is what makes
     the ruled gaps between rows even. Checked before touching it. */
  .nav-links a{padding:.85rem max(1rem,calc((100vw - min(100% - 2rem,76rem)) / 2));
    color:var(--ink)}
  .nav-links a + a{border-top:1px solid var(--line-soft)}

  /* The panel is opaque, but it opened over a hero that stayed fully lit, so
     the page read as two competing layers with the menu the weaker of the two.
     A scrim settles which one is in front and gives the dismiss gesture
     something to aim at.

     `pointer-events` stays on deliberately: app.js closes on any click outside
     `#nav-toggle` and `#nav-menu`, so the scrim swallows the tap and closes the
     panel instead of letting it fall through and follow a hero link the visitor
     could not see. Driven by `:has()` off the same `.open` class the panel uses,
     so app.js still owns one piece of state and nothing new is toggled. */
  body::after{content:"";position:fixed;inset:var(--nav-h) 0 0;z-index:19;
    background:rgba(10,10,10,.32);opacity:0;pointer-events:none;
    transition:opacity .2s var(--ease)}
  body:has(.nav-links.open)::after{opacity:1;pointer-events:auto}
}

.pill{display:inline-flex;align-items:center;justify-content:center;height:2.5rem;
  padding:0 1.375rem;border-radius:var(--pill);font-size:.875rem;font-weight:600;
  text-decoration:none;background:var(--ink);color:#fff;border:0;cursor:pointer;
  white-space:nowrap;transition:background .16s var(--ease),transform .16s var(--ease)}
.pill:hover{background:#2a2a2a}
.pill:active{transform:translateY(1px)}
.pill.light{background:var(--mint);color:var(--forest)}
.pill.light:hover{background:#96f5cd}

/* ---------- hero ---------- */

/* One viewport, and nothing below it. `svh` not `vh` so a phone's shrinking
   URL bar cannot push the next band into view, minus the nav because the nav
   is in flow above this.

   The earlier attempt at this pinned the hero to 100svh and centred a short
   composition in a tall box, which banked the remainder as dead air. The fix
   is not to give up on the viewport, it is to let the composition itself grow
   into a tall one: every gap in the stack below is keyed to `vh` with a
   ceiling high enough to matter, so a 1440-tall display spends its height on
   the spacing rather than on one lump of padding. What is left after that is
   centred, and on a display that tall the remainder reads as air around a
   composition rather than a composition stranded in a box.

   Above roughly 1200px of viewport height the h1 is pinned at the craft
   floor's 6rem display ceiling and cannot take up more, so some centred
   remainder is the honest cost of the guarantee. */
/* the nav is --nav-h tall plus its own 1px bottom hairline, and that hairline
   is the difference between landing on the fold and hanging 1px past it */
.hero{min-height:calc(100svh - var(--nav-h) - 1px);
  display:flex;flex-direction:column;justify-content:center;
  padding-block:clamp(1.5rem,5.6vh,7rem);text-align:center}
.hero h1{max-width:calc(17 * var(--ch));margin:0 auto;
  /* the vh term protects a short laptop, the vw term lets a big display use its
     size; 6rem is the craft floor's display ceiling. A 5rem cap sat below what
     the viewport was already asking for (109px at 2560, 82px at 1920) and banked
     the difference as dead air. Nothing changes at 1440 and below. */
  font-size:clamp(2.2rem,min(5.2vw,7.6vh),6rem)}
/* The headline marker. A real highlighter: a mint band sized to the x-height,
   sitting behind ink type rather than reversing it out. Because the band is
   shorter than the glyphs it can never touch the line above, which is what a
   filled slab could not promise (its box came out 77px against a 75px line
   box and sat on the descenders of the line above).

   Painted as a background gradient rather than a positioned pseudo-element so
   that `box-decoration-break:clone` carries it across a line break: on a phone
   the phrase wraps, and a ::before would have banded only the first fragment. */
.hl{
  background-image:linear-gradient(var(--mint),var(--mint));
  background-repeat:no-repeat;
  /* Measured from DM Sans itself, not guessed: at 68.4px the x-height top sits
     0.482em below the content-box top and the baseline at 0.994em. The band
     starts a hair above the first and ends a hair below the second, so it
     covers the letter bodies and clears the cap heights entirely. */
  background-size:100% .60em;
  background-position:0 .45em;
  padding-inline:.09em;
  -webkit-box-decoration-break:clone;box-decoration-break:clone;
}

.lede{font-size:clamp(1rem,min(1.3vw,2vh),1.25rem);line-height:1.55;color:var(--body);
  max-width:calc(52 * var(--ch));margin:clamp(.9rem,2.2vh,2.25rem) auto 0;
  /* `balance`, not `pretty`. This is a short centred block under display type,
     where an even rag is the shape; `pretty` only refuses a one-word last line
     and let it break 3 lines with the last at 18% of the longest at 2560, and
     4 lines with the last at 27% on a phone. */
  text-wrap:balance}

/* The product's one control, at full strength. It was 40px tall and outlined in
   `--line` — a white pill on a near-white canvas, held by a hairline you have to
   look for — on a page whose display type runs to 96px. The closing band is the
   worse case: there the field is the only object, and it read as a placeholder.

   The amplification is the system's own language rather than a new one: hairline
   chrome turned up to `--ink`, and the control sized to match what it is. No
   colour is spent, so the two forest cards keep the whole budget. */
.field{display:flex;gap:.5rem;background:var(--white);border:1px solid var(--ink);
  border-radius:var(--pill);padding:.4rem .4rem .4rem 1.7rem;align-items:center;
  max-width:46rem;margin:clamp(1.4rem,3.2vh,3.25rem) auto 0}
/* The resting border is now ink, so `border-color` can no longer say "focused".
   This is the system's own focus ring — the same 2px ink at 3px offset that
   every other control uses — moved onto the wrapper, with the input's ring
   suppressed so the two do not stack. `:has(input:focus)` rather than
   `:focus-within` keeps the ring off the field when the button inside is the
   thing being tabbed to; the button draws its own. */
.field:has(input:focus){outline:2px solid var(--ink);outline-offset:3px}
.field input:focus-visible{outline:0}
.field input{flex:1;border:0;outline:0;font:inherit;font-size:1.125rem;background:none;
  min-width:0;color:var(--ink);height:3.5rem}
.field input::placeholder{color:#78716a} /* 4.8:1 on white; #9d968c was 2.9:1 */
.field button{border:0;background:var(--ink);color:#fff;font:inherit;font-weight:600;
  font-size:1rem;height:3.5rem;padding:0 1.9rem;border-radius:var(--pill);cursor:pointer;
  white-space:nowrap;transition:background .16s var(--ease),transform .16s var(--ease)}
.field button:hover{background:#2a2a2a}
/* the same press the nav pill has. It is the button the whole page drives at;
   it should not be the one control that gives nothing back on the way down */
.field button:active:not([disabled]){transform:translateY(1px)}
/* was #b6b1a9, which is 2.13:1 against white — the status indicator during
   the longest wait on the site, unreadable. #5f5a53 is 6.5:1 and still
   reads as 'not pressable' beside the ink of the enabled state. */
.field button[disabled]{background:#5f5a53;cursor:default}

@media (max-width:700px){
  .field{flex-direction:column;gap:.5rem;border-radius:1.375rem;padding:.9rem;align-items:stretch}
  /* left-aligned, not centred with the band: a URL is content being edited, and
     a centred caret drifts as it is typed.

     `flex:none` is load-bearing. The form turns into a column here, so `flex:1`
     from the base rule applies to the vertical axis, and its `flex-basis:0%`
     outranks `height`. The site's primary input was rendering 21px tall on
     every phone because of it: half its intended size and far under any touch
     guideline. */
  .field input{flex:none;height:3rem;padding:0 .65rem;text-align:left;font-size:1.0625rem}
  .field button{width:100%;height:3.125rem}
}

/* ---------- the hero, grouped ---------- */

/* The stack holds three groups — [h1 + lede] the claim, [field + pills] the
   control, [sub] the footnote — and until 2026-08-09 its gaps said the
   opposite at every width. Measured at 390x844: 18.5 / 27 / 43.7 / 14.4, and at
   1440x900: 19.8 / 28.8 / 44.3 / 14.4. The widest gap on the whole stack fell
   *inside* the control group and the tightest fell between the pills and an
   unrelated sentence, so the pills read as part of the caption rather than as
   the thing that governs the field directly above them.

   The 44px was nobody's decision: 8px, plus the 22px reserved-but-invisible
   `.field-error` row, plus the pills' own margin. That reservation earns its
   place — inserting the row on a rejected submit used to move the h1 by 16.5px
   — but it does not have to sit in the middle of a group to do its job. The
   shell is a flex column so `order` can take the reserved row *below* the
   pills. The DOM is untouched, so `aria-describedby` still binds the message to
   the input and `role="alert"` still announces it on show.

   Fixed on the phone first (Roman reported it there), then on desktop when he
   saw the same fault survive at 1440. One rule for both now, rather than a
   phone exception that quietly disagrees with the wide layout. */
.hero .shell{display:flex;flex-direction:column}
/* An auto inline margin overrides `align-items:stretch` in a flex column, so
   every child that had been filling the shell shrinks to fit its own text the
   moment the shell turns flex — the field lost 114px of width. This hands the
   block behaviour back: the max-widths still cap, the auto margins still
   centre. Found in the browser, not in the stylesheet. */
.hero .shell > *{width:100%}
.hero h1{order:0}
.hero .lede{order:1}
.hero .field{order:2}
.hero .modes-row{order:3}
.hero .hero-note{order:4}

/* The control group's rhythm. Measured 2026-08-09 at 20 / 29 / 14 / **38** — Roman
   called the last one out, and he was right: the reassurance line sat further from the
   pills than the lede sits from the field, so the closing note read as detached rather
   than as the end of the group.

   The 38 was not a decision. `.field-error` had its own row here (12px margin + 22px
   reserved height + the sub's 4px), parked below the pills by an earlier fix that was
   right to get it out of the field/pills group but left its height behind. Stacking the
   two notes in one grid cell deletes the artefact and makes this gap a number somebody
   chose. `.field-error[hidden]` keeps `display:block` and fades on `visibility`, so the
   cell is always as tall as the taller child and a rejected submit still moves the h1
   0.0px — now by construction rather than by reservation.

   Both gaps stay keyed to `vh` like the rest of this stack, because the hero's whole
   premise is that the composition grows into a tall viewport rather than banking the
   remainder as dead air. */
.hero .modes-row{margin-top:clamp(.75rem,1.6vh,1.4rem)}
.hero .hero-note{display:grid;margin-top:clamp(1.1rem,2.2vh,1.5rem)}
/* one cell, both children in it; their own vertical margins would offset the stack */
.hero .hero-note > *{grid-area:1/1;margin-block:0}
/* Sharing a cell means they have to take turns, or the message prints on top of the
   reassurance line — which it did, at both widths, until this rule. `:has()` keeps the
   swap in CSS rather than adding a second class for app.js to hold in sync with
   `hidden`, and it reads off the same attribute `errorFor()` already toggles. The
   reassurance line is the one that yields: it is true at every moment, and the message
   is only true at this one. */
.hero .hero-note .sub{transition:opacity .2s var(--ease),visibility .2s}
.hero .hero-note:has(.field-error:not([hidden])) .sub{opacity:0;visibility:hidden}

@media (max-width:700px){
  /* `.lede` carries `balance` at every width now, so these only set the gaps */
  .hero .lede{margin-top:1rem}
  .hero .field{margin-top:2rem}
  .hero .modes-row{margin-top:.875rem}
}

/* At 320 the validation message wraps to two lines and overruns a row reserved
   for one, which is what the reservation exists to prevent — the h1 moved 4.5px
   there even before the regrouping. Reserving the second line costs 20px at the
   narrowest supported width, and the hero simply grows past the fold, which
   DESIGN.md already accepts: nothing from the next band is visible either way. */
@media (max-width:380px){
  .hero .field-error{min-height:2.625rem}
}

/* scroll-margin so scrollIntoView('nearest') leaves the line clear of the edge
   rather than flush against it */
/* The row is reserved, not inserted. The hero is a centred flex column, so
   adding this element on a rejected submit redistributed the whole stack: the
   h1 rose 16.5px and everything below the field dropped by the same, at every
   viewport tested. The one moment the page must hold still was the one moment
   it moved, and what moved was the headline, which had nothing to do with the
   mistake. Measured cost of reserving it: zero. The hero's centred slack
   absorbs the 22px, so the fold guarantee still holds at 1440x900, 1366x768,
   1440x700 and 390x844. */
.field-error{max-width:46rem;margin:.75rem auto 0;font-size:.875rem;color:var(--error);font-weight:500;
  min-height:1.375rem;
  scroll-margin-block:2.5rem;
  /* the line drops out of the field rather than popping into the gap. Discrete
     `display` plus @starting-style means the `hidden` toggle in app.js animates
     on its own, both ways, with no JS. Same .16s the field border uses, so a
     rejected submit reads as one movement. */
  opacity:1;transform:none;
  transition:opacity .16s var(--ease),transform .16s var(--ease),
             display .16s allow-discrete}
/* `visibility`, not `display`: `[hidden]`'s UA `display:none` would take the
   reserved box out of flow again and reinstate the jump. */
.field-error[hidden]{opacity:0;transform:translateY(-.25rem);
  display:block;visibility:hidden}
@starting-style{.field-error{opacity:0;transform:translateY(-.25rem)}}
/* A measure and a wrap rule, which this line never had. Without them it fills
   whatever column it is given and spills the remainder: at 390px it broke
   345 / 28, leaving "it is." alone on line two under a full line. `.lede`
   directly above it has carried a cap and a wrap rule since the beginning;
   this is the same treatment, and it lands the break at 175 / 198.

   **The cap is 40ch, and the exact number is load-bearing.** It was 34ch for
   half a day, which is narrower than the sentence, so a line that had always
   been one line on desktop started breaking into two at every width above the
   phone — a regression introduced by a fix aimed at phones and caught by Roman
   in a 1440 screenshot. At 40ch the cap sits just wider than the sentence, so
   desktop keeps one line, while a phone column (358px at 390) is narrower than
   the cap anyway and `balance` still splits it into two even lines. One value,
   both behaviours; re-measure it if this copy ever changes. */
.sub{font-size:.9375rem;color:var(--muted);margin-top:.9rem;
  max-width:calc(40 * var(--ch));margin-inline:auto;text-wrap:balance}

/* lives inside the forest floor now, so it is tinted from that hue, not grey */
.demo-note{font-size:.875rem;color:var(--on-forest);line-height:1.5;
  margin-top:clamp(.7rem,1.5vh,1.1rem);padding-top:clamp(.7rem,1.5vh,1.1rem);
  border-top:1px solid var(--forest-line)}
.demo-note.has-action{display:flex;align-items:center;gap:1.25rem;flex-wrap:wrap}
.demo-note.has-action .pill{height:2.75rem;padding:0 1.75rem;font-size:.9375rem}
/* 52ch capped this sentence to 499px inside a space ~900px wide, so an 88-character
   line that fits comfortably on one broke into two with "being set up." alone on the
   second. 62 is the measure it actually needs and stays inside the system's own
   60–66 rule; `balance` covers the wrap if the sentence is ever reworded longer. */
.note-aside{max-width:calc(62 * var(--ch));text-wrap:balance}
/* The checkout failed. `--error-on-forest` is the colour a page that did not
   render already uses, so the one thing on the panel that costs money reports a
   problem in the panel's own established vocabulary rather than a new one. */
.note-aside.note-error{color:var(--error-on-forest)}
/* The paid folder's own address, printed so it can be carried to another
   machine. Underlined rather than given the mint pill: it is a place to come
   back to, not the action on this screen — the download beside it is that. */
.note-link{color:#fff;text-decoration:underline;text-underline-offset:2px;
  text-decoration-color:var(--forest-line);word-break:break-all}
.note-link:hover{text-decoration-color:var(--mint)}

/* ---------- the live export panel ---------- */

/* A contained card in the column, not a full-bleed floor. Same width as the
   field above it, so the claim, the action and the proof read as one stack. */
.run-panel{max-width:44rem;margin:clamp(1.4rem,3.2vh,3.25rem) auto 0;
  background:var(--forest);color:#fff;border-radius:1.5rem;text-align:left}
.floor-in{padding:1.25rem 1.5rem 1.4rem}

/* The swap from pipeline to verdict, which is the whole product in one moment.
   It used to be a single frame: the stage stack vanished, and the card jumped
   several hundred pixels while the rows were still fading in underneath. Now
   the stack steps aside first, then the card grows into its new size and the
   rows settle into the space as it opens.

   The height itself is animated in app.js, where both measurements exist; all
   this needs to do is clip while the box is moving, so growing content cannot
   spill the radius on the way. */
.floor-idle{transition:opacity .14s var(--ease),transform .14s var(--ease)}
.floor-idle.leaving{opacity:0;transform:translateY(-.25rem)}
.run-panel.resizing{overflow:hidden}
.hero-foot{margin-top:clamp(1rem,2.4vh,2.5rem);font-size:.875rem;color:var(--muted);
  max-width:calc(56 * var(--ch));margin-inline:auto;text-wrap:pretty}

.floor-head{display:flex;align-items:center;gap:1rem;flex-wrap:wrap;
  padding-bottom:clamp(.6rem,1.4vh,1.15rem);border-bottom:1px solid var(--forest-line)}
.floor-head strong{font-size:1.0625rem;font-weight:600;letter-spacing:-.015em;flex:1;
  min-width:0;overflow-wrap:break-word}
/* Wedged between two nowrap siblings, the visitor's own hostname was
   rendering in a 33px column across six lines at 360px — a product whose
   one claim is 'your site will not break' showing their domain broken, in
   the panel that exists to prove it. Given its own line, it has the full
   width and needs no breaking at all. */
@media (max-width:560px){
  .floor-head{row-gap:.5rem}
  .floor-head strong{flex-basis:100%;order:-1;font-size:1rem}
}
.live{display:inline-flex;align-items:center;gap:.55rem;font-size:.875rem;
  color:var(--on-forest);white-space:nowrap}
/* The dot and its `beat` keyframes are gone — see the comment in index.html. The
   state now reads off the word alone, which is what it always actually said. */
[data-state="running"] .live,[data-state="done"] .live{color:var(--mint)}

/* idle: the real pipeline, waiting. Nothing here is invented. */
.floor-idle{padding-top:clamp(.9rem,2vh,1.4rem)}
.floor-title{font-size:1.125rem;font-weight:600;letter-spacing:-.02em;
  margin-bottom:clamp(.8rem,1.8vh,1.15rem)}
/* `hidden` is a UA-level `display:none`, and any author rule that sets
   `display` on the same element outranks it. `.stage-chips` and `.meters` both
   declare `display:grid`, so `showFailure()` set `hidden = true` on the meters
   and the browser rendered them anyway: a failed export displayed four counters
   reading 0 pages, 0 assets, 0MB, 0s. Measurements that were never taken, shown
   as though they had been. The rest are guarded too so the trap cannot come
   back the next time one of them gains a display rule.

   **That last sentence was wrong, and it cost a bug on 2026-08-16.** This guard
   is `(0,2,0)` — one class plus one attribute — so it does not outrank a later
   `(0,2,0)` rule, it only outranks an earlier one. The stats/log split added
   `.run-grid > .log{display:block}` further down the file at exactly the same
   specificity, and source order handed it the win: `#log` rendered 1216x31 with
   `hidden` set, an empty dark bar sitting under the headline on idle, both
   failure states and stopped. Measured, not inferred — `hasAttribute('hidden')`
   true, `getComputedStyle().display` block.

   So the rule for anything added later: **a selector that sets `display` on one
   of these elements must carry `:not([hidden])` itself.** The guard cannot do
   it for you at this specificity, and raising the guard with `!important` would
   only move the fight somewhere less obvious. */
.stage-chips[hidden],.meters[hidden],.log[hidden],.bar[hidden],
.picker[hidden],.floor-result[hidden],.viewer[hidden],
/* `.picker-acts` and `.picker-list` joined the list 2026-08-16, when the picker
   grew a wait screen that hides them: `.picker-acts` declares `display:flex`
   and `.picker-list` takes `display:grid` above 60rem, so setting `hidden` on
   either did precisely nothing. Third instance of this trap in one file. */
.picker-acts[hidden],.picker-list[hidden]{display:none}

.stage-chips{list-style:none;margin:0;padding:0;display:grid;
  grid-template-columns:repeat(auto-fit,minmax(6.5rem,1fr));gap:.5rem}
.stage-chips li{display:flex;align-items:center;gap:.5rem;border:1px solid var(--forest-line);
  border-radius:.6rem;padding:.7rem .75rem;font-size:.8125rem;
  /* .42 composited over forest is 3.51:1 — an AA failure on the most
     prominent text above the fold, and it contradicted this system's own
     'verified, not assumed' claim. .66 is 5.4:1 and the dim/lit contrast
     still reads clearly against .done and .now below. */
  color:rgba(255,255,255,.66);
  transition:color .3s var(--ease),border-color .3s var(--ease)}
.stage-chips li::before{content:"";width:.5rem;height:.5rem;border-radius:50%;flex:none;
  box-shadow:inset 0 0 0 1px currentColor;transition:background .3s var(--ease)}
.stage-chips li.done{color:var(--on-forest);border-color:rgba(255,255,255,.2)}
.stage-chips li.done::before{background:var(--mint);box-shadow:none}
.stage-chips li.now{color:#fff;border-color:var(--mint)}
/* The active stage no longer pulses either. These five dots stay, because unlike the
   ones deleted above they carry state — an empty ring is a stage that has not run, a
   filled one has — but the chip is already the only white one with a mint border, so
   the pulse was a third signal for a fact twice stated. It was also the last thing
   moving in a panel where the bar and the counters are already moving. */
.stage-chips li.now::before{background:var(--mint);box-shadow:none}
/* The stage's own count, printed inside the chip it belongs to rather than in a
   separate readout. "Crawling 7/10" answers the question the bar cannot: not how
   far along, but how much there turned out to be.

   It moved from the chip's right edge onto a second line, 2026-08-16. Two
   reasons, and the first is the one that decided it. As a right-aligned number
   it could only ever be a number — the three stages with nothing to count
   (`launch`, `rewrite`, `verify`) rendered an empty chip, so three of five said
   nothing about themselves for the whole run. On its own line it can hold a
   phrase, so every stage says where it got to: "Done", "10 pages", "156 files",
   "Creating ZIP…". Second, at the band's full width `margin-left:auto` put the
   count ~150px from the label it belonged to, which is the same fault the
   picker's route had, one component over. */
.stage-chips li{flex-direction:column;align-items:flex-start;gap:.15rem;
  position:relative;padding-left:1.9rem}
.stage-chips li::before{position:absolute;left:.8rem;top:1.05rem}
.stage-chips li b{font-weight:400;font-variant-numeric:tabular-nums;
  color:var(--on-forest);font-size:.6875rem;opacity:.8}
.stage-chips li.now b{color:var(--mint);opacity:1}
.stage-chips li b:empty{display:none}

/* Counters. Tabular figures because these tick upward and a proportional font
   makes the whole row jitter on every increment. */
/* The bar's own label row: what is being measured, hard left; how far, hard
   right. Tabular figures so the percentage does not jitter as it climbs. */
.bar-head{display:flex;justify-content:space-between;align-items:baseline;
  margin:clamp(1rem,2.2vh,1.35rem) 0 .45rem;font-size:.8125rem;color:var(--on-forest)}
.bar-head b{color:#fff;font-weight:600;font-variant-numeric:tabular-nums}
.bar-head[hidden]{display:none}
.bar-head + .bar{margin-top:0}
/* The head follows the bar rather than being toggled beside it. `.bar` is
   hidden and unhidden from four places in app.js and a fifth would eventually
   forget its label; a sibling selector cannot look backwards, but `:has` on the
   shared parent can, so the two states cannot drift apart. */
.floor-idle:has(#bar[hidden]) .bar-head{display:none}

/* Stats column beside the log. The stats set the row height and the log takes
   it exactly: `height:0` keeps the log out of grid row sizing, so twenty-two
   saved files cannot stretch four tiles into four tall empty boxes, and
   `min-height:100%` then fills whatever the tiles asked for. Bottom-anchored,
   because a log that overflows should drop its oldest line, not its newest. */
.run-grid{display:grid;gap:.6rem;margin-top:clamp(.8rem,1.8vh,1.1rem)}
.run-grid:not(:has(> :not([hidden]))){margin-top:0}
@media (min-width:52rem){
  .run-grid:has(> .meters:not([hidden])):has(> .log:not([hidden])){
    grid-template-columns:minmax(11rem,1fr) 3.2fr;align-items:stretch}
  .run-grid:has(> .log:not([hidden])) > .meters{grid-template-columns:1fr;
    align-content:start;gap:.5rem}
  .run-grid:has(> .log:not([hidden])) > .meters div{text-align:left}
  /* `height:0` keeps the log out of grid row sizing so it cannot stretch the
     tiles; `min-height:100%` then fills exactly what the tiles asked for.

     It fills from the TOP and is scrolled to the bottom by app.js as lines
     arrive, rather than being bottom-anchored with `justify-content:flex-end`.
     Bottom-anchoring is only correct once the log overflows: before that it
     pushes four lines to the floor of a 340px box and leaves a void above them
     for the first ten seconds of every run, which is the part of the run that
     most needs to look like something is happening. Scrolling gives both — top
     alignment while it is short, oldest-line-drops once it is long. */
  /* `:not([hidden])` is the guard this rule has to carry itself — see the note
     on the `[hidden]` block above. Without it this sets `display` at the same
     specificity as that guard and later in the file, so a hidden log renders. */
  .run-grid > .log:not([hidden]){height:0;min-height:100%;overflow-y:auto;scrollbar-width:none;
    /* `display:block` is load-bearing, not tidying. The base `.log` rule is a
       flex column with `justify-content:flex-end`, which is how it was
       bottom-anchored inside its old fixed 8.5rem box — and overriding only
       `overflow` left that intact, so the first build of this scrolled a
       container whose content was still pinned to its floor and looked exactly
       as it had before. Normal block flow is what makes scrollTop mean
       anything here. */
    display:block}
  .run-grid > .log::-webkit-scrollbar{display:none}
}

.meters{display:grid;grid-template-columns:repeat(4,1fr);gap:.5rem;
  margin-top:clamp(.85rem,1.9vh,1.2rem)}
.meters div{background:var(--forest-well);border:1px solid var(--forest-line);
  border-radius:.6rem;padding:.7rem .5rem;text-align:center}
.meters b{display:block;font-size:clamp(1.25rem,3.4vw,1.6rem);font-weight:600;
  letter-spacing:-.02em;font-variant-numeric:tabular-nums;line-height:1.1}
.meters b i{font-style:normal;font-size:.6em;font-weight:500;margin-left:.1em;
  color:var(--on-forest)}
.meters span{display:block;margin-top:.25rem;font-size:.75rem;color:var(--on-forest)}
@media (max-width:520px){
  .meters{grid-template-columns:repeat(2,1fr)}
}

/* The log. aria-hidden in the markup on purpose: every line here is also spoken
   through #report-live as one short sentence per stage, and letting a screen
   reader follow a filename-per-frame feed would be unusable. This is for the
   eyes — evidence that something real is happening to real files. */
.log{list-style:none;margin:.6rem 0 0;padding:.6rem .75rem;border-radius:.6rem;
  background:var(--forest-well);border:1px solid var(--forest-line);
  font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.75rem;
  line-height:1.7;height:8.5rem;overflow:hidden;display:flex;
  flex-direction:column;justify-content:flex-end}
.log li{display:flex;gap:.6rem;color:var(--on-forest);white-space:nowrap}
/* .45 composites to ~4.3:1 on the well and fails AA at 12px. .55 is ~5.7:1 and
   still reads as the quieter half of the line. */
.log li span{color:rgba(255,255,255,.55);flex:none;font-variant-numeric:tabular-nums}
.log li em{font-style:normal;overflow:hidden;text-overflow:ellipsis}
.log li:last-child{color:#fff}
@media (prefers-reduced-motion:no-preference){
  .log li{animation:logIn .18s var(--ease) both}
}
@keyframes logIn{from{opacity:0;transform:translateY(.3rem)}}

/* ---------- the preview ---------- */

/* Browser chrome around the visitor's own rebuilt site. The frame is deliberately
   plain: everything inside it belongs to someone else's design, and a decorated
   surround would compete with the one thing this panel exists to show. */
/* The top margin is load-bearing, added 2026-08-12. Without it the gap between
   `.floor-head`'s bottom edge and this frame's top edge measured exactly 0px, and
   both draw a 1px `--forest-line` — so the two stacked into an accidental 2px
   rule, and because this frame's corners are rounded while the head's rule is
   straight, the forest ground showed through in a notch at each end of it. The
   head rule went back to reading as a divider and the frame back to reading as an
   object with an outline the moment they stopped sharing a pixel row. It matches
   the bottom margin, so the frame sits in symmetrical air. */
.viewer{margin:1.15rem 0;border-radius:.75rem;overflow:hidden;
  border:1px solid var(--forest-line);background:var(--ink-well)}
.viewer-bar{display:flex;align-items:center;gap:.75rem;padding:.55rem .75rem;
  background:rgba(255,255,255,.06);border-bottom:1px solid var(--forest-line)}
.viewer-url{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
  font-size:.75rem;color:var(--on-forest);background:var(--forest-well);
  border-radius:var(--pill);padding:.3rem .7rem}
.viewer-sizes{display:flex;gap:.25rem;flex:none}
.viewer-sizes button{border:1px solid transparent;background:none;font:inherit;
  font-size:.75rem;color:var(--on-forest);padding:.3rem .55rem;border-radius:.4rem;
  cursor:pointer;transition:background .2s var(--ease),color .2s var(--ease)}
.viewer-sizes button:hover{background:rgba(255,255,255,.09);color:#fff}
.viewer-sizes button.on{background:rgba(255,255,255,.14);color:#fff;
  border-color:var(--forest-line)}
/* The stage used to be `height:clamp(19rem,46vh,28rem)` for all three buttons,
   which is the one thing a device switcher must not be: a fixed slot that does
   not follow the device it claims to show. Measured at 1440 it gave

     Desktop  1134x448  2.53:1     a real laptop is 1.60:1
     Tablet    820x419  1.96:1     an iPad in portrait is 0.69:1
     Phone     390x419  0.93:1     a 390x844 phone is 0.46:1

   so the phone preview was very nearly square and the desktop one was a
   letterbox that cut off everything under the hero image. The width was honest
   and the height was not, which is worse than being wrong twice: the visitor
   reads the proportion as their site's proportion.

   Desktop takes `aspect-ratio` rather than a height, so it stays 16:10 at every
   panel width instead of only at 1440 — a vw-based height drifts as soon as the
   shell stops being 1216px. The framed devices take real heights, capped
   against the viewport so the panel never grows taller than the screen reading
   it: a true 390x844 phone would be 844px of stage, which is most of a laptop. */
/* `width:100%` is load-bearing and is the fix for the cropped preview, 2026-08-11.
   An aspect-ratio box whose width is `auto` runs the ratio in BOTH directions: the
   moment `min-height` or `max-height` clamps the height, Chrome recomputes the
   width from the ratio instead of leaving it filling the parent. Measured against
   the real stored job, the stage was never once the width of the frame it sits in:

     320   frame 238   stage 486.4   min-height 19rem x 1.6, clipped, and the 16px
                                     that escaped widened the layout viewport so
                                     EVERY band on the page went to 16/32 gutters
     390   frame 308   stage 486.4   same, ~180px of the preview cut off
     768   frame 670   stage 670     the one width where the two agree
    1440   frame 1134  stage 1123.2  max-height 78vh x 1.6, so a 10.8px strip of
                                     dark ground down the right of the preview

   A definite width switches the ratio off in the width direction, which is all
   the ratio was ever wanted for: height from width, never the reverse. The
   clamps then do what they read as — floor and ceiling on the height alone. */
.viewer-stage{background:#fff;display:flex;justify-content:center;width:100%;
  aspect-ratio:16/10;min-height:19rem;max-height:min(78vh,44rem);
  transition:background .3s var(--ease)}
/* `max-height:none` matters: the desktop cap above is 44rem, and without
   clearing it the framed heights below silently resolved to 704px too — which
   is how the first attempt at this shipped a phone at 0.58:1 while reporting
   itself fixed.
   Both framed devices share one height rather than taking their own. A portrait
   tablet is the honest reason: 820px wide at its true 0.69:1 needs 1,188px of
   stage, which is taller than the laptop looking at it, so it cannot be shown
   whole at any cap worth having. The width is the part that is true — the
   visitor is asking how their site *lays out* at 820px, and it genuinely does —
   and the height's only job is to show enough of it to judge. A phone lands at
   ~0.48:1 against a real 0.46:1, which is as close as a panel can honestly get. */
/* The height SNAPS, for the same reason the width does — see the note above.
   Transitioning it animates a layout property on a box containing a full
   third-party React document, so the iframe reflows every frame for the length
   of the transition. That is the exact cost the width decision already refused,
   and the opacity dip in `sizePreview()` is already there to carry the change. */
.viewer-stage.framed{background:var(--ink-well);padding:.9rem 0;
  aspect-ratio:auto;height:min(84vh,52rem);max-height:none;
  transition:background .3s var(--ease)}
/* Width snaps rather than animating, and the fade carries the change instead.
   `transform:scaleX` is not the alternative here: squashing the render is the
   opposite of what the toggle is for — the visitor is asking how the site *lays
   out* at 390px, so the iframe has to be genuinely 390px wide. But transitioning
   that width means reflowing a full third-party React document every frame for
   350ms, which is real jank on a real machine rather than a theoretical cost.
   A viewport toggle is a discrete choice anyway; browsers' own device modes snap
   too. Opacity is compositor-only and says "this changed" for free. */
.viewer iframe{width:100%;height:100%;border:0;display:block;background:#fff;
  transition:opacity .2s var(--ease)}
.viewer-stage.resizing iframe{opacity:.35}
.viewer-stage.framed iframe{border-radius:.4rem;box-shadow:0 .5rem 2rem rgba(0,0,0,.4)}
/* `.viewer-note` was the strip under the frame, removed 2026-08-09 — see the
   note in app.js at the preview branch for why, and for what it would cost to
   bring back. The rule goes with it rather than being left orphaned, which is
   what happened to `.price-note` and made PRODUCT.md wrong about the page. */
@media (max-width:560px){
  /* Three labelled buttons plus an address pill will not fit; the address is the
     one that carries meaning, so the size toggles wrap under it. */
  .viewer-bar{flex-wrap:wrap;row-gap:.5rem}
  .viewer-url{order:-1;flex-basis:100%}
  .viewer-sizes{margin-left:auto}
}

/* `balance`, not the base `pretty` — 2026-08-09. This is two short sentences in
   a 62ch measure, which is the "short by construction" case the balance list
   exists for, and `pretty` was leaving it badly ragged: 575 + 280 at every
   width from 768 to 1920, so the second line ran half the first, and
   249/272/246/80 at 360 — a 29% tail, which clears the 22% runt threshold and
   would not have been reported even if anything tested it.
   Nothing did. `adapt-sweep.mjs` only ever loads the idle page, and this
   element lives inside the export band, which is `display:none` until
   `revealRun()`. Every string it carries is written by app.js at runtime, so
   the whole panel is invisible to the wrap sweep — if more copy lands in here,
   measure it by revealing the band, not by running the sweep.
   Balanced it is 423 + 432 at 1440 and 280/290/280 at 390. The failure strings
   are longer and can pass six lines, where Chrome drops balance and falls back
   on its own; that is the correct degradation, not a reason to leave it. */
.floor-hint{margin-top:clamp(.85rem,1.9vh,1.2rem);font-size:.875rem;color:var(--on-forest);
  max-width:calc(62 * var(--ch));line-height:1.55;text-wrap:balance}

/* ---------- the page picker ---------- */

.picker{padding-top:clamp(.9rem,2vh,1.4rem)}
/* `balance`, and a measure, both 2026-08-16. At 390 this wrapped 2 lines with
   the second at 14% of the first — "so." alone under a full line, which is the
   exact runt class the page-wide wrap pass exists to catch and which that pass
   could not see, because it sweeps static copy and every string in this panel
   is written by app.js at runtime. Both strings it carries are short by
   construction (the summary and the over-ceiling warning), so `balance` is
   safe here: neither can reach the line count where browsers stop balancing. */
.picker-sum{margin:.35rem 0 0;font-size:.875rem;color:var(--on-forest);line-height:1.55;
  max-width:calc(62 * var(--ch));text-wrap:balance}
.picker-sum b{color:#fff;font-weight:600}
.picker-sum.over b{color:var(--amber)}
.picker-acts{display:flex;gap:.5rem;margin:.85rem 0 .6rem}
.picker-acts button{border:1px solid var(--forest-line);background:none;font:inherit;
  font-size:.75rem;color:var(--on-forest);padding:.35rem .7rem;border-radius:var(--pill);
  cursor:pointer;transition:background .2s var(--ease),color .2s var(--ease)}
.picker-acts button:hover{background:rgba(255,255,255,.1);color:#fff}
/* Two columns from 60rem, and a cap inside each row — both added 2026-08-16,
   and they fix two different halves of one fault.

   The row was `.p-title{flex:1}` beside `.p-route{flex:none}`, which pins the
   title left and the route right. In the 704px card that was a scannable
   two-column list. In the full-bleed band the row is 1216px, and measured on
   the rendered text rather than the element box — the title's box is `flex:1`
   so it always touches the route and always reports ~10px — the hole between
   "Work" and "/work" was **1096px**. Two halves of one row, too far apart to
   read as one row.

   Swept against the real twelve-page list at 1440:

     now                    1096px gap   scrolls   1 row clipped
     two columns             530px       no        0
     route under the title    -27px      yes       1   (rows grow 44 -> 57)
     capped measure           389px      yes       1
     both (shipped)          332px       no        0

   Columns alone leave a 530px hole in a 583px cell, which is the same fault at
   half scale; the cap alone leaves the list scrolling. Together the twelve
   routes a real Framer site has stop needing a scrollbar at all, which is the
   part that matters — this is the screen where someone agrees to what they are
   about to pay for, and it was showing six of twelve with the sixth sliced in
   half. */
.picker-list{list-style:none;margin:0;padding:.35rem;max-height:24rem;overflow-y:auto;
  border:1px solid var(--forest-line);border-radius:.6rem;background:var(--forest-well)}
@media (min-width:60rem){
  .picker-list:not([hidden]){display:grid;grid-template-columns:1fr 1fr;
    column-gap:1.25rem;align-content:start}
}
.picker-list label{max-width:calc(46 * var(--ch))}
/* A list that has to scroll says so. It was clipping its last visible row
   through its own border with nothing to separate "there is more below" from
   "this rendered wrong", and a half-height row against a rounded edge reads as
   the second.

   The class is set from app.js rather than guessed in CSS, and the reason is
   that the guess is unsafe here: this container has `max-height` and no height,
   so a list that fits shrinks to its content — and an unconditional mask would
   then fade out the bottom 2rem of the *last row* of a list that has nothing
   below it at all. There is no CSS predicate for "is actually overflowing", so
   the one place that knows measures it. */
.picker-list.scrolls{
  -webkit-mask-image:linear-gradient(#000 calc(100% - 2rem),transparent);
  mask-image:linear-gradient(#000 calc(100% - 2rem),transparent)}
.picker-list li{margin:0}
.picker-list label{display:flex;align-items:center;gap:.65rem;padding:.55rem .6rem;
  border-radius:.4rem;cursor:pointer;font-size:.875rem;color:var(--on-forest);
  min-height:2.75rem;transition:background .15s var(--ease)}
.picker-list label:hover{background:rgba(255,255,255,.07);color:#fff}
.picker-list input{accent-color:var(--mint);width:1.05rem;height:1.05rem;flex:none;margin:0}
.picker-list input:focus-visible{outline:2px solid var(--mint);outline-offset:2px}
.picker-list .p-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;
  white-space:nowrap;color:#fff}
.picker-list .p-route{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
  font-size:.75rem;color:rgba(255,255,255,.55);flex:none;max-width:45%;
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* Placed AFTER the base `.p-title` / `.p-route` rules on purpose: it overrides
   them at equal specificity, so source order is the entire mechanism. Sitting
   above them — where this first shipped — the query parsed fine, matched fine
   and changed nothing, which measured as "the fix did not work" rather than as
   a cascade bug. Same class of mistake as the `[hidden]` guard above.

   Below the two-column breakpoint the row runs out of width and both halves
   ellipsise: measured at 390, the worst title rendered 151px of 347 with 56% of
   the name unreachable, and the route beside it was truncated too. Two truncated
   strings competing for one line is the worst of both — so the route drops to
   its own line and the title takes the full row.

   It costs height (44px -> ~56px) and that is the right trade here: the list
   already scrolls at this width whichever way it is laid out, so the cost is
   rows below the fold, which scrolling solves. Truncation is not solvable by
   scrolling. Above 52rem nothing changes; the desktop row has the width to keep
   its scannable right-hand route column. */
@media (max-width:51.999rem){
  .picker-list label{flex-wrap:wrap;align-items:flex-start;
    row-gap:.05rem;padding-block:.5rem;max-width:none}
  .picker-list input{margin-top:.15rem}
  .picker-list .p-title{flex:1 1 auto;white-space:normal;overflow:visible}
  .picker-list .p-route{flex:1 0 100%;max-width:100%;margin-left:1.7rem;
    text-align:left}
  /* more of the list on screen, since the rows are taller now */
  .picker-list{max-height:min(24rem,58vh)}
}
.picker-go{display:flex;align-items:center;gap:1rem;margin-top:.9rem;flex-wrap:wrap}
.picker-go .pill{border:0;cursor:pointer;font:inherit}
.picker-go .pill[disabled]{opacity:.5;cursor:default}
.picker-cancel{background:none;border:0;font:inherit;font-size:.875rem;
  color:var(--on-forest);text-decoration:underline;text-underline-offset:3px;
  cursor:pointer;padding:.5rem 0;min-height:2.75rem}
.picker-cancel:hover{color:#fff}

/* ---------- export scope ---------- */

/* One control, not three buttons.

   It was three separate pills at 13px, each outlined in `--line` — which is
   1.15:1 against the canvas, the same near-invisible hairline the field one row
   above was amplified away from in August. So the page carried the fixed
   version of that fault and the unfixed version of it, fourteen pixels apart.
   Three pills with gaps also read as three buttons, which made the filled one
   look like a primary action sitting between two secondary ones rather than
   like the selected state of a set.

   Now a segmented track in the field's own chrome: 1px `--ink`, pill radius,
   nothing new borrowed. It is sized to its own labels rather than to the field,
   deliberately — the field is the product's one control and has to stay the
   heaviest object in the composition, so the selected segment stays small
   enough not to compete with the submit directly above it. */
.modes-row{display:flex;justify-content:center;margin-top:clamp(.5rem,1.15vh,.9rem)}
.modes{display:flex;gap:4px;margin:0;padding:4px;
  border:1px solid var(--ink);border-radius:var(--pill);background:var(--white)}
.modes label{position:relative;display:block;margin:0}
.modes input{position:absolute;inset:0;opacity:0;cursor:pointer;margin:0}
.modes span{display:flex;align-items:center;justify-content:center;height:2.375rem;
  padding:0 1.1rem;border-radius:var(--pill);font-size:.875rem;color:var(--body);
  cursor:pointer;white-space:nowrap;
  transition:background .18s var(--ease),color .18s var(--ease)}
.modes label:hover span{background:var(--line-soft);color:var(--ink)}
.modes input:checked + span{background:var(--ink);color:#fff;font-weight:500}
.modes input:focus-visible + span{outline:2px solid var(--ink);outline-offset:2px}

/* Hugging its labels wants 385px and a 390px phone column is 358. Below 700 the
   track takes the field's full width and splits into three equal segments,
   which is the better shape there anyway: three equal targets rather than
   three different ones, already at the 44px floor. */
@media (max-width:700px){
  .modes{display:grid;grid-template-columns:repeat(3,1fr);width:100%}
  .modes span{height:2.75rem;padding:0 .35rem;white-space:normal;line-height:1.2;
    text-align:center}
}
.chip{background:var(--mint);color:var(--forest);font-size:.8125rem;font-weight:600;
  padding:.3rem .75rem;border-radius:var(--pill);white-space:nowrap;
  transition:background .3s var(--ease),color .3s var(--ease)}
.chip.warn{background:var(--amber);color:#3d2c05}
/* A run that died is not a finding. Amber is what this page uses for "one
   webfont fell back"; a total failure wearing it read as a minor note. */
.chip.failed{background:var(--error-on-forest);color:#3d0f08}
.floor-retry{margin:clamp(.9rem,2vh,1.4rem) 0 0}
/* The cancel control, in the panel header.
   Hairline ghost rather than a mint fill: cancelling is the exit from the run,
   not the thing to do next, and a second filled pill on this panel would compete
   with the stages for the eye. `--on-forest` rather than a dimmer tint keeps it
   at 6.06:1 on the well.

   It is 2rem, not the pill's 2.75. The submit that starts an export is 56px of
   ink in the hero; the control that undoes it is a 32px hairline in a corner,
   and that ratio is the whole point — an escape hatch has to be findable without
   being an invitation. It replaced a full-size `.pill.ghost` at the foot of the
   panel, which was both the wrong weight and, on a phone, below the fold.

   Not `.pill.ghost` any more: that modifier existed for this one button and
   nothing else ever took it, so it went with the move rather than being left
   behind as a variant of the system's primary shape that nothing uses. */
.floor-cancel{font:inherit;font-size:.8125rem;font-weight:500;cursor:pointer;
  flex:none;height:2rem;padding:0 .9rem;border:0;border-radius:var(--pill);
  background:transparent;color:var(--on-forest);
  box-shadow:inset 0 0 0 1px rgba(255,255,255,.38);
  transition:background .2s var(--ease),color .2s var(--ease),box-shadow .2s var(--ease)}
.floor-cancel:hover{background:rgba(255,255,255,.08);color:#fff;
  box-shadow:inset 0 0 0 1px rgba(255,255,255,.5)}
.floor-cancel:disabled{opacity:.5;cursor:default;background:transparent;
  color:var(--on-forest);box-shadow:inset 0 0 0 1px rgba(255,255,255,.38)}
.floor-cancel[hidden]{display:none}
.chip.run{background:var(--forest-soft);color:var(--on-forest)}

/* Capped at three. auto-fit gave seven columns at 2560, which flattened the whole
   slab into a single-row status bar; three keeps it a panel with real height. */
.rows{display:grid;grid-template-columns:1fr;
  column-gap:clamp(1.75rem,3.5vw,4.5rem);
  padding:clamp(.2rem,.6vh,.35rem) 0 clamp(.5rem,1.4vh,1.15rem)}
@media (min-width:761px){.rows{grid-template-columns:1fr 1fr}}
/* `center`, and it is Roman's call twice over. Baseline was tried on 2026-08-11
   and reverted the same day once he saw it rendered: on a phone the two sides
   wrap to different line counts constantly, and pinning both to their first
   lines leaves the shorter side hanging with dead space under it. Centred, a
   two-line name against a three-line verdict reads as one object.

   The rag that made the row look untidy was never the vertical alignment — it
   is the ragged left edge that right-aligned text has by definition, and it is
   fixed by `text-wrap:balance` on both sides rather than by moving the boxes.
   Renders of both: `.impeccable/shots/row-align/`. */
.row{display:flex;align-items:center;gap:.9rem;
  padding:clamp(.42rem,1vh,.7rem) 0;font-size:1.0625rem;
  border-bottom:1px solid rgba(255,255,255,.09)}
/* The label used to be `flex:1; overflow-wrap:anywhere` against a `nowrap`
   verdict. A real engine message is a sentence — "page rendered blank with the
   network off" — so the nowrap sibling took the whole row and the label
   collapsed to a ~10px column, rendering one letter per line in a 150px-tall
   row. That fired on exactly the finding this product exists to surface.

   `min-width` gives the label a floor it cannot be squeezed below, and the
   verdict is allowed to wrap.

   The break mode is `anywhere`, not `break-word`, and the difference is not
   cosmetic: `break-word` breaks the glyph run but leaves the element's
   min-content contribution at the full unbroken string, so flex still sized the
   track to fit it. A Framer page whose <title> is a URL therefore pushed 135px
   past the viewport at 390px, with the row, its label and the verdict all
   spilling outside the panel. `anywhere` reduces min-content too, which is what
   actually lets the track shrink. The reason `anywhere` collapsed the label to
   a ~10px column the first time was the absence of the `min-width` floor above;
   with the floor in place it cannot happen. */
/* Stays in real `ch`, unlike every max-width measure on the page, because it
   means a different thing: not "hold this line to 52 characters" but "keep at
   least eight characters visible", which should track whatever font is on
   screen. Freezing it would be the wrong kind of correct. Nothing is lost by
   leaving it font-dependent either -- this panel is hidden until an export is
   running, so any reflow here follows a click and is excluded from CLS. */
/* `balance` on both halves of the row, added 2026-08-11. The base wrap rule is
   `text-wrap:pretty` on `p, dd, li, figcaption, blockquote` — these two are
   `<span>`s and were in none of it, so the densest text on the site was the one
   block with no wrap rule at all. It shows: page names broke one word per line
   ("Raven / studio", "Privacy / policy") and the verdicts, being right-aligned,
   turned every uneven line into a visibly ragged LEFT edge.

   `balance` and not `pretty` for the reason DESIGN.md already gives: `pretty`
   only refuses a single-word last line, which is exactly the break happening
   here, and both of these are short by construction — a page name and a
   one-sentence verdict, never near the ~6-line ceiling where browsers stop
   balancing. Progressive: a browser without it wraps as before. */
.row span:first-child{flex:1 1 auto;min-width:8ch;color:var(--on-forest);
  overflow-wrap:anywhere;text-wrap:balance}
/* `min-width:0` let this column be squeezed below the width of its own longest
   word, and `overflow-wrap:break-word` then did as it was told: at 390 a long
   page title crushed the verdict until "works" broke across two lines as
   "wo/rks", measured at 35px and 29px wide. `min-content` is that longest word,
   so a multi-word verdict still wraps between words the way it should, and the
   title beside it absorbs the squeeze — it already carries
   `overflow-wrap:anywhere` for exactly that. */
.row .st{flex:0 1 auto;min-width:min-content;font-size:.8125rem;color:var(--mint);
  white-space:normal;text-align:right;overflow-wrap:break-word;text-wrap:balance}
.tick{width:1.375rem;height:1.375rem;border-radius:var(--pill);background:var(--forest-soft);
  flex:none;display:grid;place-items:center}
.row.warn span:first-child,.row.warn .st{color:var(--amber)}
.tick.warn{background:rgba(251,216,138,.16)}

/* A page that did not render at all is not the same as a page that rendered
   with a font missing, and the engine already tells them apart via
   `issue.severity`. The report was throwing that away and painting both amber.
   Errors get the red, a filled plate and the row's own weight; warnings keep
   amber. #ff9c8a is 7.3:1 on forest. */
.row.failed span:first-child{color:var(--error-on-forest);font-weight:500}
.row.failed .st{color:var(--error-on-forest)}
.tick.failed{background:rgba(255,156,138,.18)}
.tick.failed svg path{stroke:var(--error-on-forest)}

/* the overflow row is a count, not a verdict: no plate, no tick */
.row.more span:first-child,.row.more .st{color:var(--on-forest);opacity:.72}

/* Page rows are buttons: this list is the preview's navigation, so every page
   of the copy can be looked at before paying rather than just the home page.
   `.more` and `.lim` are not pages and stay inert <div>s.

   The tint bleeds past the row box through two offset shadows instead of inline
   padding. Padding would shift the label sideways on hover, and a negative
   margin would widen the box, pushing this row's hairline out of line with the
   `.more` and `.lim` hairlines directly beneath it. An outset shadow paints
   behind the background box, so no stacking context or z-index is involved. */
/* The tint is a pseudo-element, not the row's own background.

   It was the background plus two offset box-shadows, which meant the row needed a
   border-radius for the shadows to round with — and that radius also curved the
   hairline, so the selected row ended in two little upward hooks while every other
   separator ran straight. Roman spotted it. Splitting the two apart lets the tint be a
   rounded, bleeding block and the hairline stay a straight full-width rule.

   `isolation:isolate` gives the row its own stacking context so `z-index:-1` lands
   behind the label and in front of the panel, rather than behind the panel where it
   would be invisible. The tint stops 1px short of the bottom so the hairline reads
   continuously with its neighbours instead of being painted over. */
.row.page{position:relative;isolation:isolate;
  border-top:0;border-inline:0;background:transparent;width:100%;
  font-family:inherit;color:inherit;text-align:left;cursor:pointer}
.row.page::before{content:'';position:absolute;z-index:-1;inset:0 -.7rem 1px;
  border-radius:.4rem;background:var(--forest-soft);
  opacity:0;transition:opacity .18s var(--ease)}
.row.page[aria-current]::before{opacity:1}
@media (hover:hover){.row.page:hover::before{opacity:.5}}
/* the global ring is --ink, which is all but invisible on forest; inset so it
   cannot overlap the row above */
.row.page:focus-visible{outline:2px solid var(--mint);outline-offset:-2px}

/* Focal moment: a result landing, not a fade. The label lifts out of dim, the
   verdict arrives from the right, the tick pops to size. Runs once, when real
   results arrive — there is no idle loop to perform any more. */
.row{animation:row-settle .5s var(--ease) both}
.row .st{animation:verdict .5s var(--ease) both}
.row .tick{animation:stamp .45s var(--ease) both}
.row:nth-child(1),.row:nth-child(1) *{animation-delay:.04s}
.row:nth-child(2),.row:nth-child(2) *{animation-delay:.08s}
.row:nth-child(3),.row:nth-child(3) *{animation-delay:.12s}
.row:nth-child(4),.row:nth-child(4) *{animation-delay:.16s}
.row:nth-child(5),.row:nth-child(5) *{animation-delay:.2s}
.row:nth-child(6),.row:nth-child(6) *{animation-delay:.24s}
.row:nth-child(7),.row:nth-child(7) *{animation-delay:.28s}
.row:nth-child(8),.row:nth-child(8) *{animation-delay:.32s}
.row:nth-child(n+9),.row:nth-child(n+9) *{animation-delay:.36s}

@keyframes row-settle{from{opacity:.3}to{opacity:1}}
@keyframes verdict{from{opacity:0;transform:translateX(.5rem)}to{opacity:1;transform:none}}
@keyframes stamp{0%{opacity:0;transform:scale(.72)}70%{opacity:1;transform:scale(1.06)}
  100%{opacity:1;transform:scale(1)}}
@keyframes arrive{from{opacity:0;transform:translateY(.4rem)}to{opacity:1;transform:none}}

/* The rows are the evidence, the summary and the download are the conclusion,
   and the conclusion used to be sitting there at full opacity while the rows
   were still arriving. Ordered behind them instead: the summary rides the same
   `verdict` motion the row statuses do, and the download lands last, at .92s,
   as the final row settles. Rare moment, earned beat. */
/* The verdict sat 13px under the last row, which is the gap the rows use
   between each other, so the conclusion on the whole list read as one more
   entry in it. It is a statement about everything above it and needs to clear
   the list to say so. Measured on a real export before and after: 13px → 32px
   at 1440x900. */
.card-foot{display:flex;align-items:baseline;gap:.6rem;flex-wrap:wrap;font-size:.9375rem;
  margin-top:clamp(.9rem,2.2vh,1.6rem);
  color:var(--on-forest);line-height:1.5;animation:verdict .4s var(--ease) .42s both}
.card-foot b{color:#fff;font-weight:500}
/* The summary row is a statement about everything below the fold of the list,
   not another page, so it spans the pair rather than sitting in the left
   column with a hole beside it. */
/* The count still spans, because it is what closes the page list and a
   half-width summary would read as a twelfth page. What it stopped doing on
   2026-08-11 is stretching: `flex:1` on the label pushed "no findings" to the
   far right of a 1,970px row, ~1,400px from the count it belongs to, so the two
   halves of one short sentence had a hairline's worth of nothing between them.
   Packed left, with the slack thrown to the right, it reads as the one line it
   is. */
@media (min-width:761px){
  .row.more{grid-column:1 / -1}
  .row.more span:first-child{flex:0 0 auto}
  .row.more .st{flex:0 1 auto;margin-right:auto;text-align:left}
}

/* A one-page site, fixed 2026-08-12 after Roman spotted it on a real export.
   The list is a two-column grid, so a site with exactly one page put its only
   row in the left column and left the right half of the panel empty — and that
   row still carried the divider whose whole job is to separate it from a row
   underneath it that does not exist. Two faults from the same cause: rules
   written for a list, applied to a list of one.

   It spans and packs left for the reason `.row.more` above already does — with
   the row at full width the default `flex:1` label throws the verdict and its
   tick ~1,000px from the name they belong to, so the three parts of one short
   statement end up with a panel's width of nothing between them. `margin-right`
   goes on the tick, not the verdict, so the slack lands after the whole group
   rather than splitting it. Below 761px the grid is one column anyway and only
   the divider needs removing. */
.rows > .row:only-child{border-bottom:0}
@media (min-width:761px){
  .rows > .row:only-child{grid-column:1 / -1}
  .rows > .row:only-child span:first-child{flex:0 0 auto}
  .rows > .row:only-child .st{flex:0 1 auto;text-align:left}
  .rows > .row:only-child .tick{margin-right:auto}
}

/* The engine's limitations — the things that stop being true once a site is
   static. They are findings, so they wear the findings colour, and they are not
   pages, so they sit under the page list rather than inside it and span both
   columns like the summary does.

   The divider is space, not a rule: the last page row already draws a hairline
   and a border-top here would put two lines a few pixels apart. */
/* They stopped spanning on 2026-08-11, and that premise is what every earlier
   argument about this row rested on. Full width, the term stretched and the
   verdict hugged the right edge of a 1,970px row: "Forms" and "need a new
   submission endpoint after export." sat ~1,400px apart with a hairline between
   them, which is the one thing a definition row must not do. The old note here
   reasoned about whether the verdict should be left or right GIVEN the span,
   and never asked about the span.

   In the two-column flow they are ordinary rows — term left, verdict right,
   mark right — on the same ~950px rhythm as every page row above, which is what
   the right-align decision was reaching for in the first place. The group still
   breaks cleanly because the first one starts a fresh grid row, and it is still
   marked as global by the amber and by the gap above it, not by its width. */

/* The gap that separates the caveats from the page list has to land on the
   whole first grid ROW of them, not just the first cell. Two columns means the
   first two share that row, and giving the margin only to the first one dropped
   it 12px below its neighbour — the two rows of one block sitting at two
   different heights, which is the fault this whole round was about. The second
   rule is scoped to the two-column layout on purpose: stacked, `+ .row.lim` is
   the row below, and the same margin there would open a hole in the middle of
   the block. */

.row.lim span:first-child,.row.lim .st{color:var(--amber)}
/* Right, matching every page row above — Roman asked twice, and rendering both
   against a real report settled it (`lim-comp.mjs`).

   This reverses an earlier decision here, so the reasoning that lost is worth
   keeping: hugging the label and running the sentence on directly after it does
   read as one statement, "Forms | need a new submission endpoint". But the row
   spans both columns, so left-aligning stranded the `!` about 1,300px away with
   nothing between it and the sentence, and these two rows became the only ones
   in the panel that did not put their verdict beside its mark. By the time the
   eye arrives it has read five rows of subject-left / verdict-right and is
   reading the panel as a table; breaking that costs more than the gap does. The
   amber and the hairline hold the label and its sentence together across it.

   The label takes the free space now, which is what pushes the verdict right;
   `text-align` alone would do nothing to a box already sized to its content.

   Scoped to the same breakpoint as the span, because the argument is the span.
   Below 761px the row is one column wide, the label and its sentence are
   already adjacent, and right-aligning only buys three ragged-left lines on a
   390px screen — verified. The engine's limitation vocabulary is eight fixed
   sentences (`rewriter.ts:425`), the longest 75 characters once the label is
   lifted, so none of them wraps at the widths where this applies. */
/* No layout overrides at all any more, 2026-08-11. These used to hug the term
   and run the verdict on after it below 761px, and stretch-plus-right above it,
   because the row spanned the whole panel and had its own geometry to solve. It
   does not span now, so every one of those rules was making the caveats the only
   rows in the panel laid out differently from the rest — left-aligned verdicts on
   a phone against right-aligned ones directly above them. They inherit `.row`
   whole: term left, verdict right, mark right, at every width. Roman asked for
   them to be the same as the page checks and this is what the same means. */

/* Separated as a block, but not restyled as rows — Roman's call, 2026-08-11.
   The break is a fresh grid row plus one gap, and the gap goes on every cell in
   that first row rather than only the first: two columns share it, and putting
   it on one dropped that cell 12px below its neighbour. Nothing else about
   these rows differs from a page row, which is the point — the separation is
   the space above them, not a different shape.

   The second rule is scoped above 761px on purpose. Stacked, `+ .row.lim` is
   simply the next row down and the same margin would open a hole in the middle
   of the block instead of above it. */
/* The block opens with its own rule, and that is what makes the gap read as a
   gap. Measured at 390 without it: the page list's last hairline sat at 1934 and
   the caveat's at 2004, so the eye read one band 70px tall — but the 13.5px of
   margin lives OUTSIDE the row's border box, so `align-items:center` centred the
   content in 1947 to 2004 and it landed 6.3px low. Everything was centred and it
   still looked dropped, because the box the browser centres in was not the band
   a reader sees.

   A top border gives the row both of its own edges back. The band becomes the
   row, the content sits dead centre in it, and the empty strip above reads as
   what it is: the space between the page list and the caveats. */
@media (min-width:761px){
  .row:not(.lim) + .row.lim{grid-column:1}
  .row:not(.lim) + .row.lim + .row.lim{margin-top:clamp(.7rem,1.6vh,1.25rem);
    border-top:1px solid rgba(255,255,255,.09)}
}
.row:not(.lim) + .row.lim{margin-top:clamp(.7rem,1.6vh,1.25rem);
  border-top:1px solid rgba(255,255,255,.09)}

/* The integrity score, given the weight the thing it measures actually has.
   Tabular figures so 94 and 100 occupy the same width and the lockup does not
   shift as the number changes. */
.card-foot{gap:.9rem 1.1rem}
.card-foot .score{font-family:var(--sans-display);font-size:clamp(2rem,3.6vw,2.6rem);font-weight:600;
  letter-spacing:-.045em;line-height:1;color:#fff;font-variant-numeric:tabular-nums}
.card-foot .score i{font-style:normal;font-size:.4em;font-weight:500;
  letter-spacing:-.02em;color:var(--on-forest);margin-left:.08em}
/* The verdict leads the foot line at full white, so the outcome is read before
   the method sentence explaining how it was reached. The severity colour stays
   on the chip alone: a second coloured object saying the same thing is a
   duplicate signal, not a stronger one, and the colour budget counts surfaces. */
.card-foot .verdict{color:#fff;font-weight:600}
/* `min(18rem,100%)`, not a bare 18rem, fixed 2026-08-11. The 288px floor is
   there so the sentence never wraps into a sliver beside the score, and it is
   right down to about 360px — but a 320px phone gives this panel 240px, so a
   hard floor overshot by 48px, and because nothing between here and <body>
   clips, the document went 8px wider than the device. That is not a local
   defect: `.shell` is `margin-inline:auto`, so a document wider than the
   viewport pushes every band on the page off centre at once — the nav, the
   hero, all of it, measured at 16px left against 32px right. A floor that
   yields at the one width where it cannot be met keeps the intent and cannot
   take the page's gutters down with it. */
/* `balance`, not a wider measure. This sentence is ~150 characters; on one line it
   would run past 100 characters wide, which this system caps at 60–66 for good
   reason, so two lines is correct and the fault was only ever where they broke —
   the tail landed as "with what to do about it." under a full line. It is a `<span>`,
   so the base `text-wrap:pretty` on `p, dd, li` never reached it, and `pretty` would
   not have saved it anyway: it refuses a single-word last line, and this tail is
   five. Short by construction, so it stays under the line-count ceiling where
   browsers stop balancing. */
/* The ceiling is new with the full-bleed band, 2026-08-16, and it is what keeps
   the paragraph above true. `balance` only chooses where lines break; it cannot
   force a second line. Inside the old 704px card this sentence had to wrap, so
   `balance` had something to balance. Taking the panel's horizontal padding off
   to sit on the shell gave this flex child ~1100px, and the whole 155-character
   sentence fitted on one line — the verdict, the one line on the panel that
   says whether the export worked, set at 155 characters across 1083px.

   56 restores the two-line wrap it had as a card, which is the wrap the comment
   above is describing. Counted by walking every character and bucketing it by
   its own rect top, against the real report at 1440 — a plain rect count is off
   by one here, because the inline `<code>` in `WEBSITE-CHECK.txt` sits on its
   own rect and reads as an extra line:

     44 / 52   box 453 / 535   3 lines   49, 57, 47 chars
     56 / 64   box 576 / 658   2 lines   76, 78 chars
     no cap    box 1099        1 line    155 chars

   56 is the narrowest box that still lands on two, so it is doing the work
   rather than riding on a wider box's slack, and it is a measure the system
   already uses elsewhere. Two lines of a 155-character sentence is ~77 per
   line, which is over the 60–66 this system prefers; that is inherited, not
   introduced — it is what the panel has always rendered — and pulling it to
   three lines makes a 47-character column of the one sentence that should read
   as a single statement. */
.card-foot .foot-line{flex:1;min-width:min(18rem,100%);
  max-width:calc(56 * var(--ch));text-wrap:balance}
.floor-result .demo-note{animation:arrive .4s var(--ease) .52s both}

/* progress bar, shown only while a real export is running */
/* The read has no percentage to report — `/api/pages` returns once or not at
   all — so the bar sweeps instead of filling. It exists to answer one question
   on a screen that used to answer nothing: is this still going? Transform-only,
   so it costs the compositor and not layout, and it is stopped outright under
   reduced motion, where a persistent sweep is exactly the thing to refuse. */
.bar.indeterminate #bar-fill{transform-origin:left;animation:bar-sweep 1.4s var(--ease) infinite}
@keyframes bar-sweep{
  0%{transform:translateX(-100%) scaleX(.35)}
  100%{transform:translateX(340%) scaleX(.35)}
}
@media (prefers-reduced-motion:reduce){
  .bar.indeterminate #bar-fill{animation:none;transform:scaleX(1);opacity:.45}
}
.bar{height:.25rem;border-radius:var(--pill);background:var(--forest-soft);overflow:hidden;
  margin-top:clamp(.8rem,1.8vh,1.2rem)}
.bar span{display:block;height:100%;width:100%;background:var(--mint);border-radius:var(--pill);
  transform:scaleX(0);transform-origin:left;transition:transform .5s var(--ease)}

/* ---------- bands ---------- */

.band{padding:var(--band) 0}
.band.alt{background:var(--surface)}
.band > .shell > h2{max-width:calc(22 * var(--ch))}

.split{display:grid;grid-template-columns:1fr 1fr;gap:clamp(2rem,5vw,4.5rem);
  align-items:start;margin-top:var(--gap-head)}
@media (max-width:1100px){.split{grid-template-columns:1fr}}
.split h2{margin-bottom:1.15rem}
.split p{color:var(--body);max-width:calc(60 * var(--ch))}
.split p + p{margin-top:1rem}

/* 2 · reasons, and the year that makes the case
   ------------------------------------------------------------------
   This was three columns of prose, the second thing anyone sees and the
   most text-heavy band on the page. The reasons became a stacked list so
   the argument has room to be drawn beside it. */

/* Four situations, two by two. See the comment in index.html for why the content
   changed and why this is not four across.

   The sub was the counting line — "Four reasons owners export." — and it did real work
   against the 2x2, where nothing about the layout told you how many there were until you
   had read all of them. **The ledger numbers them 01-04, so the sub now restates
   something the index already says.** Flagged for Roman 2026-08-11 rather than cut: he
   asked for a heading and subheading above the list, and replacing the line is a copy
   decision, not a layout one.

   The hairline runs unbroken across the row here, the reverse of the 2x2 rule. That rule
   existed because a continuous rule over two side-by-side cells reads as one row of one
   thing. Stacked, the opposite is true: the rules are what separate one situation from
   the next, and each row genuinely is one row of one thing. */
.why-sub{color:var(--on-ink-dim);font-size:1.0625rem;margin-top:.85rem;
  max-width:calc(54 * var(--ch));text-wrap:balance}
/* A printed page on ink — Roman's pick 2026-08-15, from comps/why-paper-ink.
   It replaces a ruled 2x2 matrix, which replaced a three-column ledger, which
   replaced a 2x2 of floating cells. The history matters because all three were
   the same idea: four titles and four sentences in a column. This one is a page.

   THE ONE PROPERTY THAT MUST NOT DRIFT. `.reasons h3` is var(--sans) at 1rem,
   not var(--sans-display) at a clamp. A small head in the text face hanging in
   a margin beside prose reads as a printed page; the same row with a large
   display title beside a caption is a ledger, which is the shape that was
   rejected. If a later pass "improves" this by scaling the h3 up, it has
   quietly reverted the redesign.

   Three columns: mark, side-head, prose. The mark track is a fixed 1.75rem and
   must stay fixed — declared `auto` it absorbs the row's leftover space, and an
   18px mark then sits in a 139px column with the side-head starting a third of
   the way across the margin (measured 139.219px against the 208px title track).
   The narrow gutter after the mark plus the full 3.5rem gutter after the head
   cannot both come from one grid column-gap, so the paragraph carries the
   second one as its own margin-left.

   Baseline alignment across the row is what makes it a page rather than a
   table: the side-head sits on the same line as the first line of its
   paragraph. Protect that before anything else here.

   The marks are Phosphor regular, the set already inlined 33 times on this
   page. They do not breach the card ban for the same reason `.limits` does not,
   and this file says so above: they sit in a shared left column of one list,
   small beside a head and a full sentence, and no row is a tile. */
.why-page{max-width:58rem;margin-inline:auto}
/* The band h2 cap, restated here because the shared one stopped reaching this
   heading. `.band > .shell > h2` is a direct-child selector and this h2 is now
   a grandchild, inside .why-page — so it was computing `max-width:none` and
   running the full 58rem, which only looked right because the two sentences
   happen to break at the full stop. Measured at 1440: box 928px, longest line
   629px, cap none.

   24ch rather than the shared 22ch on purpose: this heading is deliberately two
   sentences where every other band's is one, and 22ch (603px) breaks the first
   sentence mid-clause. 24ch is the narrowest value that lets sentence one hold
   a line and forces sentence two onto its own — so the line break is the
   grammar rather than the container. */
.why-page h2{max-width:calc(24 * var(--ch))}
.reasons{list-style:none;margin:var(--gap-head) 0 0;padding:0;
  border-top:1px solid var(--ink-line)}
.reasons li{display:grid;align-items:baseline;
  grid-template-columns:1.75rem minmax(0,13rem) minmax(0,34rem);
  column-gap:clamp(.85rem,1.6vw,1.15rem);
  padding-block:clamp(1.35rem,2.4vw,2rem);
  border-bottom:1px solid var(--ink-line)}
.reasons li > p{margin-left:clamp(.65rem,2.4vw,2.35rem)}
/* Sized off the side-head's cap height rather than a round number. An inline
   SVG sets its own bottom edge on the baseline, which lands it a shade high
   against lowercase, hence the nudge. */
.reason-mark{width:1.125rem;height:auto;fill:var(--on-ink-dim);
  transform:translateY(.14em)}
.reasons h3{margin:0;font-family:var(--sans);font-size:1rem;font-weight:600;
  letter-spacing:-.005em;line-height:1.5;color:#fff;text-wrap:balance}
/* `.band.ink p` sets --on-ink and outranks a bare `.reasons p`, which is the
   bug that left the old sub the same colour as the sentences under it. Named
   with the band so the cascade lands: the prose is one step back from the white
   head, which is what keeps the margin scannable now the rules are quieter on
   ink than they were on paper. 7.6:1. */
.band.ink .reasons p{margin:0;font-size:1.0625rem;line-height:1.65;
  color:var(--on-ink-soft);text-wrap:pretty}

/* One column below 52rem. The 13rem head track and a 34rem measure stop fitting
   side by side well before they stop technically fitting; stacked, the mark
   keeps its own column so the head still hangs. */
@media (max-width:52rem){
  .reasons li{grid-template-columns:1.75rem minmax(0,1fr);row-gap:.35rem}
  .reasons li > p{grid-column:2;margin-left:0}
}

/* The twelve-month figure lives in design-archive/why-months/ as of 2026-08-09. */

/* 3 · folder tree */

.tree{background:var(--white);border:1px solid var(--line);border-radius:var(--r-lg);
  overflow:hidden;font-size:.9375rem}
.tree-head{padding:.9rem 1.15rem;border-bottom:1px solid var(--line);background:var(--surface);
  font-family:var(--mono);font-size:.875rem;font-weight:500}
.tree ul{list-style:none;margin:0;padding:.5rem .35rem}
.tree li{display:flex;align-items:baseline;gap:1rem;padding:.5rem 1rem;
  font-family:var(--mono);font-size:.875rem}
.tree li span{margin-left:auto;font-family:var(--sans);font-size:.8125rem;
  color:var(--muted);text-align:right}
.tree li.dir{font-weight:500}

/* 4 · the check */
.strong-line{margin-top:1.25rem!important;font-weight:500;color:var(--ink)!important}
.widths{display:grid;grid-template-columns:1.55fr 1fr .58fr;gap:1.35rem;align-items:end}
.widths figure{margin:0}
.screen{position:relative;background:var(--white);border:1px solid var(--line);
  border-radius:var(--r-md);padding:.7rem;display:flex;flex-direction:column;gap:.4rem}
.screen.desktop{height:12.875rem}
.screen.tablet{height:11.375rem}
.screen.phone{height:9.625rem}
.scr-hd{flex:none;height:.8125rem;border-radius:3px;background:var(--ink);opacity:.82}
.scr-hero{flex:1.5;border-radius:4px;background:var(--surface)}
.scr-line{flex:none;height:.3125rem;border-radius:3px;background:var(--line-soft)}
.scr-line.short{width:55%}
.scr-tiles{display:flex;gap:.4rem;flex:1}
.scr-tiles i{flex:1;border-radius:4px;background:var(--line-soft)}
.ok{position:absolute;right:-.5rem;bottom:-.5rem;width:1.625rem;height:1.625rem;
  border-radius:var(--pill);background:var(--forest);display:grid;place-items:center;
  border:2px solid var(--canvas)}
/* The heading above these says we open every page at three widths and look at
   what renders. So that is what they do: each mock builds the way a page paints
   — header, hero, copy, tiles — and only once it is standing does the check
   land on it. The order is the argument. The tick uses the same keyframe the
   live report's ticks do, so the demonstration and the real thing speak with
   one voice.

   All of it hangs off `.in`, the same class the reveal system sets, so the
   figures arriving and their contents building are one event, driven by one
   observer. Fires once, never on scroll back, and without the class the mocks
   are simply there. */
.widths.in .screen > span:not(.ok){animation:build .4s var(--ease) both}
.widths.in .screen > span:not(.ok):nth-child(1){animation-delay:.12s}
.widths.in .screen > span:not(.ok):nth-child(2){animation-delay:.17s}
.widths.in .screen > span:not(.ok):nth-child(3){animation-delay:.22s}
.widths.in .screen > span:not(.ok):nth-child(4){animation-delay:.27s}
.widths.in .screen > span:not(.ok):nth-child(5){animation-delay:.32s}
@keyframes build{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}

.widths.in .ok{animation:stamp .45s var(--ease) both}
.widths.in figure:nth-child(1) .ok{animation-delay:.44s}
.widths.in figure:nth-child(2) .ok{animation-delay:.52s}
.widths.in figure:nth-child(3) .ok{animation-delay:.60s}
.widths figcaption{margin-top:.9rem;font-size:.8125rem;color:var(--muted)}
.widths figcaption b{color:var(--ink);font-weight:500}
@media (max-width:650px){
  .widths{grid-template-columns:1.4fr 1fr .6fr;gap:.85rem}
  .screen.desktop{height:9.375rem}
  .screen.tablet{height:8.25rem}
  .screen.phone{height:7rem}
}

/* 4a · the scale
   ------------------------------------------------------------------
   A legend for a real instrument, so it is built from the report's own
   parts: the same three status colours the panel uses, on the hairlines
   the limits ledger uses. Not a card and not a three-up grid — this is the
   band's second element and it must read as a footnote to the check,
   not as a competing block. */
.scale{margin-top:clamp(2.5rem,4.5vw,3.75rem)}
.scale h3{color:#fff;margin-bottom:1.15rem}
.scale dl{margin:0;border-top:1px solid var(--ink-line)}
.scale-row{display:grid;grid-template-columns:minmax(0,11rem) minmax(0,1fr);
  gap:.35rem clamp(1rem,3vw,2.5rem);padding:1.05rem 0;
  border-bottom:1px solid var(--ink-line);align-items:baseline}
.scale-row dt{display:flex;align-items:baseline;gap:.65rem;flex-wrap:wrap}
.scale-row dd{margin:0;color:var(--on-ink);font-size:.9375rem;max-width:calc(56 * var(--ch));
  text-wrap:pretty}
.scale-row dd b{color:#fff;font-weight:500}
.scale-row dt em{font-style:normal;font-size:.8125rem;color:var(--on-ink-dim)}
/* the report's own chips, at the report's own weights */
.scale-chip{display:inline-flex;align-items:center;height:1.5rem;padding:0 .6rem;
  border-radius:var(--pill);font-size:.75rem;font-weight:600;letter-spacing:.01em}
.scale-row.pass .scale-chip{background:rgba(126,240,192,.16);color:var(--mint)}
.scale-row.warn .scale-chip{background:rgba(251,216,138,.16);color:var(--amber)}
.scale-row.fail .scale-chip{background:rgba(255,156,138,.16);color:var(--error-on-forest)}
.scale-note{margin-top:1.15rem;font-size:.9375rem;color:#fff;max-width:calc(60 * var(--ch));
  text-wrap:pretty}

@media (max-width:640px){
  .scale-row{grid-template-columns:1fr;gap:.5rem}
}
.v-label{width:33.3%}
.v-usual{width:30.3%}
.v-ours{width:36.4%}

/* Stacked, every cell becomes its own line and the header row goes away, so
   each value has to name its own column. The roles in the markup are what keep
   this a table to a screen reader once `display` stops saying so. */
@media (max-width:760px){
  .versus,.versus tbody,.versus tr,.versus th,.versus td{display:block}
  .versus colgroup,.versus thead{display:none}
  .versus-row{padding:.95rem 0;border-bottom:1px solid var(--ink-line)}
  .versus-row > *{padding:0;border-bottom:0}
  .versus-row > * + *{margin-top:.2rem}
  .versus-row td:first-of-type::before{content:"The usual: "}
  .versus-row b::before{content:"Dehydrate: ";color:var(--on-ink-dim);font-weight:400}
}

/* 5 · limits */
/* A standfirst is short by construction and always wants an even rag, which is
   what `balance` is for; `pretty` left it breaking 2 lines with the second at
   a fifth of the first on every phone. Same reasoning for `.price-sub` — it is
   one or two lines at any width, so the line-count ceiling `balance` carries
   can never be reached. (The price band's own bullets used to be in this list;
   `.shift` replaced them with prose notes long enough to want `pretty`.) */
.lead-line{margin-top:1rem;color:var(--body);font-size:1.0625rem;
  max-width:calc(60 * var(--ch));text-wrap:balance}
.limits{margin:var(--gap-head) 0 0;display:grid;gap:0}
/* The term column is fluid, not a flat 15rem. The mark column costs `dd` 76px
   that the old two-column grid gave it, which at 1024 pushed three answers into
   a runt last line — the fault `adapt-sweep` exists to catch and the one DESIGN.md
   records as swept to zero. Fluid, `dd` keeps its measure from 950 up to the
   width where 15rem stops mattering. */
.limits > div{display:grid;grid-template-columns:44px clamp(10rem,17vw,15rem) minmax(0,1fr);
  gap:1.5rem 2rem;align-items:start;padding:1.5rem 0;border-top:1px solid var(--ink-line)}
/* No bottom rule on the last row: the slab's own edge closes the list, and a
   hairline 3rem above it reads as a row that lost its content. Its bottom
   padding goes too, or the slab carries 80px under the last line against 52px
   over the heading and sits visibly low in its own box. */
.limits > div:last-child{padding-bottom:0}
/* Stacked, the mark and the term share a line and the text drops below both.
   Holding `dd` in column 2 instead cost 23px of gap under every term — the icon
   sets a 44px row that `dt` cannot fill, and the row gap then stacks on top of
   the leftover — and it gave a 358px screen a 300px measure to read in. */
@media (max-width:950px){
  .limits > div{grid-template-columns:36px minmax(0,1fr);gap:.55rem .9rem;
    align-items:center;padding:1.35rem 0}
  .limits .lim-ico{width:36px;height:36px;border-radius:10px}
  .limits .lim-mark{width:19px;height:19px}
  .limits dt{padding-top:0}
  .limits dd{grid-column:1 / -1;padding-top:0}
}
.limits dt{font-weight:600;letter-spacing:-.015em;padding-top:.7rem}
/* `balance`, against the base `pretty`, and measured at all eleven viewports:
   at 1440 the Forms answer goes from a 34% last line to 90% and the CMS one from
   73% to 93%. Both are two-liners at every width from 768 up, so they sit far
   under the ~6-line ceiling where browsers abandon balancing.

   The last answer takes `balance` too, as of 2026-08-10. It used to be the
   exception: at four sentences it ran 6 lines at 1440 and 12 at 320, over the
   ceiling where balancing degrades to normal wrapping and takes `pretty`'s runt
   protection with it. Cutting it to two sentences puts it at 5 lines at 1440 and
   8 at 320, and a re-measure across all eleven viewports finds no runt under
   balance at any of them. Above 430px it is inside the ceiling outright.

   The 320/360 case is still technically unprotected — 8 lines is over the ceiling
   — so if this sentence is reworded, re-measure rather than assuming. */
.limits dd{margin:0;padding-top:.7rem;max-width:calc(60 * var(--ch));text-wrap:balance}

/* The marks are Phosphor regular, swapped off Lucide on 2026-08-10. Lucide was
   the wrong call not because it is worse but because this page already had a set:
   the features band runs Phosphor, and `@phosphor-icons/core` is a declared
   dependency. Two icon families on one page is a defect no individual icon can
   fix. Phosphor draws filled outline paths on a 256 grid, so there is no stroke
   width to thin — the weight matches the features band by construction. */
.lim-mark{width:22px;height:22px;display:block;fill:currentColor}
.lim-ico{display:grid;place-items:center;width:44px;height:44px;border-radius:12px;
  flex:0 0 auto;background:var(--ink-card);color:var(--on-ink-soft)}

/* 6 · steps — the band that turns the page over.

   This used to be three columns of heading-plus-paragraph under a hairline,
   which is the same shape the reasons band uses higher up, so the page ran
   the same move twice. It was also the only band carrying no artifact, on the
   section the nav's "How it works" points at.

   Now: near-black ground, three lifted cards, and each card ends in a
   recessed well holding the artifact that step actually produces. The well is
   what keeps this from being a card inside a card — the artifact sits in a
   recess of the card's own material, not in a second box. Still monochrome:
   ink is a ground, not colour, so the two forest surfaces stay the only
   saturated things on the page. */

/* The page alternates canvas and ink. Four bands are dark, so this is not a
   background swap: every component those bands carry has to be built for ink,
   or the band is just a dark rectangle with light-mode parts sitting on it. */
.band.ink{background:var(--ink);color:#fff}
.band.ink h2,.band.ink h3,.band.ink dt{color:#fff}
.band.ink p,.band.ink dd{color:var(--on-ink)}
.band.ink .lead-line{color:var(--on-ink)}
.band.ink .strong-line{color:#fff!important}

/* The `.band.ink .limits` overrides that lived here are gone: `.limits` carries
   its own ink ground now and never appears on a canvas surface, so the rules were
   restating what the component already does. */

/* the accordion */
.band.ink .faq{border-top-color:var(--ink-line)}
.band.ink .faq details{border-bottom-color:var(--ink-line)}
.band.ink .faq summary{color:#fff}
.band.ink .faq summary::after{border-color:var(--on-ink-dim)}
@media (hover:hover) and (pointer:fine){
  .band.ink .faq summary:hover{color:var(--on-ink)}
  .band.ink .faq summary:hover::after{border-color:#fff}
}

/* The width mocks. On ink these stop being paper and start being screens,
   which is closer to what they are meant to depict anyway: a lit panel in a
   dark room rather than a printed page. */
.band.ink .screen{background:var(--ink-card);border-color:var(--ink-line)}
.band.ink .scr-hd{background:#fff;opacity:.86}
.band.ink .scr-hero{background:rgba(255,255,255,.13)}
.band.ink .scr-line,.band.ink .scr-tiles i{background:rgba(255,255,255,.07)}
/* the tick's ring punches out of the band it sits on, so it follows the band */
.band.ink .ok{border-color:var(--ink)}
.band.ink figcaption{color:var(--on-ink-dim)}
.band.ink figcaption b{color:#fff}

/* the folder tree, if it ever lands on a dark band */
.band.ink .tree{background:var(--ink-card);border-color:var(--ink-line)}
.band.ink .tree-head{background:var(--ink-well);border-bottom-color:var(--ink-line);color:#fff}
.band.ink .tree li{color:var(--on-ink)}
.band.ink .tree li span{color:var(--on-ink-dim)}

/* focus has to stay visible on ink */
/* A light ring on every dark ground, not just on `.band.ink`.

   The footer was the hole. It is `background:var(--ink)` but it is not a
   `.band.ink`, so it kept the base ink ring — `#0a0a0a` outlined on `#0a0a0a`,
   which is 1:1 and therefore no focus indicator at all on all five footer
   links. Measured with a pixel scan, not inferred: the scanline through a
   focused link is unbroken ground with no ring pixels in it.

   `.shift-b` is the same fault on the other ground: the mint "Export your
   site" pill sits on `--forest-soft`, and the ink ring scored 2.15:1 against
   it, under the 3:1 that WCAG 2.4.11 asks of a focus indicator. The offset gap
   shows forest on both sides of the ring, so the mint fill never rescues it.
   (It was `.ledger-col.pick` until 2026-08-14; the selector moved with the
   component, and the hole it patches is a property of the ground, so any new
   forest container has to be added here or it reopens.) */
/* `.band.run` is the third instance of the same hole, found 2026-08-12 while
   moving the cancel control into the panel header. The run band is forest from
   edge to edge but it is not a `.band.ink`, so every control inside it kept the
   base ink ring: measured on the focused cancel button, `rgb(10,10,10)` outlined
   on `#0e3b2f` is **1.59:1** — not a weak focus indicator, no focus indicator.
   It applied to the old stop control too; moving it did not cause this, it just
   put a keyboard target somewhere it finally got measured. */
.band.ink :where(a,button,summary,input):focus-visible,
.footer :where(a,button,summary,input):focus-visible,
.band.close :where(a,button,summary,input):focus-visible,
.band.run :where(a,button,summary,input):focus-visible,
.shift-b :where(a,button,summary,input):focus-visible{outline-color:#fff}
/* Two controls in the run band chose mint deliberately and clear 9.1:1 on
   forest, so the white default above must not quietly take them. Re-asserted
   rather than reordered, because `:where()` keeps the rule above at (0,3,0) and
   both of these would otherwise lose it on specificity alone. */
.band.run .picker-list input:focus-visible,
.band.run .row.page:focus-visible{outline-color:var(--mint)}

/* Two rows shared across all three cards, so every well starts on the same
   line and the recess reads as one floor rather than three ledges. The wells
   size to the tallest content instead of a guessed height; without subgrid
   the min-height below is the fallback and the floor goes slightly ragged. */
.steps{list-style:none;counter-reset:s;margin:var(--gap-head) 0 0;padding:0;
  display:grid;grid-template-columns:repeat(3,1fr);gap:clamp(1rem,1.5vw,1.35rem);
  grid-template-rows:1fr auto}
.steps li{counter-increment:s;display:flex;flex-direction:column;
  background:var(--ink-card);border:1px solid var(--ink-line);
  border-radius:var(--r-lg);overflow:hidden}
@supports (grid-template-rows:subgrid){
  .steps li{display:grid;grid-row:span 2;grid-template-rows:subgrid}
}

.step-body{flex:1;padding:clamp(1.5rem,2.3vw,1.9rem)}
/* the counter is on the li, and counters inherit, so the numeral can live
   inside the padded body instead of above it */
.step-body::before{content:counter(s);display:block;
  font-family:var(--sans-display);font-size:clamp(2.25rem,3.6vw,3rem);font-weight:600;letter-spacing:-.05em;
  line-height:1;color:#fff;margin-bottom:clamp(1rem,2vw,1.5rem)}
.steps h3{color:#fff;margin-bottom:.5rem}
.steps p{color:var(--on-ink);font-size:.9375rem}

.step-well{background:var(--ink-well);border-top:1px solid var(--ink-line);
  padding:clamp(1.15rem,1.8vw,1.5rem);min-height:8.5rem;
  display:flex;flex-direction:column;justify-content:center}

/* 1 · the field, the real control in the real shape */
.art-field{display:flex;align-items:center;gap:.5rem;background:rgba(255,255,255,.05);
  border:1px solid rgba(255,255,255,.14);border-radius:var(--pill);
  padding:.28rem .28rem .28rem .95rem}
.art-field .u{flex:1;min-width:0;font-family:var(--mono);
  font-size:.8125rem;color:var(--on-ink-soft);overflow:hidden;text-overflow:ellipsis;
  white-space:nowrap}
.art-field .b{flex:none;background:#fff;color:var(--ink);font-size:.75rem;font-weight:600;
  border-radius:var(--pill);padding:.45rem .9rem}

/* 2 · the pipeline, borderless so the well stays one surface */
.art-run{display:grid;grid-template-columns:1fr 1fr;gap:.5rem .9rem}
.art-run span{display:flex;align-items:center;gap:.5rem;font-size:.8125rem;
  color:var(--on-ink-dim)}
.art-run span::before{content:"";width:.4375rem;height:.4375rem;border-radius:50%;flex:none;
  box-shadow:inset 0 0 0 1px currentColor}
.art-run .done{color:var(--on-ink-soft)}
.art-run .done::before{background:var(--on-ink-soft);box-shadow:none}
.art-run .now{color:#fff;font-weight:500}
.art-run .now::before{background:#fff;box-shadow:none}
.art-bar{margin-top:.9rem;height:3px;border-radius:var(--pill);
  background:rgba(255,255,255,.12);overflow:hidden}
.art-bar i{display:block;height:100%;width:68%;background:#fff;border-radius:var(--pill);
  transform-origin:left}
/* A still progress bar is a lie about what a progress bar is. It fills once,
   when the band arrives, riding the same .in the stagger already sets. */
.steps.in .art-bar i{animation:bar-fill .9s var(--ease) .3s both}
@keyframes bar-fill{from{transform:scaleX(0)}to{transform:scaleX(1)}}

/* 3 · the folder */
.art-files{display:grid}
.art-files span{display:flex;align-items:baseline;gap:.75rem;
  font-family:var(--mono);font-size:.8125rem;color:var(--on-ink-soft);
  padding:.36rem 0;border-bottom:1px solid var(--ink-line)}
.art-files span:first-child{padding-top:0}
.art-files span:last-child{border-bottom:0;padding-bottom:0}
.art-files span i{margin-left:auto;font-style:normal;font-family:var(--sans);
  font-size:.75rem;color:var(--on-ink-dim)}

/* Stacked, the shared rows have nothing left to align, and the 1fr row just
   opens a gap under the first card's copy. Back to plain flex columns. */
@media (max-width:1100px){
  .steps{grid-template-columns:1fr;gap:1rem;grid-template-rows:none}
  .steps li{display:flex;grid-row:auto}
  .step-well{min-height:0}
}

/* 7 · price — one object, two states.

   `.shift` replaced `.ledger` on 2026-08-14. The old component was two matched
   columns side by side, and the shape itself made a claim that is not true:
   a two-up of equal cards reads as "two comparable plans, pick one". These are
   not two plans. The left one is where the visitor's site already is, the right
   one is what a single payment changes it to. So the band is a state change and
   it is now built as one — two rows of the same object, stacked, meeting on a
   hard seam where the material changes.

   Three things fall out of that, and they are why this is the shape rather than
   a preference between two layouts:

   1. Reading down removes the back-and-forth. A two-up asks anyone genuinely
      comparing to saccade left-right four times; stacked rows are read once.
   2. `--surface` -> `--forest-soft` at the seam carries the argument with no
      words spent on it. The old rule this band was held to — "do not style the
      left column as a loser" — is satisfied by construction here, because beige
      is not a weaker card, it is the earlier state.
   3. Both amounts sit hard right on the same edge, so `$180` and `$15` are
      compared on one axis. Side by side they had two, and the numeral widths
      alone made $180 look like the smaller commitment on the narrower card.

   The mint ring that used to sit on `.ledger-col.pick` is gone with it. That
   ring existed to mark the chosen column of a set; there is no set now, and a
   ring around the lower half of a two-tone object reads as a border, not as a
   selection. The elevation is `.limits-fold`'s recipe rather than the old
   card's, so the two large plates in this band are lit by the same light —
   the band was previously carrying two different shadow recipes 100px apart.

   Chosen from four built comps; the three that lost are kept in comps/price2/
   with the argument for each in its README. */
.price-head{text-align:center;max-width:calc(20 * var(--ch));margin-inline:auto}
.price-sub{text-align:center;color:var(--muted);margin:.85rem auto 0;max-width:calc(44 * var(--ch));
  text-wrap:balance}

/* One radius for the pair, clipped, so the seam is a straight edge across the
   full width rather than two abutting rounded boxes. `--r-hero` (32px) and not
   `--r-lg`, matched to `.limits-fold` directly below it and stepping down at
   the same breakpoint: two big plates in one band want one radius language. */
.shift{margin-top:var(--gap-head);border-radius:var(--r-hero);overflow:hidden;
  box-shadow:0 24px 60px -32px rgba(14,59,47,.5)}
@media (max-width:43.75rem){.shift{border-radius:24px}}

/* `align-items:end` and not `start`: it lands the amount's baseline on the last
   line of the note beside it, so each row closes level. Top-aligned, a 4.75rem
   numeral sets its cap far above a 15px label and the row reads as two
   unrelated blocks that happen to share a strip. */
.shift-row{display:grid;grid-template-columns:minmax(0,1fr) auto;
  gap:clamp(1.5rem,5vw,5rem);align-items:end;
  padding:clamp(1.75rem,3.4vw,2.75rem) clamp(1.5rem,3.4vw,2.75rem)}
.shift-a{background:var(--surface);color:var(--body)}
.shift-b{background:var(--forest-soft);color:#fff}

.shift-when{margin:0;font-size:.9375rem;font-weight:500;letter-spacing:0;color:var(--muted)}
.shift-b .shift-when{color:var(--on-forest)}
/* The claim of each row, and the only line in the band set above body size.
   One line down to 360; `balance` is for 320, where "Your site runs from a
   folder you own." breaks with `own.` alone on line two at 33% of the measure. */
.shift-say{margin:.7rem 0 0;font-size:clamp(1.0625rem,1.7vw,1.375rem);line-height:1.45;
  letter-spacing:-.015em;color:var(--ink);max-width:calc(34 * var(--ch));text-wrap:balance}
.shift-b .shift-say{color:#fff}
/* 48ch, not 58. `--ch` is DM Sans's *zero* advance, and DM Sans's figures are
   far wider than its lowercase, so a measure written in it overshoots badly for
   prose: 58 rendered 556px, which is 83 characters a line — well past the 65-75
   the rest of the page holds. 48 lands at ~73.

   `balance` rather than `pretty` for the same reason `.lead-line` uses it: both
   notes are two or three lines at every width above the stack, so the line-count
   ceiling balance carries is never reached, and pretty was leaving the forest
   row's third line as the single word "step." at 19% of the measure — measured
   at seven widths from 700 to 1920, so it was the resting state, not an edge.

   1rem and not the .9375rem the band's small print is set at, which is a
   composition fix as much as a reading one: the measure is a character count,
   so the only way to spend more of the row's width without spending legibility
   is to set the same 73 characters larger. It is also the right call on its own
   terms — this is the paragraph someone reads while deciding whether to pay. */
.shift-note{margin:.9rem 0 0;font-size:1rem;color:var(--muted);
  max-width:calc(48 * var(--ch));text-wrap:balance}
.shift-b .shift-note{color:var(--on-forest)}

/* Bigger than the old `.ledger-amount`'s 4rem ceiling, and deliberately so:
   the amount is now the only object on its side of the row, where it used to
   sit at the top of a card full of other things. 5.75rem also closes the void
   the capped measure opens in the middle of a 1216px row — measured, the copy
   and the number were 469px apart before this and the note's step up. */
.shift-cost{margin:0;text-align:right}
.shift-cost strong{display:block;font-family:var(--sans-display);
  font-size:clamp(3rem,7vw,5.75rem);font-weight:600;letter-spacing:-.05em;
  line-height:.92;color:var(--ink)}
.shift-b .shift-cost strong{color:#fff}
.shift-cost span{display:block;margin-top:.6rem;font-size:.9375rem;color:var(--muted)}
.shift-b .shift-cost span{color:var(--on-forest)}
.shift-b .pill{margin-top:1.35rem}

/* Stacked, the cost has nothing to sit opposite, so it stops being a right-hand
   column and becomes the row's closing line. `start` because a one-column grid
   has no second child to close level with. */
@media (max-width:52rem){
  .shift-row{grid-template-columns:1fr;align-items:start;gap:1.5rem}
  .shift-cost{text-align:left}
}

.price-note{margin:1.25rem 0 0;font-size:.8125rem;color:var(--muted);
  max-width:calc(74 * var(--ch));line-height:1.6;text-wrap:pretty}
.price-note .mono{font-size:.95em}

/* 8 · faq */
.faq{margin-top:var(--gap-head);border-top:1px solid var(--line)}
.faq details{border-bottom:1px solid var(--line)}
.faq summary{display:flex;align-items:center;gap:1rem;padding:1.15rem 0;cursor:pointer;
  font-weight:500;list-style:none;letter-spacing:-.01em;color:var(--ink);
  transition:color .15s var(--ease)}
.faq summary::-webkit-details-marker{display:none}
/* nine rows that only changed the cursor; the chevron answers the pointer too */
@media (hover:hover) and (pointer:fine){
  .faq summary:hover{color:var(--body)}
  .faq summary:hover::after{border-color:var(--ink)}
}
.faq summary::after{content:"";margin-left:auto;flex:none;width:.625rem;height:.625rem;
  border-right:2px solid var(--muted);border-bottom:2px solid var(--muted);
  transform:rotate(45deg) translateY(-3px);
  transition:transform .22s var(--ease),border-color .15s var(--ease)}
.faq details[open] summary::after{transform:rotate(225deg) translateY(-3px)}
.faq details p{padding:0 0 1.35rem;color:var(--body);max-width:calc(64 * var(--ch));
  text-wrap:balance}
.faq details[open] p{animation:answer .22s var(--ease) both}
@keyframes answer{from{opacity:0;transform:translateY(-.35rem)}to{opacity:1;transform:none}}

/* Nine of these in a stack, so opening one used to teleport everything below
   it: the answer faded in politely while the box it sat in snapped to full
   height. `::details-content` makes the box itself animate, on the exact .22s
   the chevron already turns on, so arrow and answer move as one thing.
   The @supports wrapper is the fallback: where this is not understood the rule
   set is dropped whole and the old fade stays, which is today's behaviour. */
@supports (interpolate-size:allow-keywords) and selector(details::details-content){
  :root{interpolate-size:allow-keywords}
  .faq details[open] p{animation:none}
  .faq details::details-content{
    block-size:0;opacity:0;overflow:hidden;
    transition:block-size .22s var(--ease),opacity .22s var(--ease),
               content-visibility .22s allow-discrete}
  .faq details[open]::details-content{block-size:auto;opacity:1}
}

/* 9 · close. Everything that makes this band a band lives in section 7 below,
   next to the plate it replaced; only the padding is here, with the rest of the
   band rules. It is a hair tighter than `--band` because the band's own ground
   is doing the separating now, so it does not also need the full gutter. */
.band.close{padding-block:clamp(3.75rem,7.5vw,6.5rem)}

/* ---------- footer ---------- */

.footer{background:var(--ink);color:#fff;padding:clamp(3rem,5vw,4rem) 0 2rem}

/* Grid, not flex-wrap. `margin-left:auto` kept shoving the link row to the right
   wall *after* it had wrapped, so between roughly 700 and 1000px the links sat
   ragged-left in mid-air under a left-aligned wordmark. A grid either has two
   columns or one; it cannot half-wrap.

   `last baseline`, not `flex-start` or `flex-end`. The brand is two lines, and
   the links used to align to the wordmark's box top — optically high against
   19px/700 type. This sits them on the *tagline's* baseline instead: 14px
   against 15px, which is a true baseline match rather than a box match. */
.footer-in{display:grid;grid-template-columns:auto auto;
  justify-content:space-between;align-items:last baseline;gap:2rem 3rem;
  padding-bottom:2.5rem;border-bottom:1px solid var(--ink-line)}
@media (max-width:900px){
  .footer-in{grid-template-columns:1fr;justify-content:start;gap:1.75rem}
}
.footer .mark{font-size:1.1875rem;font-weight:700;letter-spacing:-.04em;margin-bottom:.4rem}
.footer-line{color:var(--on-ink-soft);font-size:.9375rem}
.footer-links{display:flex;gap:1.5rem;flex-wrap:wrap;font-size:.875rem}
.footer-links a{color:var(--on-ink);text-decoration:none;transition:color .15s var(--ease)}
.footer-links a:hover{color:#fff}
/* Two rules, because the right answer changes with the room available.
   This is 13px compliance boilerplate in the dimmest tint on the page. On one
   line it reads as a footnote, which is the weight a trademark notice should
   carry; as a balanced two-line block it reads as a paragraph and claims more
   attention than it deserves. The sentence needs 803px and the shell offers
   1216px, so a 72ch (642px) cap was splitting it with 574px to spare.

   But the cap is what keeps the wrap clean once it *has* to wrap: below 900px
   the uncapped line breaks 669 + 130, and that 130px tail is the runt the
   2026-08-09 sweep went through the whole page to remove. So the cap stays as
   the base and is lifted only where one line fits — 900px is the first width
   whose shell (852px) clears 803px. Verified at ten widths, 320 to 1440: one
   line at and above 900, 395 + 404 below it, 284 + 254 + 258 at 390, no runt
   and no overflow anywhere.

   Both numbers are tied to this exact sentence. Reword it and re-measure. */
.legal{margin-top:1.5rem;font-size:.8125rem;color:var(--on-ink-dim);
  max-width:calc(72 * var(--ch));text-wrap:balance}
@media (min-width:56.25rem){.legal{max-width:none}}

/* ---------- scroll reveal ---------- */

/* One primitive for the whole page rather than a rule per section. An element
   opts in with `data-reveal`; a group opts in with `data-stagger` and its own
   children ride the `--i` index below. app.js only ever adds `.in`.

   The `.reveal` class this all hangs off is set by an inline script in <head>,
   and only when there is an IntersectionObserver and motion is wanted. So the
   page's resting state is *visible*: no JS, old browser, or reduced motion and
   nothing is hidden waiting for a class that will never arrive. That is the
   right way round for a page whose whole job is to be read.

   Keyframes rather than transitions here, deliberately: several of these
   elements already own a `transition` (the field's border, the pill's
   background) and a reveal built on transitions would overwrite it. These fire
   once on scroll, so there is nothing to retarget. */
.reveal [data-reveal],
.reveal [data-stagger] > *{opacity:0}
.reveal [data-reveal].in,
.reveal [data-stagger].in > *{
  animation:reveal .5s var(--ease) both;
  animation-delay:calc(var(--i,0) * var(--step,60ms))}
@keyframes reveal{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}

[data-stagger] > *:nth-child(1){--i:0}
[data-stagger] > *:nth-child(2){--i:1}
[data-stagger] > *:nth-child(3){--i:2}
[data-stagger] > *:nth-child(4){--i:3}
[data-stagger] > *:nth-child(5){--i:4}
[data-stagger] > *:nth-child(6){--i:5}
[data-stagger] > *:nth-child(7){--i:6}
[data-stagger] > *:nth-child(8){--i:7}
[data-stagger] > *:nth-child(n+9){--i:8}

/* eight rows at 60ms would trail half a second behind the card they sit in */
.tree ul{--step:40ms}

/* ---------- touch targets ---------- */

/* Held to 44px on a coarse pointer only, so a mouse keeps the tighter
   proportions the design was drawn at. This has to sit near the end of the
   file: every rule below is a plain class selector, so it wins on source order
   rather than specificity, and an earlier placement silently lost to the base
   `.pill` height.

   `min-height` with a flex centre rather than `height`, so a control that
   wraps to two lines grows instead of clipping. */
@media (pointer:coarse){
  .pill{height:auto;min-height:2.75rem}
  .mark{display:inline-flex;align-items:center;min-height:2.75rem}

  /* same short-label case as the footer: "Price" is 31px wide between 700 and
     844, where the bar is still a horizontal row on a touch screen */
  .nav-links a{display:inline-flex;align-items:center;justify-content:center;
    min-height:2.75rem;min-width:2.75rem}
  .footer-links{gap:.35rem 1.25rem}
  /* "Price" is the one label short enough to miss the target on width alone:
     33x44. It clears WCAG 2.5.8 at AA and fails 2.5.5 at AAA, and the gap
     between rows is large enough that widening it costs nothing. */
  .footer-links a{display:inline-flex;align-items:center;justify-content:center;
    min-height:2.75rem;min-width:2.75rem}

  .field input{height:2.75rem}
  .field button{height:2.75rem}
  /* the segmented track rests at 38px, which is right under a mouse and under
     the touch floor. Only the segment grows; the track's 4px padding already
     carries the rest. Above 700 this is the tablet case, since the phone block
     has already taken it to 2.75rem itself. */
  .modes span{height:2.75rem}
  .faq summary{padding-block:1.35rem}

  /* The export panel, added 2026-08-16. This block reached the nav, the field,
     the scope track, the FAQ and the footer, and every control the panel adds
     of its own was missed — the panel inherited the touch floor only where it
     happened to reuse `.pill`. Measured on a real touch context at 390 (not a
     narrow window with a mouse, which reports the fine-pointer sizes and hides
     exactly this): "Select all" 74x31, "Clear" 54x31, Cancel 71x32, the three
     preview widths 66x30 and 54x30, and every result row 40px tall. All of
     them clear WCAG 2.5.8 at AA (24x24) and all of them miss 2.5.5 at AAA,
     which is the floor the rest of this page already holds itself to.

     The picker's own rows are already right and are deliberately not here:
     each checkbox is a 17px input inside a `min-height:2.75rem` label, and the
     label is the hit area. Sizing the input would be sizing the wrong box. */
  .picker-acts button{min-height:2.75rem;padding-inline:1rem}
  .viewer-sizes button{min-height:2.75rem}
  .row.page{min-height:2.75rem}
  /* The one deliberate exception, kept deliberate. `.floor-cancel` is 32px
     against the submit's 44 so the control that undoes the export cannot
     out-weigh the one that starts it, and that reasoning is about visual
     weight, which a touch screen does not change. What a touch screen does
     change is whether a finger can hit it — so it takes the floor on the axis
     that matters and holds its width, staying visibly the lighter object. */
  .floor-cancel{min-height:2.75rem}
}

/* ---------- motion preferences ---------- */

@media (prefers-reduced-motion:reduce){
  html{scroll-behavior:auto}
  *,*::before,*::after{
    animation-duration:.001ms!important;
    animation-iteration-count:1!important;
    transition-duration:.001ms!important;
  }
  /* the row keyframes open on a dim frame; with motion off, show them outright */
  .row,.row .st,.row .tick{opacity:1!important;transform:none!important}
  .card-foot,.demo-note,.ok,.screen > span{opacity:1!important;transform:none!important}
  /* the head script will not arm the reveal under this preference, but it can
     be switched on after the page is already loaded and armed. Without this the
     page would be left holding its content at opacity 0. */
  [data-reveal],[data-stagger] > *{opacity:1!important;transform:none!important}

  /* Fewer and gentler, not none. Every movement above is gone: no rise, no
     slide, no stagger, and app.js reads this same query to swap the card's
     height outright instead of growing it. The two changes that would
     otherwise happen with nothing at all to mark them keep a plain opacity
     fade, because a hard cut in the hero panel, or a validation line blinking
     into existence under the field, is harder to follow rather than easier. */
  .floor-idle{transition-duration:.14s!important}
  .floor-idle.leaving{transform:none}
  .field-error{transition-duration:.16s!important}
  .field-error[hidden]{transform:none}
}

/* ── 3 · features ─────────────────────────────────────────────────────────
   A pull.page bento, measured off their page: four tracks, one tile at 2x2 in
   columns 2-3, two small tiles stacked either side. Wells on hairlines, not
   filled cards — the step cards stay the only cards on this page.

   The 3+2 / 2+3 checkerboard this replaces was not itself the fault. The wells
   were already full width; everything inside them was capped at 11-22rem and
   pinned left, so all four ran 60-85% empty, and three of the four artifacts
   were lists of filenames that restated the heading above them. The bento is
   what gives the offline check the weight PRODUCT.md says it earns: it is the
   one claim no competitor makes, and it was tied for fourth.

   Generated by .impeccable/tools/feat-apply.mjs from comps/features/. Edit the
   comp sources, not this block. Order is artwork, motion, grid — the grid must
   come last, because it is what sets the well floor.
   ───────────────────────────────────────────────────────────────────────── */

/* ══════════════════════════════════════════════════════════════════════════
   Feature artwork — comp stylesheet.

   The fault this replaces: every well is 168px tall and full-width, but the
   thing inside it is capped at 11–22rem and pinned to the left, so all four
   wells run 60–85% empty and read as four tan rectangles rather than as four
   pictures. letaiworkforme's panels are 630x180 and 274x180 with the artwork
   filling them edge to edge; that is the whole difference.

   Second fault: three of the four artifacts were lists of filenames, which
   restate the heading instead of adding to it. These five are drawn depictions
   of the mechanism — a crawl, an eased path, a test rig, three named removals,
   and the six asset kinds the engine actually stores.
   ══════════════════════════════════════════════════════════════════════════ */

/* The well stops padding its contents; each artwork owns its own inset, so a
   piece that wants to bleed (the fan, the size ladder) can.

   `container-type` is what makes one artwork survive five slot widths. The
   same four pictures appear at 277px in the bento's small tiles and at 1216px
   in the closer's lead block, and a viewport media query cannot tell those
   apart — it reads 1440 for both. The routes list clipped its last CMS badge
   in a 381px well while the identical rule looked fine at 710px, which is the
   fault a container query exists for. */
/* The tinted recess itself, not just its layout. This rule used to inherit
   `background`, `border-radius`, `display:grid` and `overflow` from the block
   in styles.css that this artwork replaced; spliced in, that block was gone and
   every picture ended up sitting directly on the canvas with no well at all.
   `place-items` was also inert without `display:grid`. */
.feat-well{background:var(--well);border-radius:var(--r-md);
  display:grid;place-items:stretch;padding:0;overflow:hidden;
  container-type:inline-size}
.fx{width:100%;height:100%;min-width:0}

/* ── 1 · the collection, unpacked ─────────────────────────────────────────
   Was `.fx-routes`: a globe with five bezier curves fanning out to a route
   list. Replaced 2026-08-11 from a four-way round (`comps/features/art-pages.html`).

   Roman's report was that the fan appeared to start *inside* the globe. It did:
   `.fx-fan` was absolutely positioned across the whole cell, so every curve
   began at the cell's left edge and ran behind the mark. That was a fixable
   fault. The one that decided it is not — a fan is a diagram of an idea, and
   `.fx-ease` was retired the day before on exactly that charge. Four of the
   band's five wells are pictures of output; this was the last chart.

   The heading is "Every page, including the CMS ones", and only the second half
   is hard. Any capture can follow a nav; what a single-URL scraper misses is a
   Framer collection. So the picture is one collection becoming individual pages,
   each landing as its own `index.html` — which is what `exporter.ts` writes per
   route, not a decorative filename. */
.fx-collection{display:grid;align-content:center;gap:.5rem;padding:.9rem 1.05rem}
.co-src{display:flex;align-items:center;gap:.5rem;background:var(--plate);
  border:1px solid var(--plate-line);border-radius:var(--r-sm);padding:.45rem .6rem;
  min-width:0}
.co-src svg{width:1rem;height:1rem;display:block;flex:none;fill:var(--ink);opacity:.5}
.co-src b{font-size:.8125rem;font-weight:600;letter-spacing:-.01em;color:var(--ink)}
.co-src > span{margin-left:auto;font-size:.75rem;color:var(--muted);
  white-space:nowrap;padding-left:.5rem}
/* One guide dropping from the collection into its entries. Deliberately not the
   nested indent `#how-it-works` already owns: that card is a folder tree of
   files at rest, this is one object expanding into several.

   The guide is drawn per row, not as one absolute line down the list. It was
   `.co-out::before` with `bottom:.65rem` — a magic number, and it landed
   **1.4px short** of the last stub, so the bottom connector floated free of the
   line it belonged to. Roman spotted it.

   Per row there is no number left to get wrong: every row draws from the gap
   above it to its own bottom, so consecutive segments meet edge to edge, and
   only the last row stops half way, exactly where its stub is.

   **`bottom:0` on every row and `bottom:50%` on the last, not `bottom:50%` on
   all of them.** The first attempt at this ran each segment from the gap above
   down to its own *centre*, which looks right in a screenshot and paints
   `-9.6→9`, `18→33.4`, `42.4→57.8` — a 9px hole between every pair of rows. It
   shipped that way for one deploy because the end of the guide was measured and
   the middle of it was not. Measure the segments, not the terminus. */
.co-out{list-style:none;margin:0;padding:0 0 0 .85rem;display:grid;gap:.4rem;
  position:relative}
.co-out li{display:flex;align-items:center;gap:.45rem;position:relative;
  font-family:var(--mono);font-size:.75rem;color:var(--body);
  white-space:nowrap;min-width:0}
.co-out li::before{content:"";position:absolute;left:-.55rem;top:50%;
  width:.4rem;height:1px;background:var(--plate-line)}
.co-out li::after{content:"";position:absolute;left:-.55rem;top:-.4rem;bottom:0;
  width:1px;background:var(--plate-line)}
/* the first segment reaches up under the collection card, whose gap is .5rem */
.co-out li:first-child::after{top:-.6rem}
/* and the last stops on its own stub rather than running past it */
.co-out li:last-child::after{bottom:50%}
.co-out li > span{overflow:hidden;text-overflow:ellipsis;min-width:0}
.co-out svg{width:.8125rem;height:.8125rem;display:block;flex:none;
  fill:var(--ink);opacity:.4}
/* The count row keeps its connector. It was briefly cut on the argument that a
   count is not an entry — the rule the report panel's summary row follows, where
   no tick is drawn because a tick is a verdict on a page you can see. Roman's
   answer was that he asked for the connection fixed, not removed, and the
   argument does not transfer: a tick is a *claim about* a page, while this line
   is the collection's own bracket, and "and 3 more entries" is inside the
   collection whether it is named or not. Its 1.26rem indent lands the text on
   the same rail as the rows above (13px mark + .45rem gap = 20.2px). */
.co-out li.rest{color:var(--muted);font-family:var(--sans);padding-left:1.26rem}
/* The file each route lands as. Hidden in the 252px bento tile, where the route
   itself already takes the row; shown from 20rem up, which is every other slot
   this artwork appears in — the 449px two-column tile and the 327px phone card
   both have the width and read as padded without it. */
/* No `opacity` on it. `--muted` is 5.6:1 on canvas by design; knocking it back
   to .75 to "demote" the column composited to 3.36:1 and failed AA — the
   demotion has to come from the type, which the mono face and 12px already do. */
.co-out em{margin-left:auto;font-style:normal;font-family:var(--mono);
  font-size:.75rem;color:var(--muted);padding-left:.75rem;display:none}
@container (min-width:20rem){.co-out em{display:block}}

/* ── 2 · the motion marks ────────────────────────────────────────────────
   Was `.fx-ease`, a plotted cubic-bezier with its control handles, knobs and
   endpoints — a motion *editor's* notation. Its own comment argued that a still
   image of movement is the one thing movement cannot be, which is true, and the
   conclusion drawn from it was wrong: the answer is not to draw the curve's
   authoring UI instead. It diagrammed what an animation is and said nothing
   about the visitor's animations surviving, and it was the only figure in the
   band that was a chart rather than a picture of output. Roman rejected it and
   picked the labelled icon row from a three-way round, 2026-08-10.

   It reuses `.fx-kinds`, the assets card's own row, with three overrides. Two
   identical rows in one band is the duplication DESIGN.md warns about, and the
   thing that keeps them apart is that the assets row is six mono-labelled file
   *kinds* and this is three sans-labelled *behaviours* at a larger mark size.
   If this ever grows to six items, they collapse into the same object and one
   of them has to change. */
/* `minmax(0,1fr)`, never `1fr`. A bare `1fr` is `minmax(auto,1fr)`, so a track
   cannot shrink below its content's min-content width — and with `nowrap` on
   the captions that floor was "page transitions" at 90px inside a 65px slot.
   The row overflowed the well by 10px from 1280 up and the well clipped it, so
   the third mark lost its right border and the label read "page transition".
   It shipped that way because the fault is invisible at 1024, where the tiles
   are wider than the longest caption. */
.fx.fx-motion{grid-template-columns:repeat(3,minmax(0,1fr));gap:.7rem .55rem}
.fx.fx-motion span{height:2.75rem}
.fx.fx-motion svg{width:1.25rem;height:1.25rem;opacity:.6}
/* Sans, not the inherited mono. The assets row's labels are file kinds, which
   is data and earns mono; "hover states" is prose and would be mono as costume,
   which the type rules ban outright. `nowrap` because "page transitions" wraps
   to two lines in a third of the well and leaves the row ragged. */
/* 12px, not 11. `adapt-sweep` enforces a 12px floor and caught this at all
   twelve viewports; the assets row's own captions are .75rem for the same
   reason. "page transitions" still clears a third of the well at this size. */
.fx.fx-motion figcaption{font-family:var(--sans);white-space:nowrap;font-size:.75rem}
/* The six-column switch belongs to the assets row only; three behaviours spread
   across six columns would leave half the row empty. */
@container (min-width:34rem){
  .fx.fx-motion{grid-template-columns:repeat(3,minmax(0,1fr));max-width:none;justify-self:stretch}
  .fx.fx-motion span{height:3rem}
}

/* ── 4 · the report panel ─────────────────────────────────────────────────
   Was `.fx-check`: a "network blocked" chip, three width frames, a six-row
   findings list and a score, stacked loose in one well with a hairline rule
   between two of them. Four figures, four alignments, and — because the tile
   spans two grid rows and a content-sized list cannot fill them — about 330px
   of empty well between the last row and the number. Rebuilt 2026-08-11 from a
   four-way round (`comps/features/art-check.html`); Roman picked this one.

   One object with a head, a body and a foot, which is the shape the product's
   own report panel has. Binding the parts is what closes the hole: the panel is
   a grid whose row track for the list is `1fr`, so the height the tile used to
   give away goes into the rows.

   It reuses `.fx-widths` rather than redrawing three frames. That component
   carries a responsive round's worth of work — proportional flex bases that
   keep 1440 : 768 : 390 exact rather than merely ordered all the way down to a
   320px well — and a second set of frames would have to learn it again. */
/* `grid-template-columns:minmax(0,1fr)`, never an implicit `auto` column. An
   auto track cannot go below the widest min-content among its items, and this
   panel is full of things that refuse to be narrow: the three frames carry
   explicit widths, every page name is `nowrap`, the chip is `nowrap`. At a
   327px well that summed to a **384px** track inside a 327px panel, and
   `overflow:hidden` turned the excess into the caveat sentence being sliced
   down the middle. Capping the minimum at 0 makes the track the panel and lets
   each row solve its own width. */
.fx-report{display:grid;grid-template-columns:minmax(0,1fr);
  grid-template-rows:auto auto 1fr auto;margin:0;
  background:var(--plate);border:1px solid var(--plate-line);
  border-radius:var(--r-md);overflow:hidden;min-width:0}
.rp-head{display:flex;align-items:center;justify-content:space-between;gap:1rem;
  padding:.8rem 1.05rem;border-bottom:1px solid var(--plate-line);min-width:0}
.rp-head > b{font-family:var(--mono);font-size:.8125rem;font-weight:400;
  color:var(--body);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rp-chip{display:inline-flex;align-items:center;gap:.4rem;font-size:.75rem;
  font-weight:500;color:var(--body);white-space:nowrap;flex:none}
.rp-chip svg{width:.9375rem;height:.9375rem;display:block;flex:none;
  fill:var(--ink);opacity:.55}
/* The conditions row. `.fx-widths` caps itself at its own natural size, so the
   note takes the slack rather than the frames stretching into it.
   `min-width:0` on the group is what lets the frames scale instead of spilling:
   their figures carry explicit widths, so the group's own min-content is the
   full 297.6px and without this it simply refuses to shrink. The proportional
   flex bases inside it are what the shrinking is for. */
.rp-cond{display:flex;align-items:flex-end;gap:1rem;flex-wrap:wrap;
  padding:.8rem 1.05rem;border-bottom:1px solid var(--plate-line)}
.rp-cond .fx-widths{min-width:0}
/* Right rail, and bottom-aligned so its baseline lands on the frame captions
   rather than on the middle of a 96px block. It used to carry a hard `<br>`
   ("every page, / at all three") which Roman read as the panel truncating it —
   a line break that looks like a defect is a defect. */
.rp-note{margin-left:auto;align-self:flex-end;font-size:.75rem;color:var(--muted);
  text-align:right;line-height:1.4;flex:none;white-space:nowrap}
/* Three frames, proportional to the three widths they name, and the proportion
   is the whole point: they are the picture of "at three screen sizes".
   `max-width` holds the group at its natural size where there is room.

   ⚠ The first six lines of this comment were lost in the 2026-08-11 splice that
   replaced `.fx-check` with `.fx-report`; everything from "the old 4.5 / 2.6 /
   1.35rem" down is the original wording, and the paragraph above is a
   reconstruction of what it said. The reasoning below is the load-bearing part
   and it survived intact.

   Each figure's width is
   the old 4.5 / 2.6 / 1.35rem carried as flex bases — so the group's natural
   size is still exactly what it was, and it shrinks from there.

   The basis has to be a length, not 0. `flex:450 1 0` was tried and collapses
   the row: with a zero basis the flex container's max-content is the sum of the
   items' own max-contents, which here is the three captions, so `width:100%` in
   an auto grid track resolved to 99px and the frames became label-sized at every
   viewport. With a real basis the max-content is the sum of the bases, and
   shrinkage is weighted by basis — which is what keeps 1440 : 768 : 390 exact
   all the way down rather than merely ordered. No `min-width:0` on the figures:
   `auto` keeps each at least as wide as its own caption, so the group stops
   shrinking before a label is lost rather than after. */
.fx-widths{display:flex;align-items:flex-end;gap:.5rem;max-width:9.45rem}
.fx-widths figure{margin:0;display:grid;gap:.35rem;justify-items:center}
.fx-widths figure:nth-child(1){width:4.5rem}
.fx-widths figure:nth-child(2){width:2.6rem}
.fx-widths figure:nth-child(3){width:1.35rem}
.fx-widths i{display:block;background:var(--plate);border-radius:3px;position:relative;
  overflow:hidden;width:100%;
  border:1px solid var(--plate-line)}
.fx-widths figure:nth-child(1) i{height:2.75rem}
.fx-widths figure:nth-child(2) i{height:2.2rem}
.fx-widths figure:nth-child(3) i{height:1.9rem}
/* Each frame carries a header band and two content lines, scaled to its own
   width, so the three read as the same page rendered at three sizes rather than
   as three empty plates. */
.fx-widths i::before{content:"";position:absolute;left:12%;right:12%;top:14%;height:14%;
  border-radius:1px;background:var(--ink);opacity:.16}
.fx-widths i::after{content:"";position:absolute;left:12%;right:22%;top:40%;height:9%;
  border-radius:1px;background:var(--ink);opacity:.1;
  box-shadow:0 200% 0 0 rgba(10,10,10,.1)}
.fx-widths figcaption{font-family:var(--mono);font-size:.75rem;color:var(--muted)}
/* The page list. `align-content:stretch` is what actually closes the hole the
   old figure had: the rows share whatever height the `1fr` track gives them, so
   the panel is full at 675px in the bento and still correct at 490px on a
   phone, with no value tuned to either. */
.rp-rows{list-style:none;margin:0;padding:0;display:grid;align-content:stretch;
  font-size:.8125rem;min-width:0}
/* `center`, and the reason is `align-content:stretch` above. The rows share the
   panel's spare height, so a row box is ~35px tall for 19px of text; under
   `baseline` every name pinned to the top of its own row and left 15px of slack
   under it, eleven times down the panel. Roman reported exactly that.

   Baseline is right when rows are content-sized and wrong when they are
   stretched, which is the distinction worth keeping — `.row` in the live panel
   is content-sized, so it stays baseline. Here the row is a band and its content
   belongs on the band's centre line. */
/* `padding-block`, not `min-height`, is what stops the rows collapsing where the
   panel is content-sized. At 390 the well gave the list 264px for eleven rows —
   20.5px each, which is the text plus its hairline and no air at all. Padding
   sets a floor of 33.9px that `align-content:stretch` then grows to 35.4px in the
   2x2 tile, so the rows are within 1.5px of the same height at every width.

   A `min-height` was tried first and is the trap: in the tile the panel is
   exactly the well's 675px with `overflow:hidden`, so a floor 1px above what
   stretch was already giving pushes the list past the panel and clips the foot.
   Padding cannot do that, because stretch only ever distributes height that
   exists. Measured: 10 rows x 33.9 + the caveat's 54.4 = 393.4 against 408.4
   available at 1440. */
.rp-rows li{display:flex;align-items:center;gap:.65rem;
  padding:.45rem 1.05rem;
  color:var(--ink);border-bottom:1px solid var(--plate-line);min-width:0;
  overflow:hidden}
.rp-rows li:last-child{border-bottom:0}
.rp-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rp-rows em{font-style:normal;font-family:var(--sans);font-weight:500;
  font-size:.75rem;letter-spacing:.04em;color:var(--muted);flex:none}
/* A tick, not the word "fine" ten times down a column, and it sits at the END of
   the row — which is where `renderReport` puts it (`name | status | tick`), so
   the figure and the live panel agree on order as well as on treatment.

   That order is what buys the alignment. Leading marks put every page name on a
   second left rail 25px inside the site name, the frames and the score; measured
   at 1440 the panel had *four* left rails (21 / 46 / 85 / 107px). With the mark
   trailing, `.rp-name{flex:1}` pins every name to the panel's left padding and
   every mark to its right, so the rows share both edges with the head, the
   conditions row and the foot. */
/* .5, the same opacity `.fx-kinds` and `.fx-strip` draw their marks at, so the
   band's glyphs read as one set. It is also the floor that keeps this legible:
   ink at .42 composites to #9c9c9c on white — 2.6:1 — and on a clean report the
   tick is the only thing on ten rows that says "passed". At .5 it is 3.1:1. */
.rp-tick{width:.9375rem;height:.9375rem;display:block;flex:none;
  fill:var(--ink);opacity:.5}
/* ~~Only the caveat mark needs a nudge.~~ **Struck 2026-08-15, on Roman spotting
   it: the caveat row is centred now, and this nudge went with the baseline.**

   The old note said the caveat row "stays baseline (its sentence wraps, and a
   one-word label centred against three lines is the fault `.row.lim` was fixed
   for)". That fear was never tested against this figure. Measured: the sentence
   here wraps to **two** lines at 390 and one line everywhere above it, so the
   three-line case the baseline was defending against does not occur — and the
   cost was that at 390 the CMS label sat **9.25px above** the row's centre with
   the mark 8.4px above it, which is what Roman saw.

   The nudge has to go with it. `translate:0 .315em` existed only to correct a
   `display:block` SVG baselining on its bottom margin edge; under `center` it
   is no longer a correction, it is a 4px error in the other direction. Both
   label and mark measure 0.00px off centre at 390, 1024 and 1440 now.

   If the sentence ever does reach three lines, revisit — the stacked variant
   (label and mark on their own row above the text) was comped and works, but
   it grows the row from 58px to 82px and breaks the panel's rhythm, which is
   why it was not taken. */
.rp-bang{translate:none}
/* The caveat row. It is not a fault and must not read as one: `report.limitations`
   are the things that stop being true once a site is static, and the engine emits
   this exact sentence (`rewriter.ts:426`) for any site with a CMS. Amber on the
   label and the mark, body copy on the sentence — the same weighting `.row.lim`
   uses on the live panel, where the label is the subject the engine's sentence
   was written to have lifted out of it. */
/* The one row that keeps baseline. Its sentence wraps to two or three lines in a
   narrow well, so it has to align to its *first* line; centred, the one-word
   label would float against the middle of a three-line answer, which is the
   exact fault `.row.lim` was fixed for on the live panel this same week. */
/* `center`, not `baseline` — see the `.rp-bang` note above for the measurements
   and for why the nudge was struck at the same time. */
.rp-rows li.lim{align-items:center;padding-block:.6rem;color:var(--body)}
.rp-rows li.lim .rp-name{flex:none;color:var(--amber-ink);font-weight:500}
/* `balance`, not `pretty`, and it is the same call the type rules make for every
   other block that is short by construction. This is one fixed sentence — the
   engine's own wording for the CMS limitation — so it cannot grow past the ~6
   lines where browsers stop balancing and fall back to normal wrapping. Three at
   320 is the deepest it ever gets.

   `pretty` only refuses a *single-word* last line, which is why it passed the
   earlier sweep and still broke badly: 240/130 at 430 and 168/115/83 at 360, a
   last line under half the longest in both. Balanced: 194/176 and 109/128/130. */
.rp-rows li.lim em{flex:1;text-align:right;white-space:normal;
  font-weight:400;letter-spacing:0;font-size:.8125rem;color:var(--muted);
  text-wrap:balance}
.rp-bang{width:.9375rem;height:.9375rem;display:block;flex:none;
  fill:var(--amber-ink)}
/* The score, in the report panel's own treatment: the number at display weight,
   the scale demoted beside it. A bare "100" gives a reader nothing to judge
   against, which is the same reasoning `.card-foot .score` carries in app.js.
   100, not 96, since 2026-08-11 — see the note above the markup in index.html. */
.rp-foot{display:flex;align-items:baseline;gap:.6rem;flex-wrap:wrap;
  padding:.9rem 1.05rem;border-top:1px solid var(--plate-line)}
.rp-foot b{font-size:1.75rem;font-weight:600;letter-spacing:-.035em;
  color:var(--ink);line-height:1}
.rp-foot i{font-style:normal;font-size:.9375rem;font-weight:400;
  color:var(--muted);letter-spacing:0}
.rp-foot > span{font-size:.8125rem;color:var(--muted)}

/* ── 5 · what comes off ───────────────────────────────────────────────────
   Three named removals, struck. A list of things being deleted is not the
   filename-list fault the other artifacts were rebuilt out of: the strike and
   the mark carry the operation, which is the part the heading cannot show.
   Framer is named in our own type, never in their lettering or colour —
   nominative reference, per PRODUCT.md's brand constraint. */
.fx-strip{padding:1.15rem 1.25rem;display:grid;align-content:center;gap:.5rem;
  justify-items:start}
.fx-strip .fx-chip{display:inline-flex;align-items:center;gap:.55rem;
  background:var(--plate);border:1px solid var(--plate-line);border-radius:var(--pill);
  padding:.32rem .55rem .32rem .7rem;font-size:.75rem;color:var(--muted);
  max-width:100%;min-width:0}
.fx-strip .fx-chip s{position:relative;text-decoration:none;
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
/* `text-decoration` cannot be animated, so the strike is a drawn line. Resting
   state is a full-width rule; the keyframe only supplies the scaleX(0) start,
   so an unsupported browser still shows the text struck. */
.fx-strip .fx-chip s::after{content:"";position:absolute;left:0;right:0;top:53%;
  height:1px;background:rgba(10,10,10,.38);transform-origin:left}
.fx-strip .fx-chip.mono s{font-family:var(--mono);font-size:.75rem}
.fx-strip .fx-chip svg{width:.6875rem;height:.6875rem;display:block;flex:none;
  fill:var(--ink);opacity:.5}
/* the last one is already leaving */
.fx-strip .fx-chip:nth-child(3){opacity:.45;translate:8% 0}

/* narrow slots: the head stacks rather than truncating the site name.
   At a 223px well the name (145px, mono 13px) plus the chip (122px) needs 267,
   and `text-overflow:ellipsis` on the name is the wrong answer — the domain is
   the one piece of the head that says whose site this is. */
@container (max-width:22rem){
  .rp-head{flex-direction:column;align-items:flex-start;gap:.4rem}
}
/* Below this the conditions row cannot hold both. `.fx-widths` caps at 151px and
   the note takes ~135px on one line, so under a ~290px inner width the row wraps
   and the note drops to a line of its own — 33px of extra panel height for an
   annotation, sitting 32.8px below the captions it was meant to sit level with.
   21rem, not 20: a 390px phone gives this well 327px (20.4rem), which was on the
   wrong side of the old threshold and wrapped. The frames are the claim; the
   note only labels them. */
@container (max-width:21rem){
  .rp-note{display:none}
}

/* ── slot-width behaviour ─────────────────────────────────────────────────
   Keyed to the well, not the viewport. Thresholds are the real slot widths
   these compositions produce: 252 (bento small), 327 (phone, one column),
   449 (bento two-column), 561 (the 2x2 tile), 945 (the 2x2 tile at 1024).

   Everything the crawl needed here went with it on 2026-08-11 — a fan cap at
   15rem, a badge trim at 22rem, a globe that only appeared once the source chip
   was hidden at 30rem. `.fx-collection` needs none of them: it is a stacked
   list, so it survives every slot on ellipsis alone, and the only thing it
   trades on width is the `index.html` column (declared with the component
   above, at 20rem). */

/* The report's rows tighten before anything is dropped. 12px is the project's
   floor and `adapt-sweep` enforces it, so this is as small as the list goes;
   below here the panel gets shorter by having a shorter site, not smaller type. */
@container (max-width:20rem){
  .rp-rows{font-size:.75rem}
  /* the same trim as before, on the basis rather than on the bar: 4.5rem to
     3.5rem, and the group's cap comes down by the same 1rem */
  .fx-widths{max-width:8.45rem}
  .fx-widths figure:nth-child(1){width:3.5rem}
  .fx-widths figure:nth-child(1) i{height:2.25rem}
}

/* ── 6 · the asset kinds ──────────────────────────────────────────────────
   Replaces the specimen-plus-ladder, which drew two of the six kinds the
   engine actually stores. Phosphor regular at one size and one opacity: the
   set has to read as a system, or six glyphs in a small tile become soup. */
/* 2.25rem plates, not 2.5rem: at the taller size two rows plus their captions
   measured 174px against the 168px floor its three neighbours sit on, and four
   wells in one band ending 6px apart reads as a mistake rather than a choice. */
.fx-kinds{display:grid;grid-template-columns:repeat(3,1fr);gap:.6rem .5rem;
  align-content:center;padding:1.15rem 1.25rem}
.fx-kinds figure{margin:0;display:grid;gap:.35rem;justify-items:center}
.fx-kinds span{display:grid;place-items:center;width:100%;height:2.25rem;
  background:var(--plate);border:1px solid var(--plate-line);border-radius:var(--r-sm)}
.fx-kinds svg{width:1.125rem;height:1.125rem;display:block;fill:var(--ink);opacity:.5}
/* `width:100%`, so the label is centred on its own plate and not merely centred
   until it stops fitting. Added 2026-08-11. `justify-items:center` on the figure
   sizes the caption shrink-to-fit and centres it, which is correct right up to
   the point the caption is wider than the column — then CSS's overflow-alignment
   safety pins it to `start` instead, and the label jumps left while the plate
   above it stays centred. Measured on the motion row, where the plate is
   `width:100%` and therefore always centred: at 1440 "transitions" (59.45px in a
   64.95px column) sat dead centre while "hover states" (68.86px) sat 3.91px left
   of it and "scroll reveal" 1.06px left — three labels under three identical
   plates, each at a different offset.

   `display:flex` and not `text-align:center`, which was tried first and is a
   no-op here: `text-align` only distributes space that exists, so on a line
   wider than its box Chrome puts the whole overflow on the end side and the
   glyph centre lands exactly where the old pinned-to-start box had put it.
   Flex `justify-content:center` is *unsafe* centring by default — it is allowed
   to overflow both edges — so the label straddles its plate's axis instead of
   hanging off one side of it. Verified on the ink, not the box: "hover states"
   goes from 1.96px right of its plate's centre to 0. */
.fx-kinds figcaption{font-family:var(--mono);font-size:.75rem;color:var(--muted);
  letter-spacing:0;width:100%;min-width:0;
  display:flex;justify-content:center}
@container (min-width:34rem){
  .fx-kinds{grid-template-columns:repeat(6,1fr);max-width:36rem;justify-self:start}
  .fx-kinds span{height:3rem}
  .fx-kinds svg{width:1.25rem;height:1.25rem}
}

/* ── the bento grid ─────────────────────────────────────────────────────── */

/* the hairline each tile hangs from, and the type under it */
.feat{border-top:1px solid var(--line);padding-top:clamp(1.25rem,2vw,1.75rem)}
.feat h3{margin:1.25rem 0 .5rem;font-size:1.125rem;font-weight:600;letter-spacing:-.02em}
/* two or three lines under an h3 at every width, so `balance` rather than
   `pretty` — the same call as `.lede` and `.lead-line` */
.feat p{margin:0;color:var(--body);max-width:calc(52 * var(--ch));text-wrap:balance}
    /* pull.page's grid, measured off their page: four tracks, one tile twice the size in both directions, sitting dead centre in columns 2-3 with two small tiles stacked either side of it. Their materials are filled cards on a border; ours stay wells on hairlines, because the step cards are the only cards on this page. */
    /* The band's four grounds, scoped here rather than to :root because they
       mean nothing outside it. Chosen 2026-08-10 from three rendered
       candidates (feat-tints.mjs): tan well on a white card, this one, and an
       inverted tan card holding a white well.
       There is no well fill at all. Two tinted candidates were rejected for
       the same reason: --surface is spoken for as the price band's "the state
       you are already in is beige", and five feature cards wearing it would
       spend the meaning. A near-white #f1efeb well was tried and shipped for a
       day, and the question that killed it is whether a 1.06:1 step is doing
       visible work or merely sitting there. Nothing is tinted now, so the drawn
       artwork is the only thing carrying tone in the whole band, which is what
       it should be. The plates keep a hairline, since white on white cannot
       carry an edge on its own. */
    /* `--amber-ink` is `--amber` for a *white* ground, and it exists because the
       real one cannot come here. `--amber` (#fbd88a) is a 9.2:1 colour on
       forest, which is the only place the live report panel ever paints it; on
       white it is 1.4:1, and the caveat row's label is text. Darkened to the
       same hue at 30% lightness it clears **5.1:1**, so the label and its mark
       can both carry it and the row still reads amber rather than grey.

       Scoped to the band, not promoted to `:root`: one row uses it, and a
       token in the global sheet would invite a second white-ground amber
       somewhere the contrast has not been checked. */
    #features{--card:var(--white);--well:transparent;
      --plate:var(--white);--plate-line:var(--line);
      --amber-ink:#8a6c12}
    /* Cards, on Roman's call 2026-08-10. The band previously ran bare blocks on
       hairlines, on DESIGN.md's argument that the step cards should stay the
       only cards on the page. That rule is retired here rather than broken
       quietly: five wells floating on canvas with no container read as five
       loose pictures, and the bento's whole point is that the tiles are objects
       of two sizes. The colour budget is untouched — a white card is not a
       saturated surface. */
    /* --gap-head sits under every band heading on this page, and the splice
       that brought the bento in dropped it: measured 0px here against 56px at
       #how-it-works and #questions, which is what Roman saw. */
    .feats{margin-top:var(--gap-head);display:grid;grid-template-columns:repeat(4,1fr);
      gap:clamp(1rem,1.4vw,1.25rem)}
    .feat{grid-column:auto;display:flex;flex-direction:column;
      background:var(--card);border:1px solid var(--line);border-radius:var(--r-lg);
      padding:clamp(.9rem,1.2vw,1.1rem)}
    .feat-well{flex:none;min-height:10.5rem}
    .feat-copy,.feat > h3{padding-inline:clamp(.35rem,.6vw,.6rem)}
    .feat > p{padding-inline:clamp(.35rem,.6vw,.6rem);
      padding-bottom:clamp(.35rem,.8vw,.7rem)}
    /* Explicit placement, so the DOM can stay in reading order (the two
       supports, the hero, the two supports) while the hero renders in the
       middle. */

    /* ── Roman's combination, 2026-08-11 ────────────────────────────────────
       T2 + T3 + T4 from `comps/features/card-treatment.html`, chosen together
       and shipped as the rendered artifact he approved rather than as an
       improved version of it.

       Three moves: the proof tile takes a forest ground, the other four keep a
       forest hairline, and the drawings carry forest where they carry meaning.

       ⚠ THIS IS THE THIRD SATURATED SURFACE. Rule 1 caps the page at two and
       both were spent — the price band's forest row (`.shift-b`, `.ledger-col
       .pick` when this was written) and the closing band.
       Raised twice before it shipped and taken deliberately both times; see the
       rule itself, which now records the count as three.

       The three compose rather than fight, and the mechanism is specificity, not
       luck: every `.f-check ...` rule is one step above its `#features ...`
       counterpart, so **mint wins on the forest ground and forest wins on the
       white cards**. That split is not cosmetic — mint is 9.1:1 on forest and
       1.5:1 on white, so a mint glyph on a white card would be a colour nobody
       can see. Keep any new pair on the same footing. */

    /* ═══ SUPERSEDED 2026-08-15 — the band is INK, not forest ═══════════════
       Roman: "change up the colors so it's more clean", then picked comp B from
       a four-way round in `comps/feat-tone/` (paper / ink / lit / pale, built by
       `.impeccable/tools/feat-tone.mjs`). The four side cards were never the
       problem — "they're fine, especially the 4 on the sides" — the centre tile
       was, and it was carrying forest, forest-soft, mint, amber and white type
       while its four neighbours were pure greyscale.

       ⚠ RULE 1 IS BACK TO TWO SURFACES. The warning above is now history: the
       forest ground leaves the band entirely, so the third saturated surface is
       handed back to the price band's forest row and the closing band. DESIGN.md
       still records the count as three and needs updating.

       Ink is a ground, not colour — the same argument the step cards and the
       footer already run — so the tile keeps its whole silhouette and costs the
       budget nothing. Mint does not merely survive the move, it gains —
       measured 13.89:1 on `--ink-well` against 9.1:1 on the forest it
       replaced — so the pass ticks and the score keep the colour that
       means passed, and mint is now the only hue in the band.

       The specificity split below is unchanged and still load-bearing: every
       `.f-check ...` rule sits one step above its `#features ...` counterpart.
       Only the pairing changed — mint wins on the ink ground, and the white
       cards carry no hue at all now rather than a forest one. */

    /* T4 · the hairline. `--surface-line`, not `--forest`, and not `--line`:
       Roman asked for the four side cards' borders to be "a little visible"
       after seeing them on `--line`, which measures 1.256:1 against a white
       card. That *clears* hairline-sweep.mjs's 1.2 floor, which is the useful
       finding here — the tool would not have caught this, because at 1px
       clearing the floor and reading as an edge are different questions.
       `--surface-line` is 1.413:1 and is the page's own ink at 20%, so the
       hairline stays on the same warm axis as `--canvas`. A forest trace at
       equal contrast was comped (`b-ink-w3`) and is not perceptible at 1px.

       `.f-check` is excluded: on ink its border and its ground are the same
       colour, so a hairline there would draw a rim around the one tile that
       should read as a solid object. */
    #features .feat:not(.f-check){border-color:var(--surface-line)}

    /* T3 · ~~forest~~ ink inside the CMS drawing. Struck 2026-08-15 with the
       rest of the forest: these two marks were the only hue on the four side
       cards, and leaving them green while the tile went ink would have made the
       band's one remaining trace of forest a pair of 15px icons. They join the
       other four drawings' greyscale at the opacity those already run. */
    #features .co-src svg,#features .co-out svg{fill:var(--ink);opacity:.5}

    /* T2 · the proof tile takes the ground. `--ink-card` over `--ink-well`,
       the same lift-and-recess pair the step cards use, so the panel reads as a
       recess in the card's own material rather than a second box. */
    #features .f-check{background:var(--ink-card);border-color:var(--ink-card);
      --plate:var(--ink-well);--plate-line:var(--ink-line)}
    #features .f-check h3{color:#fff}
    #features .f-check > p{color:var(--on-ink)}
    #features .f-check .rp-head > b{color:#fff}
    #features .f-check .rp-chip{color:var(--on-ink)}
    #features .f-check .rp-chip svg{fill:var(--mint);opacity:1}
    /* The width frames stop being paper and read as lit screens, which is nearer
       to what they depict — the same gain the ink bands get from their mocks. */
    #features .f-check .fx-widths i::before{background:#fff;opacity:.38}
    #features .f-check .fx-widths i::after{background:#fff;opacity:.2;
      box-shadow:0 200% 0 0 rgba(255,255,255,.2)}
    #features .f-check .fx-widths figcaption,
    #features .f-check .rp-note{color:var(--on-ink-soft)}
    #features .f-check .rp-rows li{border-bottom-color:var(--ink-line)}
    #features .f-check .rp-name{color:#fff}
    /* No mint wash behind the tags here: pale mint under --on-forest text is a
       low-contrast pill, and on this ground the tag needs no plate to be read. */
    #features .f-check .rp-rows em{color:var(--on-ink-soft);background:none;padding:0}
    /* Mint, and this is the tile's whole argument: the pass mark can finally be
       the colour that means "passed". 13.89:1 on --ink-well, measured on the
       shipped page, against 9.1:1 on the forest this replaced and 1.5:1 on
       white. It is the only hue left in the
       band, which is the point of the ink move rather than a side effect. */
    #features .f-check .rp-tick{fill:var(--mint);opacity:1}
    /* --amber, not --amber-ink. The white-ground variant exists because #fbd88a
       is 1.4:1 on white; on a dark ground the real one is back and --amber-ink
       would be the unreadable one. Held through the forest-to-ink move: amber on
       ink is a higher contrast than amber on forest was, not a lower one. */
    #features .f-check .rp-bang{fill:var(--amber)}
    #features .f-check .rp-rows li.lim .rp-name{color:var(--amber)}
    #features .f-check .rp-rows li.lim em{color:var(--on-ink-soft)}
    #features .f-check .rp-foot{border-top-color:var(--ink-line)}
    #features .f-check .rp-foot b{color:var(--mint)}
    #features .f-check .rp-foot i,
    #features .f-check .rp-foot > span{color:var(--on-ink-soft)}

    .f-strip {grid-column:1;grid-row:1}
    .f-pages {grid-column:1;grid-row:2}
    .f-check {grid-column:2/4;grid-row:1/3}
    .f-assets{grid-column:4;grid-row:1}
    .f-motion{grid-column:4;grid-row:2}
    /* The hero is a grid, not the flex column the small tiles use. As a flex
       column its well took height:100% of a two-row area and then the heading
       and paragraph were added *after* that, so the tile overflowed its own
       grid area by ~230px and the copy ran out of the band. Here the well owns
       the 1fr row and the copy owns the two auto rows, so the three of them
       share the height the rows actually give. */
    .f-check{display:grid;grid-template-rows:1fr auto auto}
    .f-check .feat-well{min-height:0;height:auto}
    /* The panel owns the whole well. It is a four-row grid whose list track is
       `1fr`, so it fills the two-row tile without a single value tuned to 675px
       — which is the difference from what was here: the old figure was four
       loose blocks and a `1fr` spacer that pushed the score to the foot and left
       the gap in the middle rather than closing it. */
    .f-check .fx-report{height:100%}
    .f-check > h3{margin-top:1.1rem}
    /* On the two-row tile the frames get real size. At 72/42/22px wide they were
       decoration in a corner — the figure asserted "three screen sizes" with
       three marks too small to read as screens. They are the second claim in the
       sentence and carry proportional width here, which is the whole point of
       showing three.

       Proportional, not fixed, as of 2026-08-11. At 9 / 5.2 / 2.7rem plus two
       0.85rem gaps this group is 297.6px wide and it was that wide at every
       width, so on a 320px phone it sat in a 217px column and `.feat-well` cut
       80px off the right — the figure whose whole claim is "three screen sizes"
       showed one and a fragment of the second. 390 lost 141px of it.

       The proportion is the argument, so the proportion is what the flex ratios
       carry: 90 / 52 / 27 is the old rem widths to the pixel. `max-width` holds
       the group to exactly its old size wherever it already fitted, so nothing
       changes from 360 up through the two-column tile; below that it scales
       instead of spilling. No `min-width:0` on the figures on purpose — auto
       keeps each one at least as wide as its own caption, which is what stops
       "1440" being clipped by its own frame at 320. */
    .f-check .fx-widths{gap:.85rem;align-self:end;max-width:18.6rem}
    .f-check .fx-widths figure:nth-child(1){width:9rem}
    .f-check .fx-widths figure:nth-child(2){width:5.2rem}
    .f-check .fx-widths figure:nth-child(3){width:2.7rem}
    .f-check .fx-widths figure:nth-child(1) i{height:5.5rem}
    .f-check .fx-widths figure:nth-child(2) i{height:4.4rem}
    .f-check .fx-widths figure:nth-child(3) i{height:3.8rem}
    /* The head and the conditions row get the frames' extra height back in
       padding, so the panel's three horizontal bands read at the tile's scale
       rather than at the phone's. */
    .f-check .rp-head,.f-check .rp-cond{padding:1.05rem 1.25rem}
    .f-check .rp-rows li{padding-inline:1.25rem}
    .f-check .rp-foot{padding:1.15rem 1.25rem}
    @media (max-width:1100px){
      .feats{grid-template-columns:1fr 1fr}
      .feat{display:flex}
      .f-pages,.f-motion,.f-assets,.f-strip{grid-column:auto;grid-row:auto}
      .f-check{grid-column:span 2;grid-row:auto;display:flex}
      .f-check .feat-well{min-height:15rem}
    }
    @media (max-width:640px){
      .feats{grid-template-columns:1fr}
      .f-check{grid-column:span 1}
    }

/* ── limits, folded under the price ──────────────────────────────────────────
   A slab on ink, as of 2026-08-10. It was a hairline rule and five bare rows on
   the page floor, which put the least finished thing on the page directly under
   the two most finished: the band ran tinted card, forest card, then nothing.
   The section a visitor reads *while deciding* was the one that looked like the
   page had run out of budget.

   Ink, not a third colour. DESIGN.md rule 1 caps the page at two forest surfaces
   and this spends neither: ink is a ground here exactly as it is on the step
   cards, so the band now reads tinted → forest → ink, three of the system's own
   materials, and the fold becomes the object that closes the band.

   The 32px radius is deliberate and is the same signal the forest cards use.
   Below 700px it steps down to 24: at a 358px shell a 32px corner eats a
   disproportionate amount of the slab's own width.

   The side padding floor is 1.5rem and not 1.75. At 28px the CMS answer wrapped
   to five lines with an 18% last line at 360px — the runt class `adapt-sweep`
   reports and DESIGN.md records as swept to zero. Measured across 320 / 360 /
   390 / 430 at seven paddings: 24px is the widest floor where all four clear,
   worst last line 28%. Only widths under ~686px see it; above that 3.5vw wins. */
.limits-fold{margin-top:clamp(3rem,6vw,5rem);background:var(--ink);border-radius:32px;
  padding:clamp(2.25rem,4vw,3.25rem) clamp(1.5rem,3.5vw,3rem) clamp(2.5rem,4.5vw,3.5rem);
  box-shadow:0 24px 60px -32px rgba(10,10,10,.5)}
.limits-fold h3{margin:0 0 .35rem;font-size:1.5rem;font-weight:600;letter-spacing:-.03em;
  color:#fff}
/* One `--gap-head` under the head group, not two: `.limits` already opens with
   it, so a matching bottom margin here doubled the gap to 112px at 1440. */
.limits-fold .lead-line{margin:0;color:var(--on-ink);font-size:.9375rem}
.limits-fold dt{color:#fff}
.limits-fold dd{color:var(--on-ink)}
.limits-fold a{color:#fff;text-decoration-thickness:1px;text-underline-offset:3px}
@media (max-width:43.75rem){.limits-fold{border-radius:24px}}

/* ── 7 · the closing band ────────────────────────────────────────────────────
   Was a `.close-plate`: a forest rectangle at the 32px radius, floating in a
   canvas band, holding a centred headline / field / caption stack. Three things
   were wrong with it and only the third is a matter of taste.

   It was 728px tall to carry 228px of content, so the dead space read as
   unfinished rather than calm. It repeated the hero's composition exactly,
   which meant the last thing on the page added nothing to having read the page.
   And a centred slab with a big rounded corner is the single most common
   closing band on the web, on a site that refuses the stock three-card grid
   everywhere else on the same argument.

   Now the colour is the ground. Full bleed, so there is no dead margin to
   have; asymmetric and left-aligned, so it is not the hero again; and the
   caption is replaced by the three facts that answer what a visitor is risking
   at the moment they are asked to act. 475px, carrying more. */
.band.close{background:var(--forest)}
/* `stretch`, not `center` — 2026-08-09. The history below is kept because it
   is measured, and because it rules out the two obvious redraws.

   Rebuilt 2026-08-08 after the left column was called out as the worst thing
   on the page. Three candidates were measured at 2560 and 1440 rather than
   argued about — empty ground under the left column, and the gap between where
   the two columns end:

   | candidate                              | dead under left | columns differ |
   |----------------------------------------|-----------------|----------------|
   | as it was (start-aligned)               | 109px           | 109px          |
   | headline spanning both columns          | 190px           | 190px          |
   | facts as a base row across the width    | 169px           | 169px          |
   | shared centre line (was)                | 34px            | 23px           |
   | **shared hairline grid (this one)**     | **0px**         | **0px**        |

   Both restructures made it worse and neither should be tried again: pulling
   the headline out of the left column leaves the field alone in a tall box,
   which is the same fault with more space around it.

   What centring could not fix is that 23px is *near* alignment, and near
   alignment reads as a mistake where no alignment reads as a choice. The two
   columns ended 11px apart at the top and 11px at the bottom — close enough to
   look intended, far enough to look wrong.

   So the left column joins the grid the right one already sits in. `.close-ask`
   takes the same top and bottom hairline as `.close-deal`, stretch makes both
   cells the height of the taller, and the two edges are then 0px apart because
   they are the same two lines. */
.band.close .shell{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(0,.9fr);
  gap:clamp(2.5rem,5vw,5.5rem);align-items:stretch}
/* `center` inside the cell, because the cell is now taller than its content by
   definition — it is sized by the facts list opposite, not by what it holds. */
.close-ask{display:flex;flex-direction:column;justify-content:center;
  border-top:1px solid var(--forest-line);border-bottom:1px solid var(--forest-line);
  padding-block:1.05rem}
/* 68px, and the hero's h1 is 68.4px. The 2026-08-08 rebuild changed the
   arrangement and left the type weight alone, so the last band on the page was
   still shouting exactly as loud as the first — which is most of why it read as
   a reprise even after it stopped being one compositionally. At 36px it stops
   competing, fits on one line at every width above 900, and lets the field be
   the largest object in the band, which is the thing actually being asked for. */
.band.close h2{color:#fff;font-size:clamp(1.75rem,2.5vw,2.25rem);
  line-height:1.15;letter-spacing:-.025em;max-width:calc(24 * var(--ch));
  margin:0 0 1.5rem}
/* the field fills its column rather than sitting in the middle of one: the
   ask and the terms are two columns of one object, not two centred stacks */
/* Capped, not stretched. Filling a 1.1fr column put a 630px box around a
   40-character URL, which is most of why the left side read as empty: a wide
   input is mostly whitespace, so the column looked padded rather than filled. */
.band.close .field{margin:0;max-width:32rem;border-color:transparent}
.band.close .field button{background:var(--mint);color:var(--forest)}
.band.close .field button:hover{background:#96f5cd}
.band.close .field button[disabled]{background:#4d7f6d;color:#dff2ea}
/* the resting border is gone on this ground, so the focus ring cannot be the
   ink one the canvas fields use: mint is what reads against forest */
.band.close .field:has(input:focus){outline-color:var(--mint)}
/* Out of flow, anchored under the field. A reserved row inside the cell would
   push the visible group off the centre line, and by a different amount at
   each width: the message fits one line in the 1440 column and wraps to two by
   1024, which moved the headline 10px there while 1440 measured a clean 0.
   Absolute costs nothing at any width and any line count — the ruled cell
   already carries ~68px of slack under the field for it to draw into. */
.close-field{position:relative}
.band.close .field-error{position:absolute;top:100%;left:0;right:0;
  color:var(--error-on-forest);margin:.75rem 0 0;max-width:none}

/* The three facts, on the limits list's hairlines rather than in boxes. Same
   reasoning as `.limits`: this reads as candour precisely because it is not
   dressed up, and three cards here would be the pattern the whole page avoids.
   `--forest-line` is the hairline these already use inside the report panel. */
/* Zero. This carried 1.05rem to put the first hairline on the headline's cap
   line, which was correct while the columns were top-aligned — and became
   meaningless the moment they started sharing a centre line, since there is no
   longer a shared top edge to align to. Kept as a note because the cap-line
   figure is right if this band ever goes back to `align-items:start`: at 56px
   DM Sans the cap top falls 17.1px below the box top. */
.close-deal{margin:0;border-top:1px solid var(--forest-line)}
.close-deal > div{padding:1.05rem 0;border-bottom:1px solid var(--forest-line)}
.close-deal dt{font-weight:500;font-size:1.0625rem;letter-spacing:-.015em;color:#fff}
.close-deal dd{margin:.25rem 0 0;font-size:.9375rem;line-height:1.5;color:var(--on-forest)}

/* One column below 900: the two-column split needs a real measure on each side
   and a tablet does not have one to give. The facts keep their hairlines, so
   stacked they read as a list rather than as a second composition. */
@media (max-width:900px){
  .band.close .shell{grid-template-columns:1fr;gap:clamp(2rem,5vw,2.75rem)}
  .band.close h2{max-width:calc(20 * var(--ch))}
  /* The rules come off with the second column. They exist to put `.close-ask`
     on the same two lines as the facts list beside it; stacked there is no
     beside, and the cell's own bottom rule would land 32px above the list's
     top rule as a second hairline saying nothing. Without the rules the cell
     is auto-height again, so there is no slack for the error to draw into and
     it goes back into the flow to reserve its own space honestly — an absolute
     one here would print over the facts list underneath it. */
  .close-ask{display:block;border:0;padding-block:0}
  .close-field{position:static}
  .band.close .field-error{position:static;margin:.75rem 0 0}
}

/* ── 2 · the export band ──────────────────────────────────────────────────
   The panel used to be a 44rem card wedged under the hero fold, which is why
   its preview iframe could never show a desktop layout honestly. Then it became
   a full-shell card. It is now the band itself: forest edge to edge, no radius,
   content on the same shell every other section uses.

   The card was the last thing making the export read as a widget dropped onto a
   marketing page. Measured at 1440 it left 112px of canvas down each side and
   ~190px of empty canvas above it, so the one moment the product is actually
   working was also the moment the page looked least like it was doing anything.
   Nothing else here is an inset plate; this is now consistent with `.band.ink`
   and `.band.close`, which both take their colour to the edge.

   Cost, taken deliberately: while a run is on screen the page carries two
   full-bleed forest bands (this and the close). DESIGN.md rule 1 caps the page
   at two forest *surfaces* and this is a transient app state that is absent on
   first load, so the marketing read is unchanged — but it is the most saturated
   the page ever gets, and that is the trade. */
.band.run[hidden]{display:none}
/* Negative, and that is not a typo. A card wanted air between itself and the
   sticky nav or it read as tucked under it. A band wants the opposite: landing
   flush with the nav's bottom edge means forest fills everything below the
   header, and a strip of canvas above a full-bleed colour reads as a seam, not
   as breathing room. The band's own padding-block is what holds the content off
   the edge.

   Getting to flush needs the negative because two properties stack here and
   only one of them is local: `html` carries
   `scroll-padding-top:calc(var(--nav-h) + 1.5rem)` for every anchor on the
   page, and the target's own scroll-margin-top adds to it. The old
   `calc(var(--nav-h) + 1rem)` therefore landed the band 176px down, not 84 —
   68 of nav, 24 of site-wide clearance, 84 of local margin. Cancelling just the
   1.5rem of scrollport padding leaves exactly the nav's height, so the band's
   top edge meets the nav's bottom edge and no canvas shows between them.
   Measured: nav bottom 69, band top 68. */
.band.run{background:var(--forest);
  padding-block:calc(var(--band) * .8);
  scroll-margin-top:-1.5rem}

/* Arrives rather than appears. app.js unhides the section, forces a layout,
   then adds .shown so there are two states to interpolate between.

   The band fades and the CONTENT rises, which is the swap the full-bleed
   version forces. Translating the band moves its background with it: at
   `translateY(.75rem)` the band sits 12px below its own layout box, so a 12px
   strip of canvas shows above the forest and the band overhangs the ink section
   under it by the same amount — a cream hairline that snaps shut at the end of
   the fade. As a card the translate was invisible because the ground behind it
   never moved. Fading canvas to forest across the full width is the arrival now;
   the rise belongs to the thing that arrived. */
.band.run{opacity:0;transition:opacity .42s var(--ease)}
.band.run.shown{opacity:1}
.band.run .run-panel{transform:translateY(.75rem);
  transition:transform .42s var(--ease)}
.band.run.shown .run-panel{transform:none}

/* The panel keeps its box — app.js animates its height on every screen swap and
   `.resizing` still clips the growing content — but gives up the two things that
   made it an object: its own ground and its corners. */
.run-panel{max-width:none;margin:0;background:none;border-radius:0}
.band.run .floor-in{padding:0}

@media (prefers-reduced-motion:reduce){
  .band.run{opacity:1;transition:none}
  .band.run .run-panel{transform:none;transition:none}
}

/* ============================================================
   ARTWORK  ·  added 2026-08-16

   Backgrounds from a Figma pack of AI renders, applied to the
   page's three light surfaces — the hero, #features and
   #pricing — as ONE landscape rather than three pictures.

   The idea the whole system rests on: the page stands on a
   ground. The hero shows it fully; the two bands below show
   only its floor, faintly, so the same ground keeps reappearing
   as you scroll. The ink bands between them break it, and that
   break is what gives the sequence its rhythm. Three separate
   photographs, one per band, would be soup.

   Which artwork is a per-build choice: every value the pages
   need is a custom property set once in `:root` below, and
   nothing further down this file names an image.

   None of it touches the ink bands or the forest close. A
   photograph on a dark band is a different idea, and the page's
   canvas/ink alternation is doing real work.
   ============================================================ */
:root{
  /* The chosen artwork. Two files, not one: a phone gets a 900px
     asset and a desktop gets 2000px, swapped by the media query
     at the foot of this block. A 140KB hero image is a fair price
     on a laptop and a bad one on a phone over cellular, and the
     phone cannot use the extra pixels — it is 390px wide.
     Set per build by comps/hero-figma/build-variants.mjs. */
  --art:url(img/054-sm.webp);
  --art-pos:62%;
  --art-strength:1;
  /* how strong the ground is where it reappears under the lower
     bands. Much weaker than the hero on purpose: it is a memory
     of the hero's picture, not a second showing of it. */
  --art-echo:0.26;

  /* Both ends of every artwork mask are smoothstep ramps, not
     linear ones. A `linear-gradient(#000,#0000)` fades at one
     constant rate and then stops dead: the alpha is continuous
     but its slope is not, and the eye finds that kink and draws
     a line on it. That is why a plain fade still reads as an
     edge. `3t² − 2t³` arrives at both ends with zero slope, so
     there is no kink anywhere to catch. CSS has no easing inside
     a gradient, so the curve is sampled at nine points. */
  --art-rise:
    rgb(0 0 0 / 0)    30.0%, rgb(0 0 0 / .043) 35.0%, rgb(0 0 0 / .156) 40.0%,
    rgb(0 0 0 / .316) 45.0%, rgb(0 0 0 / .500) 50.0%, rgb(0 0 0 / .684) 55.0%,
    rgb(0 0 0 / .844) 60.0%, rgb(0 0 0 / .957) 65.0%, rgb(0 0 0 / 1)    70.0%;
  --art-tail:
    rgb(0 0 0 / 1)    82.0%, rgb(0 0 0 / .957) 84.2%, rgb(0 0 0 / .844) 86.5%,
    rgb(0 0 0 / .684) 88.8%, rgb(0 0 0 / .500) 91.0%, rgb(0 0 0 / .316) 93.3%,
    rgb(0 0 0 / .156) 95.5%, rgb(0 0 0 / .043) 97.8%, rgb(0 0 0 / 0)   100.0%;
}

/* `isolation` and an explicit --canvas fill, so the artwork is
   composited against this section's own ground and never against
   whatever the browser has painted behind the page. */
.hero,
.band.art{position:relative;isolation:isolate;overflow:hidden;
  background:var(--canvas)}

.hero-art{position:absolute;inset:0;z-index:-1;pointer-events:none;
  background-repeat:no-repeat}

/* ---------- the hero: the full statement ---------- */
.art-photo .hero-art{
  background-image:var(--art);
  background-size:cover;
  background-position:center var(--art-pos);
  opacity:var(--art-strength);
  -webkit-mask-image:linear-gradient(var(--art-rise),var(--art-tail));
  mask-image:linear-gradient(var(--art-rise),var(--art-tail))}

/* The reassurance line is the one piece of the composition that
   ends up on the ground rather than on paper — it is the lowest
   thing in the hero, where the image is fully opaque. Measured
   there `--muted` fell to 3.45:1, a real AA failure. It takes
   `--body` instead: `--muted` is a value chosen against a
   99%-luminance sheet, and this replaces the sheet under that
   one line with sunlit ground at ~58%, so the tint has to follow
   the ground it is actually printed on. A white scrim behind the
   line would have fixed the number by putting a soft rectangle
   in the middle of the landscape. */
.art-photo .hero-note .sub{color:var(--body)}

/* ---------- the cut-out hero ---------- */
/* Two keyed pieces standing in the bottom corners with nothing
   behind the type at all. They bleed off their own side rather
   than sitting inside the viewport: a cut-out with air on all
   four sides reads as a sticker. */
.art-cutout .hero-l,
.art-cutout .hero-r{position:absolute;bottom:0;display:block;
  background-repeat:no-repeat;background-size:contain;
  opacity:var(--art-strength);
  --art-cliff:
    rgb(0 0 0 / 1)    55.0%, rgb(0 0 0 / .957) 60.6%, rgb(0 0 0 / .844) 66.3%,
    rgb(0 0 0 / .684) 71.9%, rgb(0 0 0 / .500) 77.5%, rgb(0 0 0 / .316) 83.1%,
    rgb(0 0 0 / .156) 88.8%, rgb(0 0 0 / .043) 94.4%, rgb(0 0 0 / 0)   100.0%;
  -webkit-mask-image:linear-gradient(var(--art-cliff));
  mask-image:linear-gradient(var(--art-cliff))}
.art-cutout .hero-l{left:0;width:clamp(13rem,22vw,26rem);height:38%;
  background-image:var(--art-l);background-position:bottom left;
  transform:translateX(-14%)}
.art-cutout .hero-r{right:0;width:clamp(15rem,26vw,32rem);height:62%;
  background-image:var(--art-r);background-position:bottom right;
  transform:translateX(16%)}
/* Below 900px the two of them close in on the field, and the
   control group is what matters at that width. */
@media (max-width:900px){ .art-cutout .hero-l,.art-cutout .hero-r{display:none} }

/* ---------- the echo, under #features and #pricing ---------- */
/* Only the floor of the same image, at --art-echo. It is painted
   on a pseudo-element rather than a div because these bands have
   no art element in the markup and do not need one: there is
   nothing here to position independently.

   `background-attachment:fixed` is deliberately NOT used. It
   would make the ground continuous across the whole page, which
   sounds like the idea and is not: it also makes it move against
   the content on every scroll, and it is a known compositing
   cost on mobile Safari. */
.band.art::before{content:"";position:absolute;z-index:-1;
  pointer-events:none;
  /* A STRIP along the floor of the band, not the whole band. At `inset:0` on
     a 1266px-tall section, `cover` fits the entire photograph into the band
     and you get a second full showing of the hero's picture — which is the
     "three separate pictures" failure this whole system exists to avoid. A
     bounded strip anchored to the bottom shows the image's own lower edge at
     roughly the scale the hero shows it, so it reads as the same ground
     continuing rather than as another photograph. */
  left:0;right:0;bottom:0;
  height:clamp(9rem,20vw,17rem);
  background-image:var(--art);
  background-size:cover;
  background-position:center bottom;
  opacity:var(--art-echo);
  /* Rises from nothing and is gone again before the band ends, so it never
     meets the ink band below it on a line. Smoothstep at both ends, same
     reasoning as `--art-rise` and `--art-tail` above. */
  -webkit-mask-image:linear-gradient(
    rgb(0 0 0 / 0)    0%,   rgb(0 0 0 / .156) 14%,  rgb(0 0 0 / .500) 30%,
    rgb(0 0 0 / .844) 46%,  rgb(0 0 0 / 1)    60%,  rgb(0 0 0 / .844) 76%,
    rgb(0 0 0 / .500) 87%,  rgb(0 0 0 / .156) 95%,  rgb(0 0 0 / 0)   100%);
  mask-image:linear-gradient(
    rgb(0 0 0 / 0)    0%,   rgb(0 0 0 / .156) 14%,  rgb(0 0 0 / .500) 30%,
    rgb(0 0 0 / .844) 46%,  rgb(0 0 0 / 1)    60%,  rgb(0 0 0 / .844) 76%,
    rgb(0 0 0 / .500) 87%,  rgb(0 0 0 / .156) 95%,  rgb(0 0 0 / 0)   100%)}

/* The cut-out variant has no field to echo — its whole idea is
   that there is no photograph on the page — so the lower bands
   stay plain paper under it. */
.art-cutout .band.art::before{display:none}

/* Nothing is printed. The artwork is atmosphere, and atmosphere
   costs a page of toner. */
@media print{
  .hero-art,.band.art::before,.hero-l,.hero-r{display:none!important}
}

/* ---------- devices ---------- */
/* The artwork ships to every device, not just desktop. A landing
   page shared on social is read on a phone more often than not,
   and "desktop only" means the plainer page is the one most
   people see. What changes is the file, not the presence:
   `--art` starts at the 900px asset above and steps up here.

   The breakpoint is 700px rather than a device class, because
   what decides which file is wanted is how many CSS pixels the
   band is wide, and nothing else. */
@media (min-width:700px){
  :root{
    --art:url(img/054.webp);
  }
}

/* A phone's hero is portrait, so a landscape frame scaled to
   cover shows a tall middle slice — mostly sky on most of these.
   Pulling the focal point down keeps the ground in shot. */
@media (max-width:699px){
  .art-photo .hero-art{background-position:center calc(var(--art-pos) + 12%)}
  /* and the echo is dropped entirely below the fold on a phone:
     at 390px the lower bands are long scrolling columns, and a
     faint landscape behind 900px of stacked cards is not a memory
     of the hero, it is noise. */
  .band.art::before{display:none}
}

/* Data Saver on, or a metered connection: no artwork at all. It
   is decoration and it is the largest thing on the page, so it is
   exactly what should go first when someone has asked for less. */
@media (prefers-reduced-data:reduce){
  .hero-art,.band.art::before,.hero-l,.hero-r{display:none!important}
}
