/* ═══════════════════════════════════════════════════════════════════════
   Handwrite — design system

   Graphite and green. Deliberately never pure black or pure white: #000 on a
   backlit screen crushes the shadow detail out of the glyph previews, and #fff
   glares next to the warm off-white the paper previews use. Both ends of the
   scale are pulled a few percent toward the middle.

   Geist is self-hosted rather than pulled from a CDN. The app's whole promise
   is that nothing leaves the device, and a font request would quietly break
   that on the very first page load.
   ═══════════════════════════════════════════════════════════════════════ */

@font-face {
  font-family: 'Geist';
  src: url('assets/fonts/Geist-Variable.woff2') format('woff2');
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Geist Mono';
  src: url('assets/fonts/GeistMono-Variable.woff2') format('woff2');
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}

/*
 * Colour tokens.
 *
 * Each token is declared once with light-dark(), so the two palettes sit on the
 * same line and cannot drift apart. Which branch resolves is decided entirely
 * by the used `color-scheme` on :root, set in the three rules below.
 *
 * The alternative — a light palette duplicated inside a media query and again
 * under [data-theme='light'] — means every colour is written twice and a later
 * edit can silently change one copy. That is the failure this avoids.
 *
 * Setting `color-scheme` also fixes native scrollbars, form controls and the
 * default canvas for free, which a custom-property swap alone cannot do.
 */
:root {
  color-scheme: dark;

  /*
   * The light branch is built the other way up from the dark one, and this is
   * deliberate rather than an inconsistency.
   *
   * It used to sit the page at #f4f5f7 with cards a shade lighter at #fbfcfd,
   * so the page read as grey next to the browser's own white and the seam
   * between the two was visible on every scroll. There is no way to raise the
   * page toward white and keep cards above it without reaching #ffffff, which
   * this palette does not use — #fff glares beside the warm paper previews, and
   * the note at the top of this file has said so from the beginning.
   *
   * So the light theme inverts: the page is the lightest thing on it and
   * panels sit *below* it, which is how every light interface built around a
   * white canvas works, GitHub's included. #fcfcfd rather than #ffffff — two
   * points off pure, indistinguishable beside browser chrome, and not the value
   * that glares.
   *
   * The dark branch is untouched. Dark has the opposite problem and the
   * opposite answer: there the page is the darkest thing and surfaces lift off
   * it, which is already what these say.
   */
  --bg: light-dark(#fcfcfd, #14161a);
  --bg-deep: light-dark(#f2f4f7, #101216);
  --surface: light-dark(#f6f8fa, #1a1e24);
  --surface-2: light-dark(#eef1f5, #21262d);
  --surface-3: light-dark(#e8ecf1, #2a3038);
  --border: light-dark(#d8dee4, #2a3038);
  --border-strong: light-dark(#c2c9d1, #3a424c);

  --text: light-dark(#1c2128, #e4e9ef);
  --text-2: light-dark(#57606a, #a5adb8);
  /*
   * The quiet tier still has to be readable. At #848d97 / #757d88 it measured
   * 3.07:1 in light and 4.36:1 in dark against --bg — below the 4.5:1 floor in
   * both, on the privacy assurances, the footer, the legal version line and the
   * file-size labels. "Quiet" is a tone, not permission to be faint, and this is
   * the tier the instruction to make everything readable at a glance was aimed
   * at.
   *
   * Graded against --surface-2, not --bg. Fixing it against the page background
   * alone left it passing there and failing everywhere it is actually most
   * used: 4.48:1 on --surface, where the download list's file sizes live, and
   * 4.08:1 on --surface-2. A token has to clear the floor on the darkest
   * surface it is put on, not on the one it was first noticed against.
   *
   * Re-measured after the light branch was inverted, since every surface it is
   * graded against moved: 5.12:1 on --bg, 4.93:1 on --surface, 4.63:1 on
   * --surface-2. All three clear the floor and all three are within a tenth of
   * where they were. --surface-3 is the one that does not, at 4.42:1 — it did
   * not before either, at 4.31:1, and it is the hover fill on buttons whose
   * labels are --text at 13:1, so the quiet tier is not put on it. Improved
   * rather than fixed, and named here so the next person does not have to
   * measure it again to find that out.
   */
  --text-3: light-dark(#656d76, #8a929c);

  /*
   * The accent is ink.
   *
   * It was green — GitHub's, tuned per theme for contrast — and green is a
   * decision this app was making about forty times a page for no reason it
   * could defend. Nothing here is green: the subject is somebody's handwriting,
   * which is ink on paper, and the one place colour genuinely says something is
   * a warning or a failure. A palette that spends its only hue on "this is a
   * button" has nothing left to say with when it needs to say "this went
   * wrong".
   *
   * So the accent inverts with the theme rather than sitting on it: ink on a
   * light page, near-white on a dark one. A primary button is therefore black
   * on white or white on black, and --accent-fg is the page colour, which makes
   * every accent surface a straight inversion of the page it is on. Both
   * directions clear 15:1, where the green pair cleared 5.0 and 4.9 after being
   * tuned twice to get there.
   *
   * Neither end is pure. #1c2128 is the same ink --text uses, and the near-white
   * is a shade off #fff for the reason at the top of this file.
   */
  --accent: light-dark(#1c2128, #e6e9ee);
  --accent-hover: light-dark(#0f1419, #f4f6f8);
  /* The quiet tier is a tint of the page, not of the accent — there is no hue
     left to tint with. Distinct from --surface-2 by a couple of points so a
     step number still reads as a marked area rather than as a panel. */
  --accent-quiet: light-dark(#e9edf2, #232a32);
  --accent-fg: light-dark(#fcfcfd, #14161a);

  --warn: light-dark(#9a6700, #d9a441);
  --warn-quiet: light-dark(#fbf1d4, #33280f);
  --danger: light-dark(#cf222e, #f0685e);
  --danger-quiet: light-dark(#ffe6e4, #3a1c1a);

  /* The colour of paper in previews — warm, so ink reads as ink. */
  --paper: #f4f1e9;
  --ink: #1b1d20;

  /*
   * Corners.
   *
   * Raised across the board, and a pill added. The old scale — 6/10/16/22 —
   * was the cautious default a stylesheet reaches for before anyone has
   * decided what the thing looks like, and the result is a page of shapes
   * that are neither square nor round and read as neither.
   *
   * Softer corners are also what makes a monochrome page survive. With a hue
   * doing none of the work, the only things left to distinguish one surface
   * from another are value, spacing and shape, and shape is the one with the
   * most room in it.
   */
  --r-sm: 8px;
  --r-md: 12px;
  --r-lg: 20px;
  --r-xl: 28px;
  --r-pill: 999px;

  /*
   * The accent, in one place, on purpose.
   *
   * Every button, link, focus ring and marked area on this page resolves to
   * --accent and --accent-hover above, which means the whole thing changes hue
   * by editing two lines. It is monochrome because that is the decision, not
   * because the stylesheet could not express anything else.
   *
   * To make it blue, replace the two accent declarations above with:
   *
   *     --accent:       light-dark(#0052ff, #4d7fff);
   *     --accent-hover: light-dark(#0043cc, #6b93ff);
   *
   * and nothing else. --accent-fg is already the page colour at both ends, so
   * white-on-blue and near-black-on-light-blue both come out right, and both
   * pairs clear 4.5:1. --accent-quiet is a tint of the page rather than of the
   * accent, so it does not need to move with it.
   */

  /*
   * These have to be built from a light-dark() *colour*, not wrapped in one.
   *
   * light-dark() is a colour function. It takes two <color> arguments and
   * nothing else. Written as `light-dark(0 1px 2px rgb(...), 0 1px 2px
   * rgb(...))` the whole thing fails to parse, `var(--shadow-1)` substitutes
   * an invalid value, the declaration is invalid at computed-value time, and
   * `box-shadow` falls back to its initial value: none.
   *
   * That is how every shadow in this app came to be silently absent — all
   * three tokens computed to `none`, on the modal panels, the toasts, the
   * paper preview, the browser-gate panel, the active segmented button and the
   * specimen sheet. Nothing errored. The interface was simply flat, and the
   * dark-theme modal panel having almost no edge against its own backdrop was
   * a downstream symptom of it rather than a separate problem.
   */
  --shadow-tint-1: light-dark(rgb(20 25 35 / .07), rgb(0 0 0 / .3));
  --shadow-tint-2: light-dark(rgb(20 25 35 / .12), rgb(0 0 0 / .45));
  --shadow-tint-3: light-dark(rgb(20 25 35 / .18), rgb(0 0 0 / .6));

  --shadow-1: 0 1px 2px var(--shadow-tint-1);
  --shadow-2: 0 4px 16px -4px var(--shadow-tint-2);
  --shadow-3: 0 18px 50px -12px var(--shadow-tint-3);

  --wrap: 1180px;
  --ease: cubic-bezier(.22, .68, .36, 1);

  /*
   * One lever for the whole interface. Every size in this sheet is in rem, so
   * multiplying the root multiplies the type, the padding and the hit targets
   * together — which is the point. Scaling the text alone gives you large
   * labels crammed into small buttons.
   *
   * 1.0625 puts the root at 17px rather than the browser's 16px. The old sheet
   * set `body { font-size: 17px }` and left the root at 16, so every `rem`
   * silently measured against 16 — .68rem was 10.9px, which is a size you read
   * by leaning in. Nothing is below 13px now, and the settings panel can push
   * this to 1.25 for anyone who wants more.
   */
  --ui-scale: 1.125;
}

html { font-size: calc(16px * var(--ui-scale)); }
:root[data-textsize='large'] { --ui-scale: 1.25; }

/*
 * Follow the system unless the visitor has explicitly chosen. The :not() is
 * what makes an explicit choice win over the operating system; without it a
 * visitor on a light desktop could never select dark.
 *
 * The explicit rules come after the media query deliberately — they have the
 * same specificity, so source order is what settles a tie.
 */
@media (prefers-color-scheme: light) {
  :root:not([data-theme='dark']) { color-scheme: light; }
}
:root[data-theme='light'] { color-scheme: light; }
:root[data-theme='dark'] { color-scheme: dark; }

/* ── Reset ──────────────────────────────────────────────────────────── */

*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; }

html { -webkit-text-size-adjust: 100%; }

body {
  font-family: 'Geist', system-ui, -apple-system, 'Segoe UI', sans-serif;
  font-size: 1.0625rem;
  line-height: 1.6;
  color: var(--text);
  background: var(--bg);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
}

h1, h2, h3 { line-height: 1.15; letter-spacing: -.02em; font-weight: 600; }
h1 { font-size: clamp(2.6rem, 6vw, 4.2rem); letter-spacing: -.035em; }
h2 { font-size: clamp(1.7rem, 3.2vw, 2.4rem); }
h3 { font-size: 1.05rem; letter-spacing: -.01em; }

p { text-wrap: pretty; }
button, input, textarea { font: inherit; color: inherit; }
svg { width: 1.25em; height: 1.25em; fill: none; stroke: currentColor; stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }
code, pre, .mono { font-family: 'Geist Mono', ui-monospace, monospace; }

::selection { background: var(--accent); color: var(--accent-fg); }

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: var(--r-sm);
}

.skip {
  position: absolute; left: -9999px; top: 0; z-index: 100;
  background: var(--accent); color: var(--accent-fg);
  padding: .7rem 1.1rem; border-radius: 0 0 var(--r-md) 0;
}
.skip:focus { left: 0; }

.wrap { width: 100%; max-width: var(--wrap); margin-inline: auto; padding-inline: clamp(1rem, 4vw, 2rem); }
.muted { color: var(--text-2); }

/* ── Top bar ────────────────────────────────────────────────────────── */

.topbar {
  position: sticky; top: 0; z-index: 40;
  background: color-mix(in srgb, var(--bg) 86%, transparent);
  backdrop-filter: blur(14px) saturate(1.4);
  border-bottom: 1px solid var(--border);
}
/*
 * One row for as long as one row is possible.
 *
 * The first version scrolled the whole bar, which kept every label but pushed
 * Settings off the right edge with no scrollbar to say so. The second let the
 * bar wrap, which kept Settings reachable but cost a whole second row — and
 * once the root scale went up, that second row appeared on a 1200px desktop,
 * where there was no shortage of space at all. The bar was simply refusing to
 * use it: `flex: none` on every child meant nothing could give.
 *
 * The rail is the thing that should give. It is the only genuine list here,
 * everything else is a single control, and it is now the only shrinkable item:
 * it takes whatever is left after the logo and the three actions and scrolls
 * inside it. The two objections that produced the earlier versions are both
 * answered — no control is ever pushed out of reach, and the rail's own scroll
 * is discoverable, because renderSteps() scrolls the current step into view on
 * every change and the edges are faded so there is visibly more.
 */
.topbar-inner {
  display: flex; align-items: center; gap: 1.25rem; min-height: 3.4rem;
  /* nowrap is what makes the rail shrink. With wrapping on, flexbox moves an
     item to a new line rather than compressing the line it is on, so
     `flex: 0 1 auto` on the rail never got the chance to do anything and the
     two actions dropped to a second row on a 1440px desktop. Wrapping comes
     back below the breakpoint, where the rail genuinely does need its own row. */
  flex-wrap: nowrap;
  row-gap: 0;
}
.topbar-inner > * { flex: none; }

.topbar-inner .brand { margin-right: auto; }

/*
 * `order` puts the rail last in the flow so it is the item that wraps, while
 * `margin-inline: auto` still centres it on the wide layout. Below the
 * breakpoint it takes the full width and scrolls; `min-width: 0` is what makes
 * that `overflow-x` do anything at all, since a flex item defaults to
 * `min-width: auto` and refuses to shrink past its content.
 */
/*
 * `min-width: 0` is the load-bearing declaration: a flex item defaults to
 * `min-width: auto` and refuses to shrink below its content, which is what made
 * the rail push the actions onto a second row instead of scrolling.
 *
 * The mask fades both edges, so a rail with more in it than fits looks like it
 * — the affordance the earlier hidden scrollbar took away. `mask-image` does
 * not paint, so it costs nothing when the rail fits and there is nothing under
 * the fade.
 */
/*
 * The progress pill.
 *
 * A capsule holding a bar and a count — the same shape language as the buttons,
 * which are pills too, so the top bar reads as one family rather than as a
 * control and a widget. It is not a control: nothing in it is focusable and
 * nothing in it is pressable, which is the whole point of replacing the chips.
 *
 * The rail this replaces needed `overflow-x: auto`, a masked fade at both ends,
 * a hidden scrollbar and a scrollIntoView on every step change, because six
 * chips did not fit and the current one kept drifting off the end. A capsule
 * with a bar in it is the same information in a fixed width that never needs
 * any of that.
 */
.progress {
  order: 2;
  flex: 0 1 auto;
  min-width: 0;
  display: inline-flex;
  align-items: center;
  gap: .6rem;
  height: 2rem;
  padding: 0 .8rem;
  margin-inline: auto;
  border-radius: var(--r-pill);
  background: var(--surface-2);
  border: 1px solid var(--border);
}
.progress__track {
  inline-size: clamp(3.5rem, 11vw, 7.5rem);
  block-size: .375rem;
  flex: none;
  border-radius: var(--r-pill);
  background: var(--surface-3);
  overflow: hidden;
}
.progress__fill {
  display: block;
  block-size: 100%;
  inline-size: 0;
  border-radius: var(--r-pill);
  background: var(--accent);
  transition: inline-size .5s var(--ease);
}
.progress__count {
  font-family: 'Geist Mono', monospace;
  font-size: .8rem;
  font-variant-numeric: tabular-nums;
  color: var(--text-2);
  flex: none;
}
@media (prefers-reduced-motion: reduce) { .progress__fill { transition: none; } }
:root[data-lite='on'] .progress__fill,
:root[data-decor='off'] .progress__fill { transition: none; }
.topbar-inner .btn-ghost { order: 3; }

/* The rail that used to need a row of its own below 860px is gone — the pill
   that replaced it is a fixed 2rem tall and clamps its own width, so it sits
   in the single row with everything else at every size. The gap tightening
   stays, since four things in one bar is still four things. */
@media (max-width: 860px) {
  .topbar-inner { gap: .7rem; }
}

/*
 * On a narrow phone there is no width for a logo, a switch and two labelled
 * buttons, and letting them wrap turned the bar into three rows — a fifth of a
 * phone screen given over to chrome. Below this width the two action labels
 * collapse to their icons.
 *
 * The labels are moved out of sight rather than removed. `display: none` would
 * take the accessible name with them and leave two buttons that announce as
 * nothing, which is the failure this whole batch of components kept shipping.
 */
@media (max-width: 540px) {
  .topbar-inner .btn-ghost { padding: .5rem; }
  .topbar-inner .btn-ghost > span {
    position: absolute;
    width: 1px; height: 1px;
    margin: -1px; overflow: hidden;
    clip-path: inset(50%); white-space: nowrap;
  }
}

/*
 * And below this, the wordmark goes too.
 *
 * Collapsing the two action labels was not enough. Measured on a 375px phone:
 * the logo took 153px of about 335 usable, and the mark, the switch and the two
 * icon buttons came to roughly ten pixels more than the row had. Ten pixels is
 * all it takes — flexbox does not compress a line that cannot fit, it moves an
 * item to a new one, so Settings dropped to a row of its own and the rail took
 * a third. The bar stood 178px tall on an 812px screen: not a fifth of the
 * chrome budget, closer to a quarter, before a word of the page.
 *
 * The pen alone is the home button now, which is the ordinary reading of a logo
 * at this width. As with the labels, the name is moved out of sight rather than
 * removed — `display: none` would take the button's accessible name with it and
 * leave a control that announces as nothing.
 */
@media (max-width: 420px) {
  .topbar-inner .brand-name {
    position: absolute;
    width: 1px; height: 1px;
    margin: -1px; overflow: hidden;
    clip-path: inset(50%); white-space: nowrap;
  }
}

/*
 * Touch targets.
 *
 * A finger is not a mouse pointer, and every one of these was sized for the
 * pointer: the two header actions came to 42px, the theme switch 33, the deck's
 * dots 12, and the footer links 27. The floor both platform guidelines settle
 * on is 44.
 *
 * Keyed to the pointer rather than the width, because the thing that changed is
 * the input device, not the screen — a phone in landscape is still a phone, and
 * a narrow window on a desktop is still driven by a mouse.
 *
 * The dots grow their hit area through a pseudo-element rather than through
 * padding. Padding was tried first and it works, but the box grows with it —
 * four dots at 1rem of padding each came to 288px of a 375px row, and on a
 * 320px screen that is a horizontal scrollbar on the whole page. An absolutely
 * positioned ::after is outside the layout entirely: the dot stays 0.6rem, the
 * row stays the width it was, and the finger gets its 44px.
 */
@media (pointer: coarse) {
  .topbar-inner .btn-ghost { min-width: 44px; min-height: 44px; }
  .topbar-inner .brand { min-height: 44px; }

  .stepshow__dot { position: relative; }
  .stepshow__dot::after {
    content: '';
    position: absolute;
    /* Out to 44px from a 0.6rem dot, measured from the dot's own edges so the
       active one — a stadium, not a circle — grows by the same margin. */
    inset: -1rem;
  }

  .footer-links a,
  .footer-links button {
    min-height: 44px;
    display: inline-flex;
    align-items: center;
  }

  /* The style picker on the Refine screen — Regular, Bold, Italic, Both. Its
     buttons came to 35px, and unlike the footer links these are not a place
     someone visits once: choosing a weight is the whole purpose of that screen,
     and it is the screen most likely to be used on the phone that took the
     photograph. The group's own padding is trimmed so four buttons at a
     reachable height still fit a 375px row rather than wrapping mid-control. */
  .seg button { min-height: 44px; }
  .seg { padding: 2px; }

  /* Straighten and Kerning on the same screen. The checkbox itself is a 1px
     visually-hidden input — correct, and it means the label is the whole target,
     so the label is what has to be reachable. 29px before. */
  .check { min-height: 44px; }
}

.brand {
  display: flex; align-items: center; gap: .55rem;
  font-weight: 600; letter-spacing: -.02em;
  background: none; border: 0; padding: .3rem .4rem; margin-left: -.4rem;
  border-radius: var(--r-md); cursor: pointer; color: inherit;
  transition: background .16s var(--ease);
}
:where(html[data-pointer="fine"]) .brand:hover { background: var(--surface-2); }
:where(html[data-pointer="fine"]) .brand:hover .brand-mark { transform: rotate(-12deg); }
.brand-mark { transition: transform .25s var(--ease); }
.brand-mark { width: 26px; height: 26px; color: var(--accent); }
.brand-name { font-size: 1.05rem; }

/* ── The trace under the brand ──────────────────────────────────────────
   Go near the brand and the pen writes a line under the word; leave, and the
   line un-writes itself the way it came.

   It is one transition on stroke-dashoffset, which with pathLength="1" and a
   dasharray of 1 is a clean wipe in both directions: from 1 to 0 the visible
   run grows from the left, and from 0 back to 1 it retreats to the left again.
   No second keyframe set for the reverse, and interrupting it mid-write simply
   turns it around from wherever it got to.

   Hover and focus, with no script at all. It was a click toggle first, which
   put a second meaning on a button whose job is to go back to the start, and
   left a mark on screen that outlasted the gesture. Nothing here reports state:
   the trace is aria-hidden, because "is the squiggle showing" is not something
   anybody needs announced.
   ─────────────────────────────────────────────────────────────────────── */

.brand-word { position: relative; display: inline-block; }
.brand-trace {
  position: absolute;
  /* Starts under the nib and runs past the last letter. It began at the left
     edge of the word, which read as a line appearing from nowhere — the pen is
     right there, and it is the thing supposedly drawing this.

     The left inset reaches back over the mark (26px) and the flex gap (.55rem)
     that separate it from the word.

     `width: auto` is load-bearing. The sheet has a global `svg { width: 1.25em }`,
     which pinned this to 24px regardless of the left/right pair — the trace was
     a quarter of the width of the word it underlines. With both insets set, auto
     resolves to fill the space between them. */
  left: calc(-26px - .55rem); right: -.9em; top: 100%;
  width: auto;
  height: .5em;
  overflow: visible;
  pointer-events: none;
}
.brand-trace path {
  fill: none;
  stroke: var(--accent);
  stroke-width: 2.4;
  stroke-linecap: round;
  stroke-dasharray: 1;
  stroke-dashoffset: 1;
  transition: stroke-dashoffset .8s var(--ease);
}
:where(html[data-pointer="fine"]) .brand:hover .brand-trace path,
.brand:focus-visible .brand-trace path { stroke-dashoffset: 0; }

@media (prefers-reduced-motion: reduce) {
  .brand-trace path { transition: none; }
}
:root[data-lite='on'] .brand-trace,
:root[data-decor='off'] .brand-trace { display: none; }

@media (max-width: 660px) { .topbar-inner { gap: .8rem; } }

/* ── Buttons ────────────────────────────────────────────────────────── */

.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: .5rem;
  /* A pill. Every control on this page used the same 10px corner as every
     panel, so a button and the box it sat in were the same object at two
     sizes. Rounding the controls all the way and leaving the surfaces
     rectangular-with-soft-corners is what separates the two families. */
  padding: .6rem 1.15rem; border-radius: var(--r-pill);
  background: var(--surface-2); color: var(--text);
  border: 1px solid var(--border); cursor: pointer;
  font-size: .95rem; font-weight: 500; letter-spacing: -.01em;
  transition: background .16s var(--ease), border-color .16s var(--ease), transform .1s var(--ease);
}
:where(html[data-pointer="fine"]) .btn:hover:not(:disabled) { background: var(--surface-3); border-color: var(--border-strong); }
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn svg { width: 1.05em; height: 1.05em; }

.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); font-weight: 600; }
:where(html[data-pointer="fine"]) .btn-primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); }

.btn-ghost { background: none; border-color: transparent; color: var(--text-2); }
:where(html[data-pointer="fine"]) .btn-ghost:hover:not(:disabled) { background: var(--surface-2); color: var(--text); }

.btn-lg { padding: .85rem 1.7rem; font-size: 1.02rem; border-radius: var(--r-pill); }
.btn-block { width: 100%; }
.btn-icon { padding: .5rem; border-radius: var(--r-pill); }
.btn-icon svg { width: 1.15em; height: 1.15em; }

/* ── Steps ──────────────────────────────────────────────────────────── */

main { flex: 1; }

.step { display: none; padding-block: clamp(2.5rem, 6vw, 4.5rem) 4rem; }
.step.is-active { display: block; animation: rise .32s var(--ease) both; transform-origin: top center; }

@keyframes rise {
  from { opacity: 0; transform: translateY(10px); }
  to { opacity: 1; transform: none; }
}
/* Deliberately left as `rise`. An entire screen hinging off the header was the
   single biggest contributor to the page feeling unsteady — it moved everything
   the reader was about to look at, all at once, on the axis that reads as
   tipping. A screen change is already the largest visual event in the app and
   does not need help being noticed. */
@media (prefers-reduced-motion: reduce) {
  .step.is-active { animation: none; }
  * { transition-duration: .01ms !important; }
}

.step-head { max-width: 62ch; margin-bottom: 2.25rem; }
.step-head .sub { color: var(--text-2); font-size: 1.06rem; margin-top: .65rem; }

.step-actions {
  display: flex; gap: .75rem; justify-content: space-between;
  margin-top: 2.75rem; padding-top: 1.75rem; border-top: 1px solid var(--border);
}

/* ── Hero ───────────────────────────────────────────────────────────── */

/*
 * No max-width here. `ch` is the advance of "0" in the element's own font, and
 * .hero's own font is the body's 1.0625rem — 19.125px at the 18px root. The
 * prose it was capping is .lede at 1.18rem (21.24px), which already declares
 * its own measure of 54ch. 40 * 19.125 against 54 * 21.24 is 0.667: the parent
 * clipped every line at two thirds of the measure the child asked for, so
 * `.lede { max-width: 54ch }` could never once take effect and the rest of the
 * hero grid track stood empty beside it.
 *
 * Every child already carries its own measure at its own size — .lede 54ch,
 * .privacy 52ch, and the h1 breaks on a hard <br> — which is where a measure
 * belongs, because that is the only place ch means anything.
 */
.hero { padding-block: clamp(1rem, 5vw, 3.5rem); }
/* A caption, not a badge.
 *
 * This was a filled pill — accent text on accent-quiet, rounded to 999px — which
 * is the treatment for something that carries state, like a count or a status.
 * It carries neither. It names the section underneath it, and it was shouting
 * that four times down one page. Small, tracked, quiet, and the heading below
 * does the work. */
.eyebrow {
  display: block; font-size: .8rem; font-weight: 600;
  letter-spacing: .1em; text-transform: uppercase; color: var(--text-3);
  margin-bottom: .85rem;
}
.hero h1 { margin-bottom: 1.4rem; }
.lede { font-size: 1.18rem; color: var(--text-2); max-width: 54ch; }
.hero-actions { display: flex; flex-wrap: wrap; gap: .75rem; margin-top: 2.25rem; }

.privacy {
  display: flex; align-items: center; gap: .55rem;
  margin-top: 2rem; font-size: .9rem; color: var(--text-3); max-width: 52ch;
}
.privacy svg { flex: none; width: 2.1em; height: 2.1em; color: var(--accent); }
.feature-grid .locklock { width: 34px; height: 34px; }

/*
 * Explicit column counts, not auto-fit.
 *
 * The separators here are `gap: 1px` over `background: var(--border)`, so an
 * empty tail cell is not whitespace — it paints #dce0e5 / #2a3038 for the full
 * height of the row, inside a card that is rounded and `overflow: hidden`. With
 * auto-fit over minmax(240px, 1fr) the four cards sit at three columns for a
 * ~250px band of viewports (grid content box 722px..962px, which at
 * padding-inline clamp(18px, 4vw, 36px) is roughly 787px..1037px of viewport),
 * and across that band two thirds of row two is that slab.
 *
 * 1, 2 and 4 all divide four exactly, so no width can leave a cell alone on a
 * row. 3 is the only count that can, and it is the one auto-fit reaches first.
 */
.feature-grid {
  list-style: none; padding: 0; margin-top: clamp(3rem, 7vw, 5.5rem);
  display: grid; gap: 1px; background: var(--border);
  grid-template-columns: minmax(0, 1fr);
  border: 1px solid var(--border); border-radius: var(--r-lg); overflow: hidden;
}
/* Two columns need 2*240 + 1 = 481px of grid content. At 560px viewport the
   padding is clamp(18px, 22.4px, 36px) = 22.4px a side, leaving 513.2px. */
@media (min-width: 560px) {
  .feature-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
/* Four need 4*240 + 3 = 963px. At 1060px the padding is capped at 36px a side,
   leaving 986px. */
@media (min-width: 1060px) {
  .feature-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
.feature-grid li { background: var(--surface); padding: 1.6rem 1.5rem; }
.feature-grid svg { width: 28px; height: 28px; color: var(--accent); margin-bottom: .9rem; }

/* ── The specimen ───────────────────────────────────────────────────────
   A sheet of handwriting that writes itself, once, on load.

   Sequence: the ruled sheet arrives, the letters are written stroke by
   stroke, the capture brackets snap in, a scan passes over, and the outline
   nodes land in its wake. That is the app's actual sequence — paper, capture,
   trace — which is the only reason it earns the space next to the headline.

   The settled state is the *base* state
   -------------------------------------
   Every stroke's resting `stroke-dashoffset` is 0, every node's resting
   opacity is 1, and each keyframe set runs `from` the hidden state with
   `backwards` fill. So the drawing is what the element IS, and the animation
   is a thing that happens on the way in.

   That inversion is the whole robustness story. Kill the animations — reduced
   motion, lite mode, an engine that does not know these keyframes, a printer —
   and what is left is the finished picture rather than an empty box. The usual
   arrangement, resting at opacity 0 and relying on an animation to arrive,
   fails to a blank rectangle in every one of those cases.

   It runs once and stops. A hero that loops forever is competing with the
   headline for the same pair of eyes, and the settled frame is the informative
   one anyway — it is the one with the outlines on it.
   ─────────────────────────────────────────────────────────────────────── */

.hero-row {
  display: grid;
  gap: clamp(2rem, 5vw, 3.5rem);
  align-items: center;
  grid-template-columns: minmax(0, 1fr);
}
@media (min-width: 940px) {
  .hero-row { grid-template-columns: minmax(0, 1fr) minmax(0, .92fr); }
}

.spec {
  margin: 0;
  /* The sheet is rotated, so its corners swing outside the content box. */
  padding: .5rem;
  --spec-paper: light-dark(#f7f4ec, #e8e3d7);
  --spec-ink: #23262c;
  --spec-rule: #cfc7b4;
  --spec-base: #a89f89;
  /* Fixed rather than var(--accent): the sheet is light in both themes, and the
     dark theme's accent is near-white, which on paper is not a mark at all.
     A grey rather than the sheet's own ink, so the capture brackets read as
     something laid over the page instead of as more writing. 7.41:1 on the
     paper colour above. */
  --spec-mark: #4a5058;
  /* Without this the sheet is invisible in light theme: #f7f4ec paper on a
     #f4f5f7 page, so the ink, the rules and the brackets appear to float on
     the page and the whole point of the picture — a photograph of paper — is
     gone. Fixed rather than light-dark(), like every token in this block: the
     sheet is light in both themes. Measured 3.24:1 on the light page, which
     clears the 3:1 floor if the edge counts as a graphic rather than pure
     decoration. */
  --spec-edge: #8f8874;
}

.spec__sheet {
  position: relative;
  overflow: hidden;
  border-radius: var(--r-lg);
  background:
    radial-gradient(120% 90% at 50% 0%, #fffdf7 0%, transparent 60%),
    var(--spec-paper);
  box-shadow: 0 0 0 1px var(--spec-edge), var(--shadow-3);
  /* A sheet set down on a desk, not a diagram. */
  transform: rotate(-1.15deg);
}

/*
 * Everything below is scoped to .spec--play, which src/specimen.js removes and
 * re-adds to replay the drawing after it has been still for a while. The class
 * is in the markup, so the first pass runs with no script at all — the module
 * only re-triggers what the sheet already does by itself.
 */
.spec--play .spec__sheet { animation: spec-sheet .8s var(--ease) backwards; }

/* The fade before a replay. Without it the second pass writes letters on top of
   letters that are already finished. */
.spec__page { transition: opacity var(--spec-erase, .62s) var(--ease); }
.spec--erasing .spec__page { opacity: 0; }

.spec__page { display: block; width: 100%; height: auto; }

@keyframes spec-sheet {
  from { opacity: 0; transform: rotate(-1.15deg) translateY(1.4rem) scale(.96); }
}

/* ── ruled sheet ── */
.spec__rule { fill: none; stroke-dasharray: 1; stroke-dashoffset: 0; stroke-linecap: round; }
.spec__rule--faint { stroke: var(--spec-rule); stroke-width: 1.6; stroke-dasharray: .006 .01; }
.spec__rule--base { stroke: var(--spec-base); stroke-width: 2.2; }
.spec--play .spec__rules path {
  animation: spec-rule .5s var(--ease) backwards;
  animation-delay: calc(.3s + var(--i) * .09s);
}
/* The dashed rules cannot share the draw keyframes — their dasharray is the
   dash pattern, so animating the offset would slide the dashes along rather
   than extend the line. They wipe in instead, from a clip. */
@keyframes spec-rule { from { clip-path: inset(0 100% 0 0); opacity: 0; } }
.spec__rules path { clip-path: inset(0 0 0 0); }

/* ── the writing ── */
.spec__stroke {
  fill: none;
  stroke: var(--spec-ink);
  /* Per-letter, set on the group: a nib does not lay down the same width for
     five letters in a row, and identical weight is the tell that reads as a
     typeface rather than as writing. */
  stroke-width: var(--sw, 7.5);
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-dasharray: 1;
  stroke-dashoffset: 0;
}
.spec--play .spec__stroke {
  animation: spec-write .6s cubic-bezier(.5, .05, .55, .95) backwards;
  animation-delay: calc(.95s + var(--i) * .17s);
}
@keyframes spec-write { from { stroke-dashoffset: 1; } }

/* ── the trace result ── */
.spec__node {
  fill: var(--spec-paper);
  stroke: var(--spec-mark);
  stroke-width: 2.4;
  transform-box: fill-box;
  transform-origin: 50% 50%;
}
.spec--play .spec__node {
  /* Two animations: the pop as it lands, then a slow blink that never stops.
     `backwards` fill applies to the first only, which is what is wanted — the
     second must not hold the node hidden before its turn.

     The blink delays are spread by a much larger step than the pops, so the
     twenty-eight nodes drift out of phase almost immediately and only one or
     two are ever dipping at once. A synchronised blink would read as the whole
     drawing flickering. */
  animation:
    spec-node .4s var(--ease) backwards,
    spec-blink 9s var(--ease) infinite;
  animation-delay:
    calc(3.7s + var(--i) * .028s),
    calc(5.6s + var(--i) * .41s);
}

/* Almost all of the cycle is spent doing nothing. */
@keyframes spec-blink {
  0%, 86%, 100% { opacity: 1; }
  90% { opacity: .18; }
  94% { opacity: 1; }
}
@keyframes spec-node {
  from { opacity: 0; transform: scale(.2); }
  62%  { opacity: 1; transform: scale(1.45); }
}

/* ── the capture frame ── */
.spec__frame path {
  fill: none;
  stroke: var(--spec-mark);
  stroke-width: 3.4;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-dasharray: 1;
  stroke-dashoffset: 0;
}
.spec--play .spec__frame path {
  animation: spec-write .45s var(--ease) backwards;
  animation-delay: calc(3.15s + var(--i) * .09s);
}

/* ── the scan ──
   Rests invisible, so switching the animation off leaves nothing behind. */
.spec__scan {
  position: absolute;
  inset: 0 auto 0 0;
  width: 26%;
  opacity: 0;
  pointer-events: none;
  background: linear-gradient(90deg, transparent, rgb(23 114 47 / .16) 55%, rgb(23 114 47 / .5));
  border-right: 2px solid rgb(23 114 47 / .55);
}
.spec--play .spec__scan { animation: spec-scan 1.1s cubic-bezier(.4, 0, .5, 1) 3.35s; }
@keyframes spec-scan {
  from { transform: translateX(-100%); opacity: 0; }
  18%, 82% { opacity: 1; }
  to { transform: translateX(calc(100% / .26)); opacity: 0; }
}

.spec__cap {
  margin-top: 1.5rem;
  font-size: .88rem;
  line-height: 1.55;
  color: var(--text-3);
  max-width: 44ch;
}
.spec--play .spec__cap { animation: spec-cap .6s var(--ease) 4.2s backwards; }
@keyframes spec-cap { from { opacity: 0; transform: translateY(.5rem); } }

/* Both switches leave the finished drawing, because that is the base state —
   there is no "final" rule to keep in step here, which is the point. */
@media (prefers-reduced-motion: reduce) {
  .spec--play :is(.spec__sheet, .spec__rules path, .spec__stroke,
    .spec__node, .spec__frame path, .spec__cap) { animation: none; }
  .spec__page { transition: none; }
  .spec__scan { display: none; }
}
:root[data-lite='on'] .spec--play :is(.spec__sheet, .spec__rules path, .spec__stroke,
  .spec__node, .spec__frame path, .spec__cap),
:root[data-decor='off'] .spec--play :is(.spec__sheet, .spec__rules path, .spec__stroke,
  .spec__node, .spec__frame path, .spec__cap) { animation: none; }
:root[data-lite='on'] .spec__page,
:root[data-decor='off'] .spec__page { transition: none; }
:root[data-lite='on'] .spec__scan,
:root[data-decor='off'] .spec__scan { display: none; }
.feature-grid h3 { margin-bottom: .45rem; }
.feature-grid p { font-size: .93rem; color: var(--text-2); }


/* ── Download button ────────────────────────────────────────────────────
   Adapted from a Uiverse.io button by Na3ar-17 (MIT).

   The original runs on a fixed timeline: it fills for 3s, then declares
   "installed" at 3.5s, unconditionally. Nothing is being measured — the
   animation would report success just as confidently if the work had failed or
   were still running. Here the fill height comes from --dl-fill, which
   bundleFamily sets from the actual WOFF conversions, so the ring cannot reach
   the top before the zip exists.

   Also changed:

   - It is a checkbox. Downloading is an action, not a two-way state, and
     unchecking it would play the whole sequence in reverse. Rebuilt on a real
     <button> with a data-state attribute.
   - `.input { display: none }` again removes the control from the
     accessibility tree and from keyboard reach — the same defect as the theme
     toggle. The button element carries its own semantics now, and its label is
     announced through aria-live as the state changes.
   - The finish state was a plain square. It is a checkmark that draws itself,
     as asked.
   - Hard-coded pixel choreography (`right: 18px` animating to `right: 56px`)
     broke with any other label. It is a flex row.
   - Purple re-graded onto the palette, and a reduced-motion branch added: the
     pulse and the draw are exactly the kind of decoration that setting exists
     to switch off.
   ─────────────────────────────────────────────────────────────────────── */

.dlbtn {
  --dl-fill: 0%;
  --dl-size: 46px;

  display: inline-flex;
  align-items: center;
  gap: .85rem;
  padding: 5px 1.5rem 5px 5px;
  border: 2px solid var(--accent);
  border-radius: 50px;
  background: transparent;
  font: inherit;
  color: var(--text);
  cursor: pointer;
  transition: border-color .4s var(--ease), transform .3s var(--ease);
}

.dlbtn:active { transform: scale(.975); }
.dlbtn:disabled { cursor: default; }
.dlbtn:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

/* All three labels in one grid cell: the track is as wide as the widest, the
   visible one is aligned to its start, and the button stops changing size when
   dlState() rewrites the text. The sizers are `visibility: hidden` rather than
   `display: none` precisely because they must still be measured. */
.dlbtn__label { display: grid; justify-items: start; }
.dlbtn__label > * { grid-area: 1 / 1; }
.dlbtn__sizer { visibility: hidden; }

.dlbtn__circle {
  position: relative;
  flex: none;
  display: grid;
  place-items: center;
  width: var(--dl-size);
  height: var(--dl-size);
  border-radius: 50%;
  background: var(--accent);
  color: var(--accent-fg);
  overflow: hidden;
  transition: background .3s var(--ease);
}

/* Rises from the bottom in proportion to work actually completed. */
.dlbtn__fill {
  position: absolute;
  left: 0;
  bottom: 0;
  width: 100%;
  height: var(--dl-fill);
  background: color-mix(in srgb, var(--accent-fg) 26%, transparent);
  transition: height .25s linear;
}

.dlbtn__arrow,
.dlbtn__check {
  position: absolute;
  width: 24px;
  height: 24px;
  transition: opacity .25s var(--ease), transform .25s var(--ease);
}

.dlbtn__check {
  opacity: 0;
  stroke-dasharray: 30;
  stroke-dashoffset: 30;
}

.dlbtn[data-state='working'] .dlbtn__arrow { opacity: 0; transform: translateY(6px); }
.dlbtn[data-state='working'] .dlbtn__circle { animation: dl-pulse 1.4s ease-out infinite; }

.dlbtn[data-state='done'] { border-color: var(--accent); }
.dlbtn[data-state='done'] .dlbtn__arrow { opacity: 0; }
.dlbtn[data-state='done'] .dlbtn__fill { height: 100%; }
.dlbtn[data-state='done'] .dlbtn__check {
  opacity: 1;
  animation: dl-draw .45s cubic-bezier(.65, 0, .35, 1) forwards;
}

@keyframes dl-pulse {
  0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); }
  70% { box-shadow: 0 0 0 12px transparent; }
  100% { box-shadow: 0 0 0 0 transparent; }
}

@keyframes dl-draw { to { stroke-dashoffset: 0; } }

@media (prefers-reduced-motion: reduce) {
  .dlbtn__fill { transition: none; }
  .dlbtn[data-state='working'] .dlbtn__circle { animation: none; }
  .dlbtn[data-state='done'] .dlbtn__check { animation: none; stroke-dashoffset: 0; }
  .dlbtn:active { transform: none; }
}

/* ── Expanding CTA ──────────────────────────────────────────────────────
   Adapted from a Uiverse.io button by cssbuttons-io (MIT).

   The original opens with a bare `button { background: transparent; border: 0;
   padding: 0 }` rule and then styles state through unscoped `button:hover
   .circle`. Pasted in as-is that flattens every button in this app — the
   toolbar, the modals, the segmented controls, all of it. Everything below is
   scoped to .learn-more for that reason.

   Also changed:

   - `outline: none` with nothing put back removes the focus ring outright,
     which strands keyboard users on what is the primary call to action on the
     page. There is a real focus style here now.
   - Fixed 12rem width clipped any longer label. Rebuilt as an inline-flex box
     that sizes to its text, with the circle absolutely placed behind it. Text
     no longer re-centres as the circle grows, which also removes a small
     horizontal wobble the original had mid-hover.
   - Added the press interaction, and a reduced-motion branch.
   - Re-graded onto the palette; dropped the uppercase, since nothing else in
     this interface shouts.
   ─────────────────────────────────────────────────────────────────────── */

.learn-more {
  --circle: 3rem;
  --spring: cubic-bezier(.34, 1.56, .64, 1);

  position: relative;
  display: inline-flex;
  align-items: center;
  min-height: var(--circle);
  padding: 0 1.7rem 0 3.7rem;
  border: 0;
  border-radius: calc(var(--circle) / 2);
  background: transparent;
  font: inherit;
  cursor: pointer;
  isolation: isolate;
  -webkit-tap-highlight-color: transparent;

  /* The release. A spring easing on the way back out is what makes letting go
     feel like letting go of something physical rather than a state flip. */
  transition: transform .34s var(--spring);
}

/*
 * The press. Deliberately much faster than the release — a finger going down
 * should be met immediately, while coming back up can take its time and
 * overshoot. Matching the two durations is what makes CSS buttons feel mushy.
 */
.learn-more:active {
  transform: scale(.955);
  transition: transform .08s ease-out;
}

.learn-more .circle {
  position: absolute;
  left: 0;
  top: 50%;
  translate: 0 -50%;
  z-index: -1;
  display: block;
  width: var(--circle);
  height: var(--circle);
  border-radius: calc(var(--circle) / 2);
  background: var(--accent);
  transition: width .45s cubic-bezier(.65, 0, .076, 1), background .2s var(--ease);
}

:where(html[data-pointer="fine"]) .learn-more:hover .circle,
.learn-more:focus-visible .circle { width: 100%; }
.learn-more:active .circle { background: var(--accent-hover); }

/*
 * The arrow has two jobs, and they want different positions.
 *
 * At rest the circle is 3rem and only the chevron is visible — the shaft has no
 * background until hover — so the chevron has to sit in the middle of that
 * circle. Its centre is
 *
 *     left + shaft width − its own inset − half its own width
 *   = left + 1.125rem − .0625rem − .375rem
 *   = left + .6875rem
 *
 * and half of 3rem is 1.5rem, which gives left = .8125rem.
 *
 * Expanded, the circle is the whole pill and the shaft is visible, so the arrow
 * is no longer a mark in a disc — it is the first thing in a row that ends with
 * the label. What matters then is that it sits as far from the left edge as the
 * label sits from the right, and the label's inset is the button's own
 * padding-right: 1.7rem. At .8125rem it was hugging the edge, which is what was
 * still wrong after the centring was fixed. The centring was not wrong; it was
 * answering the resting state and being asked about the expanded one.
 *
 * So it moves, on the same duration and easing as the width it is moving with —
 * the arrow slides out as the pill opens rather than jumping to a new place
 * once it has.
 */
.learn-more .icon {
  position: absolute;
  top: 0;
  bottom: 0;
  margin: auto;
  left: .8125rem;
  width: 1.125rem;
  height: .125rem;
  background: none;
  transition: transform .45s cubic-bezier(.65, 0, .076, 1), background .45s var(--ease);
}

.learn-more .icon::before {
  content: '';
  position: absolute;
  /* The chevron's box is .625rem plus a .125rem top border, so .75rem tall, and
     it has to sit centred on a shaft that is .125rem tall:
       top = shaft centre − half the box = .0625rem − .375rem = −.3125rem.
     It was −.29rem, which hung the chevron .0225rem low — half a pixel, and
     exactly the kind of half a pixel that reads as "not quite right" without
     being nameable. */
  top: -.3125rem;
  right: .0625rem;
  width: .625rem;
  height: .625rem;
  border-top: .125rem solid var(--accent-fg);
  border-right: .125rem solid var(--accent-fg);
  transform: rotate(45deg);
}

/*
 * Expanded, the arrow is not a mark in a disc any more — it is the first thing
 * in a row that ends with the label, and it should sit as far from the left
 * edge as the label sits from the right.
 *
 * The label's inset is the button's own padding-right, 1.7rem. The arrow starts
 * at left: .8125rem — the value that centres the chevron in the 3rem disc at
 * rest — so the travel is the difference:
 *
 *     1.7rem − .8125rem = .8875rem
 *
 * It was 1rem, which overshot by .1125rem and left the arrow sitting nearer the
 * edge than the label. Moved by `transform` rather than by `left`, which is
 * both what was here already and the one of the two that runs on the
 * compositor.
 */
:where(html[data-pointer="fine"]) .learn-more:hover .icon,
.learn-more:focus-visible .icon {
  background: var(--accent-fg);
  transform: translateX(.8875rem);
}

/* The arrow leads the press by a hair, so the button reads as moving forward
   rather than merely being squashed. Same .3375rem beyond the hover position
   it always had. */
.learn-more:active .icon { transform: translateX(1.225rem); }

.learn-more .button-text {
  position: relative;
  color: var(--text);
  font-weight: 600;
  line-height: 1.6;
  padding: .7rem 0;
  transition: color .45s cubic-bezier(.65, 0, .076, 1);
}

:where(html[data-pointer="fine"]) .learn-more:hover .button-text,
.learn-more:focus-visible .button-text { color: var(--accent-fg); }

.learn-more:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

@media (prefers-reduced-motion: reduce) {
  .learn-more,
  .learn-more .circle,
  .learn-more .icon,
  .learn-more .button-text { transition-duration: .01ms; }
  .learn-more:active { transform: none; }
  .learn-more:active .icon { transform: translateX(1rem); }
}

/* ── Continue button ────────────────────────────────────────────────────
   Adapted from a Uiverse.io button by alexmaracinaru (MIT). It takes the
   forward action at the foot of every step, where a badge that leans toward
   the next screen carries the movement a flat arrow glyph cannot.

   Changed from the original:
   - It styles a bare `button` selector, plus unscoped `button:hover svg` and
     `button:active`. Dropped in as written it would repaint and shrink every
     button on the page — the top bar, the modals, the segmented controls,
     the step chips. Scoped to a class.
   - `stroke="black"` and `fill="black"` sat in the markup, so the badge
     stayed black whatever the theme did. It is `currentColor` now, and the
     icon moved into the sprite the rest of the interface draws from, so it
     inherits the same stroke weight as every other icon here.
   - No `:disabled` rule. The first of these ships disabled until a
     photograph exists, so without one it would look live while doing
     nothing — the one state this button genuinely had to have.
   - No focus ring, and a single `.3s` transition covering press and release
     alike. Press is met immediately and release is allowed to take its time
     and overshoot, the same asymmetry the expanding CTA uses.
   ─────────────────────────────────────────────────────────────────────── */

.contbtn {
  display: inline-flex;
  align-items: center;
  gap: .65rem;
  padding: .4rem .45rem .4rem 1.3rem;
  border: 1px solid var(--accent);
  border-radius: 999px;
  background: var(--accent);
  color: var(--accent-fg);
  font-size: .95rem;
  font-weight: 600;
  letter-spacing: -.01em;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: background .16s var(--ease), border-color .16s var(--ease),
              transform .34s cubic-bezier(.34, 1.56, .64, 1);
}

:where(html[data-pointer="fine"]) .contbtn:hover:not(:disabled) {
  background: var(--accent-hover);
  border-color: var(--accent-hover);
}

.contbtn:active:not(:disabled) {
  transform: scale(.965);
  transition: transform .08s ease-out;
}

.contbtn:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

/*
 * Not opacity. `opacity: .45` composites the whole subtree against whatever is
 * behind it, and the label and the fill fade *together*, so their relationship
 * to each other collapses along with their relationship to the page. On --bg it
 * put --accent-fg on --accent at 1.92:1 light / 2.44:1 dark for the label, and
 * the pill itself at 1.87:1 / 2.39:1 against the page — under both floors, in
 * both themes, on the one control whose job is to say "not yet".
 *
 * Stated instead of faded: a flat outlined pill. Every colour is a token that
 * is already opaque, so nothing composites and the figures hold.
 *
 *   label  --text-3 on --surface-2        4.68:1 light / 4.84:1 dark
 *   shape  --text-3 border on --bg        4.81:1 light / 5.75:1 dark
 *
 * The border carries the shape deliberately: --surface-2 on --bg is 1.03:1 in
 * light and could not carry it alone.
 */
.contbtn:disabled {
  background: var(--surface-2);
  border-color: var(--text-3);
  color: var(--text-3);
  cursor: not-allowed;
}

.contbtn svg {
  width: 1.75em;
  height: 1.75em;
  stroke-width: 1.5;
  transition: transform .3s var(--ease);
}

:where(html[data-pointer="fine"]) .contbtn:hover:not(:disabled) svg,
.contbtn:focus-visible:not(:disabled) svg { transform: translateX(5px); }

/* The nudge is the whole gesture, so reduced motion removes it rather than
   shortening it — a 5px jump on hover is exactly what that setting asks not
   to happen. The colour change still reports the hover. */
@media (prefers-reduced-motion: reduce) {
  .contbtn svg { transition: none; }
  :where(html[data-pointer="fine"]) .contbtn:hover:not(:disabled) svg,
  .contbtn:focus-visible:not(:disabled) svg { transform: none; }
  .contbtn:active:not(:disabled) { transform: none; }
}

/* ── Inverting action ───────────────────────────────────────────────────
   Adapted from a Uiverse.io button by kamehame-ha (MIT). On the one button in
   the app that hands you to somebody else's server.

   Every other filled button here is an internal action, and a control that
   leaves the site should not look identical to one that does not. Filled at
   rest, outlined on hover, is a distinct signature at no extra cost — and the
   GitHub mark is the only icon in this batch that carries destination
   information rather than direction. It sits in a dialogue whose entire subject
   is what leaves your machine, where "who am I about to arrive at" is the
   question actually being asked.

   Changed from the original:
   - Bare `.button`, `.text` and `.button svg path` selectors. There is no
     `.button` in this sheet yet, which is exactly how that collision gets
     shipped: the next component to arrive claims the name and both break.
     A modifier on `.btn.btn-primary` instead, so it inherits the padding,
     radius, disabled state and focus ring already agreed for every button.
   - `outline: 3px solid #181717` with `outline-offset: -3px` is the original's
     border, parked on the one property `:focus-visible` needs. A keyboard ring
     would have *replaced* the visible edge rather than surrounding it, so
     focusing the button would read as it losing its border. It is a border
     here, identical in both states so nothing reflows, and outline is left free.
   - `:hover { background: transparent }` assumes a light page behind it. On the
     dark palette the original's `#181717` label would land on `--surface` at
     about 1.05:1 — invisible. Inverted onto the accent instead, which is
     legible in both directions and both themes.
   - `transition: 400ms` with no property list, long enough that the button is
     still changing after the pointer has gone. Named properties at .16s, the
     duration every other button here uses.
   ─────────────────────────────────────────────────────────────────────── */

.btn-invert {
  border: 1px solid var(--accent);
  transition: background .16s var(--ease), color .16s var(--ease), border-color .16s var(--ease);
}
:where(html[data-pointer="fine"]) .btn-invert:hover:not(:disabled) {
  background: transparent;
  border-color: var(--accent);
  color: var(--accent);
}
/* The mark is a filled path, so it opts out of the sheet's stroked icon
   default; inheriting currentColor is what carries it through the inversion. */
.btn-invert svg { fill: currentColor; stroke: none; }

/* ── Closing call to action ─────────────────────────────────────────────
   Adapted from a Uiverse.io button by mdanarul_9390 (MIT), colour-graded as
   asked. Replaces the second copy of the hero's expanding button rather than
   adding a fourth button pattern: the page ended with a literal duplicate of
   the control it opened with, which reads as a template rather than an ending.

   Changed from the original:
   - Seven rainbow blobs on 3-8s loops. Five now, on the palette — accent,
     accent-hover and the warm end of the warning tone — because seven colours
     that are all green is just noise, and the original's `#ff007f` next to
     `#00fff0` is not a decision this palette can absorb.
   - Every offset was a percentage of the blob, which is 150px regardless of
     the button, so the motion had no relationship to the thing it sat behind.
     Sized in em and driven off the button's own box.
   - `blur(10px)` on seven layers, animating `transform` on all of them,
     permanently. Under lite mode and reduced motion the blobs stop and the fill
     resolves to flat accent — the button has to still look like a button when
     the decoration is gone, which is the test the original fails.
   - The original's `.button::before` covers the blobs with opaque black at rest
     and reveals them on hover, so the default state is a black pill. Reversed:
     the colour is the resting state, and hover deepens it.
   ─────────────────────────────────────────────────────────────────────── */

.inkbtn {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: .85rem 2.4rem;
  border: 0;
  border-radius: 999px;
  background: var(--accent);
  color: var(--accent-fg);
  font: inherit;
  font-size: 1.05rem;
  font-weight: 600;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: transform .34s cubic-bezier(.34, 1.56, .64, 1);
}

.inkbtn:active { transform: scale(.965); transition: transform .08s ease-out; }
.inkbtn:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

.inkbtn__label { position: relative; z-index: 2; }

.inkbtn__blobs {
  position: absolute;
  inset: -60%;
  z-index: 1;
  filter: blur(1.1em);
  opacity: .85;
}

.inkbtn__blobs i {
  position: absolute;
  width: 6em;
  height: 6em;
  border-radius: 50%;
  will-change: transform;
}

.inkbtn__blobs i:nth-child(1) { background: var(--accent-hover); top: -10%; left: -5%; animation: inkdrift-a 9s ease-in-out infinite; }
.inkbtn__blobs i:nth-child(2) { background: var(--accent); top: 20%; left: 20%; animation: inkdrift-b 11s ease-in-out infinite -2s; }
.inkbtn__blobs i:nth-child(3) { background: var(--warn); top: -20%; left: 45%; animation: inkdrift-c 13s ease-in-out infinite -5s; opacity: .55; }
.inkbtn__blobs i:nth-child(4) { background: var(--accent-hover); top: 15%; left: 70%; animation: inkdrift-a 10s ease-in-out infinite -7s; }
.inkbtn__blobs i:nth-child(5) { background: var(--accent); top: -25%; left: 85%; animation: inkdrift-b 12s ease-in-out infinite -3s; }

/* Deepens on hover rather than revealing on it — the colour is the resting
   state, so the button never sits there as a flat slab waiting to be noticed. */
.inkbtn::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 1;
  background: var(--accent);
  opacity: 0;
  transition: opacity .4s var(--ease);
}
:where(html[data-pointer="fine"]) .inkbtn:hover::after { opacity: .55; }
/* The blobs are the only thing that moved on hover, and both switches remove
   them — leaving the page's final call to action with no hover state at all.
   A border shift costs nothing and is not motion. */
:where(html[data-pointer="fine"]):root[data-lite='on'] .inkbtn:hover:not(:disabled),
:where(html[data-pointer="fine"]):root[data-decor='off'] .inkbtn:hover:not(:disabled) { border-color: var(--accent); }
@media (prefers-reduced-motion: reduce) {
  :where(html[data-pointer="fine"]) .inkbtn:hover:not(:disabled) { border-color: var(--accent); }
}

@keyframes inkdrift-a {
  0%, 100% { transform: translate(0, 0); }
  33% { transform: translate(2.2em, -1.4em); }
  66% { transform: translate(-1.6em, 1.2em); }
}
@keyframes inkdrift-b {
  0%, 100% { transform: translate(0, 0); }
  40% { transform: translate(-2.4em, 1.1em); }
  70% { transform: translate(1.8em, -.9em); }
}
@keyframes inkdrift-c {
  0%, 100% { transform: translate(0, 0); }
  50% { transform: translate(1.4em, 1.6em); }
}

/* With the blobs still, a blurred five-circle pile-up is muddier than the flat
   fill it is standing in for — so they go entirely rather than freezing. */
@media (prefers-reduced-motion: reduce) {
  .inkbtn__blobs { display: none; }
  .inkbtn:active { transform: none; }
  .inkbtn::after { transition: none; }
}

/* ── Browser gate ───────────────────────────────────────────────────────
   Shown when a capability the app has no fallback for is missing. See
   src/browsergate.js for why it is a warning with a door rather than a wall.

   Every colour here is stated twice: once from the tokens, and once as a
   literal inside @supports not (color: light-dark(...)). The whole reason this
   panel might be on screen is that light-dark() did not parse — in which case
   every token in :root is invalid at computed-value time, and a panel styled
   from them would render as unstyled text on a default background, or worse,
   the same colour as it. The literals are the light palette's values, because
   an unstyled page renders on white.
   ─────────────────────────────────────────────────────────────────────── */

.bgate {
  position: fixed;
  inset: 0;
  z-index: 200;
  display: grid;
  place-items: center;
  padding: 1.5rem;
  overflow-y: auto;
  background: var(--bg);
}

/* The page behind keeps rendering — the app still initialises — so it must not
   scroll under a panel that covers it. */
:root.bgate-open, :root.bgate-open body { overflow: hidden; }
/* Set from applyInert(), which already computes whether anything is over the
   page — a dialogue, or the build overlay. Without it, wheeling over the dim
   backdrop scrolled the app about underneath a dialogue that stayed put, with
   two scrollbars on screen at once. */
:root.modal-open, :root.modal-open body { overflow: hidden; }

.bgate__panel {
  max-width: 34rem;
  padding: clamp(1.5rem, 5vw, 2.5rem);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-3);
}

.bgate__title { font-size: clamp(1.5rem, 4vw, 2rem); margin-bottom: .9rem; }
.bgate__lede { color: var(--text-2); margin-bottom: 1.25rem; }

.bgate__list { list-style: none; padding: 0; margin: 0 0 1.5rem; display: grid; gap: 1rem; }
.bgate__list li {
  padding: .9rem 1.1rem;
  background: var(--surface-2);
  border-radius: var(--r-md);
  border-left: 3px solid var(--warn);
}
.bgate__list b { display: block; margin-bottom: .25rem; }
.bgate__list p { color: var(--text-2); font-size: .93rem; line-height: 1.5; }

.bgate__advice { color: var(--text-2); font-size: .95rem; line-height: 1.6; }
.bgate__actions { margin-top: 1.5rem; }

/* Restated rather than reusing .btn, for the same reason the colours are
   restated: .btn's own background is a token. */
.bgate__go {
  padding: .7rem 1.4rem;
  border-radius: var(--r-md);
  border: 1px solid var(--accent);
  background: var(--accent);
  color: var(--accent-fg);
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
:where(html[data-pointer="fine"]) .bgate__go:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.bgate__go:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

.bgate__note { margin-top: .9rem; color: var(--text-3); font-size: .87rem; }

@supports not (color: light-dark(#000, #fff)) {
  .bgate { background: #fcfcfd; color: #1c2128; }
  .bgate__panel { background: #f6f8fa; border: 1px solid #d8dee4; border-radius: 16px; }
  .bgate__lede, .bgate__advice, .bgate__list p { color: #57606a; }
  /* The warning stripe keeps its colour. This panel exists to tell somebody
     their browser is too old to run the app, and a grey stripe beside that
     sentence is a stripe that says nothing. */
  .bgate__list li { background: #eef1f5; border-left: 3px solid #9a6700; border-radius: 10px; }
  .bgate__go { background: #1c2128; border: 1px solid #1c2128; color: #fcfcfd; border-radius: 10px; }
  :where(html[data-pointer="fine"]) .bgate__go:hover { background: #0f1419; border-color: #0f1419; }
  .bgate__note { color: #656d76; }
}

/* ── Landing demo ───────────────────────────────────────────────────────
   The one thing on the landing page a visitor can actually do. See src/demo.js
   for why it draws the real tracer rather than a canned example.
   ─────────────────────────────────────────────────────────────────────── */

.demo-mount { margin-top: 2rem; }

.demo-fallback {
  color: var(--text-2);
  max-width: 60ch;
}

.demo-stage {
  display: grid;
  grid-template-columns: minmax(0, 22rem) minmax(0, 1fr);
  gap: clamp(1.5rem, 4vw, 3rem);
  align-items: start;
}
@media (max-width: 860px) { .demo-stage { grid-template-columns: 1fr; } }

.demo-pad {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  padding: 1.1rem;
}

/* ── The pad's own furniture ──
   The pad sizes itself from the box it is dropped into, so its toolbar has to
   survive being narrow. Laid out as three equal columns rather than a flex row
   pushed to one end: a row leaves the widths to the labels, and at 354px the
   longest one broke across two lines mid-phrase — "Trace / it" — which then
   set the height of all three. Equal columns cannot do that, and the buttons
   read as one control group instead of three loose chips. */
.draw-pad {
  position: relative; margin: 0 auto; touch-action: none;
  /* The pad's width is set by the box it was dropped into, not by the viewport
     — the same pad is 440px in a dialogue and 260px in a phone-width card on
     the same screen. So the toolbar has to respond to the pad, not the page. */
  container-type: inline-size;
}
.draw-pad__stage { position: relative; }
.draw-tools {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: .5rem;
  margin-top: .75rem;
}
/* Below roughly 340px a third of the row is narrower than the longest label.
   Undo and Clear pair off and the primary action takes the full width, which is
   the right emphasis on a phone anyway. A browser without container queries
   keeps the three-up row, which is the layout this degrades from. */
@container (max-width: 340px) {
  .draw-tools { grid-template-columns: 1fr 1fr; }
  .draw-tools .btn-primary { grid-column: 1 / -1; }
}
.draw-tools .btn {
  /* A label is a phrase, not a paragraph. If it genuinely cannot fit it should
     run past its column, where it is obvious, rather than fold in half. */
  white-space: nowrap;
  padding-inline: .7rem;
  min-width: 0;
}

.demo-result { display: grid; gap: 1.75rem; min-width: 0; }

.demo-idle { color: var(--text-2); max-width: 46ch; }

.demo-panel { margin: 0; }
.demo-panel figcaption {
  margin-top: .85rem;
  color: var(--text-2);
  font-size: .93rem;
  line-height: 1.55;
}
.demo-panel figcaption b { color: var(--text); font-weight: 600; }

.demo-glyph {
  /* Sized in em so the whole demo grows with the text-size setting rather than
     staying small next to labels that grew. */
  width: 7.5em;
  height: 7.5em;
  /* The global `svg` reset sets fill:none and stroke:currentColor, which is
     right for the icon set and wrong for a glyph — a letter is a filled shape,
     and its counters come from the fill rule, not from a stroke. */
  fill: var(--ink);
  stroke: none;
  background: var(--paper);
  border-radius: var(--r-md);
  border: 1px solid rgb(0 0 0 / .1);
}

.demo-glyph__fill { fill: var(--ink); }

/* The outline and its nodes sit on top of the filled shape so the curve
   structure is legible against it — the point of this panel is that these are
   real Béziers, and a solid silhouette alone would not show that. */
.demo-glyph__stroke {
  fill: none;
  stroke: var(--accent);
  stroke-width: .6;
  vector-effect: non-scaling-stroke;
}
.demo-glyph__node {
  fill: var(--accent);
  stroke: var(--paper);
  stroke-width: .5;
}

.demo-variants {
  display: flex;
  flex-wrap: wrap;
  gap: .55rem;
}
.demo-variants .demo-glyph { width: 4.6em; height: 4.6em; }

.demo-note {
  color: var(--text-3);
  font-size: .93rem;
  max-width: 62ch;
}

/* ── The greeting ───────────────────────────────────────────────────────
   Shown once, on arrival. Deliberately plain: it is the first thing anyone
   sees, and a dialogue that performs on the way in is a dialogue people learn
   to dismiss without reading. The entrance it does have comes free from
   .sheet-modal-panel's lift, and reduced motion turns that off already.
   ─────────────────────────────────────────────────────────────────────── */

.welcome-lead {
  color: var(--text-2);
  margin-bottom: 1.4rem;
  max-width: 46ch;
}

.welcome-steps {
  list-style: none;
  margin: 0 0 1.6rem;
  padding: 0;
  display: grid;
  gap: 1rem;
}
.welcome-steps li {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  align-items: start;
  gap: .85rem;
  line-height: 1.5;
  color: var(--text-2);
}
.welcome-steps b { color: var(--text); font-weight: 600; }

/* The counter is the icon's job here — a number beside a pen and a camera
   would be saying the same thing twice. */
.welcome-step__icon {
  display: grid;
  place-items: center;
  width: 2.2rem; height: 2.2rem;
  flex: none;
  border-radius: var(--r-md);
  background: var(--surface-2);
  border: 1px solid var(--border);
  color: var(--accent);
}
.welcome-step__icon svg { width: 1.15rem; height: 1.15rem; }

/* On a phone the two actions stack and the primary one goes full width, which
   is the same shape the draw pad's toolbar takes below its own breakpoint. */
@media (max-width: 480px) {
  #welcome .fb-actions { display: grid; gap: .6rem; }
  #welcome .fb-actions > * { width: 100%; }
}

/* ── Leaving the site ───────────────────────────────────────────────── */

.sheet-modal-panel.is-narrow { max-width: 34rem; }

.leaving-lead { font-size: 1.05rem; margin-bottom: 1.1rem; }

.leaving-url {
  display: flex; align-items: flex-start; gap: .7rem;
  padding: .95rem 1.1rem;
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  /* The address can be long and must not be allowed to widen the dialogue —
     it is the one thing here the reader is meant to inspect closely. */
  overflow-wrap: anywhere;
}
.leaving-url svg { flex: none; margin-top: .18rem; color: var(--accent); }
.leaving-url svg.is-insecure { color: var(--warn); }
.leaving-url b { font-weight: 600; font-size: 1.02rem; }
.leaving-url .mono { color: var(--text-2); font-size: .92rem; }

.leaving-note { font-size: .92rem; margin-top: 1rem; line-height: 1.55; }

/* ── The lock shutting itself ───────────────────────────────────────────
   Every line in this app that promises your handwriting stays on your device
   carries a padlock. It arrives open and closes itself as you reach it, which
   is the claim the sentence is making, drawn.

   The shackle pivots at the leg that stays seated, the way a real one does,
   rather than sliding straight down — a shackle that translates reads as a
   picture being assembled, not as something shutting. It overshoots slightly
   and settles, and the body takes a short squash on the beat the shackle
   lands, so the two halves feel like one event.

   Held open by the same `[data-reveal]` state the rest of the page uses, so
   there is no second mechanism to keep in step. Where reveal is off — lite
   mode, reduced motion, no script — the lock is simply drawn shut, which is
   the state the sentence is about anyway.
   ─────────────────────────────────────────────────────────────────────── */

.locklock__shackle {
  /* Without fill-box the origin resolves against the SVG's user space, which
     for a 24-unit viewBox puts the pivot somewhere off the shape entirely. */
  transform-box: fill-box;
  transform-origin: 88% 100%;
}

/* The first pass at this was too quiet to register: a 30° lift on a shackle
   drawn at 1.1em is about a pixel and a half of travel, which is not an
   animation, it is a rendering difference. The shackle now swings most of the
   way open, hangs there while the eye arrives, and drops with enough overshoot
   to read as weight — and the icon itself is half again as large, because no
   amount of choreography rescues a motion too small to see. */
.reveal-ready [data-reveal] .locklock__shackle {
  transform: translateY(-5px) rotate(-92deg);
}

.reveal-ready [data-reveal='in'] .locklock__shackle {
  animation: lock-shut .96s var(--ease) .32s both;
}
.reveal-ready [data-reveal='in'] .locklock__body {
  animation: lock-seat .96s var(--ease) .32s both;
}

@keyframes lock-shut {
  from, 20% { transform: translateY(-5px) rotate(-92deg); }
  36%  { transform: translateY(-5.6px) rotate(-104deg); }  /* winds back first */
  66%  { transform: rotate(14deg); }
  78%  { transform: rotate(-7deg); }
  88%  { transform: rotate(3.5deg); }
  95%  { transform: rotate(-1.5deg); }
  to   { transform: none; }
}

/* Scaled about the bottom edge so the body settles onto the line of text
   rather than growing symmetrically out of it. */
@keyframes lock-seat {
  from, 62% { transform: none; }
  72% { transform: scale(1.24, .78); }
  84% { transform: scale(.92, 1.11); }
  92% { transform: scale(1.05, .96); }
  97% { transform: scale(.99, 1.01); }
  to  { transform: none; }
}
.locklock__body { transform-box: fill-box; transform-origin: 50% 100%; }

@media (prefers-reduced-motion: reduce) {
  .reveal-ready [data-reveal] .locklock__shackle { transform: none; }
  .reveal-ready [data-reveal='in'] .locklock__shackle,
  .reveal-ready [data-reveal='in'] .locklock__body { animation: none; }
}
:root[data-lite='on'] .locklock__shackle,
:root[data-lite='on'] .locklock__body,
:root[data-decor='off'] .locklock__shackle,
:root[data-decor='off'] .locklock__body { transform: none; animation: none; }

/* ── Scroll reveal ──────────────────────────────────────────────────────
   Content arrives as you reach it.

   Both rules are gated on `.reveal-ready`, which src/reveal.js puts on the
   root only once it has decided to run. Without that gate a browser that never
   executed the module — script blocked, module error, an engine that chokes on
   the syntax — would render a page of permanently invisible elements. An
   effect must never be able to take the content down with it.

   `transform` and `opacity` only, so the whole thing runs on the compositor
   and does not compete with the main thread while a font is compiling.
   ─────────────────────────────────────────────────────────────────────── */

/*
 * One transition, many arrivals.
 *
 * The first version hinged every revealed element on its own top edge — the
 * same rotateX on cards, headings, list rows, paragraphs and dialogs. Eighty
 * elements all pivoting the same way down one page is dizzying, and it also
 * says nothing: a rotation on a heading is decoration, but on a panel that
 * genuinely opens it is a description of what happened.
 *
 * So the hinge is now reserved for things that actually fold, and everything
 * else declares its own arrival through `--reveal-from`. One rule reads it, so
 * the timing stays consistent by construction while the movement does not.
 */
.reveal-ready [data-reveal] {
  opacity: 0;
  transform: var(--reveal-from, translateY(1.1rem));
  transform-origin: top center;
  /* Slightly slower out than in, and eased so it decelerates into place rather
     than arriving and stopping. The delay staggers a row without needing a
     rule per child — reveal.js sets --reveal-i from screen position, and
     --reveal-step from how many are in the group, so four cards keep their
     unhurried 65ms while nine questions compress to fit one window rather than
     the last three arriving together. */
  transition:
    opacity .62s var(--ease) calc(var(--reveal-i, 0) * var(--reveal-step, 65ms)),
    transform .62s var(--ease) calc(var(--reveal-i, 0) * var(--reveal-step, 65ms));
  will-change: opacity, transform;
}

.reveal-ready [data-reveal='in'] {
  opacity: 1;
  transform: none;
  will-change: auto;
}

/* Belt and braces: if either switch is on, nothing is ever hidden, whatever
   the class on the root says. reveal.js also returns early in both cases —
   this is the copy that survives someone flipping the setting mid-page. */
@media (prefers-reduced-motion: reduce) {
  .reveal-ready [data-reveal] { opacity: 1; transform: none; transition: none; }
}
:root[data-lite='on'] [data-reveal] { opacity: 1; transform: none; transition: none; }
/*
 * Chosen per family, so what arrives tells you something about what it is.
 *
 *   hero, band heads   settle down from just above, like text landing
 *   the four claims    lift and grow a little, like cards being dealt
 *   detail articles    come in from the side they sit on
 *   questions          the hinge, because they are the things that fold
 *   closing call       grows from the middle, because it is one object
 *   demo               rises without scaling, so the canvas never resamples
 */
.reveal-ready .hero > *,
.reveal-ready .band-head > * { --reveal-from: translateY(-.7rem); }
.reveal-ready .feature-grid li { --reveal-from: translateY(1.5rem) scale(.965); }
.reveal-ready .detail-grid article { --reveal-from: translateX(-1.6rem); }
.reveal-ready .detail-grid article:nth-child(2n) { --reveal-from: translateX(1.6rem); }
.reveal-ready .how-list li { --reveal-from: translateX(-1.2rem); }
.reveal-ready .faq-item { --reveal-from: perspective(900px) rotateX(-14deg); }
.reveal-ready .cta > * { --reveal-from: scale(.94); }
.reveal-ready .demo-mount { --reveal-from: translateY(1.2rem); }

/* The fold switch flattens all of it to one plain rise, which is the calmest
   thing that still has sequence in it. */
:root[data-fold='off'] .reveal-ready [data-reveal] { --reveal-from: translateY(1.1rem); }

/* ── Folding ────────────────────────────────────────────────────────────
   Everything that opens, opens like paper.

   The height animation lives in src/fold.js — a <details> has no height to
   transition from, so it has to be measured. What is here is the hinge: a
   transform-origin along the top edge, and the entrance the same shape as the
   scroll reveal's, so a card arriving and a panel opening read as the same
   gesture rather than two unrelated effects.

   `unfold` is deliberately reused across a dozen unrelated components. One
   keyframe set means the timing is consistent everywhere by construction, and
   changing the feel of the whole interface is one edit.
   ─────────────────────────────────────────────────────────────────────── */

@keyframes unfold {
  from { opacity: 0; transform: perspective(900px) rotateX(-26deg) translateY(-.35rem); }
  to   { opacity: 1; transform: perspective(900px) rotateX(0) translateY(0); }
}

.fold-body {
  transform-origin: top center;
  /* Nothing here sets a height — fold.js owns it for the length of the
     animation and hands it back. A height in the sheet would fight it. */
}

/* Details that fold. The marker is ours, so it can rotate. */
details[data-fold] > summary { list-style: none; }
details[data-fold] > summary::-webkit-details-marker { display: none; }

/* ── Settings groups ── */
.setgroup {
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  background: var(--surface);
  overflow: hidden;
}
.setgroup + .setgroup { margin-top: 1rem; }
.setgroup > summary {
  display: flex; align-items: center; justify-content: space-between; gap: 1rem;
  padding: .95rem 1.15rem;
  font-weight: 600; font-size: .95rem;
  cursor: pointer;
  list-style: none;
  color: var(--text);
  background: var(--surface-2);
  transition: background .16s var(--ease);
}
.setgroup > summary::-webkit-details-marker { display: none; }
:where(html[data-pointer="fine"]) .setgroup > summary:hover { background: var(--surface-3); }
.setgroup > summary::after {
  content: ''; flex: none; width: 9px; height: 9px;
  border-right: 2px solid var(--text-2); border-bottom: 2px solid var(--text-2);
  transform: rotate(45deg) translate(-2px, -2px);
  transition: transform .28s var(--ease);
}
.setgroup[open] > summary::after { transform: rotate(-135deg) translate(-3px, -3px); }
.setgroup .setting { padding-inline: 1.15rem; }
.setgroup .setting:first-child { padding-top: 1.15rem; }
.setgroup .setting:last-child { padding-bottom: 1.15rem; }

/* ── Unfold entrances ──
   Scoped away entirely when the switch is off, rather than overridden — an
   `animation: none` override still leaves the browser parsing and matching a
   rule per element on every one of these selectors.

   `:not([data-reveal])` is load-bearing, not tidiness. Most of these are also
   scroll-reveal targets, and reveal.js holds a pending element at opacity 0
   while this animation runs 0 → 1 over the same property. An animation beats a
   transition in the cascade, so the element would fade *in* on load, finish,
   fall back to the reveal's opacity 0, and vanish again — a flash on every
   card on the page, every load. reveal.js stamps the attribute the moment it
   takes an element on, so this rule steps aside for exactly the ones it owns
   and keeps the ones it never sees: the lists built after observe() has run.
   ─────────────────────────────────────────────────────────────────────── */
:root:not([data-fold='off']) :is(
  .glyph,
  .dl-item,
  .finding,
  .doc-result,
  .lesson,
  .faq-item,
  .setgroup
):not([data-reveal]) {
  animation: var(--enter, unfold) .42s var(--ease) backwards;
  animation-delay: calc(var(--fold-i, 0) * 34ms);
  transform-origin: top center;
}

/* A grid of 120 small squares all hinging backwards is the worst case for the
   effect — it reads as the whole grid tipping over. They pop instead, which is
   what a grid of tiles wants anyway. */
.glyph { --enter: pop-in; }
/* Rows in a list come from the side, so the eye reads down the column rather
   than watching each row rotate in place. */
.dl-item, .finding, .doc-result { --enter: slide-in; }
.lesson { --enter: settle; }
/* The hinge stays where the metaphor is literal. */
.faq-item, .setgroup { --enter: unfold; }

@keyframes pop-in {
  from { opacity: 0; transform: scale(.86); }
  to   { opacity: 1; transform: none; }
}
@keyframes slide-in {
  from { opacity: 0; transform: translateX(-1.1rem); }
  to   { opacity: 1; transform: none; }
}
@keyframes settle {
  from { opacity: 0; transform: translateY(-.6rem); }
  to   { opacity: 1; transform: none; }
}
@keyframes lift {
  from { opacity: 0; transform: translateY(1rem) scale(.985); }
  to   { opacity: 1; transform: none; }
}

/* A dialog unfolds from its own top edge, which is where it visually hangs
   from — the header stays put and the body drops out of it. */
/* Lifts rather than hinges. A dialog is one object arriving in front of the
   page; rotating it made a 3,400-word document look like it was falling over. */
:root:not([data-fold='off']) .sheet-modal:not([hidden]) .sheet-modal-panel {
  animation: lift .34s var(--ease) backwards;
}

@media (prefers-reduced-motion: reduce) {
  .glyph, .dl-item, .finding, .doc-result, .lesson, .faq-item, .setgroup,
  .sheet-modal .sheet-modal-panel { animation: none; }
}
:root[data-lite='on'] :is(.glyph, .dl-item, .finding, .doc-result, .lesson, .faq-item,
  .setgroup, .sheet-modal-panel) { animation: none; }

/* ── Switch ─────────────────────────────────────────────────────────────
   Adapted from a Uiverse.io switch by namecho (MIT). Used for the rows in
   Settings, where a plain on/off needs no illustration — the sun-and-moon
   toggle in the header is the one place a picture earns its keep.

   Changed from the original:
   - `input[type="checkbox"] { display: none }`, the fault every switch in this
     batch shipped. It removes the control from the accessibility tree and from
     the tab order, so the setting becomes unreachable by keyboard and
     invisible to a screen reader. The input is still here, stretched over the
     track at zero opacity: focusable, clickable, announced.
   - No focus ring. There is one now, drawn on the track from the input's
     `:focus-visible`.
   - `--color-grey: #cccccc` against a white knob is 1.6:1, below the 3:1 that
     WCAG asks of a control's own parts. Re-graded so the knob clears 3:1
     against the track in both themes, and the accent carries the on state.
   - Sized in rem rather than em off an arbitrary root, so it grows with the
     text-size setting instead of staying small next to larger labels.
   ─────────────────────────────────────────────────────────────────────── */

.switch {
  --sw-w: 3.1rem;
  --sw-h: 1.75rem;
  --sw-knob: 1.3rem;
  --sw-pad: calc((var(--sw-h) - var(--sw-knob)) / 2);
  --sw-squish: 1.7rem;
  --sw-off: light-dark(#828b95, #545c66);

  position: relative;
  display: inline-block;
  flex: none;
  width: var(--sw-w);
  height: var(--sw-h);
}

.switch input {
  position: absolute;
  inset: 0;
  z-index: 1;
  width: 100%;
  height: 100%;
  margin: 0;
  opacity: 0;
  cursor: pointer;
}

.switch__track {
  position: absolute;
  inset: 0;
  border-radius: calc(var(--sw-h) / 2);
  background: var(--sw-off);
  transition: background .3s var(--ease);
}

.switch__track::after {
  content: '';
  position: absolute;
  top: var(--sw-pad);
  left: var(--sw-pad);
  width: var(--sw-knob);
  height: var(--sw-knob);
  border-radius: calc(var(--sw-knob) / 2);
  background: light-dark(#fbfcfd, #eef1f5);
  box-shadow: 0 1px 3px rgb(0 0 0 / .28);
  transition: width .22s var(--ease), transform .3s var(--ease);
}

.switch input:checked + .switch__track { background: var(--accent); }

.switch input:checked + .switch__track::after {
  transform: translateX(calc(var(--sw-w) - var(--sw-knob) - var(--sw-pad) * 2));
}

/* The knob stretches while held, then settles — the original's one genuinely
   good idea, and the reason this reads as a physical switch rather than a
   checkbox wearing a costume. */
.switch input:active + .switch__track::after { width: var(--sw-squish); }
.switch input:checked:active + .switch__track::after {
  transform: translateX(calc(var(--sw-w) - var(--sw-squish) - var(--sw-pad) * 2));
}

.switch input:focus-visible + .switch__track {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

@media (prefers-reduced-motion: reduce) {
  .switch__track, .switch__track::after { transition: none; }
}

/* ── Settings rows ──────────────────────────────────────────────────── */

.setting {
  display: flex;
  align-items: flex-start;
  gap: 1.25rem;
  padding: 1.15rem 0;
  border-bottom: 1px solid var(--border);
}
.setting:last-of-type { border-bottom: 0; }

.setting-text { flex: 1; cursor: pointer; }
.setting-text b { display: block; font-weight: 600; margin-bottom: .2rem; }
.setting-text span { display: block; color: var(--text-2); font-size: .9rem; line-height: 1.5; }

/* The switch sits a little below the label's cap height so the two read as one
   row rather than as a heading with something floating beside it. */
.setting .switch { margin-top: .15rem; }

/* A setting whose control is too wide to sit beside its label — the three-way
   theme picker. Stacked rather than squeezed: three buttons in the column a
   switch occupies would each be about 40px, which is under the target size the
   rest of this panel was raised to. `cursor: default` because the text here
   labels a group rather than driving one control, so the pointer should not
   promise that clicking it does something. */
.setting--stack { flex-direction: column; align-items: stretch; gap: .8rem; }
.setting--stack .setting-text { cursor: default; }
.setting--stack .seg { margin: 0; }

/* ── Docs search ────────────────────────────────────────────────────────
   Adapted from a Uiverse.io search field by Lakshay-art (MIT).

   The original is six absolutely-positioned layers at `z-index: -1`, which
   only renders because nothing behind it paints a background. Dropped into a
   modal it vanishes entirely — negative z-index puts those layers behind the
   panel's own surface. Rebuilt here as a proper stacking context: an isolated
   wrapper, the gradient ring as 1.5px of padding, and the field painted on top.
   Six layers collapse to two.

   Other changes:

   - The source is SCSS pasted as CSS. Lines like `//animation: rotate 4s` are
     not comments in CSS; the parser discards them as bad declarations. Removed
     rather than left as litter.
   - Its `#poda:hover` block is written twice, the second silently overriding
     the first. Only one behaviour was ever reachable.
   - `.filterBorder::before` rotated on an infinite loop with a blurred conic
     gradient underneath — a permanent compositor job on every page that shows
     the control. It now animates only on hover and focus, and not at all under
     reduced-motion.
   - Fixed 301px width and hard-coded icon offsets became a flex row, so it
     survives a narrow modal.
   - Indigo and magenta re-graded onto the palette's green and amber.
   ─────────────────────────────────────────────────────────────────────── */

.docsearch {
  --ring-speed: 9s;
  /* The glow reads as light spill, which needs a dark field to be legible.
     On a light background the same intensity just looks like a smudge. */
  --glow-strength: light-dark(.3, .5);

  position: relative;
  isolation: isolate;
  padding: 1.5px;
  border-radius: 14px;
  margin-bottom: 1.5rem;
}

.docsearch__ring,
.docsearch__glow {
  position: absolute;
  border-radius: inherit;
  overflow: hidden;
  pointer-events: none;
  z-index: 0;
}

.docsearch__ring { inset: 0; }

.docsearch__glow {
  inset: -10px;
  border-radius: 24px;
  filter: blur(16px);
  opacity: var(--glow-strength);
  transition: opacity .35s var(--ease);
}

.docsearch__ring::before,
.docsearch__glow::before {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  width: 160%;
  aspect-ratio: 1;
  transform: translate(-50%, -50%) rotate(0deg);
  background: conic-gradient(
    transparent 0%,
    var(--accent) 11%,
    transparent 25%,
    transparent 52%,
    var(--warn) 64%,
    transparent 79%
  );
}

/* At rest the gradient holds still. Motion is a response to the pointer or the
   caret, not a thing the page does at everyone forever. */
:where(html[data-pointer="fine"]) .docsearch:hover .docsearch__ring::before,
:where(html[data-pointer="fine"]) .docsearch:hover .docsearch__glow::before {
  animation: docsearch-spin var(--ring-speed) linear infinite;
}

.docsearch:focus-within {
  --glow-strength: light-dark(.45, .75);
  --ring-speed: 4s;
}

.docsearch:focus-within .docsearch__ring::before,
.docsearch:focus-within .docsearch__glow::before {
  animation: docsearch-spin var(--ring-speed) linear infinite;
}

@keyframes docsearch-spin {
  to { transform: translate(-50%, -50%) rotate(360deg); }
}

.docsearch__inner {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  gap: .6rem;
  height: 50px;
  padding: 0 .5rem 0 .9rem;
  border-radius: 12.5px;
  background: var(--bg-deep);
}

.docsearch__inner > svg {
  flex: none;
  width: 19px;
  height: 19px;
  stroke: var(--text-3);
  fill: none;
  stroke-width: 2;
  stroke-linecap: round;
  transition: stroke .25s var(--ease);
}

.docsearch:focus-within .docsearch__inner > svg { stroke: var(--accent); }

.docsearch input {
  flex: 1;
  min-width: 0;
  background: none;
  border: 0;
  color: var(--text);
  font: inherit;
  font-size: 1rem;
  height: 100%;
}
.docsearch input::placeholder { color: var(--text-3); }
.docsearch input:focus { outline: none; }
/* Safari draws its own clear affordance on type=search; ours is a real button. */
.docsearch input::-webkit-search-decoration,
.docsearch input::-webkit-search-cancel-button { -webkit-appearance: none; }

.docsearch__clear {
  flex: none;
  display: grid;
  place-items: center;
  width: 34px;
  height: 34px;
  border: 0;
  border-radius: 9px;
  background: none;
  color: var(--text-3);
  cursor: pointer;
}
:where(html[data-pointer="fine"]) .docsearch__clear:hover { background: var(--surface-2); color: var(--text); }
.docsearch__clear svg { width: 16px; height: 16px; }
.docsearch__clear[hidden] { display: none; }

.docsearch__hint {
  flex: none;
  font-family: 'Geist Mono', monospace;
  font-size: .85rem;
  color: var(--text-3);
  border: 1px solid var(--border);
  border-radius: 5px;
  padding: .12rem .34rem;
  margin-right: .25rem;
}

@media (prefers-reduced-motion: reduce) {
  :where(html[data-pointer="fine"]) .docsearch:hover .docsearch__ring::before,
  :where(html[data-pointer="fine"]) .docsearch:hover .docsearch__glow::before,
  .docsearch:focus-within .docsearch__ring::before,
  .docsearch:focus-within .docsearch__glow::before { animation: none; }
}

/* ── Docs results ───────────────────────────────────────────────────── */

.doc-count { font-size: .87rem; color: var(--text-3); margin-bottom: 1rem; }

.doc-result { padding: 1rem 0; border-top: 1px solid var(--border); }
.doc-result:first-of-type { border-top: 0; padding-top: 0; }
.doc-result h3 { font-size: 1.02rem; margin-bottom: .3rem; }
.doc-result p { font-size: .93rem; color: var(--text-2); max-width: 72ch; }

.doc-kind {
  display: inline-block;
  font-size: .85rem;
  letter-spacing: .04em;
  text-transform: uppercase;
  color: var(--accent);
  background: var(--accent-quiet);
  border-radius: 4px;
  padding: .1rem .38rem;
  margin-bottom: .45rem;
}

.doc-result mark,
.lesson mark {
  background: var(--accent-quiet);
  color: var(--accent);
  border-radius: 3px;
  padding: 0 .1em;
}

.doc-empty { padding: 2.5rem 0 2rem; text-align: center; color: var(--text-2); }
.doc-empty strong { color: var(--text); display: block; margin-bottom: .35rem; }
.doc-empty .btn { margin-top: 1.25rem; }

.guide-foot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  margin-top: 2.25rem;
  padding-top: 1.5rem;
  border-top: 1px solid var(--border);
}
.guide-foot p { color: var(--text-2); font-size: .93rem; }

/* ── Landing bands ──────────────────────────────────────────────────── */

/*
 * One gap between sections, not three.
 *
 * This had `padding-block` and `margin-top` both at roughly 9vw, so the space
 * between two bands was the first one's padding-bottom, plus the second's
 * margin-top, plus the second's padding-top — 345px at 1280 wide with larger
 * text on, for a rule that is meant to read as a divider. The symptom was a
 * page that looked like it had lost its content: the step deck ended, and the
 * next heading was a screen and a half away.
 *
 * The margin is gone rather than reduced, because padding alone puts the border
 * exactly halfway between the two sections it separates, which is what a
 * section rule is for. With the margin there it sat at a third of the gap and
 * read as belonging to the section below it.
 */
.band { padding-block: clamp(3.5rem, 9vw, 6.5rem); }

/*
 * Alternating panels instead of hairline rules.
 *
 * A 1px border between sections is the weakest possible way to say "this is a
 * different subject", and on a page with no hue left it was doing that job
 * alone. Every other section now sits on a panel a shade off the page, which
 * gives the scroll a rhythm you can feel rather than one you have to look for.
 *
 * `nth-of-type` counts sections, and the closing call is a section that is not
 * a band, so the run is: the demo plain, the four steps panelled, the detail
 * articles plain, the questions panelled, the call plain.
 *
 * Inside the wrap rather than bleeding to the viewport edges. Full-bleed needs
 * `100vw` arithmetic, and `100vw` includes the scrollbar on every desktop
 * browser that still reserves space for one — which is a horizontal scrollbar
 * on the whole document, appearing only for people who have a vertical one.
 */
.band:nth-of-type(even) {
  background: var(--bg-deep);
  border-radius: var(--r-xl);
  padding-inline: clamp(1.25rem, 4vw, 3.25rem);
}
.band-head { max-width: 58ch; margin-bottom: 2.75rem; }
.band-head h2 { margin-top: .35rem; }
.band-head .lede { font-size: 1.08rem; margin-top: 1rem; }

.how-list { list-style: none; padding: 0; display: grid; gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: var(--r-lg); overflow: hidden; }
.how-list li { background: var(--surface); display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 1.25rem; padding: 1.6rem 1.5rem; align-items: start; }
/* A flat tinted disc with a digit in it was the cheapest possible answer, and
   looked it. It is now circled by hand — an overshooting pen loop that draws
   itself as the step arrives, in the same ink as the specimen in the hero.
   The disc stays underneath as the resting state, so a browser that never
   runs the animation still gets a legible, finished-looking number. */
.how-num {
  position: relative;
  width: 46px; height: 46px; flex: none; border-radius: 50%;
  display: grid; place-items: center;
  background: var(--accent-quiet); color: var(--accent);
  font-weight: 600; font-size: 1.15rem; font-family: 'Geist Mono', monospace;
}
.how-num b { font-weight: 600; }
.how-ring {
  position: absolute; inset: -5px;
  width: calc(100% + 10px); height: calc(100% + 10px);
  overflow: visible; pointer-events: none;
}
.how-ring path {
  fill: none;
  stroke: var(--accent);
  stroke-width: 2.6;
  stroke-linecap: round;
  stroke-dasharray: 1;
  stroke-dashoffset: 0;
}
/* Drawn on arrival, driven off the row's own reveal state so there is no
   second mechanism to keep in step. Resting offset is 0, so with the reveal
   switched off — or never started — the circle is simply there. */
.reveal-ready [data-reveal] .how-ring path { stroke-dashoffset: 1; }
.reveal-ready [data-reveal='in'] .how-ring path {
  animation: how-circle .62s cubic-bezier(.5, .05, .55, .95) .18s both;
}
@keyframes how-circle { from { stroke-dashoffset: 1; } }

@media (prefers-reduced-motion: reduce) {
  .reveal-ready [data-reveal] .how-ring path { stroke-dashoffset: 0; }
  .reveal-ready [data-reveal='in'] .how-ring path { animation: none; }
}
:root[data-lite='on'] .how-ring path,
:root[data-decor='off'] .how-ring path { stroke-dashoffset: 0; animation: none; }
.how-list h3 { margin-bottom: .35rem; }
.how-list p { color: var(--text-2); font-size: .96rem; max-width: 68ch; }

/*
 * Two columns or one, never three.
 *
 * auto-fit over minmax(310px, 1fr) fits three columns at 3*310 + 2*18 = 966px
 * and four only at 1240 + 54 = 1294px. The widest this grid ever gets is the
 * .wrap content box, 1108px — so above about 1038px of viewport it is locked at
 * exactly three columns and the fourth article is orphaned at every desktop
 * width there is, permanently, not just in a band.
 */
.detail-grid { display: grid; gap: 1rem; grid-template-columns: minmax(0, 1fr); }
/* Two columns need 2*310 + 18 = 638px. At 760px viewport the padding is
   clamp(18px, 30.4px, 36px) = 30.4px a side, leaving 699.2px. */
@media (min-width: 760px) {
  .detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.detail-grid article { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 1.6rem 1.5rem; }
.detail-grid h3 { margin-bottom: .7rem; color: var(--accent); }
.detail-grid p { font-size: .95rem; color: var(--text-2); }
.detail-grid p + p { margin-top: .7rem; }
.detail-grid b { color: var(--text); font-weight: 600; }
.detail-grid .mono { font-size: .88em; background: var(--surface-2); padding: .1rem .35rem; border-radius: 4px; }

/* `overflow: hidden` was here to clip the children to the rounded corners, and
   it clipped the keyboard focus ring off every question with it. The corners
   are now the children's own responsibility, so nothing needs clipping. */
.faq-list { display: grid; gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: var(--r-lg); }
.faq-item:first-child { border-radius: var(--r-lg) var(--r-lg) 0 0; }
.faq-item:last-child { border-radius: 0 0 var(--r-lg) var(--r-lg); }
.faq-item:only-child { border-radius: var(--r-lg); }
.faq-item summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: inherit; }
.faq-item { background: var(--surface); }
.faq-item summary {
  cursor: pointer; padding: 1.15rem 1.4rem; font-weight: 500;
  display: flex; align-items: center; justify-content: space-between; gap: 1rem;
  list-style: none;
}
.faq-item summary::-webkit-details-marker { display: none; }
.faq-item summary::after {
  content: ''; flex: none; width: 9px; height: 9px;
  border-right: 2px solid var(--text-3); border-bottom: 2px solid var(--text-3);
  transform: rotate(45deg) translate(-2px, -2px); transition: transform .2s var(--ease);
}
.faq-item[open] summary::after { transform: rotate(-135deg) translate(-3px, -3px); }
:where(html[data-pointer="fine"]) .faq-item summary:hover { background: var(--surface-2); }
.faq-item p { padding: 0 1.4rem 1.3rem; color: var(--text-2); font-size: .95rem; max-width: 74ch; }

.cta {
  text-align: center; padding-block: clamp(3.5rem, 9vw, 6rem);
  border-top: 1px solid var(--border); margin-top: clamp(3.5rem, 9vw, 6rem);
}
.cta h2 { margin-bottom: .6rem; }
.cta p { color: var(--text-2); margin-bottom: 2rem; }

/* ── Legal ──────────────────────────────────────────────────────────── */

.footer-links { display: flex; flex-wrap: wrap; gap: 1.1rem; margin-top: .6rem; }
/* --text-3 gave 3.09:1 here. These are small standalone links, not muted
   supporting copy, so they take the stronger secondary tone. */
.footer-links a { color: var(--text-2); font-size: .85rem; text-decoration: none; }
:where(html[data-pointer="fine"]) .footer-links a:hover { color: var(--accent); text-decoration: underline; }

.legal-summary {
  padding: .9rem 1.1rem; border-radius: var(--r-md);
  background: var(--accent-quiet); color: var(--text);
  font-size: .95rem; margin-bottom: 1.75rem;
}
.legal-section + .legal-section { margin-top: 1.75rem; }
.legal-section h3 { margin-bottom: .55rem; }

/* ── Folded articles ──
   Only the Terms folds — the threshold is section count, in app.js. */
.legal-tools {
  display: flex; flex-wrap: wrap; align-items: center; gap: .75rem 1rem;
  margin-bottom: 1.75rem; padding-bottom: 1.25rem;
  border-bottom: 1px solid var(--border);
}
.legal-tools__hint { font-size: .85rem; color: var(--text-3); }

details.legal-section {
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  background: var(--surface);
  overflow: hidden;
}
details.legal-section + details.legal-section { margin-top: .6rem; }
details.legal-section > summary {
  display: flex; align-items: center; justify-content: space-between; gap: 1rem;
  padding: .95rem 1.15rem;
  cursor: pointer;
  list-style: none;
  transition: background .16s var(--ease);
}
details.legal-section > summary::-webkit-details-marker { display: none; }
:where(html[data-pointer="fine"]) details.legal-section > summary:hover { background: var(--surface-2); }
/* The heading keeps its level for the outline; it just stops looking like a
   block heading now that it is the label on a control. */
details.legal-section > summary h3 { margin: 0; font-size: 1rem; }
details.legal-section > summary::after {
  content: ''; flex: none; width: 9px; height: 9px;
  border-right: 2px solid var(--text-2); border-bottom: 2px solid var(--text-2);
  transform: rotate(45deg) translate(-2px, -2px);
  transition: transform .28s var(--ease);
}
details.legal-section[open] > summary::after { transform: rotate(-135deg) translate(-3px, -3px); }
details.legal-section .fold-body { padding: 0 1.15rem 1.15rem; }
details.legal-section > summary:focus-visible {
  outline: 2px solid var(--accent); outline-offset: -2px;
}
.legal-section p { font-size: .94rem; color: var(--text-2); }
.legal-section p + p { margin-top: .6rem; }

/* Numbered clauses. The number sits in its own column so the prose keeps one
   left edge all the way down and a citation can be found by scanning rather
   than by reading. `ch` rather than `rem` because the column has to fit the
   widest number, and numbers are what it is measuring. */
.legal-section p.clause {
  display: grid;
  grid-template-columns: 3.4ch 1fr;
  gap: .9rem;
  align-items: baseline;
}
.legal-section p.clause > b {
  font-weight: 600;
  color: var(--text-3);
  font-variant-numeric: tabular-nums;
  font-size: .88rem;
}
.legal-section p.clause + p.clause { margin-top: .5rem; }
.legal-meta { margin-top: 2rem; padding-top: 1.25rem; border-top: 1px solid var(--border); font-size: .87rem; color: var(--text-3); }

/* ── Cards ──────────────────────────────────────────────────────────── */

.card {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--r-lg); padding: 1.35rem;
}
.card + .card { margin-top: 1rem; }
.card > h3 { margin-bottom: .8rem; }
.card > p { font-size: .93rem; }
.card > .btn { margin-top: 1rem; }

/* ── Write step ─────────────────────────────────────────────────────── */

.write-layout { display: grid; gap: 1.75rem; grid-template-columns: minmax(0, 1fr) 320px; align-items: start; }
@media (max-width: 900px) { .write-layout { grid-template-columns: 1fr; } }

.sheet-stack { display: grid; gap: 1.25rem; }

.paper {
  background: var(--paper); color: var(--ink);
  border-radius: var(--r-lg); padding: 1.6rem 1.5rem;
  box-shadow: var(--shadow-2);
  border: 1px solid rgb(0 0 0 / .1);
}
/*
 * The other three sheets, folded away.
 *
 * Deliberately not paper. It is a control that reveals paper, and dressing it
 * as a sheet would make the screen look like four sheets again — which is the
 * thing being fixed.
 */
.paper-more {
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  background: var(--surface);
  padding: 1.1rem 1.35rem;
}
.paper-more > summary {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 1rem; cursor: pointer; list-style: none;
  min-height: 44px; padding-block: .35rem;
}
.paper-more > summary::-webkit-details-marker { display: none; }
.paper-more__label { font-weight: 600; }
.paper-more__label::before {
  content: '+'; display: inline-block; width: 1.1em;
  font-family: 'Geist Mono', monospace; color: var(--text-3);
}
.paper-more[open] .paper-more__label::before { content: '−'; }
.paper-more > summary .paper-count { color: var(--text-3); }
:where(html[data-pointer="fine"]) .paper-more > summary:hover .paper-more__label { color: var(--text); }
.paper-more > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: var(--r-sm); }
/* One line above the four folds, not inside any of them — it is about all of
   them, and repeating it four times would be four times as much reading for
   the same fact. */
.paper-more__intro {
  font-size: .95rem; color: var(--text-2); max-width: 62ch;
  margin: .5rem 0 -.35rem;
}
/* The reason to bother, visible without opening the fold. Straight from
   charset.js's `blurb`, which is where the sheet's own purpose is recorded. */
.paper-more__why {
  font-size: .87rem; color: var(--text-3);
  margin: 0 0 1.1rem 1.1em;
}
.paper-more[open] .paper-more__why { margin-bottom: 1.35rem; }
.paper-more .paper { margin-top: 0; }

/* Scrolled to from a link that named it. A jump on a long document is
   ambiguous about which of the headings now on screen was the one asked for,
   so the answer marks itself for a couple of seconds. */
.legal-section.is-targeted {
  background: var(--accent-quiet);
  border-radius: var(--r-md);
  box-shadow: 0 0 0 .5rem var(--accent-quiet);
  transition: background .3s var(--ease), box-shadow .3s var(--ease);
}
@media (prefers-reduced-motion: reduce) { .legal-section.is-targeted { transition: none; } }

.paper-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; margin-bottom: 1.1rem; }
.paper-head h3 { font-size: 1.15rem; color: var(--ink); }
.paper-count { font-size: .87rem; color: #6b6459; font-family: 'Geist Mono', monospace; }
.paper-hint { font-size: .87rem; color: #6b6459; margin-bottom: 1.2rem; max-width: 60ch; }

.paper-row {
  display: flex; flex-wrap: wrap; gap: .35rem .5rem;
  padding-block: .55rem;
  border-bottom: 1px dashed rgb(0 0 0 / .13);
}
.paper-row:last-child { border-bottom: 0; }
.paper-cell {
  min-width: 2.1rem; height: 2.6rem;
  display: grid; place-items: center;
  font-size: 1.5rem; font-weight: 500;
  border-radius: var(--r-sm);
  background: rgb(0 0 0 / .035);
}
.paper-badge {
  display: inline-flex; align-items: center; gap: .4rem;
  font-size: .85rem; font-weight: 600; text-transform: uppercase; letter-spacing: .05em;
  color: #6b6459; background: rgb(0 0 0 / .06);
  padding: .2rem .55rem; border-radius: 999px;
}

.tips { list-style: none; padding: 0; display: grid; gap: .8rem; }
.tips li { display: flex; gap: .6rem; font-size: .92rem; color: var(--text-2); }
.tips li::before {
  content: ''; flex: none; width: 6px; height: 6px; margin-top: .6rem;
  border-radius: 50%; background: var(--accent);
}

/* ── Capture step ───────────────────────────────────────────────────── */

.capture-list { display: grid; gap: 1rem; }

.drop {
  display: grid; gap: 1rem; align-items: center;
  grid-template-columns: 64px minmax(0, 1fr) auto;
  padding: 1.25rem; border-radius: var(--r-lg);
  background: var(--surface); border: 1.5px dashed var(--border-strong);
  transition: border-color .18s var(--ease), background .18s var(--ease);
}
.drop.is-over { border-color: var(--accent); background: var(--accent-quiet); }
.drop.is-done { border-style: solid; border-color: var(--accent); }
.drop.is-optional { opacity: .82; }

.drop-thumb {
  width: 64px; height: 64px; border-radius: var(--r-md);
  background: var(--surface-2); display: grid; place-items: center;
  color: var(--text-3); overflow: hidden;
}
.drop-thumb svg { width: 24px; height: 24px; }
.drop.is-done .drop-thumb { color: var(--accent); }

.drop-body h3 { margin-bottom: .2rem; }
.drop-body p { font-size: .88rem; color: var(--text-2); }

/* ── Which sheet to do first ──
   Four identical rows read as four things that all have to be done before
   anything works, which is the ask people decline. One of them is marked, and
   the marked one is the one that alone makes a font. */
.drop-tier {
  display: inline-block; vertical-align: middle;
  margin-left: .1rem; padding: .12em .5em;
  font-size: .7em; font-weight: 600; letter-spacing: .02em;
  border-radius: 999px;
  background: var(--accent-quiet); color: var(--accent);
  border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
}
.drop-tier.is-quiet {
  background: var(--surface-2); color: var(--text-3);
  border-color: var(--border);
}
.drop.is-essential { border-color: var(--accent); opacity: 1; }
.drop.is-essential:not(.is-done) .drop-thumb { color: var(--accent); }
.drop-actions { display: flex; gap: .5rem; flex-wrap: wrap; }
@media (max-width: 700px) {
  .drop { grid-template-columns: 48px minmax(0, 1fr); }
  .drop-actions { grid-column: 1 / -1; }
}

/* ── Review step ────────────────────────────────────────────────────── */

.review-bar {
  display: flex; flex-wrap: wrap; gap: 1rem; align-items: center;
  padding: .9rem 1.15rem; margin-bottom: 1.5rem;
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-lg);
}
.stat { display: flex; align-items: baseline; gap: .45rem; }
.stat b { font-size: 1.35rem; font-weight: 600; letter-spacing: -.02em; }
.stat span { font-size: .87rem; color: var(--text-2); }
.stat.is-warn b { color: var(--warn); }
.stat.is-bad b { color: var(--danger); }

.glyph-grid {
  display: grid; gap: 6px;
  grid-template-columns: repeat(auto-fill, minmax(78px, 1fr));
}
.glyph {
  position: relative; aspect-ratio: 1;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--r-md); cursor: pointer; padding: 0;
  display: grid; place-items: center; overflow: hidden;
  transition: border-color .15s var(--ease), transform .12s var(--ease);
}
:where(html[data-pointer="fine"]) .glyph:hover { border-color: var(--accent); transform: translateY(-2px); z-index: 1; }
.glyph canvas { width: 100%; height: 100%; }
.glyph .label {
  position: absolute; left: 5px; top: 3px;
  font-size: .85rem; font-family: 'Geist Mono', monospace; color: var(--text-3);
}
.glyph.is-missing { border-style: dashed; background: var(--surface-2); }
/* The ::after that used to echo attr(data-ch) here is gone, not the .label.
   renderReview() appends a real <span class="label"> carrying the character to
   every cell, present or missing, so the ghost was a second copy of text that
   was already on screen — and at --text-3 over --surface-2 at half opacity it
   composited to #abb0b6 / #565c65, 1.95:1 light and 2.26:1 dark, which is a
   duplicate nobody could read anyway. The dashed border and --surface-2 fill
   above are what say "missing"; the .label says which. */
.glyph.is-flagged { border-color: var(--warn); }
/* Redraw, revealed on hover — and the reason data-pointer exists.
 *
 * `opacity: 0` hides an element from the eye and from nothing else. This button
 * is stretched across the bottom edge of every tile, so on a phone, where no
 * hover ever fires to explain it, the bottom strip of all 112 tiles was an
 * invisible control that opened the drawing pad when a thumb landed on it. The
 * screen it happens on is the review grid, which is the screen people scroll
 * furthest on a phone.
 *
 * So the base state is inert as well as transparent, and there are three ways
 * back:
 *
 *   - a pointer that hovers gets exactly what it had before;
 *   - a pointer that does not gets the button outright, slightly under full
 *     opacity so the glyph stays the subject of its own tile;
 *   - focus reveals it on any device, which is not optional. Scoping a reveal
 *     to pointer type strands the keyboard on every device the guess calls
 *     coarse, and :focus-within is what catches it, because the thing being
 *     focused is the button inside the tile.
 */
.glyph .redraw {
  position: absolute; inset: auto 0 0 0; padding: .2rem;
  font-size: .85rem; background: var(--accent); color: var(--accent-fg);
  opacity: 0; transition: opacity .15s var(--ease);
  pointer-events: none;
}
:where(html[data-pointer="fine"]) .glyph:hover .redraw { opacity: 1; pointer-events: auto; }
:where(html[data-pointer="coarse"]) .glyph .redraw { opacity: .9; pointer-events: auto; }
.glyph:focus-within .redraw,
.glyph .redraw:focus-visible { opacity: 1; pointer-events: auto; }

/* ── Refine step ────────────────────────────────────────────────────── */

.refine-layout { display: grid; gap: 1.5rem; grid-template-columns: minmax(0, 1fr) 330px; align-items: start; }
@media (max-width: 980px) { .refine-layout { grid-template-columns: 1fr; } }

.preview-pane { display: grid; gap: 1rem; }
.preview-toolbar { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; justify-content: space-between; }

.seg { display: inline-flex; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-md); padding: 3px; gap: 2px; }
.seg button {
  padding: .38rem .8rem; border: 0; border-radius: calc(var(--r-md) - 3px);
  background: none; color: var(--text-2); cursor: pointer; font-size: .87rem; font-weight: 500;
  transition: background .15s var(--ease), color .15s var(--ease);
}
:where(html[data-pointer="fine"]) .seg button:hover { color: var(--text); }
.seg button.is-on { background: var(--surface); color: var(--text); box-shadow: var(--shadow-1); }
.seg-wide { display: flex; flex-wrap: wrap; width: 100%; margin-bottom: 1rem; }
.seg-wide button { flex: 1; }

.preview-surface {
  position: relative; /* anchors the skeleton that stands in before first compile */
  background: var(--paper); border-radius: var(--r-lg);
  padding: clamp(1.5rem, 4vw, 2.75rem);
  min-height: 320px; box-shadow: var(--shadow-2);
  border: 1px solid rgb(0 0 0 / .1);
}
/* The surface is always paper-coloured, in either theme, so the skeleton on
   top of it has to be keyed to paper rather than to the page background. */
.preview-surface .skel { background: color-mix(in srgb, var(--ink) 11%, var(--paper)); }
.preview-surface .skel::after {
  background: linear-gradient(100deg, transparent 20%,
    color-mix(in srgb, var(--ink) 8%, transparent) 50%, transparent 80%);
}
.preview-text {
  color: var(--ink); font-size: 40px; line-height: 1.5;
  outline: none; min-height: 220px; word-break: break-word;
}
.preview-text:empty::before { content: attr(data-placeholder); color: rgb(0 0 0 / .28); }

.preview-samples { display: flex; flex-wrap: wrap; gap: .5rem; }
.chip {
  padding: .4rem .8rem; border-radius: 999px; cursor: pointer;
  background: var(--surface-2); border: 1px solid var(--border);
  color: var(--text-2); font-size: .88rem;
  transition: color .15s var(--ease), border-color .15s var(--ease);
}
:where(html[data-pointer="fine"]) .chip:hover { color: var(--text); border-color: var(--border-strong); }

.controls .field { display: grid; gap: .45rem; margin-bottom: 1.1rem; }
.controls .field:last-of-type { margin-bottom: 0; }
.field > span { display: flex; justify-content: space-between; font-size: .88rem; color: var(--text-2); }
.field output { font-family: 'Geist Mono', monospace; font-size: .87rem; color: var(--text); }
.field-inline { display: flex !important; align-items: center; gap: .7rem; margin: 0 !important; }
.field-inline > span { font-size: .85rem; }
/* Was a flat 130px. The toolbar wraps, so that never overflowed — it just left
   the slider stuck at its smallest useful size on the screen where precision
   matters most, while the row it sits in had width going spare. */
.field-inline input { flex: 1 1 130px; min-width: 110px; max-width: 220px; }

/* ── Skeleton ───────────────────────────────────────────────────────────
   Adapted from a Uiverse.io skeleton by Nawsome (MIT), as a reusable
   primitive rather than the original's one fixed card.

   Where it is used, and why only there. A skeleton is honest when content is
   genuinely coming, its shape is roughly known, and the wait is long enough to
   notice. Most waits in this app already show a determinate progress bar,
   which tells the user strictly more than a skeleton can, so replacing those
   would be a downgrade.

   The one place it earns its keep is the preview surface before the first
   compile finishes. Until the font exists the browser renders that text in a
   fallback face — so the user is shown a sentence in someone else's
   handwriting at the exact moment they are looking for their own. A
   placeholder is not merely prettier than that; it is the difference between
   saying nothing and saying something false.

   Changes from the original:

   - Every colour was hard-coded around #e3e3e3, including the shimmer, which
     is built from the same value as the background. On a dark palette that is
     a bright slab with an invisible sweep. All tokens now.
   - The card was 240x130 with each line absolutely positioned at fixed pixel
     offsets. A skeleton has to match the shape of whatever it stands in for,
     which fixed pixels cannot do. Rebuilt as flow layout.
   - Class names `.loader`, `.circle`, `.button`, `.line-1` are broad enough to
     collide with almost any stylesheet. Namespaced.
   - Skeletons are meaningless to a screen reader, which would otherwise meet a
     handful of empty divs. Hidden from the accessibility tree, with the state
     announced in words instead.
   ─────────────────────────────────────────────────────────────────────── */

.skel {
  display: block;
  position: relative;
  overflow: hidden;
  border-radius: 6px;
  background: var(--surface-3);
}

.skel::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(
    100deg,
    transparent 20%,
    color-mix(in srgb, var(--text) 9%, transparent) 50%,
    transparent 80%
  );
  animation: skel-sweep 1.25s linear infinite;
}

@keyframes skel-sweep {
  from { transform: translateX(-100%); }
  to { transform: translateX(100%); }
}

.skel-line { height: .68em; margin-bottom: .62em; }
.skel-circle { border-radius: 50%; aspect-ratio: 1; }

.skel-preview {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: .1rem;
  padding: inherit;
  font-size: inherit;
}
.skel-preview[hidden] { display: none; }

/* Static under lite mode and reduced motion: the shape still communicates
   "content is coming", which is the part that carries meaning. */
:root[data-lite='on'] .skel::after { animation: none; opacity: .5; }

@media (prefers-reduced-motion: reduce) {
  .skel::after { animation: none; opacity: .5; }
}

/* ── Indeterminate top bar ──────────────────────────────────────────────
   Adapted from a Uiverse.io loader by satyamchaudharydev (MIT). Used only
   where the duration genuinely is not knowable — lazy module loads, and the
   preview recompile behind the tuning sliders. Anywhere the app can measure
   progress it shows a real bar instead, because a determinate bar beats an
   indeterminate one every time.

   Reimplemented on transform rather than width, for two reasons.

   The original's keyframes end with `width: 0; right: 0; left: unset`.
   `left: 0` to `left: auto` is not an interpolable pair, so that final step is
   a discrete jump the browser resolves however it likes rather than the
   right-edge collapse it reads as. Switching transform-origin at an instant
   expresses the same intent deterministically.

   More importantly, width animations run on the main thread. This bar's whole
   job is to cover moments when the main thread is busy compiling a font — so
   a width animation would freeze during precisely the work it is reporting,
   which is worse than showing nothing. Transform and opacity animate on the
   compositor and keep moving while the main thread is blocked.
   ─────────────────────────────────────────────────────────────────────── */

.topload {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 3px;
  z-index: 100;
  background: color-mix(in srgb, var(--accent) 16%, transparent);
  overflow: hidden;
  pointer-events: none;
}

.topload[hidden] { display: none; }

.topload::before {
  content: '';
  position: absolute;
  inset: 0;
  background: var(--accent);
  transform-origin: left;
  animation: topload 1.05s ease-in-out infinite;
  will-change: transform;
}

@keyframes topload {
  0% { transform: scaleX(0); transform-origin: left center; }
  48% { transform: scaleX(1); transform-origin: left center; }
  /* Flip the anchor at an instant: origin does not interpolate meaningfully,
     and this is the collapse-to-the-right the original was reaching for. */
  49% { transform: scaleX(1); transform-origin: right center; }
  100% { transform: scaleX(0); transform-origin: right center; }
}

/* In lite mode the bar still appears — it carries information — it simply
   stops moving, and reads as a filled rule instead. */
:root[data-lite='on'] .topload::before,
.topload[data-static]::before { animation: none; transform: scaleX(1); opacity: .55; }

@media (prefers-reduced-motion: reduce) {
  .topload::before { animation: none; transform: scaleX(1); opacity: .55; }
}

/* ── Copy button ────────────────────────────────────────────────────────
   Adapted from a Uiverse.io copy button by Galahhad (MIT), sitting in the
   corner of the stylesheet snippet.

   The original decides whether to say "Copied!" using
   `:focus:not(:focus-visible)` — a selector that is true when a button was
   clicked with a mouse and false when it was activated from the keyboard.
   Three things follow from that, and all three are wrong:

     - Keyboard users never see the confirmation at all. Enter gives
       :focus-visible, so the tick never appears.
     - The state is bound to focus rather than to the copy. It persists as
       long as the button holds focus and vanishes on blur, whenever that is.
     - It cannot fail. Clipboard writes are refused often enough — denied
       permission, insecure context, no user gesture — and the button would
       cheerfully report success every time.

   State is driven from the actual Promise here, via data-state.

   Also fixed: `visibility: 0` is not a value that property accepts, so the
   tooltip was never hidden by it — only opacity was doing the work. And the
   transition shorthand referenced --tooltip-transition-duration, which the
   author had commented out, making the whole declaration invalid at
   computed-value time. Defined properly.

   Font and colours moved onto the theme, and the button has an accessible
   name, which an icon-only control cannot do without.
   ─────────────────────────────────────────────────────────────────────── */

.code-wrap { position: relative; }

.copybtn {
  position: absolute;
  top: 8px;
  right: 8px;
  width: 34px;
  height: 34px;
  display: grid;
  place-items: center;
  border: 1px solid var(--border);
  border-radius: 9px;
  background: var(--surface-2);
  color: var(--text-3);
  cursor: pointer;
  transition: background .18s var(--ease), color .18s var(--ease), border-color .18s var(--ease);
}

:where(html[data-pointer="fine"]) .copybtn:hover { background: var(--surface-3); color: var(--text); border-color: var(--border-strong); }
.copybtn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.copybtn[data-state='done'] { color: var(--accent); border-color: var(--accent); }
.copybtn[data-state='failed'] { color: var(--danger); border-color: var(--danger); }

.copybtn svg { width: 17px; height: 17px; }
.copybtn .tick,
.copybtn[data-state='done'] .sheetico,
.copybtn[data-state='failed'] .sheetico { display: none; }
.copybtn[data-state='done'] .tick,
.copybtn[data-state='failed'] .tick { display: block; }

.copybtn__tip {
  position: absolute;
  bottom: calc(100% + 9px);
  left: 50%;
  translate: -50% 4px;
  opacity: 0;
  visibility: hidden; /* the original said `0`, which this property rejects */
  white-space: nowrap;
  font-family: 'Geist Mono', ui-monospace, monospace;
  font-size: 11.5px;
  color: var(--bg);
  background: var(--text);
  padding: 5px 8px;
  border-radius: 6px;
  pointer-events: none;
  transition: opacity .16s var(--ease), translate .16s var(--ease);
}

.copybtn__tip::after {
  content: '';
  position: absolute;
  bottom: -3px;
  left: 50%;
  width: 7px;
  height: 7px;
  background: inherit;
  translate: -50% 0;
  rotate: 45deg;
}

:where(html[data-pointer="fine"]) .copybtn:hover .copybtn__tip,
.copybtn:focus-visible .copybtn__tip,
.copybtn[data-state='done'] .copybtn__tip,
.copybtn[data-state='failed'] .copybtn__tip {
  opacity: 1;
  visibility: visible;
  translate: -50% 0;
}

@media (prefers-reduced-motion: reduce) {
  .copybtn, .copybtn__tip { transition: none; }
}

/* ── Sliders ────────────────────────────────────────────────────────────
   Two Uiverse.io sliders, merged: the filled track from Galahhad (MIT) and the
   round thumb with the halo from satisshTechie (MIT).

   They could not simply be stacked. Galahhad's fill is a zero-width thumb
   casting an enormous one-sided box-shadow, clipped to the track by
   `overflow: hidden` on the input — and that clip eats any other shadow on the
   thumb, which is exactly what satisshTechie's halo is. Worse, the trick meant
   the control had no visible grab handle at all: you could see where the fill
   ended and nothing you could take hold of.

   So the fill is a background gradient instead, sized from a `--fill` custom
   property that src/slider.js keeps in step with the value. That is one
   declaration for every engine rather than a WebKit shadow trick plus a
   ::-moz-range-progress fallback that could drift from it, and it frees the
   thumb entirely.

   What changed from satisshTechie's original:
     · `background-color: #000000` on the thumb, twice — this palette has no
       pure black, and a black thumb is invisible on the dark theme. It takes
       the accent, ringed in --surface so it stays legible where it crosses its
       own fill.
     · `opacity: 0.7` on the whole control, which dimmed the track AND the
       thumb AND the halo together. Contrast is set per part instead.
     · `-webkit-transition: .2s` next to `transition: opacity .2s` — the first
       has no property, so it means `all`, and the two contradict each other.
     · No focus style at all. `appearance: none` removes the platform ring and
       the original never put one back, so the control was unusable by keyboard.
     · A 15px thumb on a 4px track. The input now carries a 2.2rem hit area
       with the track painted as a thin strip through the middle of it, so the
       target passes on touch without the track looking like a bar.
     · ::-moz-range-thumb had no border reset, so Firefox drew its own.
   ─────────────────────────────────────────────────────────────────────── */

input[type=range] {
  --track: 6px;
  --thumb: 18px;
  /* Only a starting point. src/slider.js sets the real value, and a slider
     whose script never ran still drags correctly — it just stops colouring. */
  --fill: 0%;

  -webkit-appearance: none;
  appearance: none;
  width: 100%;
  /* A real hit area, with the visible track painted as a strip through the
     middle of it. The old control was 6px tall in total, which fails every
     touch-target guideline there is. */
  height: 2.2rem;
  background:
    linear-gradient(90deg, var(--accent) 0 var(--fill), var(--surface-3) var(--fill) 100%)
    center / 100% var(--track) no-repeat;
  border-radius: 999px;
  cursor: pointer;
  transition: --fill .06s linear;
}

/* WebKit and Blink. */
input[type=range]::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: var(--thumb);
  height: var(--thumb);
  border-radius: 50%;
  background: var(--accent);
  /* Ringed, because for most of its travel the thumb sits on top of the
     accent-coloured fill it is marking the end of. */
  border: 2px solid var(--surface);
  cursor: pointer;
  transition: box-shadow .2s var(--ease), transform .12s var(--ease);
}

/* Firefox: the same, natively. The track is transparent so the gradient on the
   input itself shows through rather than being covered by it. */
input[type=range]::-moz-range-track {
  height: var(--track);
  background: transparent;
  border-radius: 999px;
}
input[type=range]::-moz-range-thumb {
  width: var(--thumb);
  height: var(--thumb);
  border-radius: 50%;
  background: var(--accent);
  border: 2px solid var(--surface);
  cursor: pointer;
  transition: box-shadow .2s var(--ease), transform .12s var(--ease);
}

/* The halo. This is what `overflow: hidden` used to make impossible. */
:where(html[data-pointer="fine"]) input[type=range]:hover::-webkit-slider-thumb,
input[type=range]:focus-visible::-webkit-slider-thumb {
  box-shadow: 0 0 0 8px color-mix(in srgb, var(--accent) 20%, transparent);
}
:where(html[data-pointer="fine"]) input[type=range]:hover::-moz-range-thumb,
input[type=range]:focus-visible::-moz-range-thumb {
  box-shadow: 0 0 0 8px color-mix(in srgb, var(--accent) 20%, transparent);
}
input[type=range]:active::-webkit-slider-thumb { transform: scale(1.12); }
input[type=range]:active::-moz-range-thumb { transform: scale(1.12); }

/* The platform ring is gone with `appearance: none`; this puts one back. */
input[type=range]:focus { outline: none; }
input[type=range]:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* A thicker track where a hover state cannot be relied on to reveal anything. */
@media (pointer: coarse) {
  input[type=range] { --track: 10px; --thumb: 22px; }
}

@media (prefers-reduced-motion: reduce) {
  input[type=range],
  input[type=range]::-webkit-slider-thumb,
  input[type=range]::-moz-range-thumb { transition: none; }
  input[type=range]:active::-webkit-slider-thumb,
  input[type=range]:active::-moz-range-thumb { transform: none; }
}

/* ── Hard-shadow text field ─────────────────────────────────────────────
   Adapted from a Uiverse.io input by anniekoop (MIT).

   The original is the exact mistake this sheet's palette note warns about,
   three times over: `border: 1.5px solid #000`, `box-shadow: 2.5px 3px 0 #000`
   and `outline: none`. Only the offset shadow comes across.

   The shadow colour is derived, not picked. An outer box-shadow paints below
   the element's background and border while an outline paints above it, so the
   focus ring is never broken — but it does cross the shadow along the right
   and bottom edges, and its contrast there is measured against the shadow
   rather than against the card. --accent over each candidate, light / dark:

     --border-strong   3.08 / 4.01   <- darkest that clears 3:1 in both
     --text            3.19 / 2.08   <- the #000 analogue; fails dark
     --text-2          1.26 / 1.12   <- fails both
     --text-3          1.03 / 1.24   <- fails both

   So --border-strong is as hard as this can get and stay legible. At 1.61:1
   light / 1.64:1 dark against --surface it is decoration; the 1px border is
   still what bounds the control.

   The step happens on :focus-visible rather than :focus, matching the ring, so
   a mouse click that already told you where you are does not also move the
   furniture.
   ─────────────────────────────────────────────────────────────────────── */
.input {
  width: 100%; padding: .55rem .75rem; border-radius: var(--r-md);
  background: var(--surface-2); border: 1px solid var(--border); color: var(--text);
  box-shadow: 3px 3px 0 var(--border-strong);
  transition: box-shadow .18s var(--ease), border-color .18s var(--ease);
}
/* Equal specificity, so source order settles a field that is both hovered and
   focused. Focus wins, which is correct. */
:where(html[data-pointer="fine"]) .input:hover { box-shadow: 4px 4px 0 var(--border-strong); }
.input:focus-visible { box-shadow: 5px 5px 0 var(--border-strong); }
/*
 * `outline: none` was here, which meant the app's only two text fields — the
 * font name and the feedback box — were the two controls with no focus ring.
 * A border that changes colour is not a substitute: it is one pixel, it is the
 * same colour as several resting states elsewhere, and it disappears entirely
 * for anyone who has forced their own colours on.
 *
 * :focus-visible rather than :focus, so the ring appears for the keyboard and
 * not for a mouse click that has already told the user where they are.
 */
.input:focus { border-color: var(--accent); }

/* ── Morphing checkbox ──────────────────────────────────────────────────
   Adapted from a Uiverse.io checkbox by SelfMadeSystem (MIT).

   One path draws either an envelope outline or a tick, depending on which
   stretch of it the dash pattern exposes. The numbers are not arbitrary and
   must not be rounded: pathLength normalises the path to 575.05 units, the
   rest state shows the first 241 of them, and the checked state shows a 70.51
   unit window starting 262.27 units in — which is exactly the V of the flap.
   Change any one of them and the tick stops being a tick.

   Easing, per request: a true exponential rather than `ease`.

     f(t) = 1 − 2⁻¹⁰ᵗ

   sampled into linear(). cubic-bezier(.16, 1, .3, 1) is the usual stand-in for
   this curve, but it only approximates it — linear() can express the real
   function. Half the distance is covered in the first tenth of the duration,
   which is what makes it read as immediate rather than as something being
   played back at you.

   The two directions are not symmetrical. Ticking is a decision and gets the
   full exponential at 320ms; unticking is a correction and gets a calmer curve
   with a little longer to settle. Identical timing both ways is what makes a
   toggle feel like a slideshow.

   Fixed from the original: `input { display: none }` — the fourth component in
   this app to ship that — which removes the control from the accessibility
   tree and makes it unreachable by keyboard. Hidden but focusable now, with a
   focus ring. Stroke was hard-coded white; it takes the palette.
   ─────────────────────────────────────────────────────────────────────── */

.check {
  --ease-expo: linear(
    0, 0.2929 5%, 0.5 10%, 0.6464 15%, 0.75 20%, 0.8232 25%, 0.875 30%,
    0.9375 40%, 0.9688 50%, 0.9844 60%, 0.9922 70%, 0.9972 85%, 1
  );

  display: flex;
  align-items: center;
  gap: .65rem;
  margin-top: 1rem;
  font-size: .9rem;
  color: var(--text-2);
  cursor: pointer;
}

/* Hidden from view, still in the accessibility tree and still tabbable. */
.check input {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.check svg {
  flex: none;
  width: 1.35em;
  height: 1.35em;
  overflow: visible;
  border-radius: 5px;
}

.check .path {
  fill: none;
  stroke: var(--text-3);
  stroke-width: 6;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-dasharray: 241 9999999;
  stroke-dashoffset: 0;
  /* Unticking: calmer, and given longer to settle. */
  transition:
    stroke-dasharray .42s cubic-bezier(.33, 1, .68, 1),
    stroke-dashoffset .42s cubic-bezier(.33, 1, .68, 1),
    stroke .25s var(--ease);
}

.check input:checked ~ svg .path {
  stroke: var(--accent);
  stroke-dasharray: 70.5096664428711 9999999;
  stroke-dashoffset: -262.2723388671875;
  /* Ticking: the exponential, and faster. */
  transition:
    stroke-dasharray .32s var(--ease-expo),
    stroke-dashoffset .32s var(--ease-expo),
    stroke .18s var(--ease);
}

:where(html[data-pointer="fine"]) .check:hover .path { stroke: var(--text-2); }
:where(html[data-pointer="fine"]) .check:hover input:checked ~ svg .path { stroke: var(--accent-hover); }

.check input:focus-visible ~ svg {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

@media (prefers-reduced-motion: reduce) {
  .check .path,
  .check input:checked ~ svg .path { transition: stroke .15s linear; }
}

/* Health report */
.score { display: flex; align-items: center; gap: .9rem; margin-bottom: 1rem; }
.score-ring { width: 52px; height: 52px; flex: none; position: relative; }
.score-ring svg { width: 100%; height: 100%; transform: rotate(-90deg); stroke-width: 4; }
.score-ring .track { stroke: var(--surface-3); }
.score-ring .fill { stroke: var(--accent); transition: stroke-dashoffset .5s var(--ease); }
.score-ring b { position: absolute; inset: 0; display: grid; place-items: center; font-size: .95rem; font-weight: 600; }
.score-text b { display: block; font-size: 1rem; }
.score-text span { font-size: .85rem; color: var(--text-2); }
.score.is-ok .fill { stroke: var(--warn); }
.score.is-bad .fill { stroke: var(--danger); }

/* ââ Findings âââââââââââââââââââââââââââââââââââââââ
   A restyle of what was already here, not a second notification channel.

   The component this came from is a loading-spinner button, with the note
   "notification ish". Only its proportions are borrowed: a leading indicator,
   then a line of text, at a size you can read without leaning in.

   The spin is not borrowed. A spinner is a claim that something is happening,
   and every statement this element carries is about a measurement that already
   finished.

   The class names are deliberately unchanged. `.finding` is also named in the
   unfold entrance, its stagger delay, the reduced-motion list and the lite
   list in this sheet, and in TARGETS in src/reveal.js. Renaming it would have
   silently dropped the entrance animation and left four stale selectors
   behind. It is the same element in three more places instead.

   Measured against :root; every home paints var(--surface).
     is-info   --surface-2   : --text 14.43 light / 12.47 dark,
                               --text-2 5.70 / 6.72, icon --text-3 4.68 / 4.84
     is-warn   --warn-quiet  : --text 14.36 / 11.85,
                               --text-2 5.67 / 6.39, icon --warn 4.32 / 6.43
     is-error  --danger-quiet: --text 13.63 / 12.64,
                               --text-2 5.38 / 6.81, icon --danger 4.51 / 5.03
   The body tier is --text-2 and never --text-3, because --text-3 on
   --danger-quiet is 4.42:1 â under the floor on exactly the tone that
   carries the worst news.
   âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */

.findings { list-style: none; padding: 0; margin: 0; display: grid; gap: .7rem; }

.finding {
  display: flex; gap: .7rem;
  padding: .8rem .9rem;
  border-radius: var(--r-md);
  background: var(--surface-2);
}

/* rem rather than the fixed 16px it was, so --ui-scale still governs: 1.25rem
   is 22.5px at the default scale and 25px at large. A child combinator, so an
   icon inside .chars one day cannot inherit this. */
.finding > svg { flex: none; width: 1.25rem; height: 1.25rem; margin-top: .14rem; }

.finding.is-error { background: var(--danger-quiet); }
.finding.is-error > svg { color: var(--danger); }
.finding.is-warn { background: var(--warn-quiet); }
.finding.is-warn > svg { color: var(--warn); }
.finding.is-info > svg { color: var(--text-3); }

.finding b { display: block; font-size: 1rem; font-weight: 600; line-height: 1.3; }
.finding p { font-size: .95rem; color: var(--text-2); margin-top: .2rem; line-height: 1.5; }

.finding .chars {
  display: flex; flex-wrap: wrap; gap: .25rem; margin-top: .5rem;
  font-family: 'Geist Mono', monospace; font-size: .9rem;
}
.finding .chars span { padding: .05rem .35rem; border-radius: 4px; background: rgb(128 128 128 / .18); }
/* The "+N more" that finding() appends when the chip list is capped. Not a
   chip: no tint, and out of the mono face the characters are set in, so it
   reads as a note about the row rather than as one more character in it.
   --text-2 clears the floor on all three finding tones — 5.70 / 6.72 on
   --surface-2, and the sheet's own figures for --warn-quiet and --danger-quiet
   are 5.67 / 6.39 and 5.38 / 6.81. */
.finding .chars .chars__more {
  background: none; padding-inline: .15rem;
  font-family: 'Geist', system-ui, -apple-system, 'Segoe UI', sans-serif;
  color: var(--text-2);
}

/* The capture row is a three-column grid collapsing to two, so without this the
   list would land inside the 64px thumbnail column. 1 / -1 spans both shapes. */
.drop .findings { grid-column: 1 / -1; margin-top: .25rem; }

/* ── Export step ────────────────────────────────────────────────────── */

.export-layout { display: grid; gap: 1.5rem; grid-template-columns: minmax(0, 1fr) 340px; align-items: start; }
@media (max-width: 980px) { .export-layout { grid-template-columns: 1fr; } }
.export-main { display: grid; gap: 1rem; }

.download-card {
  display: flex; flex-wrap: wrap; gap: 1.25rem; align-items: center; justify-content: space-between;
  padding: 1.6rem; border-radius: var(--r-lg);
  background: linear-gradient(135deg, var(--accent-quiet), var(--surface));
  border: 1px solid var(--accent);
}
.download-card h3 { font-size: 1.25rem; margin-bottom: .25rem; }

.dl-row { display: grid; gap: .5rem; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
.dl-item {
  display: flex; align-items: center; justify-content: space-between; gap: .75rem;
  padding: .7rem .9rem; border-radius: var(--r-md);
  background: var(--surface); border: 1px solid var(--border);
  cursor: pointer; text-align: left;
}
:where(html[data-pointer="fine"]) .dl-item:hover { border-color: var(--accent); }
.dl-item b { font-weight: 500; font-size: .93rem; }
.dl-item span { font-size: .86rem; color: var(--text-3); font-family: 'Geist Mono', monospace; }

.install-steps { padding-left: 1.15rem; display: grid; gap: .5rem; font-size: .93rem; color: var(--text-2); }
.install-steps li::marker { color: var(--accent); font-weight: 600; }

.summary dl { display: grid; grid-template-columns: 1fr auto; gap: .55rem 1rem; font-size: .9rem; }
.summary dt { color: var(--text-2); }
.summary dd { font-family: 'Geist Mono', monospace; text-align: right; }

.code {
  background: var(--bg-deep); border: 1px solid var(--border); border-radius: var(--r-md);
  padding: .85rem; font-size: .86rem; line-height: 1.55; overflow-x: auto;
  max-height: 190px; color: var(--text-2); white-space: pre;
}

/* ── Modals, busy, toasts ───────────────────────────────────────────── */

.sheet-modal {
  position: fixed; inset: 0; z-index: 60;
  background: rgb(6 8 11 / .68); backdrop-filter: blur(6px);
  display: grid; place-items: center; padding: 1.25rem;
  animation: fade .2s var(--ease);
}
@keyframes fade { from { opacity: 0 } to { opacity: 1 } }
.sheet-modal[hidden] { display: none; }
.sheet-modal-panel {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--r-xl); box-shadow: var(--shadow-3);
  width: min(760px, 100%); max-height: min(86dvh, 900px);
  display: flex; flex-direction: column; overflow: hidden;
}
.sheet-modal-panel.narrow { width: min(460px, 100%); }
.modal-head {
  display: flex; align-items: center; justify-content: space-between; gap: 1rem;
  padding: 1.1rem 1.35rem; border-bottom: 1px solid var(--border);
}
.modal-head h2 { font-size: 1.2rem; }
/* `overscroll-behavior: contain` stops a gesture that reaches the bottom of the
   Terms from silently handing the rest of itself to the page behind, which left
   the reader somewhere they never navigated to once they closed the dialogue.
   The page lock below is the other half of the same problem. */
.modal-body { padding: 1.35rem; overflow-y: auto; overscroll-behavior: contain; }
/* Focusable so PageDown reaches the document rather than the page behind it —
   these dialogues hold nothing else focusable. Inset so the ring is not clipped
   by the panel's own `overflow: hidden`. */
.modal-body[tabindex]:focus { outline: none; }
.modal-body[tabindex]:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }

.lesson + .lesson { margin-top: 1.75rem; padding-top: 1.75rem; border-top: 1px solid var(--border); }
.lesson { scroll-margin-block-start: .75rem; }
.lesson h3 { margin-bottom: .6rem; }

/*
 * A permalink beside each lesson heading, so /guide/pen is something a reader
 * can obtain rather than only something the router understands.
 *
 * Always visible rather than revealed on hover: a control that exists only
 * under a pointer does not exist at all on a touch screen. Because it neither
 * appears nor moves, there is nothing in these rules for prefers-reduced-motion,
 * :root[data-lite='on'] or :root[data-decor='off'] to suppress — the honest
 * way to satisfy all three is to have no motion to switch off. The lesson it
 * sits in still carries the existing `--enter: settle` entry animation, which
 * those switches already handle above.
 *
 * Contrast, computed from the tokens against --surface, which is what
 * .sheet-modal-panel is painted with. --text-3 #656d76 on #f6f8fa is 4.93:1;
 * #8a929c on #1a1e24 is 5.32:1. The --accent hover state is now the ink itself,
 * #1c2128 on #f6f8fa at 15.20:1 and #e6e9ee on #1a1e24 at 13.75:1 — it used to
 * be green at 4.94:1 and 6.59:1, so the hover got considerably stronger when
 * the accent stopped being a hue. All four clear the 4.5:1 text floor and so
 * also the 3:1 floor that applies to the glyph as a graphic.
 *
 * text-decoration is cleared explicitly: there is no base `a` rule in this
 * stylesheet, so an anchor otherwise arrives underlined and browser-blue.
 */
.lesson-link {
  display: inline-flex;
  align-items: center;
  margin-left: .4rem;
  color: var(--text-3);
  text-decoration: none;
  vertical-align: baseline;
}
.lesson-link svg { width: .78em; height: .78em; }
:where(html[data-pointer="fine"]) .lesson-link:hover,
.lesson-link:focus-visible { color: var(--accent); }
.lesson p { font-size: .95rem; color: var(--text-2); }
.lesson p + p { margin-top: .6rem; }
.lesson .tips { margin-top: .9rem; }

.busy {
  position: fixed; inset: 0; z-index: 80;
  background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(8px);
  display: grid; place-items: center;
}
.busy[hidden] { display: none; }
.busy-panel { text-align: center; width: min(340px, 84vw); }
.busy-stage { font-size: 1.06rem; font-weight: 500; margin-bottom: 1rem; }
.busy-eta {
  font-size: .85rem; color: var(--text-2); margin-top: .7rem;
  min-height: 1.3em; /* reserved, so the panel does not jump when it appears */
}
.busy-note { font-size: .87rem; color: var(--text-3); margin-top: .5rem; }

/* The Stop control on the busy panel.
 *
 * It borrows .btn for its box and overrides one thing: the border. There is no
 * card behind this panel, only the blurred page, and .btn's own --border edge
 * measures 1.22:1 against --bg in light (#dce0e5 on #f4f5f7) and 1.36:1 in dark
 * (#2a3038 on #14161a) — 1.18:1 and 1.14:1 against the button's own --surface-2
 * fill. No boundary a reader could find, in either theme.
 *
 * --text-3 gives that edge 4.81:1 in light (#656d76 on #f4f5f7) and 5.76:1 in
 * dark (#8a929c on #14161a), and 4.68:1 / 4.83:1 against the --surface-2 fill.
 * The label is --text on --surface-2: 14.43:1 light, 12.47:1 dark.
 *
 * The :hover line is not cosmetic and must not be dropped. .btn:hover:not(
 * :disabled) swaps the edge to --border-strong, which is 1.51:1 light and
 * 1.78:1 dark against --bg, and 1.36:1 / 1.31:1 against the --surface-3 hover
 * fill — the border would vanish at exactly the moment the pointer is on it.
 * Holding --text-3 through hover keeps the edge at 4.31:1 light and 4.23:1 dark
 * over --surface-3, and the label at 13.29:1 / 10.90:1. It has the same
 * specificity as .btn:hover:not(:disabled) — (0,3,0) both — so it wins only by
 * being later in this file. Do not move these rules above the button block.
 *
 * Every figure above is computed from the hex pairs in :root, both branches of
 * every light-dark() involved. The overlay is
 * color-mix(in srgb, var(--bg) 88%, transparent) over blurred page chrome, so
 * the real composite behind this button is a few percent off --bg and is not
 * something this sheet can state exactly — unmeasured, and the reason the
 * border was taken well past 3:1 rather than to it.
 *
 * `display: none` has to be restated because .btn sets `display: inline-flex`,
 * which beats the user-agent rule the `hidden` attribute relies on — the same
 * reason `.busy[hidden]` exists a few lines above.
 *
 * Nothing here animates. The transition inherited from .btn is already clamped
 * by the blanket `* { transition-duration: .01ms !important }` under
 * prefers-reduced-motion, and by the :root[data-lite='on'] rule that carries
 * the same transition-duration clamp alongside its animation caps. There is no
 * decoration for :root[data-decor='off'] to remove.
 */
.busy-cancel { margin-top: 1.15rem; border-color: var(--text-3); }
:where(html[data-pointer="fine"]) .busy-cancel:hover:not(:disabled) { border-color: var(--text-3); }
.busy-cancel[hidden] { display: none; }

/* Already pressed. Deliberately not dimmed: .btn:disabled's opacity: .45 would
   pull the label off the ratios measured above, and it is the changed wording,
   not a faded box, that says the press was heard. */
.busy-cancel[aria-disabled='true'] { cursor: default; }
:where(html[data-pointer="fine"]) .busy-cancel[aria-disabled='true']:hover { background: var(--surface-2); border-color: var(--text-3); }

.bar { height: 4px; border-radius: 999px; background: var(--surface-3); overflow: hidden; }
.bar-fill { height: 100%; width: 0; background: var(--accent); border-radius: 999px; transition: width .25s var(--ease); }

/* ── Arc loader ─────────────────────────────────────────────────────────
   Concentric arcs adapted from a Uiverse.io loader by mobinkakei (MIT); the
   per-letter text wave from a loader by joao-canais (MIT).

   Changes worth naming:

   - The original hangs everything off `#wifi-loader`, an ID. A loader is a
     component and may legitimately appear more than once, so it is a class.
   - Its markup carries a third `<circle class="new">` that no rule ever
     styles — it inherits `fill: none` with no stroke and paints nothing.
     Removed.
   - `svg circle { position: absolute }` does nothing; `position` does not
     apply to SVG geometry elements. Dropped.
   - The label was driven by `content: attr(data-text)` on two pseudo-elements
     with a clip-path sweep. That cannot work here: the stage text changes as
     the pipeline advances, and pseudo-element content is unreachable to screen
     readers. Rebuilt as real spans.
   - The reference wave hard-codes thirteen `nth-child` delays, which caps it
     at thirteen characters. Each span carries its index in --i instead, so it
     fits any label.
   - Letters are aria-hidden with the plain string exposed alongside them.
     Without that a screen reader announces "R, e, a, d, i, n, g" one letter
     at a time.
   ─────────────────────────────────────────────────────────────────────── */

.arcloader {
  position: relative;
  width: 86px;
  height: 86px;
  margin: 0 auto 1.6rem;
  display: grid;
  place-items: center;
}

.arcloader svg {
  position: absolute;
  overflow: visible;
}

.arcloader circle {
  fill: none;
  stroke-width: 6px;
  stroke-linecap: round;
  stroke-linejoin: round;
  transform: rotate(-100deg);
  transform-origin: center;
}

.arcloader .back { stroke: var(--surface-3); }
.arcloader .front { stroke: var(--accent); }

.arcloader .outer { width: 86px; height: 86px; }
.arcloader .outer circle { stroke-dasharray: 62.75 188.25; }
.arcloader .outer .back { animation: arc-outer 1.8s ease infinite .3s; }
.arcloader .outer .front { animation: arc-outer 1.8s ease infinite .15s; }

.arcloader .middle { width: 60px; height: 60px; }
.arcloader .middle circle { stroke-dasharray: 42.5 127.5; }
.arcloader .middle .back { animation: arc-middle 1.8s ease infinite .25s; }
.arcloader .middle .front { animation: arc-middle 1.8s ease infinite .1s; }

.arcloader .inner { width: 34px; height: 34px; }
.arcloader .inner circle { stroke-dasharray: 22 66; }
.arcloader .inner .back { animation: arc-inner 1.8s ease infinite .2s; }
.arcloader .inner .front { animation: arc-inner 1.8s ease infinite .05s; }

@keyframes arc-outer {
  0% { stroke-dashoffset: 25; }
  25% { stroke-dashoffset: 0; }
  65% { stroke-dashoffset: 301; }
  80%, 100% { stroke-dashoffset: 276; }
}
@keyframes arc-middle {
  0% { stroke-dashoffset: 17; }
  25% { stroke-dashoffset: 0; }
  65% { stroke-dashoffset: 204; }
  80%, 100% { stroke-dashoffset: 187; }
}
@keyframes arc-inner {
  0% { stroke-dashoffset: 9; }
  25% { stroke-dashoffset: 0; }
  65% { stroke-dashoffset: 106; }
  80%, 100% { stroke-dashoffset: 97; }
}

/* The label wave. Delay comes from --i, set per character in JS. */
.shimmer { display: inline-block; }
.shimmer__ch {
  display: inline-block;
  opacity: .38;
  animation: shimmer-wave 2.1s ease-in-out infinite;
  animation-delay: calc(var(--i) * 55ms);
}

@keyframes shimmer-wave {
  0%, 100% { opacity: .38; }
  18% { opacity: 1; text-shadow: 0 0 9px color-mix(in srgb, var(--accent) 55%, transparent); }
  42% { opacity: .68; text-shadow: none; }
}

.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap;
}

.field-hint {
  margin-top: .5rem;
  font-size: .87rem;
  color: var(--text-2);
  line-height: 1.5;
}

@media (prefers-reduced-motion: reduce) {
  .arcloader circle { animation: none; }
  .arcloader .front { stroke-dashoffset: 0; }
  .shimmer__ch { animation: none; opacity: 1; }
}

/* ── Lite mode ──────────────────────────────────────────────────────────
   One switch that strips the decoration: the rotating conic gradients, the
   blurred glows, the backdrop filters, the loader arcs, the letter wave.
   Everything here is ornament — nothing it removes carries meaning.

   Deliberately scoped to presentation. It does not touch tracing precision,
   variant count or kerning, because a setting called "lite" quietly producing
   a worse font would be a trap: the user cannot see the difference until after
   they have installed it.
   ─────────────────────────────────────────────────────────────────────── */

:root[data-lite='on'] *,
:root[data-lite='on'] *::before,
:root[data-lite='on'] *::after {
  animation-duration: .01ms !important;
  animation-iteration-count: 1 !important;
  transition-duration: .01ms !important;
}

/* Blur is the expensive part on weak GPUs, so it goes rather than freezes. */
:root[data-lite='on'] .docsearch__glow { display: none; }
:root[data-lite='on'] .busy { backdrop-filter: none; }
:root[data-lite='on'] .topbar { backdrop-filter: none; background: var(--bg); }
:root[data-lite='on'] .shimmer__ch { opacity: 1; }
:root[data-lite='on'] .arcloader .front { stroke-dashoffset: 0; }
:root[data-lite='on'] .inkbtn__blobs { display: none; }

/* With transitions cut to nothing the arrow would snap 5px sideways on hover
   instead of sliding. Lite mode is meant to be calmer, not twitchier. */
:where(html[data-pointer="fine"]):root[data-lite='on'] .contbtn:hover:not(:disabled) svg,
:root[data-lite='on'] .contbtn:focus-visible:not(:disabled) svg { transform: none; }

/* It sits in the footer nav beside the legal links and had no rules of its own,
   so it fell through to the browser's default button styling — 4.37:1 in dark,
   and a different size and weight from the links it is sitting in a row with.
   Matched to them. */
.lite-toggle {
  background: none; border: 0; padding: 0; cursor: pointer;
  color: var(--text-2); font-size: .85rem;
}
:where(html[data-pointer="fine"]) .lite-toggle:hover { color: var(--text); }
.lite-toggle[aria-pressed='true'] { color: var(--accent); }
:where(html[data-pointer="fine"]) .lite-toggle[aria-pressed='true']:hover { color: var(--accent-hover); }

.toasts { position: fixed; right: 1rem; bottom: 1rem; z-index: 90; display: grid; gap: .5rem; }
.toast {
  display: flex; align-items: center; gap: .6rem;
  padding: .7rem 1rem; border-radius: var(--r-md);
  background: var(--surface); border: 1px solid var(--border);
  box-shadow: var(--shadow-2); font-size: .9rem;
  animation: rise .22s var(--ease) both;
}
.toast svg { width: 16px; height: 16px; color: var(--accent); }
.toast.is-bad svg { color: var(--danger); }

.footer { border-top: 1px solid var(--border); padding-block: 1.75rem; margin-top: auto; }
.footer p { font-size: .85rem; color: var(--text-3); }
.footer-inner { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; justify-content: space-between; }

.fb-details { margin-top: 1rem; }
.fb-details summary { cursor: pointer; font-size: .88rem; color: var(--text-2); margin-bottom: .6rem; }
:where(html[data-pointer="fine"]) .fb-details summary:hover { color: var(--text); }
.fb-actions { display: flex; flex-wrap: wrap; gap: .6rem; margin-top: 1.25rem; }
textarea.input { resize: vertical; min-height: 90px; line-height: 1.5; }

@media print { .topbar, .footer, .step-actions, .busy, .toasts { display: none !important; } }


/* ── Copy link → Copied ─────────────────────────────────────────────────
   Adapted from a Uiverse.io copy button by fabiodevbr (MIT).

   The original drove the whole thing off `:focus`, which fires on any focus —
   including a Tab that copied nothing — and had no way to say "that failed".
   State lives on `data-state`, set from the clipboard promise, matching what
   #copy-css already does. The two labels were `position: absolute` with no
   positioned ancestor, so they resolved against the page; they share one grid
   cell here instead, which also means the button cannot resize mid-swap.

   The widest label is the idle one — "Copy link to this app" at 20 characters
   against "Could not copy" at 14 — so the resting state sets the box and no
   outcome can widen it.
   ─────────────────────────────────────────────────────────────────────── */

.applink-wrap { display: grid; gap: .5rem; margin-top: .9rem; justify-items: start; }
.applink { position: relative; }
/* `.btn { display: inline-flex }` is an author declaration and outranks the UA
   stylesheet's `[hidden] { display: none }` by cascade origin. */
.applink[hidden] { display: none; }

.applink__swap { display: grid; overflow: hidden; }
.applink__swap > span {
  grid-area: 1 / 1;
  white-space: nowrap;
  transition: opacity .2s var(--ease), translate .2s var(--ease);
}
.applink__done { opacity: 0; translate: 0 100%; }

.applink[data-state='done'] .applink__idle,
.applink[data-state='failed'] .applink__idle { opacity: 0; translate: 0 -100%; }
.applink[data-state='done'] .applink__done,
.applink[data-state='failed'] .applink__done { opacity: 1; translate: 0 0; }

.applink[data-state='done'] { color: var(--accent); border-color: var(--accent); }
.applink[data-state='failed'] { color: var(--danger); border-color: var(--danger); }

/* Two classes deliberately: `.applink__ico` alone is (0,1,0) and loses to the
   sheet's `.btn svg { width: 1.05em }` at (0,1,1), because this is
   `class="btn applink"`. */
.applink .applink__ico { width: 17px; height: 17px; flex: none; }

.applink__url {
  min-width: min(100%, 22rem);
  font-family: 'Geist Mono', ui-monospace, monospace;
  font-size: .85rem;
}
.applink__url[hidden] { display: none; }

@media (prefers-reduced-motion: reduce) {
  .applink__swap > span { transition: none; }
}

/* ── Share button ───────────────────────────────────────────────────────
   Adapted from a Uiverse.io share button by SalladShooter (MIT).

   The original's scale-on-hover reflowed its neighbours in this flex row, its
   `outline: 0` on hover deleted the focus ring mid-hover, and its fixed
   #212121-on-white field could not follow the theme. The pill, the icon-and-
   label row and the soft bloom on hover are what survives.
   ─────────────────────────────────────────────────────────────────────── */

.sharebtn {
  display: inline-flex;
  align-items: center;
  gap: .6rem;
  padding: .72rem 1.3rem;
  border: 2px solid var(--accent);
  border-radius: 50px;
  /* Transparent, not --surface: its neighbour .dlbtn sits transparent over the
     card's own gradient, and an opaque pill beside a transparent one shows. */
  background: transparent;
  font: inherit;
  font-weight: 500;
  color: var(--text);
  cursor: pointer;
  transition: background .28s var(--ease), border-color .28s var(--ease),
              box-shadow .28s var(--ease);
}

/* Needed because an author `display` declaration outranks the UA stylesheet's
   `[hidden] { display: none }` by cascade origin — not by source order. */
.sharebtn[hidden] { display: none; }

:where(html[data-pointer="fine"]) .sharebtn:hover:not(:disabled) {
  background: var(--accent-quiet);
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 18%, transparent);
}
.sharebtn:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.sharebtn:disabled { cursor: default; }

.sharebtn__ico { display: grid; place-items: center; flex: none; color: var(--accent); }
.sharebtn__ico svg { width: 19px; height: 19px; }

.sharebtn[data-state='working'] .sharebtn__ico { animation: share-nudge 1.1s var(--ease) infinite; }
.sharebtn[data-state='done'] { background: var(--accent-quiet); }
.sharebtn[data-state='failed'] { border-color: var(--danger); }
.sharebtn[data-state='failed'] .sharebtn__ico { color: var(--danger); }

@keyframes share-nudge {
  0%, 100% { opacity: 1; translate: 0 0; }
  50% { opacity: .55; translate: 3px 0; }
}

/* Not redundant: the sheet's reduced-motion block only clamps transitions and
   stops the step animation — it does not reach a named keyframe animation. */
@media (prefers-reduced-motion: reduce) {
  .sharebtn { transition: none; }
  .sharebtn[data-state='working'] .sharebtn__ico { animation: none; opacity: 1; }
}

/* ── Expanding remove ───────────────────────────────────────────────────
   Adapted from a Uiverse.io expanding delete button by boryanakrasteva (MIT).

   Four things changed and one did not.

   Changed: the `.bnt:hover > .icon-wrapper .icon path` typo, so the icon
   actually recolours; the label's `visibility: hidden`, which takes the only
   word on the control out of the accessibility tree; the `width` transition,
   which animates the layout of a wrapping flex row; and the hard-coded #000,
   which this palette rules out and which is invisible on the dark theme anyway.

   Not changed: the idea. A destructive control that stays small until you go
   near it is worth having in a row where the safe action — Replace — should be
   what your eye lands on first.

   What makes it safe is that "small" is a visual state only. The word Remove
   and the name of the sheet are real text inside the element at every moment,
   and a device that cannot hover never collapses it at all.

   Measured against :root, light / dark:
     rest   icon --danger on --surface-2         4.78 / 4.96
     hover  fill --danger-quiet, label --text   13.63 / 12.64
            icon and border --danger on it       4.51 / 5.03
     focus  the sheet's global 2px --accent ring, landing on the .drop card's
            --surface                            4.94 / 6.59
   Text floor 4.5:1 and non-text floor 3:1 are clear in every state.
   ─────────────────────────────────────────────────────────────────────── */

.xdel {
  display: inline-flex;
  align-items: center;
  /* A floor, not a height: 2.6rem is 46.8px at the default scale and 52px at
     data-textsize='large', so a 44px touch target is clear in both. */
  min-block-size: 2.6rem;
  padding-inline: .55rem;
  border-radius: var(--r-md);
  background: var(--surface-2);
  border: 1px solid var(--border);
  color: var(--danger);
  font-size: .95rem;
  font-weight: 500;
  cursor: pointer;
  transition: background .18s var(--ease), border-color .18s var(--ease);
}

.xdel__icon { flex: none; display: grid; place-items: center; inline-size: 1.6rem; }
.xdel__icon svg { width: 1.35rem; height: 1.35rem; }

/* Collapsed by clipping — never by `visibility` or `display`, either of which
   would take the word out of the accessibility tree, the one thing this
   control cannot afford to lose. */
.xdel__label {
  overflow: hidden;
  max-inline-size: 0;
  padding-inline: 0;
  opacity: 0;
  white-space: nowrap;
  color: var(--text);
  transition:
    max-inline-size .2s var(--ease),
    padding-inline .2s var(--ease),
    opacity .16s var(--ease);
}

:where(html[data-pointer="fine"]) .xdel:hover,
.xdel:focus-visible { background: var(--danger-quiet); border-color: var(--danger); }

/* :focus-visible sits alongside :hover deliberately. The original expands on
   hover only, which no keyboard can reach — the reader would tab onto an icon
   whose label they can hear but not see. */
:where(html[data-pointer="fine"]) .xdel:hover .xdel__label,
.xdel:focus-visible .xdel__label {
  max-inline-size: 12ch;
  padding-inline: .35rem .15rem;
  opacity: 1;
}

.xdel:active { transform: translateY(1px); }

/* Touch: there is no hover to reveal it with, so it never hides. Tap-to-expand
   then tap-to-confirm would put a second meaning on the first tap of a
   destructive control, which is worse than a wider button. (hover: none)
   rather than (pointer: coarse), because the question is literally whether the
   device can hover — a touchscreen laptop reports hover and keeps the effect. */
@media (hover: none) {
  .xdel__label { max-inline-size: 12ch; padding-inline: .35rem .15rem; opacity: 1; }
}

/* Both switches stop it collapsing rather than collapsing it instantly.
   Cutting the duration to nothing does not make it calmer, it makes a button
   snap sideways inside a wrapping row — the same objection the lite block
   already records about the continue arrow. `transition: none` sets
   transition-property, which is what lets it beat the blanket
   `transition-duration: .01ms !important`: that caps a duration but cannot
   reinstate a property that has been removed. */
@media (prefers-reduced-motion: reduce) {
  .xdel, .xdel__label { transition: none; }
  .xdel__label { max-inline-size: 12ch; padding-inline: .35rem .15rem; opacity: 1; }
}
:root[data-lite='on'] .xdel,
:root[data-lite='on'] .xdel__label { transition: none; }
:root[data-lite='on'] .xdel__label {
  max-inline-size: 12ch; padding-inline: .35rem .15rem; opacity: 1;
}

/* ── Split form panel ───────────────────────────────────────────────────
   Adapted from a Uiverse.io login form by zanina-yassine (MIT).

   There is no sign-in in this app and there is no server, so none of the login
   half comes across: no <form>, no fields added, no password input, no submit.
   What is borrowed is the two-panel container — a form column beside a
   supporting column, sharing one radius and meeting at a seam — and the tint
   that tells them apart. The testimonial, its avatar and its attributed quote
   are dropped because they were placeholder text about a person who does not
   exist.

   The original's `.input_field:focus { border: 1px solid transparent;
   box-shadow: 0 0 0 2px #115DFC }` is dropped rather than adapted: it is the
   same ring-instead-of-outline trick this sheet's `.input` comment was written
   about. Field chrome belongs to the anniekoop input.
   ─────────────────────────────────────────────────────────────────────── */

/* The columns carry their own padding so the aside's tint reaches the panel
   edge. The panel already has overflow:hidden and a radius, so the tint is
   clipped to its corners for free. */
#feedback .modal-body { padding: 0; }

.fb-split { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr); }
.fb-split__form { padding: 1.35rem; min-width: 0; }
.fb-split__lede { font-size: .93rem; color: var(--text-2); margin-bottom: 1rem; max-width: 46ch; }

/*
 * The seam does the structural work; the wash does the tonal work. The wash
 * ceiling is a measurement rather than a preference: `.privacy` is --text-3,
 * and mixing --accent into --surface pushes it through the 4.5:1 floor.
 *
 * Re-measured when the accent became ink. A green at 8% barely moved the
 * surface; the ink moves it much faster in light and the near-white moves it
 * much faster in dark, so the same 8% that held at 4.59:1 now lands at 4.23:1.
 * 4% is the value that holds — 4.55:1 light, 4.83:1 dark — and it is enough to
 * see, because the seam beside it is doing the structural work.
 *
 * This is also why --accent-quiet is not reused here: it is a flat fill sized
 * for marked areas, not a wash, and it has no gradient in it to end.
 */
.fb-split__aside {
  padding: 1.35rem;
  min-width: 0;
  align-content: start;
  border-inline-start: 1px solid var(--border);
  background: linear-gradient(180deg,
    color-mix(in srgb, var(--accent) 4%, var(--surface)) 0%,
    var(--surface) 100%);
}
.fb-split__aside-title { font-size: .95rem; color: var(--text); margin-bottom: .75rem; }
/* Last in its column with the disclosure directly above it, so the base
   `.privacy { margin-top: 2rem }` is too much here. */
.fb-split__aside .privacy { margin-top: 1rem; }

/* --bg-deep is 1.04:1 from the 8% wash in light and would read as a hole
   rather than a block. --border-strong at 1.44:1 / 1.46:1 is the strongest
   edge the palette offers against it; a container edge, not a control part, so
   the 3:1 floor does not apply. Descendant selector deliberately — src/fold.js
   injects a .fold-body wrapper between .fb-details and this <pre> at runtime. */
.fb-split__aside .code { border-color: var(--border-strong); }

/* Form first, support after: the reading order is the source order, and the
   seam becomes a rule across the top. */
@media (max-width: 700px) {
  .fb-split { grid-template-columns: 1fr; }
  .fb-split__aside { border-inline-start: 0; border-block-start: 1px solid var(--border); }
}

/* ── The step deck ──────────────────────────────────────────────────────
   Four instruction cards, one at a time, with a mascot that does the thing
   the card describes.

   Built on scroll-snap rather than on a transform and an index, which is what
   makes it work with no script: the cards are laid out in order in a scroll
   container, so a phone swipes it, a trackpad drags it, and the arrow keys
   move it because the track is focusable and the browser already knows how to
   scroll one. src/stepshow.js adds the buttons and the dots on top.

   It never advances by itself. A carousel that moves while you are reading is
   taking the page away from you, and these are instructions — the reader is
   mid-sentence by definition.
   ─────────────────────────────────────────────────────────────────────── */

.stepshow { position: relative; }

.stepshow__track {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 100%;
  gap: 1rem;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
  list-style: none;
  padding: 0;
  margin: 0;
  border-radius: var(--r-lg);
}
.stepshow__track::-webkit-scrollbar { display: none; }
.stepshow__track:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }

.stepcard {
  scroll-snap-align: center;
  display: grid;
  grid-template-columns: minmax(0, auto) minmax(0, 1fr);
  align-items: center;
  gap: clamp(1rem, 4vw, 2.5rem);
  padding: clamp(1.5rem, 4vw, 2.5rem);
  background: linear-gradient(135deg, var(--accent-quiet), var(--surface) 62%);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  min-height: 15rem;
}
@media (max-width: 640px) {
  .stepcard { grid-template-columns: minmax(0, 1fr); justify-items: start; }
}

.stepcard__art { display: grid; place-items: center; }
.stepcard__meta {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: .6rem;
  margin-bottom: .5rem;
}
.stepcard__num {
  font-family: 'Geist Mono', monospace; font-size: .82rem; font-weight: 600;
  letter-spacing: .06em; text-transform: uppercase; color: var(--accent);
  background: var(--accent-quiet); padding: .25rem .6rem; border-radius: 999px;
}
.stepcard__time { font-size: .85rem; color: var(--text-3); }
.stepcard h3 { font-size: clamp(1.25rem, 3vw, 1.6rem); margin-bottom: .5rem; }
.stepcard__text p:last-child { color: var(--text-2); font-size: 1rem; max-width: 52ch; line-height: 1.6; }

/* ── Confetti ───────────────────────────────────────────────────────────
   Thrown when something is finished. src/celebrate.js builds the layer and
   removes it — twice over, because a tab that was hidden through the whole
   animation never fires animationend and a burst that does not clean up is one
   that accumulates.

   `position: fixed` and coordinates straight from getBoundingClientRect, which
   is viewport-relative, so the two agree without any scroll arithmetic.

   Ink and greys rather than colours. This palette keeps its only saturated
   values for warnings, and a page whose single hue appears at a party and again
   at a failure has taught the reader nothing about either. The shapes carry the
   festivity instead: mixed rectangles, slivers and dots, thrown up and falling
   past, which is what confetti actually does.
   ─────────────────────────────────────────────────────────────────────── */

.burst {
  position: fixed;
  z-index: 90;              /* over the page, under the modals at 100 */
  pointer-events: none;
  width: 0; height: 0;
}
.burst__bit {
  position: absolute; left: 0; top: 0;
  width: 7px; height: 11px;
  border-radius: 1px;
  background: var(--accent);
  opacity: 0;
  animation: burst-fly 1.1s cubic-bezier(.18, .7, .32, 1) forwards;
  will-change: transform, opacity;
}
/* Four kinds, dealt out by position rather than by four classes from script.
   The primes overlap, which is the point — it stops the mix repeating in a
   pattern the eye can pick out of a ring of thirty-four. */
.burst__bit:nth-child(3n) { background: var(--text-2); }
.burst__bit:nth-child(4n) { background: var(--text-3); }
.burst__bit:nth-child(5n) { width: 6px; height: 6px; border-radius: 50%; }
.burst__bit:nth-child(7n) { width: 3px; height: 15px; border-radius: 2px; }

/* Out along its own angle, then past — the +130px is gravity, and it is what
   separates confetti from a firework. */
@keyframes burst-fly {
  0%   { opacity: 1; transform: translate(0, 0) rotate(0deg) scale(var(--s, 1)); }
  65%  { opacity: 1; }
  100% {
    opacity: 0;
    transform: translate(var(--x, 0), calc(var(--y, 0px) + 130px))
               rotate(var(--r, 0deg)) scale(var(--s, 1));
  }
}

/* celebrate.js returns early under all three of these, so nothing is built in
   the first place. This is the copy that holds if a layer is somehow on the
   page when the switch is flipped. */
@media (prefers-reduced-motion: reduce) { .burst { display: none; } }
:root[data-lite='on'] .burst,
:root[data-decor='off'] .burst { display: none; }

/* ── The mascot ──
   An ink drop, which is the one character this app could honestly have: it is
   what comes off the pen.

   The body is an HTML box whose `border-radius` is animated, not an SVG path.
   That is what makes it read as liquid: eight radii interpolating
   independently give a surface that bulges and settles, and it passes through
   a true circle and a rounded square on the way round without any of those
   states being a separate asset. An SVG path morph would need every keyframe
   to carry the same node count and would still not be as smooth.

   It had a face — two round eyes and a mouth with four per-card expressions —
   and the face was the problem. A filled circle with two dots and a curve is an
   emoji whatever it is called in the stylesheet, and at 110px of flat green it
   read as one: not an ink drop, a smiley. The drop is the idea; the face was a
   second idea sitting on top of it and winning.

   So the face is gone and the drop is ink-coloured, which is what a drop of ink
   is. What it used to say with expressions, the prop beside it already said
   better — a pen, a camera, a magnifier, a download arrow, one per step.

   The pointer tracking survives, moved from the eyes to a highlight. A drop
   with a specular gleam that follows the cursor is being lit by it, which is a
   thing a drop of liquid does; a drop with eyeballs was being a character.

   Decoration throughout — the whole thing is aria-hidden, and every card's
   text says the same thing without it.
   ─────────────────────────────────────────────────────────────────────── */

.mascot {
  display: flex; align-items: center; gap: clamp(.5rem, 2vw, 1.1rem);
}
.mascot__stage { position: relative; width: clamp(5.5rem, 13vw, 8rem); aspect-ratio: 1; flex: none; }

.mascot__blob {
  position: absolute; inset: 0;
  background: linear-gradient(150deg, var(--accent-hover), var(--accent) 62%);
  border-radius: 52% 48% 50% 50% / 62% 60% 40% 38%;
  animation:
    blob-morph 11s ease-in-out infinite,
    mascot-bob 3.4s ease-in-out infinite;
}

/*
 * A drop, wobbling — not a shape tour.
 *
 * This used to pass through a true circle and a 22% rounded square on its way
 * round, which is what left it reading as a ball: for most of the cycle it was
 * one. Nothing about a circle says ink.
 *
 * Every keyframe now keeps the same silhouette — vertical radii larger at the
 * top than the bottom, so the mass sits low and the crown is narrow, which is
 * what a drop at rest does under its own weight. The variation between them is
 * only ever a few points, so the surface breathes instead of transforming, and
 * the two axes stay on different rhythms so it never looks like it is turning.
 */
@keyframes blob-morph {
  0%, 100% { border-radius: 52% 48% 50% 50% / 62% 60% 40% 38%; }
  25% { border-radius: 48% 52% 46% 54% / 66% 57% 43% 34%; }
  50% { border-radius: 50% 50% 53% 47% / 58% 65% 35% 42%; }
  75% { border-radius: 55% 45% 48% 52% / 64% 56% 44% 36%; }
}

/*
 * Leaning toward the pointer. src/mascot.js writes --look-x and --look-y, each
 * clamped to -1..1, and the lean below is derived from them — the module
 * measures and does not style.
 *
 * Small on purpose: 5% and 4%. A drop of ink sitting on paper does not follow
 * anybody around the room, and the point of the movement is that you notice it
 * without being able to say what moved.
 */
.mascot__blob {
  translate: calc(var(--look-x, 0) * 5%) calc(var(--look-y, 0) * 4%);
  transition: translate .3s var(--ease);
}

/*
 * There was a gleam here — a soft radial in the page colour, meant to read as
 * light catching a wet surface. It read as a thumbprint.
 *
 * The reason is worth keeping: a specular highlight is small, bright and hard-
 * edged, because it is a reflection of a light source rather than of the room.
 * A large soft one at 38% opacity is not a highlight at all, it is a stain, and
 * on a near-black drop a grey stain is the only thing the eye finds.
 *
 * Nothing replaces it. The shape below is doing the work now, and a drop of ink
 * on paper has no highlight either.
 */

/* Poked, it swells for a moment. The expression this replaces was a grin. */
.mascot__stage.is-poked .mascot__blob,
.mascot__stage.is-delighted .mascot__blob {
  scale: 1.06;
  transition: scale .22s var(--ease);
}

.mascot__shadow {
  position: absolute; left: 12%; right: 12%; bottom: -8%;
  height: .5rem; border-radius: 50%;
  background: var(--ink); opacity: .16;
  animation: mascot-shadow 3.4s ease-in-out infinite;
}

.mascot__prop {
  width: clamp(2.2rem, 6vw, 3.4rem); height: auto; flex: none;
  fill: none; stroke: var(--text-2); stroke-width: 2.6;
  stroke-linecap: round; stroke-linejoin: round;
  animation: mascot-prop 3.4s ease-in-out infinite;
}

@keyframes mascot-bob {
  0%, 100% { transform: translateY(0) rotate(0deg); }
  50% { transform: translateY(-7px) rotate(-2deg); }
}
/* The shadow tightens as it lifts, which is what sells the lift. */
@keyframes mascot-shadow {
  0%, 100% { transform: scaleX(1); opacity: .16; }
  50% { transform: scaleX(.8); opacity: .09; }
}
@keyframes mascot-prop {
  0%, 100% { transform: translateY(0) rotate(0deg); }
  50% { transform: translateY(-4px) rotate(5deg); }
}

/* Only the card you are looking at is alive. Four blobs morphing at once,
   three of them off screen, is work nobody sees. */
.stepcard:not(.is-current) :is(.mascot__blob, .mascot__shadow, .mascot__prop) {
  animation-play-state: paused;
}

/* The three switches, all saying the same thing: hold still.
 *
 * The resting shape they pin it to is a keyframe of the morph rather than a
 * fifth shape invented here, so a reader who has turned motion off sees the
 * same drop everyone else does, not moving. */
@media (prefers-reduced-motion: reduce) {
  .mascot__blob, .mascot__shadow, .mascot__prop { animation: none; }
  .mascot__blob {
    translate: none; transition: none;
    border-radius: 52% 48% 50% 50% / 62% 60% 40% 38%;
  }
  .stepshow__dot { transition: none; }
}
:root[data-lite='on'] :is(.mascot__blob, .mascot__shadow, .mascot__prop),
:root[data-decor='off'] :is(.mascot__blob, .mascot__shadow, .mascot__prop) { animation: none; }
:root[data-lite='on'] .mascot__blob,
:root[data-decor='off'] .mascot__blob {
  translate: none; transition: none;
  border-radius: 52% 48% 50% 50% / 62% 60% 40% 38%;
}

/* ── Deck controls ── */
.stepshow__nav {
  display: flex; align-items: center; justify-content: center; gap: 1rem;
  margin-top: 1.25rem;
}
/* The rule above quietly defeated the `hidden` attribute that the markup puts
   on this element and src/stepshow.js takes off again. An author `display`
   declaration outranks the user-agent stylesheet's `[hidden] { display: none }`
   by cascade origin — not by source order and not by specificity — so the nav
   laid itself out regardless, and a reader whose script never ran was given a
   full set of arrows and dots that looked live and moved nothing. That is worse
   than showing no controls at all, which is the point of hiding them: the track
   underneath is a scroll-snap container, so it still swipes, drags and answers
   the arrow keys on its own, and nothing is actually missing without them.
   Restating `display: none` behind the attribute is how the rest of this file
   handles the same collision — see `.sharebtn[hidden]` and `.busy[hidden]`. */
.stepshow__nav[hidden] { display: none; }

.stepshow__arrow {
  display: grid; place-items: center;
  width: 2.6rem; height: 2.6rem; flex: none;
  border-radius: 50%;
  background: var(--surface-2); border: 1px solid var(--border);
  color: var(--text); cursor: pointer;
  transition: background .16s var(--ease), border-color .16s var(--ease);
}
:where(html[data-pointer="fine"]) .stepshow__arrow:hover:not(:disabled) { background: var(--surface-3); border-color: var(--border-strong); }
.stepshow__arrow:disabled { opacity: .4; cursor: default; }
.stepshow__arrow svg { width: 1.2rem; height: 1.2rem; }

.stepshow__dots { display: flex; align-items: center; gap: .5rem; }
.stepshow__dot {
  width: .6rem; height: .6rem; padding: 0; border: 0; border-radius: 50%;
  background: var(--border-strong); cursor: pointer;
  transition: background .2s var(--ease), width .2s var(--ease);
}
.stepshow__dot[aria-current='step'] { background: var(--accent); width: 1.6rem; border-radius: 999px; }
.stepshow__dot:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

@media (prefers-reduced-motion: reduce) {
  /* `.mascot__body` and `.mascot__eyes` used to be named here and never
     existed — an earlier mascot's class names, left behind when it was
     rewritten. The live ones are suppressed beside the mascot itself, above. */
  .stepshow__dot { transition: none; }
}

/* ── The demo's waiting state ────────────────────────────────────────────
   The same two panels the result uses, in the same order and shape, so the
   column is already the size it will be and nothing jumps when the real
   outlines arrive. It was one sentence in an empty column before, which reads
   as a broken layout rather than a waiting one.

   Every caption is in the future tense. A placeholder that states a result
   would be claiming something about a letter that does not exist yet, and this
   band's whole argument is that its numbers are counted rather than asserted.
   ─────────────────────────────────────────────────────────────────────── */

.demo-panel.is-waiting { opacity: .72; }
.demo-waiting { display: flex; flex-wrap: wrap; gap: .55rem; }
.demo-waiting__slot {
  flex: 1 1 3.2rem;
  min-width: 2.6rem;
  aspect-ratio: 1;
  border-radius: var(--r-md);
  border: 1px dashed var(--border-strong);
  background: var(--surface-2);
}
/* One big slot for the outline panel, six small ones for the variants. */
.demo-waiting > .demo-waiting__slot:only-child { flex-basis: 8rem; max-width: 10rem; }
.demo-panel.is-waiting figcaption { color: var(--text-3); }

/* ── The notch ──────────────────────────────────────────────────────────
   index.html asks for `viewport-fit=cover`, which is what lets the page paint
   edge to edge — and which also means nothing keeps the page out from under the
   hardware. Until now nothing in this sheet read a single safe-area inset, so
   the sticky topbar sat under the notch, the footer under the home indicator,
   and in landscape the .wrap padding was measured from the screen edge rather
   than from the usable one.

   Last in the sheet on purpose: .footer sets padding-block and .toasts sets
   right/bottom earlier at equal specificity, so this has to come after them to
   win. .topbar declares no padding at all today, so its line is an addition
   rather than an override. Each declaration follows the one it adjusts, so a
   browser without env() drops it and keeps the original value.
   ─────────────────────────────────────────────────────────────────────── */

/* The bar is sticky at top: 0 and carries its own tinted, blurred background,
   so padding rather than margin — the tint still fills the notch strip while
   the row of controls sits below it. */
.topbar { padding-top: env(safe-area-inset-top, 0px); }

/* body is a flex column at min-height: 100dvh and .footer has margin-top: auto,
   so this is the last thing above the home indicator. The 1.75rem repeats the
   value from `.footer { padding-block: 1.75rem }` above — the two have to be
   changed together. */
.footer { padding-bottom: calc(1.75rem + env(safe-area-inset-bottom, 0px)); }

/* max() rather than addition: below the inset the gutter is unchanged, above it
   the inset wins, and the text never ends up doubly indented on the safe side.
   Both rules are inside the @supports because a browser with env() but without
   max() would otherwise drop them — which for .toasts would send right/bottom
   to `auto` and move the toasts out of the corner entirely, and for .wrap would
   drop the gutter to nothing rather than fall back. */
@supports (padding: max(0px)) {
  /* Fixed to the bottom-right corner, which in landscape is the corner the
     indicator and the curve both occupy. */
  .toasts {
    right: max(1rem, env(safe-area-inset-right, 0px));
    bottom: max(1rem, env(safe-area-inset-bottom, 0px));
  }
  /* Landscape is where the notch eats one flank; .wrap is also the topbar's
     inner row, so this moves the brand and the settings button too. */
  .wrap {
    padding-left: max(clamp(1rem, 4vw, 2rem), env(safe-area-inset-left));
    padding-right: max(clamp(1rem, 4vw, 2rem), env(safe-area-inset-right));
  }
}

/* ── Easter eggs ────────────────────────────────────────────────────────
   Decoration on decoration. Every one is gated on the same three switches as
   the rest of the movement in this sheet, and none of them carries meaning —
   the interface is identical without them.
   ─────────────────────────────────────────────────────────────────────── */

/* Poke the mascot: it squashes, springs, and jumps to a new shape. The blob's
   own morph is an eleven-second loop, so without the shape kick a poke would
   often land at a moment when nothing visibly changed. */
.mascot__stage { cursor: pointer; }
.mascot__stage.is-poked .mascot__blob { animation: blob-poke .7s var(--ease); }
@keyframes blob-poke {
  0%   { transform: scale(1); border-radius: 58% 42% 44% 56% / 52% 48% 52% 48%; }
  22%  { transform: scale(1.16, .8); border-radius: 46% 54% 30% 70% / 34% 30% 70% 66%; }
  48%  { transform: scale(.9, 1.14); border-radius: 62% 38% 66% 34% / 66% 62% 38% 34%; }
  72%  { transform: scale(1.05, .96); border-radius: 50%; }
  100% { transform: scale(1); border-radius: 58% 42% 44% 56% / 52% 48% 52% 48%; }
}

/* Type the word: every ink drop on the page hops. */
.mascot__stage.is-delighted .mascot__blob { animation: blob-hop 1.1s var(--ease); }
@keyframes blob-hop {
  0%, 100% { transform: translateY(0) scale(1); }
  15% { transform: translateY(0) scale(1.1, .86); }
  38% { transform: translateY(-34px) scale(.9, 1.14); }
  56% { transform: translateY(0) scale(1.12, .84); }
  74% { transform: translateY(-12px) scale(.96, 1.06); }
  90% { transform: translateY(0) scale(1.04, .96); }
}
/* The shadow has to agree with the hop or the drop looks like it is sliding. */
.mascot__stage.is-delighted .mascot__shadow { animation: shadow-hop 1.1s var(--ease); }
@keyframes shadow-hop {
  0%, 100% { transform: scaleX(1); opacity: .16; }
  38% { transform: scaleX(.5); opacity: .05; }
  56% { transform: scaleX(1.1); opacity: .2; }
  74% { transform: scaleX(.8); opacity: .1; }
}

/* The sheet says it can be poked, without saying so. */
.spec__sheet { cursor: pointer; }

/* All three switches, same as everything else. Someone who asked for less
   movement did not mean "except for the fun bits". */
@media (prefers-reduced-motion: reduce) {
  .mascot__stage { cursor: default; }
  .mascot__stage.is-poked .mascot__blob,
  .mascot__stage.is-delighted .mascot__blob,
  .mascot__stage.is-delighted .mascot__shadow { animation: none; }
}
:root[data-lite='on'] .mascot__stage,
:root[data-decor='off'] .mascot__stage { cursor: default; }
:root[data-lite='on'] :is(.is-poked .mascot__blob, .is-delighted .mascot__blob, .is-delighted .mascot__shadow),
:root[data-decor='off'] :is(.is-poked .mascot__blob, .is-delighted .mascot__blob, .is-delighted .mascot__shadow) { animation: none; }


/* ── The press ──────────────────────────────────────────────────────────
   An isometric printing press, adapted from a Uiverse.io loader by preet_7613
   (MIT). Twelve empty elements, each a face of the same object, built out of
   layered gradients and rotated into place. It sits beside "Print a template",
   which is the one place in this app where paper genuinely goes through a
   printer.

   Changes from the original, and the reasons:

   `.press *` was a scoped reset — border, margin, padding, box-sizing on every
   descendant. Dropped: the children are named now, so the reset had nothing to
   do except wait to stomp something added later.

   The palette was five hard-coded values, three of them near-black, on a
   cornflower field that appears nowhere in this app. On our tokens the frame
   is --surface-3, the paper --paper, the ink --ink, and the field is the card
   it sits in, so it follows the theme instead of fighting it.

   `height: 600px` and `font-size: 16px`. The whole thing is em-based, so one
   font-size governs its scale — it is 9px here, in a card, and the height
   comes from that rather than from a constant. The fixed 16px also ignored
   --ui-scale, so it was the one element on the page that did not grow with the
   Larger text setting.

   No reduced-motion guard on an infinite animation. There is one now, and lite
   mode and the decoration switch stop it too. When it stops it stops mid-sheet
   rather than vanishing, because the press is still a picture of a press.
   ─────────────────────────────────────────────────────────────────────── */

.press {
  /* One lever for the whole assembly. In rem, so it follows --ui-scale. */
  font-size: .56rem;
  position: relative;
  width: 100%;
  height: 13em;
  margin-bottom: 1rem;
  overflow: hidden;
  border-radius: var(--r-md);
  background: var(--surface-2);
  color: var(--ink);
}

.press__sheet, .press__roll {
  position: absolute;
  top: 50%;
  left: 50%;
}
.press__sheet { width: 4em; height: 6em; overflow: hidden; }

/* The printed page: rules and a block of text, drawn entirely in gradients and
   scrolled upward to look like it is feeding through. */
.press__sheet::before {
  content: '';
  display: block;
  position: absolute;
  top: -6em;
  left: 0;
  width: 100%;
  height: calc(100% + 6em);
  background:
    linear-gradient(#0000 5.2em, currentColor 5.2em 5.3em, #0000 5.3em 5.5em) .4em 0 / 3.2em 6em,
    linear-gradient(#0000 5.5em, currentColor 5.5em 5.7em, #0000 5.7em) .6em 0 / 2.8em 6em,
    linear-gradient(90deg, var(--paper) .4em, #0000 .4em 1.9em, var(--paper) 1.9em 2.1em,
      #0000 2.1em 3.6em, var(--paper) 3.6em) 0 0 / 4em 6em,
    repeating-linear-gradient(var(--paper) 0 .4em, #0000 .4em 5em, var(--paper) 5em 6em, #0000 6em) 0 0 / 4em 6em,
    linear-gradient(#0000 3.4em, currentColor 3.4em) 100% 0 / 50% 6em,
    repeating-linear-gradient(currentColor 0 .2em, var(--paper) .2em .4em) 0 0 / 4em 6em;
  background-repeat: repeat-y;
  animation: press-feed .5s linear infinite;
}

/* The two sheets furthest from the eye are the ones already stacked, so they
   are quieter than the page currently being printed. */
.press__sheet:nth-child(1)::before,
.press__sheet:nth-child(5)::before { color: var(--border-strong); }

.press__sheet:nth-child(n + 7):nth-child(-n + 9) { height: .4em; }

.press__sheet:nth-child(1) {
  height: 42em;
  transform: translate(-50%, -50%) rotateX(-45deg) rotateZ(-45deg) translate(-17.4em, 9.4em) rotateX(-90deg);
}
.press__sheet:nth-child(3) {
  transform: translate(-50%, -50%) rotateX(-45deg) rotateZ(-45deg) translate(-2.5em, -2.5em);
}
.press__sheet:nth-child(5) {
  transform: translate(-50%, -50%) rotateX(-45deg) rotateZ(-45deg) translate(-.5em, -.7em) rotateX(-75deg);
}
.press__sheet:nth-child(7) {
  transform: translate(-50%, -50%) rotateX(-45deg) rotateZ(-45deg) translate(1.7em, -1.85em) rotateX(-15deg);
}
.press__sheet:nth-child(7)::before { background-position-y: 5.2em; }
.press__sheet:nth-child(8) {
  transform: translate(-50%, -50%) rotateX(-45deg) rotateZ(-45deg) translate(1.7em, -1.5em) rotateX(15deg);
}
.press__sheet:nth-child(8)::before { background-position-y: 4.8em; }
.press__sheet:nth-child(9) {
  transform: translate(-50%, -50%) rotateX(-45deg) rotateZ(-45deg) translate(1.6em, -1.05em) rotateX(45deg);
}
.press__sheet:nth-child(9)::before { background-position-y: 4.4em; }
.press__sheet:nth-child(10)::before,
.press__sheet:nth-child(11)::before { background-position-y: 4em; }
.press__sheet:nth-child(10) {
  transform: translate(-50%, -50%) rotateX(-45deg) rotateZ(-45deg) translate(-.5em, 1.95em) rotateX(75deg);
}
.press__sheet:nth-child(11) {
  height: 29em;
  transform: translate(-50%, -50%) rotateX(-45deg) rotateZ(-45deg) translate(-2.5em, 19.25em);
}

.press__roll {
  width: 8em;
  height: 2em;
  border-radius: .5em / 1em;
  background-color: var(--surface-3);
  background-image: radial-gradient(1em 2em at .5em 50%, var(--ink) 49%, #0000 50%);
}
.press__roll:nth-child(2) {
  transform: translate(-50%, -50%) rotateX(-45deg) rotateZ(-45deg) translate(-3em, -4.5em) skewX(-22.5deg);
}
.press__roll:nth-child(4) {
  transform: translate(-50%, -50%) rotateX(-45deg) rotateZ(-45deg) translate(-2em, -.5em) skewX(-22.5deg);
}
.press__roll:nth-child(6) {
  transform: translate(-50%, -50%) rotateX(-45deg) rotateZ(-45deg) translate(1em, -1em) skewX(-22.5deg);
}
.press__roll:nth-child(12) {
  transform: translate(-50%, -50%) rotateX(-45deg) rotateZ(-45deg) translate(-2em, 4.45em) skewX(-22.5deg);
}

@keyframes press-feed {
  from { transform: translateY(0); }
  to { transform: translateY(6em); }
}

/* Stopped, not hidden. A still press is still a picture of a press; the paper
   simply is not moving through it. */
@media (prefers-reduced-motion: reduce) {
  .press__sheet::before { animation: none; }
}
:root[data-lite='on'] .press__sheet::before,
:root[data-decor='off'] .press__sheet::before { animation: none; }


/* ── The parcel ─────────────────────────────────────────────────────────
   The box and its folding flaps from a Uiverse.io order button by
   yeisonordonez (MIT). Only the box.

   What was left behind, and why it had to be. The original is a delivery van
   driving a package across the button — and nothing is delivered here. The
   font is written to your downloads by your own browser; a van leaving the
   frame is a picture of the one thing this app spends its whole front page
   promising does not happen. Re-colouring cannot fix a metaphor.

   The mechanism was the other problem. It is a `<label for>` over a hidden
   checkbox whose "Done" state arrives at the 50% mark of a six-second CSS
   timeline, so it announces success on a timer regardless of whether anything
   was produced. Worse, `#trigger { display: none }` takes the control out of
   the tab order and the accessibility tree entirely, so it cannot be operated
   by keyboard at all. The button beside this is a real <button> whose ring is
   filled from actual packaging progress, and it stays.

   The class names would also have collided immediately: the original ships
   `.btn`, `.container`, `.background`, `.details`, `.stars` and `.package`, and
   this app already owns `.btn` and renders many `<details>` elements.

   So what is left is the box, folding shut on --dl-flap, which src/app.js sets
   from the same real progress that fills the ring. It cannot close early.
   ─────────────────────────────────────────────────────────────────────── */

.parcel {
  position: relative;
  flex: none;
  width: 2.6rem;
  height: 2rem;
  align-self: center;
  border-radius: 3px;
  /* Kraft, out of the warm tier rather than a hard-coded orange, so it holds
     up in both themes. */
  background: var(--warn-quiet);
  border: 1px solid var(--warn);
}

.parcel__flap {
  position: absolute;
  top: -1px;
  width: 50%;
  height: 4px;
  background: var(--warn);
  /* 210deg is fully open, 0 is shut. src/app.js drives it. */
  transition: transform .25s var(--ease);
}
.parcel__flap--l { left: 0; transform-origin: left center; transform: rotate(calc(-1 * var(--dl-flap, 210deg))); }
.parcel__flap--r { right: 0; transform-origin: right center; transform: rotate(var(--dl-flap, 210deg)); }

/* The strip of tape only means anything once the box is actually shut. */
.parcel__tape {
  position: absolute;
  left: 50%;
  top: 0;
  width: 5px;
  height: 100%;
  margin-left: -2.5px;
  background: var(--warn);
  opacity: 0;
  transition: opacity .25s var(--ease);
}
.dlbtn[data-state='done'] ~ .parcel .parcel__tape,
.download-card:has(.dlbtn[data-state='done']) .parcel__tape { opacity: .55; }

/* The flaps still track real progress — that is information, not decoration —
   they simply stop easing between positions. */
@media (prefers-reduced-motion: reduce) {
  .parcel__flap, .parcel__tape { transition: none; }
}
:root[data-lite='on'] .parcel__flap,
:root[data-lite='on'] .parcel__tape,
:root[data-decor='off'] .parcel__flap,
:root[data-decor='off'] .parcel__tape { transition: none; }

/* ── Specks ─────────────────────────────────────────────────────────────
   Particle burst adapted from a Uiverse.io button by srinivasaiml (MIT), on
   the one press in the app that turns ink into a font. Tracing scatters ink
   into points; this is that, on the button that starts it.

   It is deliberately a modifier and not a new button. There are already nine
   button treatments in this sheet, and a tenth needs a reason — "Build my
   font" is the only one that earns a burst, and .contbtn is shared by three
   controls, so restyling the class would have decorated the two that do not.

   What changed:

   `background: #000`, `border: #333`, `color: #fff` on hover. This palette has
   neither pure black nor pure white; the button keeps the accent fill it
   already had and only the specks are added.

   The purple, #b56aff, appears nowhere in this app. The specks are ink.

   `overflow: hidden` on the button, which clipped the particles at the border
   the moment they started travelling outward — the effect could not be seen at
   all past a few pixels. They need to leave, so nothing is clipped, and the
   glow is a shadow rather than a scaled child, which does not need containment.

   `transition: all 0.3s` transitions every property including layout ones. The
   two that actually change are named.

   No reduced-motion guard on the original. All three switches stop it here,
   and when they do the specks are simply absent rather than frozen mid-flight.
   ─────────────────────────────────────────────────────────────────────── */

.contbtn--spark { position: relative; }

:where(html[data-pointer="fine"]) .contbtn--spark:hover:not(:disabled) {
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 22%, transparent);
}

.contbtn__specks {
  position: absolute;
  inset: 0;
  pointer-events: none;
  /* Not clipped. The whole point is that they leave the button. */
  overflow: visible;
}

.contbtn__specks i {
  position: absolute;
  width: 4px;
  height: 4px;
  border-radius: 50%;
  background: var(--accent);
  opacity: 0;
}

:where(html[data-pointer="fine"]) .contbtn--spark:hover:not(:disabled) .contbtn__specks i,
.contbtn--spark:focus-visible:not(:disabled) .contbtn__specks i {
  animation: speck-fly 1.5s var(--ease) forwards;
}

@keyframes speck-fly {
  0%   { transform: translate(0, 0) scale(0); opacity: 0; }
  18%  { opacity: .9; }
  100% { transform: translate(var(--tx), var(--ty)) scale(1); opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .contbtn__specks { display: none; }
}
:root[data-lite='on'] .contbtn__specks,
:root[data-decor='off'] .contbtn__specks { display: none; }


/* ── The folder ─────────────────────────────────────────────────────────
   Adapted from a Uiverse.io folder by Nykolas94 (MIT). It sits beside the
   per-style downloads, because "open it and see the separate files" is what
   that row actually offers. The zip's own card keeps the parcel, which is tied
   to real packaging progress; this one is a picture of the contents.

   The best-built snippet of the batch — it already had a reduced-motion guard,
   a real focusable checkbox rather than `display: none`, a focus ring, and
   spans throughout. Four things still had to change.

   `.paper`, `.paper--1`, `.paper--2`, `.paper--3` would have collided head-on.
   This app already renders `.paper` elements for the character sheets on the
   Write step, styles them across six rules, and lists `.paper` in TARGETS in
   src/reveal.js — so the snippet would have quietly restyled the sheets you
   copy your handwriting from. Renamed to .folder__paper.

   `rotateX(-32deg)` with no `perspective` anywhere in the original. Without
   one the rotation is orthographic: the flap does not tilt towards you, it
   just squashes vertically. A perspective on the shape is what makes it open.

   `--ring: #1d6cf5` is a blue that appears nowhere in this app; the focus ring
   is the accent, like every other ring in this sheet.

   `font-size: 16px` pinned it against --ui-scale, so it was the one element on
   the screen that would not grow with the Larger text setting. In rem now.

   The original's count read "13,037,486+37,115e Items", which is placeholder
   nonsense. renderExport writes the real number of styles.
   ─────────────────────────────────────────────────────────────────────── */

.dl-separate {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  gap: 1rem 1.25rem;
  align-items: center;
}
.dl-separate .dl-row { grid-column: 1 / -1; }
@media (max-width: 560px) {
  .dl-separate { grid-template-columns: minmax(0, 1fr); }
}

.folder {
  --folder-back-1: light-dark(#f7c14b, #b8862c);
  --folder-back-2: light-dark(#e9a52f, #9a6f22);
  --folder-front-1: light-dark(#ffd970, #cf9a35);
  --folder-front-2: light-dark(#fbc548, #b8862c);
  --folder-edge: light-dark(#d68f23, #7d5a1c);
  --folder-paper: light-dark(#fdfdfb, #ece7db);
  --folder-paper-2: light-dark(#f1f0ea, #d8d2c4);
  --folder-radius: .875em;

  position: relative;
  display: inline-block;
  width: 5.5rem;
  font-size: .8rem;
  cursor: pointer;
  user-select: none;
}

/* Visually gone, still focusable and still announced — the sr-only pattern,
   not `display: none`. This is one of the few snippets that got that right. */
.folder__toggle {
  position: absolute;
  width: 1px; height: 1px;
  opacity: 0;
  pointer-events: none;
}

.folder__shape {
  position: relative;
  display: block;
  width: 100%;
  aspect-ratio: 5 / 4;
  /* The declaration the original was missing. Without it rotateX squashes
     rather than tilts, and the folder never appears to open. */
  perspective: 42em;
  transition: transform .45s var(--ease);
}

.folder__back {
  position: absolute;
  inset: 14% 0 0 0;
  background: linear-gradient(135deg, var(--folder-back-1), var(--folder-back-2));
  border-radius: .25em var(--folder-radius) var(--folder-radius) var(--folder-radius);
}
.folder__back::before {
  content: '';
  position: absolute;
  top: -13%; left: 0;
  width: 46%; height: 16%;
  background: linear-gradient(135deg, var(--folder-back-1), var(--folder-back-2));
  border-radius: .375em .375em 0 0;
  clip-path: polygon(0 0, 82% 0, 100% 100%, 0 100%);
}

.folder__papers { position: absolute; inset: 6% 8% 12% 8%; z-index: 2; display: block; }

.folder__paper {
  position: absolute;
  left: 50%; bottom: 0;
  width: 86%; height: 78%;
  translate: -50% 0;
  background: var(--folder-paper);
  border-radius: .375em;
  transition: transform .45s var(--ease);
  overflow: hidden;
}
/* Two ruled lines, so each sheet reads as a document rather than a blank. */
.folder__paper::before,
.folder__paper::after {
  content: '';
  position: absolute;
  left: 14%; right: 24%;
  height: 6%;
  border-radius: .2em;
  background: var(--folder-paper-2);
}
.folder__paper::before { top: 22%; }
.folder__paper::after { top: 40%; right: 40%; }

.folder__paper--1 { width: 78%; height: 70%; }
.folder__paper--2 { width: 82%; height: 74%; }

.folder__front {
  position: absolute;
  inset: 38% 0 0 0;
  z-index: 3;
  background: linear-gradient(150deg, var(--folder-front-1), var(--folder-front-2));
  border-radius: var(--folder-radius);
  box-shadow: inset 0 1px 0 rgb(255 255 255 / .35), 0 -1px 0 var(--folder-edge);
  transform-origin: bottom center;
  transition: transform .45s var(--ease);
}

@media (hover: hover) {
  .folder:hover .folder__shape { transform: translateY(-.375em); }
  .folder:hover .folder__front { transform: rotateX(-32deg); }
  .folder:hover .folder__paper { transform: translateY(-26%); }
  .folder:hover .folder__paper--1 { transform: translate(-26%, -18%) rotate(-7deg); }
  .folder:hover .folder__paper--2 { transform: translate(22%, -22%) rotate(6deg); }
}

/* The checkbox is what makes this work without a hover — a tap opens it. */
.folder__toggle:checked ~ .folder__shape { transform: translateY(-.375em); }
.folder__toggle:checked ~ .folder__shape .folder__front { transform: rotateX(-32deg); }
.folder__toggle:checked ~ .folder__shape .folder__paper { transform: translateY(-26%); }
.folder__toggle:checked ~ .folder__shape .folder__paper--1 { transform: translate(-26%, -18%) rotate(-7deg); }
.folder__toggle:checked ~ .folder__shape .folder__paper--2 { transform: translate(22%, -22%) rotate(6deg); }

.folder__toggle:focus-visible ~ .folder__shape .folder__back {
  outline: 2px solid var(--accent);
  outline-offset: 4px;
  border-radius: var(--folder-radius);
}

@media (prefers-reduced-motion: reduce) {
  .folder__shape, .folder__front, .folder__paper { transition: none; }
}
:root[data-lite='on'] :is(.folder__shape, .folder__front, .folder__paper),
:root[data-decor='off'] :is(.folder__shape, .folder__front, .folder__paper) { transition: none; }

/* ── The export screen, folded ──────────────────────────────────────────
   It had grown to six cards across a main column and a 340px sidebar, all of
   them competing with the one button anybody came here for. Now it is one
   column: the download open and full width, and everything else a disclosure,
   so the screen reads as a short list of headings until you ask for one.

   Real <details>, so src/fold.js animates them, find-in-page reaches inside
   them where the browser supports it, and with no script they are still
   ordinary open/close controls.
   ─────────────────────────────────────────────────────────────────────── */

.export-layout {
  display: grid;
  gap: 1rem;
  grid-template-columns: minmax(0, 1fr);
  /* Reading width. Full-bleed cards on a 1180px wrap put the install steps at
     140 characters a line, which is roughly twice a comfortable measure. */
  max-width: 54rem;
}

.exportfold { padding: 0; overflow: hidden; }
.exportfold > summary {
  display: flex; align-items: center; justify-content: space-between; gap: 1rem;
  padding: 1.1rem 1.35rem;
  font-weight: 600; font-size: 1rem;
  cursor: pointer; list-style: none;
  transition: background .16s var(--ease);
}
.exportfold > summary::-webkit-details-marker { display: none; }
:where(html[data-pointer="fine"]) .exportfold > summary:hover { background: var(--surface-2); }
.exportfold > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.exportfold > summary::after {
  content: ''; flex: none; width: 9px; height: 9px;
  border-right: 2px solid var(--text-2); border-bottom: 2px solid var(--text-2);
  transform: rotate(45deg) translate(-2px, -2px);
  transition: transform .28s var(--ease);
}
.exportfold[open] > summary::after { transform: rotate(-135deg) translate(-3px, -3px); }

/* fold.js wraps everything after the summary in .fold-body; this is the
   padding that used to live on the card. */
.exportfold .fold-body { padding: 0 1.35rem 1.35rem; }
/* Before fold.js has run, and if it never does, the children still need it. */
.exportfold > :not(summary):not(.fold-body) { margin-inline: 1.35rem; }
.exportfold > :not(summary):not(.fold-body):last-child { margin-bottom: 1.35rem; }

.dl-separate__inner {
  display: flex; align-items: center; gap: 1.1rem;
  margin-bottom: 1rem;
}
.dl-separate__inner .muted { margin: 0; }

@media (prefers-reduced-motion: reduce) {
  .exportfold > summary, .exportfold > summary::after { transition: none; }
}

/* ── The shield ─────────────────────────────────────────────────────────
   Taken from a Uiverse.io verification form by Praashoo7 (MIT) — only the
   mark. The form itself is a four-digit code entry, and this app has no
   accounts, no codes and nothing to verify, so there was nothing else in it to
   use honestly.

   The original ships the shield as a 60px base64 PNG inside an <image> tag:
   about 1.8KB of markup that cannot scale, cannot take currentColor, and
   cannot follow the theme. Redrawn as two paths — one for the shield, one for
   the tick — which is roughly 200 bytes and does all three.

   Two paths rather than one so the tick can be drawn on its own as the card
   arrives. The shield is simply there; the tick is the claim being made.
   ─────────────────────────────────────────────────────────────────────── */

.shield__tick {
  stroke-dasharray: 1;
  stroke-dashoffset: 0;
}
/* Held back until the card arrives, then drawn. Resting state is drawn, so a
   browser that never runs the reveal shows a complete shield. */
.reveal-ready [data-reveal] .shield__tick { stroke-dashoffset: 1; }
.reveal-ready [data-reveal='in'] .shield__tick {
  animation: shield-tick .5s cubic-bezier(.5, .05, .55, .95) .3s both;
}
@keyframes shield-tick { from { stroke-dashoffset: 1; } }

@media (prefers-reduced-motion: reduce) {
  .reveal-ready [data-reveal] .shield__tick { stroke-dashoffset: 0; }
  .reveal-ready [data-reveal='in'] .shield__tick { animation: none; }
}
:root[data-lite='on'] .shield__tick,
:root[data-decor='off'] .shield__tick { stroke-dashoffset: 0; animation: none; }

/* ── Congratulations panel ──────────────────────────────────────────────
   One per sheet, written in after a photograph comes back readable. See
   src/congrats.js for why it exists at all; this is only how it sits.

   The specimen line is the point of the panel, so it gets the size. Everything
   else is deliberately quiet: a heading, one sentence, and a count. A panel that
   competed with the capture rows underneath it would be pushing the reader away
   from the next thing to do, which is photograph another sheet.

   `:empty` rather than a hidden attribute, so app.js can clear the slot with
   replaceChildren() and the margin goes with it. A slot holding nothing but its
   own spacing reads as a gap somebody forgot to fill.
   ─────────────────────────────────────────────────────────────────────── */

.congrats-slot:empty { display: none; }

.congrats {
  display: grid;
  gap: .4rem;
  margin: 0 0 1.5rem;
  padding: 1.35rem 1.5rem 1.5rem;
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  background: var(--surface);
  animation: rise .34s var(--ease) both;
}

.congrats__title { margin: 0; font-size: 1.35rem; line-height: 1.25; }
.congrats__line { margin: 0; color: var(--text-2); max-width: 46ch; }

.congrats__specimen {
  margin: .55rem 0 0;
  /* Large, because a specimen that has to be squinted at is not evidence. Capped
     so a sheet of thirty-two characters still wraps to two or three lines rather
     than filling the screen. */
  font-size: clamp(1.6rem, 5.5vw, 2.9rem);
  line-height: 1.4;
  word-spacing: .12em;
  /* These are single characters separated by spaces, so there are no words to
     keep whole; anywhere is the only break that cannot overflow the panel. */
  overflow-wrap: anywhere;
  color: var(--text);
}

.congrats__count { margin: .2rem 0 0; font-size: .85rem; color: var(--text-2); }

/* Decoration, so it goes when decoration goes. The panel still appears — it is
   the news, not the flourish — it simply arrives without moving. */
:root[data-lite='on'] .congrats,
:root[data-decor='off'] .congrats { animation: none; }

@media (prefers-reduced-motion: reduce) {
  .congrats { animation: none; }
}

/* ── Import a font ──────────────────────────────────────────────────────
   The second way in, for somebody who already has a handwriting font. Quiet
   on purpose: it sits under the main call to action and must not compete
   with it, because writing a sheet is still what most visitors are here for.
   ─────────────────────────────────────────────────────────────────────── */

.cta__alt { margin: 1.1rem 0 0; color: var(--text-2); font-size: .95rem; }

.linkish {
  background: none; border: 0; padding: 0; font: inherit; color: var(--text);
  text-decoration: underline; text-underline-offset: .18em; cursor: pointer;
}
:where(html[data-pointer="fine"]) .linkish:hover { color: var(--accent); }
.linkish:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 3px; }

.import-facts { display: grid; grid-template-columns: auto 1fr; gap: .35rem 1.25rem; margin: 0 0 1.1rem; }
.import-facts dt { color: var(--text-2); }
.import-facts dd { margin: 0; }

.import-licence__head { margin: 0 0 .35rem; font-size: 1rem; }
.import-licence {
  margin: 0 0 .5rem; padding: .75rem .9rem;
  background: var(--surface-2); border-radius: var(--r-md);
  color: var(--text-2); font-size: .9rem; line-height: 1.5;
  /* A licence can be a wall of text. Bounded, and scrollable inside itself,
     rather than pushing the decision off the bottom of the dialogue. */
  max-height: 11rem; overflow-y: auto; white-space: pre-wrap;
}
.import-licence__url { display: inline-block; margin-bottom: .75rem; font-size: .9rem; }

.import-note { margin: 1rem 0 0; color: var(--text-2); font-size: .9rem; }
.import-note.is-bad { color: var(--text); border-left: 3px solid var(--warn, var(--border-strong)); padding-left: .8rem; }
