/* Numpakka website — the whole design system, one file.

   Colours come from numpakka-logo/numpakka-tokens.css in the main repo: a
   neutral graphite chrome with no brand hue. The ONE accent is the editor's
   "computed value" blue, and it is used only where a number was calculated —
   never on a button, a heading or a rule. A reader who sees it on the site and
   then opens the app meets the same colour meaning the same thing.

   Type is Google Sans (the family the PDF report uses, so the site and the
   printed document speak with one voice) over Fira Code for code and data. */

/* ── tokens ─────────────────────────────────────────────────────────── */

:root {
  /* Tells the browser both themes are painted here, so the form controls, the
     scrollbars and the canvas behind the page follow the reader's choice
     instead of staying light under a dark page. */
  color-scheme: light dark;

  --bg:         #F5F5F3;
  --surface:    #ECECEA;
  --border:     #D8D8D5;
  --hair:       #C9C9C5;
  --text:       #17181C;
  --muted:      #5F6167;
  --action:     #35383E;
  --action-hi:  #2A2C31;
  --on-action:  #F5F5F3;
  /* Computed values, the SAME hue the app paints them (settings-spec:
     `editor_result_color` ships #0072B2, Okabe-Ito blue). A reader who sees
     a number on this site and then in the program must see one colour. */
  --live:       #0072B2;
  /* What may sit ON the computed blue. The PDF button is the only thing that
     does, and it is a control with a label: white clears 5.2:1 here and 9.1:1
     on the pressed shade, so ONE token covers both states. */
  --on-live:    #FFFFFF;
  --live-press: #004E7A;
  --paper:      #FFFFFF;

  --page: 68rem;
  /* 64 characters of the BODY font, resolved once. Not `64ch`: that unit reads
     the font of whatever element it lands on, so a heading, a paragraph and a
     code block each got a different reading width — six right edges down one
     column, measured 2026-09-04. A column has one width. */
  --measure: 41rem;
  --gap: 1.5rem;
  --pad-page: 1.5rem;

  --radius: 8px;

  /* The window rail in every figure, and what the sliding panels dock against.
     One number, so the two can never drift apart. */
  --rail: 28px;

  /* TWO WEIGHTS EXIST, 400 and 700 (fonts.css ships those slices and no
     others; Fira Code adds a 600 the prose never uses). A rule asking for 500
     is not a lighter bold - CSS resolves it down to 400 and the page renders
     body weight, so the file said one thing and the screen showed another in
     seven places. Ask for 400 or 700; to want anything else is to add slices. */
  --f-sans: 'Google Sans', system-ui, sans-serif;
  --f-mono: 'Fira Code', ui-monospace, monospace;
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg:         #17181C;
    --surface:    #1F2127;
    --border:     #313640;
    --hair:       #3A4049;
    --text:       #ECECEA;
    --muted:      #9296A0;
    --action:     #E2E2DF;
    --action-hi:  #F5F5F3;
    --on-action:  #17181C;
    --live:       #56B4E9;   /* Okabe-Ito sky blue: the same hue, legible on the dark ground */
    /* The sky blue is a LIGHT ground: white on it is 2.3:1 and the button's
       label was unreadable. The ink flips with the theme, the label does not.
       Pressing goes DEEPER, not darker - #004E7A on this paper is 1.7:1, a
       button that disappears when you push it. */
    --on-live:    #17181C;
    --live-press: #3A9AD0;
    --paper:      #24272E;
  }
}

/* ── base ───────────────────────────────────────────────────────────── */

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

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

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font-family: var(--f-sans);
  font-size: 1rem;
  line-height: 1.75;          /* Thai needs the room; Latin is fine with it */
  font-synthesis: none;       /* never let the browser fake bold or italic  */
  -webkit-font-smoothing: antialiased;
}

h1, h2, h3, h4 {
  margin: 0;
  line-height: 1.35;      /* Thai stacks marks above AND below; 1.3 clipped */
  font-weight: 700;
  letter-spacing: -0.01em;
  text-wrap: balance;
}
h1 {
  font-size: clamp(2.1rem, 1.3rem + 3vw, 3.5rem);
  letter-spacing: -0.02em;   /* display sizes need more tracking than body */
}
h2 { font-size: 1.5rem; }
h3 { font-size: 1.15rem; }
h4 { font-size: 1rem; }

p, ul, ol { margin: 0; }
/* Links here take the colour of the prose around them, so THE UNDERLINE IS THE
   WHOLE SIGNAL — the only thing telling a reader that a run of words can be
   clicked. Drawn in `--hair` it stood at 1.43:1 and was invisible until the
   pointer arrived, which is no use to anyone deciding where to point. It is a
   graphical object carrying meaning and takes 3:1 like any other; `--muted`
   gives 5.7:1 and still reads as quiet beside the text it underlines. */
a { color: inherit; text-decoration-color: var(--muted); text-underline-offset: 3px; }
a:hover { text-decoration-color: currentColor; }
:focus-visible { outline: 2px solid var(--live); outline-offset: 2px; }

code, kbd, samp, .mono { font-family: var(--f-mono); font-size: 0.9em; }

img, svg { max-width: 100%; }

/* ── layout ─────────────────────────────────────────────────────────── */

.wrap {
  width: 100%;
  max-width: var(--page);
  margin-inline: auto;
  padding-inline: var(--pad-page);
}

.stack     { display: flex; flex-direction: column; gap: var(--gap); }
.stack--sm { gap: 0.5rem; }
.stack--lg { gap: 2.5rem; }

/* A heading belongs to what comes AFTER it. A flex stack spaces every child
   the same, so a heading sat exactly as far from its own first paragraph as
   from the paragraph it was leaving — and a run of headings and prose read as
   one undifferentiated column. Space is added ABOVE only.

   An eyebrow is part of the heading below it, so it takes the space and the
   heading then takes none. */
.stack > h2:not(:first-child),
.stack > h3:not(:first-child),
.stack > .eyebrow:not(:first-child) { margin-top: 1.25rem; }
.stack > .eyebrow + h2 { margin-top: -1rem; }   /* a label sits ON its heading */

.section { padding-block: 4rem; }
.section + .section { border-top: 1px solid var(--border); }

.measure { max-width: var(--measure); }

/* `measure` on a `.wrap` marks a page that is nothing but reading matter. The
   column keeps the page's full width, so the nav and the footer still line up
   with its edges; the text inside is capped and CENTRED. Nothing sits to the
   right of it — no sidebar, no contents list, and by decision there never will
   be — and space on one side alone reads as a mistake where space on both
   reads as a margin. Pages that DO fill the width (home, sheets, download)
   put `measure` on the elements inside instead, and stay left.

   Cap the children, never the wrap: shrinking the wrap would shrink the page
   column itself and leave the prose on a different left edge from the chrome. */
.wrap.measure { max-width: var(--page); }
/* `width` before `max-width` on purpose: an auto inline margin on a flex item
   turns OFF its cross-axis stretch, so without it every child collapsed to the
   width of its own longest word. */
/* Left, not centred. A reading column narrower than the page used to sit in
   the middle of it, so the four pages built this way started their text 192px
   right of every other page's — and 192px right of the brand in the masthead
   directly above it. Moving between pages, the whole column jumped. */
.wrap.measure > * { width: 100%; max-width: var(--measure); margin-inline: 0; }

.lede { font-size: 1.0625rem; color: var(--muted); }

/* Two Thai rules, both learned by looking at the rendered page:

   Fira Code has NO Thai glyphs, so any Thai inside the mono face silently falls
   back to whatever the system offers — a second designer's letterforms beside
   Google Sans, which is the exact defect this site exists to avoid. The mono
   face is therefore only ever used for code, filenames and figures, never for
   a label that might hold Thai.

   And Thai letterforms join, so tracking them apart breaks the word; the script
   also has no case for `text-transform` to reach. Neither is used anywhere. */
/* 14px is the floor for anything a reader READS here, not 13. Thai stacks its
   vowels above and its tone marks above those, so a line of Thai spends height
   the Latin alphabet does not — at 13px the marks that tell สาม from ส้าม start
   closing up. The only type below this floor is inside the figures, which are
   pictures of an editor and are drawn at the editor's own size. */
.eyebrow {
  font-family: var(--f-sans);
  font-size: 0.875rem;
  color: var(--muted);
}

/* ── header ─────────────────────────────────────────────────────────── */

.masthead { border-bottom: 1px solid var(--border); background: var(--bg); }

.masthead__inner {
  display: flex;
  align-items: center;
  gap: 1.75rem;
  padding-block: 0.9rem;
  flex-wrap: wrap;
}

.brand {
  display: flex;
  align-items: center;
  gap: 0.55rem;
  font-weight: 700;
  font-size: 1.05rem;
  letter-spacing: -0.01em;
  text-decoration: none;
}
/* 22px, not the mark's own 19: the nib is a narrow shape, so at 19 its ink
   stood 13.7px against an 11.9px cap height and still read as slight — the
   shortfall is mass, not height. Measured against the wordmark, 2026-09-04. */
.brand svg { flex: none; width: 22px; height: 22px; }
.brand path { fill: var(--text); }

.nav { display: flex; gap: 1.25rem; font-size: 0.9375rem; }
.nav a { color: var(--muted); text-decoration: none; }
.nav a:hover { color: var(--text); }
.nav a[aria-current="page"] { color: var(--text); font-weight: 700; }

.masthead__spacer { flex: 1 1 auto; }

/* ── buttons ────────────────────────────────────────────────────────── */

.btn {
  display: inline-block;
  padding: 0.5rem 1rem;
  border: 1px solid var(--action);
  border-radius: var(--radius);
  background: var(--action);
  color: var(--on-action);
  font: inherit;
  font-size: 0.9375rem;
  font-weight: 400;
  text-decoration: none;
  white-space: nowrap;
}
.btn:hover { background: var(--action-hi); border-color: var(--action-hi); }

.btn--ghost { background: var(--paper); color: var(--text); border-color: var(--muted); }
.btn--ghost:hover { background: var(--surface); border-color: var(--text); }

.btn-row { display: flex; flex-wrap: wrap; gap: 0.625rem; align-items: center; }
/* A row of buttons that CLOSES a section, rather than sitting in a `.stack`
   where the gap spaces it. Not `--gap`: that is the step BETWEEN things in one
   group, and these buttons are not part of the group above them. The page's
   other step is the section's own 4rem padding, so this sits between the two -
   far enough to read as a new thing, near enough to belong to the section. */
.btn-row--end { margin-top: 2.5rem; }
.btn-row small { color: var(--muted); font-size: 0.875rem; }

/* ── hero ───────────────────────────────────────────────────────────── */

/* The lede is the headline's second line, not a paragraph after it: a short
   name over a 24px gap reads as cut off. Pulling it up makes the two read as
   one block, which is what the owner meant by the headline being abrupt — the
   fix is distance, not another sentence. */
.hero h1 + .lede { margin-top: -0.75rem; }

.hero { display: grid; gap: 2.5rem; align-items: center; }
@media (min-width: 62rem) { .hero { grid-template-columns: 1.05fr 0.95fr; } }

/* ── the editor demo (a picture of the app, drawn in CSS) ───────────── */

.demo {
  background: var(--paper);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
  font-family: var(--f-mono);
  font-size: 0.8125rem;
  line-height: 2;
}
.demo__bar {
  padding: 0.35rem 0.75rem;
  background: var(--surface);
  border-bottom: 1px solid var(--border);
  font-family: var(--f-sans);   /* a filename may be Thai; see .eyebrow */
  font-size: 0.75rem;
  color: var(--muted);
}
/* The editor reveals ONE line at a time: the line the caret sits on shows its
   source, every other line shows what it computed. The demo does the same, so
   the box is a picture of the real thing rather than a before/after slide.
   Both states share one grid cell, so nothing reflows when they swap. */
.demo__body { padding: 0.75rem 0.875rem 0.875rem; overflow-x: auto; }
/* A rendered line is SANS — that is what the editor shows once a zone has run.
   Only the revealed source stays in the code face. */
.demo__line { white-space: nowrap; font-family: var(--f-sans); }
/* The gutter is part of the editor chrome, so it keeps the code face even
   though the rendered lines beside it are sans. */
.demo__gutter {
  display: inline-block; width: 1.75rem;
  font-family: var(--f-mono); font-variant-numeric: tabular-nums;
  color: var(--muted); user-select: none;
}
.demo__head { font-weight: 700; font-size: 0.875rem; }

/* The caret on the new line below: visible exactly while the zone above is
   rendered, because leaving the zone is what rendered it. */
/* .caret is declared below with the same specificity, so this needs one more
   class to survive it — otherwise the shorthand there wipes the phase
   animation and the caret shows on both lines at once. */
.caret.caret--next { animation: caret10 5s steps(1) infinite, blink 1.06s steps(1) infinite; }
@keyframes caret10 { 0%, 83.9% { opacity: 0 } 84%, 100% { opacity: 1 } }

.caret {
  display: inline-block;
  width: 1.5px; height: 1.05em;
  margin-left: 1px;
  /* Baseline-relative, the way a real caret sits: it drops a little BELOW the
     baseline, not all the way to the descender. `text-bottom` put its foot on
     the descender line and the whole bar read as hanging low. */
  vertical-align: -0.12em;
  background: var(--text);
  animation: blink 1.06s steps(1) infinite;
}
@keyframes blink { 50% { opacity: 0 } }

/* Hovering freezes everything, so the code can actually be read. */
/* LINE 9 CARRIES THE WHOLE MECHANISM, so it plays every step: each zone is
   typed character by character, STANDS FINISHED long enough to read — closing
   backtick and all — and only then renders, which is what the caret leaving it
   does (editor-spec §2: reveal is per zone, and a caret at the zone's edge
   still counts as inside it).

   THE WHOLE LINE IS TYPED, prose included — a reader watches someone write a
   sentence with a calculation in it, which is the claim. Every run below is
   paced at the same ~1% of the loop per character, so the hand does not speed
   up when it reaches the code.

     0-19%    type the prose              ระยะโก่งตัวสูงสุด
     19-53%   type zone one as source     `{python} δ = 5*w*L**4/(384*E*I)`
     53-58%   it stands there, finished
     58%      the caret moves on          δ = 5·w·L⁴/(384·E·I)
     58-61%   type the prose               =
     61-73%   type zone two as source     `{python} δ`
     73-78%   it stands there, finished
     78%      the caret moves on          16.9
     78-81%   type the unit as prose      mm
     84%      the caret drops to line 10

   Each source run is an inline-block clipped by an animated width, so the caret
   after it rides the growing edge; a run left behind goes to width 0 and takes
   no space, which is how the line closes up as a zone collapses. Every collapse
   lands while the run to its right is still empty, so nothing visibly jumps.
   Prose runs are sized in `em` from their MEASURED width — `ch` is the width of
   a digit and overshoots a run of spaces, which left a gap after `=` and cut
   the tail off the second `m`. */
.demo__t0, .demo__t1, .demo__t2a, .demo__t2b, .demo__t3 {
  display: inline-block;
  overflow: hidden;
  white-space: nowrap;
  vertical-align: bottom;
  animation-duration: 5s;
  animation-iteration-count: infinite;
}
/* 33ch: the zone is 33 characters WITH both backticks. Counting the opening one
   only clipped the closing one clean off (measured: content 260px, box 253px). */
/* 6.9em measured, not counted: `ch` is a digit's width and Thai is nothing
   like a digit. 18 steps is the code points in the run — the tone marks and
   the vowels above the line take a step without advancing the width, which is
   what a hand typing Thai does too. */
.demo__t0  { width: 6.9em;
             animation-name: type0;  animation-timing-function: steps(18, end); }
.demo__t1  { font-family: var(--f-mono); width: 33ch;
             animation-name: type1;  animation-timing-function: steps(33, end); }
.demo__t2a { width: 1.2em;
             animation-name: type2a; animation-timing-function: steps(3, end); }
.demo__t2b { font-family: var(--f-mono); width: 12ch;
             animation-name: type2b; animation-timing-function: steps(12, end); }
.demo__t3  { width: 2em;
             animation-name: type3;  animation-timing-function: steps(3, end); }

@keyframes type0  { 0% { width: 0 } 19%, 100% { width: 6.9em } }
@keyframes type1  { 0%, 19% { width: 0 } 53%, 57.9% { width: 33ch } 58%, 100% { width: 0 } }
@keyframes type2a { 0%, 58% { width: 0 } 61%, 100% { width: 1.2em } }
@keyframes type2b { 0%, 61% { width: 0 } 73%, 77.9% { width: 12ch } 78%, 100% { width: 0 } }
@keyframes type3  { 0%, 78% { width: 0 } 81%, 100% { width: 2em } }

/* A rendered zone is not typed: it appears whole, the instant the caret goes.
   `font-size: 0` rather than `display` — every browser animates it, and a
   zero-size run takes no width. */
.demo__z1out, .demo__z2out {
  animation-duration: 5s; animation-timing-function: steps(1);
  animation-iteration-count: infinite;
}
.demo__z1out { animation-name: render1; }
.demo__z2out { animation-name: render2; }
@keyframes render1 { 0%, 57.9% { font-size: 0 } 58%, 100% { font-size: 1em } }
@keyframes render2 { 0%, 77.9% { font-size: 0 } 78%, 100% { font-size: 1em } }

.caret--calc { animation: caret9 5s steps(1) infinite, blink 1.06s steps(1) infinite; }
@keyframes caret9 { 0%, 83.9% { opacity: 1 } 84%, 100% { opacity: 0 } }

.demo:hover [class*='demo__t'],
.demo:hover [class*='demo__z'],
.demo:hover .caret { animation-play-state: paused; }

/* What is left has to be the finished line: the two zones rendered, all the
   prose — the sentence that opens it included — and no caret. */
@media (max-width: 43.99rem) {
  .demo__t1, .demo__t2b, .caret { display: none; }
  .demo__t0, .demo__t2a, .demo__t3, .demo__z1out, .demo__z2out {
    animation: none; width: auto; font-size: 1em;
  }
}

@media (prefers-reduced-motion: reduce) {
  .demo__t1, .demo__t2b, .caret { display: none; }
  .demo__t0, .demo__t2a, .demo__t3, .demo__z1out, .demo__z2out {
    animation: none; width: auto; font-size: 1em;
  }
}

.demo__note {
  padding: 0.6rem 0.875rem 0.7rem;
  border-top: 1px solid var(--border);
  background: var(--surface);
  font-family: var(--f-sans);
  font-size: 0.875rem;
  line-height: 1.55;
  color: var(--muted);
}
.live { color: var(--live); }

/* ── cards ──────────────────────────────────────────────────────────── */

/* Each card carries its own border. A shared 1px-gap grid looked tidier until a
   row came up short and the exposed gap read as a big grey tile. */
.cards { display: grid; gap: 0.75rem; }
@media (min-width: 44rem) { .cards--3 { grid-template-columns: repeat(3, 1fr); } }

.card {
  /* A grid item will not shrink below its own min-content width unless told
     to, and this one holds `white-space: nowrap` titles — so on a 320px screen
     the longest title pushed the card 1px past the edge and the whole page
     scrolled sideways. The titles already truncate; they just needed
     permission to. */
  min-width: 0;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 1.15rem 1.25rem 1.35rem;
}
a.card { text-decoration: none; display: block; }
a.card:hover { background: var(--surface); }
a.card h3 { text-decoration: underline; text-decoration-color: var(--muted); text-underline-offset: 3px; }
a.card:hover h3 { text-decoration-color: currentColor; }
.card h3, .card h4 { margin-bottom: 0.35rem; }
.card p { color: var(--muted); font-size: 0.9375rem; }

/* ── points ─────────────────────────────────────────────────────────── */

.points { display: grid; column-gap: 1.75rem; row-gap: 2.25rem; }
@media (min-width: 44rem) { .points { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 64rem) { .points { grid-template-columns: repeat(3, 1fr); } }
.points h2 { font-size: 1rem; margin-bottom: 0.25rem; }
/* One line drawing per point. currentColor so it follows the theme, and
   never the teal: that colour means a computed value everywhere else. */
/* The mark sits BESIDE the heading, not floating above it: a two-column card
   reads as one object, where an icon stacked on top reads as decoration. */
.points > div {
  display: grid;
  grid-template-columns: 20px 1fr;
  column-gap: 0.75rem;
  row-gap: 0;
  align-items: start;
  align-content: start;
}
.pic { grid-column: 1; grid-row: 1 / span 2; color: var(--muted); padding-top: 3px; }
.pic svg { width: 20px; height: 20px; display: block; }
.points h2, .points p { grid-column: 2; }
.points p { color: var(--muted); font-size: 0.9375rem; }

/* ── the sheet index, built for hundreds of rows ────────────────────── */

/* Cards stop working somewhere around thirty sheets: they are tall, they force
   two columns, and scanning them means reading paragraphs. A directory of a few
   hundred wants dense rows the eye can run down, a real search box, and a
   category page per shelf that can be linked and indexed on its own. */

.finder { display: flex; flex-wrap: wrap; gap: 0.75rem 1.25rem; align-items: baseline; }

.search {
  flex: 1 1 18rem;
  font: inherit;
  font-size: 0.9375rem;
  padding: 0.55rem 0.8rem;
  color: var(--text);
  background: var(--paper);
  border: 1px solid var(--muted);
  border-radius: var(--radius);
}
.search::placeholder { color: var(--muted); }
/* NO focus border of its own. The site's one `:focus-visible` rule already
   draws a 2px ring 2px outside this box; tinting the border as well put two
   blue lines around the field with a white gap between them, and no other
   control on the site is marked twice. The ring is the indicator. */

.count { color: var(--muted); font-size: 0.875rem; white-space: nowrap; }

/* Tags. A chip is a link when the map gives it a page, plain text otherwise. */
.chip {
  display: inline-block;
  font-size: 0.875rem;
  padding: 0.1rem 0.6rem;
  border: 1px solid var(--border);
  border-radius: 999px;
  color: var(--muted);
  text-decoration: none;
  white-space: nowrap;
}
a.chip:hover { background: var(--surface); color: var(--text); border-color: var(--hair); }
.chip--plain { border-style: dashed; }

.card__examples {
  margin: 0.5rem 0 0; padding: 0; list-style: none;
  color: var(--muted); font-size: 0.875rem;
}
.card__examples li {
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

.row {
  display: flex; flex-direction: column; gap: 0.2rem;
  padding: 0.85rem 0;
  border-bottom: 1px solid var(--border);
  text-decoration: none;
}
/* Hover belongs to the rows that GO somewhere. Scoped to `.row` it also lit a
   pending row and underlined its title - a paragraph that behaves like a link
   and then does nothing when clicked. */
a.row:hover { background: var(--surface); }
.row__title { font-weight: 700; font-size: 1rem; }
a.row:hover .row__title { text-decoration: underline; text-underline-offset: 3px; }
/* A row that OPENS says so while sitting still. Hover said it before, which no
   phone has: a whole page of links read as a page of paragraphs there. The
   chevron is on `a.row` alone, so its absence is what marks the rows that are
   not ready — one signal doing both jobs, instead of a chip on every line. */
a.row { position: relative; padding-right: 1.6rem; }
a.row::after {
  content: "\203A";
  position: absolute;
  right: 0.3rem;
  top: 50%;
  margin-top: -0.7em;
  font-size: 1.25rem;
  line-height: 1;
  /* The chevron is the only thing saying this row opens, so it is held to the
     3:1 a meaningful graphic is owed. `--hair` drew it at 1.4:1 - a mark that
     is there for a checker and gone for a reader. */
  color: var(--muted);
}
a.row:hover::after { color: var(--text); }
.row__summary { color: var(--muted); font-size: 0.875rem; }
.row__tags { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.2rem; }

.empty { padding: 2rem 0; color: var(--muted); }
.empty p + p { margin-top: 0.5rem; }

/* ── notice ─────────────────────────────────────────────────────────── */


/* Who made this and how to reach him: one block, at the end of the page that
   asks the question. Set apart by a rule rather than by a heading, because a
   heading over three lines weighs more than the three lines do. The Latin
   spelling of the name lives here rather than in the prose above — inside a
   sentence it is a bump the reader has to step over, and it is an identifier,
   which is what this block is for. */
.card-me {
  border-left: 2px solid var(--muted);
  padding-left: 1rem;
  line-height: 1.7;
}

/* ONE colour. A notice is the paragraph on the page a reader is least free to
   skip, and it was set in the grey that means "you may skip this", with only
   the imperative half lifted back out of it. Emphasis is the <strong>'s job;
   the colour says whether the sentence is worth reading, and this one is. */
.notice {
  border-left: 2px solid var(--muted);
  padding: 0.75rem 0 0.75rem 1rem;
  font-size: 0.9375rem;
}

/* ── footer ─────────────────────────────────────────────────────────── */

.footer {
  border-top: 1px solid var(--border);
  padding-block: 2.5rem 3.5rem;
  font-size: 0.875rem;
  color: var(--muted);
}
.footer a { color: var(--muted); }
.footer__row { display: flex; flex-wrap: wrap; gap: 0.5rem 1.5rem; }
/* The one thing a reader must know, set off by a rule rather than left as a
   loose sentence that reads like an afterthought. */
.footer__notes { border-left: 2px solid var(--muted); padding-left: 0.9rem; }

/* ── phone ──────────────────────────────────────────────────────────── */

@media (max-width: 43.99rem) {
  .section { padding-block: 2.75rem; }
  :root { --gap: 1.25rem; }
}

/* ── polish ─────────────────────────────────────────────────────────── */

/* Keyboard readers meet the navigation on every page; this is the way past it.
   Visible only when focused, which is when it is needed. */
.skip {
  position: absolute; left: -9999px;
  background: var(--action); color: var(--on-action);
  padding: 0.6rem 1rem; border-radius: 0 0 5px 0; z-index: 10;
  /* This one sits on its own dark chip, where the site's `--muted` underline
     is 1.9:1 and effectively gone. It keeps the underline — it is a link and
     the reader who reaches it is on a keyboard — but draws it in its own ink. */
  text-decoration-color: currentColor;
}
.skip:focus { left: 0; top: 0; }

/* A template that is listed but not yet published: not a link, and it says so
   rather than leading somewhere that 404s.
   It is marked by what it LACKS - no chevron, no hover - and by a muted title
   and its own chip, never by opacity. `opacity: .6` on the row multiplied
   through to the text and put every line in the library at 2.5:1, under the
   4.5:1 a reader is owed; with nothing published yet that was every row on the
   page. The title's own colour carries the state at 5.7:1 instead. */

.list { padding-left: 1.35rem; }
.list li + li { margin-top: 0.35rem; }
.list li::marker { color: var(--muted); }

.dl-card {
  display: flex; flex-wrap: wrap; gap: 1.25rem; align-items: center;
  border: 1px solid var(--border); border-radius: var(--radius);
  padding: 1.35rem 1.5rem;
}
.dl-card > div { flex: 1 1 14rem; }
.dl-card h2 { font-size: 1.15rem; }
.dl-card__meta { color: var(--muted); font-size: 0.875rem; }

/* On a phone every link in the chrome must be reachable with a thumb. The text
   is only ~26px tall, so the hit area is grown rather than the type: 44px is
   the size a finger actually lands on (WCAG 2.5.5). Desktop keeps its tighter
   header, where a pointer is exact. */
@media (max-width: 43.99rem) {
  .nav a,
  .footer__row a,
  .brand {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
  }
  .nav { gap: 1rem; }
  .footer__row { gap: 0.25rem 1.25rem; }

  /* TWO ROWS: the brand and the button a reader came to press, then the menu
     across the full width. Left to wrap on its own the header took THREE rows
     and 218px — a third of a phone screen gone before the page starts, on
     every page. The spacer exists to push the button right on a wide screen;
     with the two of them alone on a line, `margin-right: auto` does that job
     and the spacer is one more thing to wrap. */
  .masthead__inner { gap: 0.35rem 1rem; }
  .masthead__spacer { display: none; }
  .brand { margin-right: auto; }
  .nav { order: 1; flex-basis: 100%; }
}

/* `hidden` must win. A `display` rule in a class beats the UA's [hidden] style,
   so a row marked hidden by the search stayed on screen while every scripted
   check that asked for `.hidden` said it was gone. Nothing below this line is
   allowed to be visible while the attribute is set. */
[hidden] { display: none !important; }

/* Something the site offers but cannot deliver yet. It must not look like a
   control that works: no pointer, no hover, and it says why in its own label.

   `.chip--soon` is on no page today and may never be again: since 2026-09-10
   a row exists only when its sheet does, so nothing is pending. It is kept
   for the case of a sheet published while a revision is in flight. Everything
   else a scan for unworn classes finds is genuinely dead. */
.btn--soon,
.chip--soon {
  background: transparent;
  border-style: dashed;
  border-color: var(--muted);
  color: var(--muted);
  cursor: default;
}
.btn--soon:hover { background: transparent; border-color: var(--muted); }

/* A listed template that has no page yet: dimmed AND muted, so it reads as
   unavailable at a glance rather than only through its small chip. */
.row--pending .row__title { color: var(--muted); font-weight: 400; }

/* The example titles inside a map card. Running them together on one line was
   a mistake: Thai puts no spaces between words, so three titles joined by a
   separator read as one unbroken run. They stay on separate lines — one line
   each, truncated — and are kept clearly secondary instead, so they no longer
   look like links of their own. */
.card__examples li {
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  padding-left: 0.75rem; position: relative;
}
.card__examples li::before {
  content: "·"; position: absolute; left: 0.15rem; color: var(--muted);
}
/* "และอีก N" — a card lists three titles however many it holds, and without
   this the three read as the whole card. It is a count, not a title, so it
   takes no bullet. */
/* The one line offered while the library is still being written. It sits
   between the rows and the notice, and is not itself a notice. */
.pending-note { margin-top: 1.25rem; color: var(--muted); font-size: 0.9375rem; }

.card__more { color: var(--muted); }
.card__more::before { content: none !important; }

/* A step out of the map, not the tail of its last section: the rule and the
   space say so, since a bare button under the final row of cards reads as
   belonging to that heading. */
.see-all {
  border-top: 1px solid var(--border);
  padding-top: 1.5rem;
  margin-top: 0.5rem;
}

/* One dark button per screen. The masthead's call to action rides along on
   every page, so it steps back to an outline and lets the page's own primary
   action be the filled one — otherwise two identical dark buttons sit on
   screen together and neither wins. */
.masthead .btn {
  background: var(--paper);
  color: var(--text);
  border-color: var(--muted);
}
.masthead .btn:hover { background: var(--surface); border-color: var(--text); }

/* The search box is the primary interaction on a page built for hundreds of
   templates; it should not read as inert as the boxes around it.

   HOVER GOES STRONGER, NEVER FAINTER. These three rest on `--muted` and used
   to hover to `--hair`, which is paler in the light theme and darker in the
   dark one — either way it sinks toward the ground, so the control faded at
   the moment the pointer arrived. `--text` is the same step `a.row:hover`
   already takes, and it reads as an approach in both themes. */
.search:hover { border-color: var(--text); }

/* Sideways movement on a tag page: the neighbouring shelves, so a reader who
   arrived from a search engine is not left with one list and no way on. */
.nearby {
  display: flex; flex-wrap: wrap; gap: 0.5rem 0.75rem; align-items: center;
  padding-bottom: 0.25rem;
}
.nearby > span { color: var(--muted); font-size: 0.875rem; }
.chips { display: flex; flex-wrap: wrap; gap: 0.3rem; }

/* LaTeX's own steps, the ones the printed page uses: 1.44 / 1.2 / 1 */
.rendered__h1 { font-size: 1.44em; font-weight: 700; }
.rendered__h2 { font-size: 1.2em; font-weight: 700; margin-top: 0.9em; }


/* ── step figures on /start/ ────────────────────────────────────────────
   Every step on that page shows the same kind of picture the home page's
   editor does: a small piece of the real screen, playing the one thing the
   step is about. Six figures share ONE mechanism rather than six animations.

   `.swap` stacks two states in a single grid cell so nothing reflows when
   they trade places; `.fx-a` and `.fx-b` are the two halves of one 6 s clock,
   and every figure on the page runs off it — so the value the Inputs panel
   changes in one figure is the value the Results panel reports in the next. */
.swap { display: grid; }
.swap > * { grid-area: 1 / 1; }

/* A value is TYPED, never swapped. The field goes 20 -> 2 -> 22: one
   backspace and one keystroke, which is what a reader would actually do, and
   the document beside it changes on the SAME keystrokes because the panel is
   writing into it. The results panel follows on both, 90 -> 9 -> 99, because
   the sheet recomputes as the number changes rather than when you stop. */
.fx-a, .fx-b, .fx-c { animation: 6s steps(1) infinite; }
.fx-a { animation-name: fx-a; }
.fx-b { animation-name: fx-b; }
.fx-c { animation-name: fx-c; }
@keyframes fx-a { 0%, 24.9% { opacity: 1 } 25%, 74.9% { opacity: 0 } 75%, 100% { opacity: 1 } }
@keyframes fx-b { 0%, 24.9% { opacity: 0 } 25%, 29.9% { opacity: 1 } 30%, 69.9% { opacity: 0 }
                  70%, 74.9% { opacity: 1 } 75%, 100% { opacity: 0 } }
@keyframes fx-c { 0%, 29.9% { opacity: 0 } 30%, 69.9% { opacity: 1 } 70%, 100% { opacity: 0 } }

/* A whole BLOCK fades, because a hard cut on something that size reads as a
   page flicker. */
.fx-fa, .fx-fb { animation: 6s steps(1) infinite; }
.fx-fa { animation-name: fx-fa; }
.fx-fb { animation-name: fx-fb; }
@keyframes fx-fa { 0%, 49.9% { opacity: 1 } 50%, 94.9% { opacity: 0 } 95%, 100% { opacity: 1 } }
@keyframes fx-fb { 0%, 49.9% { opacity: 0 } 50%, 94.9% { opacity: 1 } 95%, 100% { opacity: 0 } }

/* The ring and the caret arrive a beat before the first keystroke and leave a
   beat after the last, which is what makes the document read as a CONSEQUENCE
   of the typing rather than a coincidence beside it. */
@keyframes fx-focus {
  0%, 22.9%  { opacity: 0 }
  23%, 36.9% { opacity: 1 }
  37%, 67.9% { opacity: 0 }
  68%, 78.9% { opacity: 1 }
  79%, 100%  { opacity: 0 }
}
.fig__box { position: relative; }
/* The ring stands 2px outside the field, so the field has to paint above the
   row below it or that row's own box clips the ring's bottom edge. */
.fig__box:has(.fig__ring) { z-index: 1; }
.fig__ring {
  position: absolute;
  inset: -2px;
  border: 2px solid var(--live);
  border-radius: 5px;
  animation: fx-focus 6s steps(1) infinite;
}
.fig__caret {
  display: inline-block;
  width: 1.5px;
  height: 1em;
  margin-left: 1px;
  vertical-align: -0.15em;
  background: var(--text);
  animation: fx-focus 6s steps(1) infinite, blink 1.06s steps(1) infinite;
}

/* Nothing on the page turns at the same instant as anything else: five figures
   flipping together reads as a strobe, not as five separate demonstrations. */
/* The export figure runs on the opposite half of the clock, so the page never
   has two figures turning over at the same instant. Inputs and Results stay in
   step with each other: they are one keystroke seen from two sides. */

.fig {
  background: var(--paper);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
  font-size: 0.8125rem;
  line-height: 2;
}
.fig__bar {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.35rem 0.75rem;
  background: var(--surface);
  border-bottom: 1px solid var(--border);
  font-size: 0.75rem;
  color: var(--muted);
}

/* The window's own export button, which is where the PDF really comes from. */
.fig__pdf { display: inline-flex; align-items: center; gap: 0.25rem;
  padding: 0.1rem 0.45rem; border-radius: 4px;
  background: var(--live); color: var(--on-live); font-size: 0.7rem; }
.fig__pdf svg { width: 0.62rem; height: 0.62rem; }
.fig__bar { align-items: center; }
.fig__pdf--on { background: var(--live-press); transform: translateY(1px); }
/* The button lights for the press itself, not for as long as the page it
   produced is on screen: a control that stays lit reads as a mode. */
.fig__bar .fig__pdf { animation: 6s steps(1) infinite; }
.fig__bar .fig__pdf.fx-fa { animation-name: pdf-idle; }
.fig__bar .fig__pdf.fx-fb { animation-name: pdf-press; }
/* EXACT complements, switching at the same two instants. When the two windows
   were written apart the button vanished for half a second between them, and
   overlapped itself at the other end. A press swaps the button; it never
   leaves the bar empty. */
@keyframes pdf-idle  { 0%, 15.9% { opacity: 1 } 16%, 20.9% { opacity: 0 } 21%, 100% { opacity: 1 } }
@keyframes pdf-press { 0%, 15.9% { opacity: 0 } 16%, 20.9% { opacity: 1 } 21%, 100% { opacity: 0 } }
.fig__line { white-space: nowrap; }
.fig__h { font-weight: 700; }

/* A sheet reads top to bottom like paper, so the figure for that claim rolls
   through one rather than cutting between two states. It rests at each end:
   a loop that never stops moving is a loop nobody can read. */
/* The window, rails and all. A panel OVERLAYS the page rather than pushing it:
   the sheet keeps its place and its width, and a panel slides across the blank
   margin beside it. That is why opening one never moves the line you are
   reading - the room it takes was never the page's. */
.fig__win {
  display: flex;
  align-items: stretch;
  position: relative;
  /* Tall enough for the PANEL, not just for the sheet: the panels are absolute,
     so a window sized to the page alone cuts their last row in half. */
  min-height: 8rem;
}
/* A FIXED width, as Rail.svelte gives it (32px there, 28 at figure scale) -
   and the one number the sliding panels are placed from, below. While the rail
   sized itself to its label, every panel offset was a hand-tuned constant that
   went stale the moment the rail changed, leaving a strip of ground showing
   between the panel and the rail. */
.fig__rail {
  position: relative;            /* a panel slides UNDER its own rail */
  z-index: 2;
  flex: none;                    /* a fixed strip is not a spring */
  width: var(--rail);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.9rem;
  padding: 0.6rem 4px;
  /* ONE COLOUR for the rail, the panel and the ground the paper lies on
     (owner, 2026-09-05) - chrome-bg, exactly as Rail.svelte:46 paints it. Only
     the title bar is a different tone. The hairlines are what separate them,
     which is how the app itself does it. */
  background: var(--bg);
}
.fig__rail--l { border-right: 1px solid var(--border); }
.fig__rail--r { border-left: 1px solid var(--border); }
/* One entry. It is NOT rotated: it is the box the active bar is measured
   against, and a rotated box swaps its own left and right - every bar written
   against the label before this one landed on the wrong side. */
.fig__tab {
  position: relative;
  display: flex;
  padding: 0.35rem 2px;
  color: var(--muted);
}
/* The label alone turns, bottom-to-top on BOTH edges the way AutoCAD turns a
   vertical bar (Rail.svelte:11), so the reader tilts their head once. */
.fig__tab > span {
  writing-mode: vertical-rl;
  transform: rotate(180deg);
  font-size: 0.75rem;
  line-height: 1.25;
  letter-spacing: 0.04em;
  font-weight: 400;
}
/* One panel at a time, with a beat of nothing open between them: that beat is
   what says a panel is something you put away, and a figure with something
   always open cannot show it. */
.fig__slide {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 10rem;
  animation-duration: 6s;
  animation-timing-function: ease-in-out;
  animation-iteration-count: infinite;
}
/* Docked against the rail, and casting over the page it covers, never back
   over its own rail. */
/* NO SHADOW. A blur spreads in every direction, and this panel runs the full
   height of the window, so any shadow it casts lands on the title bar above it
   with nothing there to have cast it. Tuning the spread only made the bleed
   thinner. The panel already carries a border on the edge facing the page —
   that is what says it is laid over the page, and it cannot bleed. */
.fig__slide--l { left: var(--rail); }
.fig__slide--r { right: var(--rail); }
/* A panel that is simply OPEN for the whole figure: steps two and three are
   about what you do once it is up, not about opening it. */
.fig__slide--open { animation: none; transform: none; }
/* The active entry, exactly as web/src/lib/Rail.svelte draws it: the text
   colour, weight 700, and ONE 2px bar in that same colour - not the accent,
   and not on the page side.
   Rail.svelte hangs the bar at left/right -3px, which on the real window is
   the window's own edge. Here that edge is drawn as a 1px border, so the bar
   is measured against the UNROTATED entry and lands a pixel inside the frame:
   beside it, never on top of it. */
.fig__tab::before {
  content: "";
  position: absolute;
  top: 2px;
  bottom: 2px;
  width: 2px;
  background: var(--text);
  opacity: 0;
}
.fig__rail--l .fig__tab::before { left: -3px; }
.fig__rail--r .fig__tab::before { right: -3px; }
.fig__tab--on { color: var(--text); }
.fig__tab--on > span { font-weight: 700; }
.fig__tab--on::before { opacity: 1; }
.fig__slide > * { height: 100%; }
.fig__slide--l { animation-name: slide-l; }
.fig__slide--r { animation-name: slide-r; }
.fig__tab--l, .fig__tab--r,
.fig__tab--l > span, .fig__tab--r > span,
.fig__tab--l::before, .fig__tab--r::before {
  animation-duration: 6s; animation-timing-function: steps(1);
  animation-iteration-count: infinite;
}
.fig__tab--l { animation-name: tab-l; }
.fig__tab--r { animation-name: tab-r; }
.fig__tab--l > span { animation-name: wt-l; }
.fig__tab--r > span { animation-name: wt-r; }
.fig__tab--l::before { animation-name: bar-l; }
.fig__tab--r::before { animation-name: bar-r; }
/* Closed = the whole panel, shadow included, off the window's edge. Parking it
   just under the rail left its edge showing inside the strip. */
@keyframes slide-l {
  0%, 6%    { transform: translateX(calc(-100% - var(--rail))) }
  13%, 41%  { transform: translateX(0) }
  48%, 100% { transform: translateX(calc(-100% - var(--rail))) }
}
@keyframes slide-r {
  0%, 56%   { transform: translateX(calc(100% + var(--rail))) }
  63%, 91%  { transform: translateX(0) }
  98%, 100% { transform: translateX(calc(100% + var(--rail))) }
}
/* Each property is animated where it BELONGS - colour on the entry, weight on
   the rotated label, opacity on the bar itself. Bundling them into one rule is
   how a `box-shadow` bar from an earlier attempt survived alongside ::before,
   drawing the second black line beside Results. */
@keyframes tab-l { 0%, 6%  { color: var(--muted) } 13%, 41% { color: var(--text) } 48%, 100% { color: var(--muted) } }
@keyframes tab-r { 0%, 56% { color: var(--muted) } 63%, 91% { color: var(--text) } 98%, 100% { color: var(--muted) } }
@keyframes wt-l  { 0%, 6%  { font-weight: 400 } 13%, 41% { font-weight: 700 } 48%, 100% { font-weight: 400 } }
@keyframes wt-r  { 0%, 56% { font-weight: 400 } 63%, 91% { font-weight: 700 } 98%, 100% { font-weight: 400 } }
@keyframes bar-l { 0%, 6%  { opacity: 0 } 13%, 41% { opacity: 1 } 48%, 100% { opacity: 0 } }
@keyframes bar-r { 0%, 56% { opacity: 0 } 63%, 91% { opacity: 1 } 98%, 100% { opacity: 0 } }

/* The editor is a SHEET of fixed width sitting on a ground, not a surface
   that runs edge to edge. The margin either side is the backdrop, which is
   where an opening panel goes - so it lands on nothing, never on a word. */
.fig__win { background: var(--bg); }
.fig__win .fig__page {
  flex: 1;
  min-width: 0;
  display: flex;
  justify-content: center;
  padding: 0.6rem 10.1rem;
}
.fig__win .fig__page > div { flex: none; }
/* The page is a card. 4px, not the 8px the app gives a full-size surface: the
   figure is a miniature of that window, so the radius scales with it. */
.fig__sheetwrap {
  width: 16.4rem;
  border-radius: 4px;
  padding: 0.55rem;
  background: var(--paper);
  border: 1px solid var(--border);
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.06);
}
.fig__slide--r .fig__panel { border-right: 0; border-left: 1px solid var(--border); }
/* A rail panel is narrower than the paired figures below, so its columns are
   sized for the two-digit values a demonstration actually carries. */
.fig__win .fig__row { grid-template-columns: 1.6rem 3.2rem auto; }
.fig__win .fig__box { min-width: 3.2rem; }

/* The panel and the page, side by side: that pairing IS the claim, so the
   figure keeps them on one row until the column is too narrow to hold both. */
/* The panel is the SAME ground as the rail and the work area (owner,
   2026-09-05); its BORDER is what says it is laid over the page, not a colour
   of its own and not a shadow. */
.fig__panel { border-right: 1px solid var(--border); padding: 0.7rem 0.875rem;
  background: var(--bg); }
.fig__page { padding: 0.7rem 0.875rem; }

.fig__row {
  display: grid;
  grid-template-columns: 2.6rem 5rem auto;
  gap: 0.5rem;
  align-items: center;
  padding-left: 0.5rem;
}
/* Results rows print in full black like the page they were read from. What
   says read-only is the missing field, not a dimmed number. */
.fig__box {
  display: inline-block;
  min-width: 5rem;
  padding: 0 0.4rem;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--paper);
}
/* Beside the value, both panels echo the text you typed after the number in
   your sentence. It is not a unit the program understands - the engine is
   unit-less - so it sits outside the field and cannot be edited there. */
.fig__unit { color: var(--muted); }
/* The computed blue, the same token the editor gives the same number. The app
   paints both from `--np-result` (Outputs.svelte:149, Editor.svelte:1535), so a
   black value in the panel beside a blue one on the page is simply wrong. */
.fig__val { text-align: right; color: var(--live); }


/* The printed page: the same sheet, now carrying the header block it prints
   with, shown inside the reader that opened the file.
   It is WHITE in both themes, with the ink and the computed blue the printer
   lays down - a PDF does not follow the reader's colour scheme, and a page
   that went dark with the site read as one more panel of the app. */
.fig__sheetwrap--out {
  background: #FFFFFF;
  color: #17181C;
  font-size: 0.46rem;
  line-height: 1.6;
}
.fig__sheetwrap--out .live { color: #0072B2; }
/* Copied from what `page` renders: a band across the whole sheet, the mark on
   the left, three columns of title-block fields, then a rule. */
.fig__head { display: grid; grid-template-columns: 2.2rem 1fr 1fr 0.7fr; gap: 0.35rem;
  /* The real page rules this band off at 0.5pt. At a sixth of A4 even a hairline
   reads as a stray mark, so the band is separated by space and by the bold
   title beneath it instead. */
  padding-bottom: 0.3rem; }
.fig__mark { text-align: center; line-height: 1; }
.fig__mark svg { width: 0.75rem; height: 0.75rem; display: block; margin: 0 auto 0.1rem; }
.fig__mark span { font-size: 0.34rem; letter-spacing: 0.08em; }
/* The field labels print grey on white, so the value is fixed here too - the
   theme's muted grey is a LIGHT grey in dark mode and would vanish. */
.fig__head > div:not(.fig__mark) { color: #5F6167; white-space: nowrap; }
.fig__docttl { margin: 0.4rem 0 0.3rem; font-size: 0.75rem; font-weight: 700; }


/* A value being typed. The readings that are not showing leave the flow
   ENTIRELY rather than hiding in place: deleting a digit has to close the gap
   and pull the caret and the words after it along, the way the editor does. */
.num > .fx-a { animation-name: nv-a; }
.num > .fx-b { animation-name: nv-b; }
.num > .fx-c { animation-name: nv-c; }
@keyframes nv-a { 0%, 24.9% { font-size: 1em } 25%, 74.9% { font-size: 0 } 75%, 100% { font-size: 1em } }
@keyframes nv-b { 0%, 24.9% { font-size: 0 } 25%, 29.9% { font-size: 1em } 30%, 69.9% { font-size: 0 }
                  70%, 74.9% { font-size: 1em } 75%, 100% { font-size: 0 } }
@keyframes nv-c { 0%, 29.9% { font-size: 0 } 30%, 69.9% { font-size: 1em } 70%, 100% { font-size: 0 } }

/* Windows asks where to put it, and the reader's own PDF program opens it.
   Both are separate WINDOWS standing over the editor - which is the whole
   point of this figure: Numpakka writes a file, it never shows you one. Each
   one is inset on all four sides so the editor shows around it; a panel that
   reaches an edge reads as part of the app, and that is the misreading the
   owner asked to design out. */
.dlg, .viewer {
  position: absolute;
  display: flex;
  flex-direction: column;
  border: 1px solid var(--hair);
  border-radius: 5px;
  background: var(--paper);
  box-shadow: 0 10px 30px rgb(0 0 0 / 0.26);
  animation-duration: 6s;
  animation-timing-function: steps(1);
  animation-iteration-count: infinite;
}
/* Centred on the WINDOW, not on the page: a dialog that hangs off the top of
   the sheet reads as a hole cut in the document. */
.dlg {
  left: 50%; top: 50%;
  width: 15.5rem;
  transform: translate(-50%, -50%);
  animation-name: dlg-in;
}
.dlg__bar, .viewer__bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  padding: 0.3rem 0.5rem;
  background: var(--surface);
  border-bottom: 1px solid var(--border);
  border-radius: 4px 4px 0 0;
  font-size: 0.6875rem;
  color: var(--muted);
}
/* Neither window carries title-bar buttons (owner, 2026-09-05): the frame, the
   shadow and the grey canvas already say these are other windows, and window
   furniture at this size is clutter. */
.dlg__body { padding: 0.7rem; font-size: 0.6875rem; }
.dlg__row { display: flex; align-items: center; gap: 0.4rem; color: var(--muted); }
.dlg__field {
  flex: 1;
  padding: 0.1rem 0.35rem;
  border: 1px solid var(--border);
  border-radius: 3px;
  color: var(--text);
}
.dlg__btns { display: flex; justify-content: flex-end; gap: 0.35rem; margin-top: 0.6rem; }
.dlg__btn { padding: 0.1rem 0.6rem; border: 1px solid var(--border); border-radius: 3px; }
.dlg__btn--go { background: var(--live); border-color: var(--live); color: #fff;
                animation: dlg-press 6s steps(1) infinite; }
.viewer {
  left: 7%; right: 7%; top: 7%; bottom: 7%;
  overflow: hidden;
  animation-name: viewer-in;
}
/* A reader shows the page ON something: the grey canvas is the whole reason a
   white page reads as a page and not as the program's own background. And it
   shows the TOP of the page at a size you can read, with the rest below the
   fold - a whole A4 shrunk into a window this short is a smudge. */
.viewer__body {
  position: relative;
  flex: 1;
  min-height: 0;
  padding: 0.4rem 0.4rem 0;
  display: flex;
  justify-content: center;
  align-items: flex-start;
  overflow: hidden;
  background: #6E7076;
}
/* `zoom`, not `transform: scale`: only zoom changes the LAYOUT box, so the
   page below the fold is genuinely below it rather than painted over the
   window's own frame. */
.viewer__body .fig__sheetwrap--out {
  zoom: 1.45;
  border: 0;
  border-radius: 2px 2px 0 0;
  box-shadow: 0 2px 8px rgb(0 0 0 / 0.35);
}
/* The thumb is what makes the cut edge read as SCROLLED rather than clipped. */
.viewer__scroll {
  position: absolute;
  top: 0.4rem;
  right: 3px;
  width: 4px;
  height: 34%;
  border-radius: 2px;
  background: rgb(255 255 255 / 0.45);
}
/* One take, no dead air: press, Windows asks, Save, the dialog closes, the
   reader comes up. The only gap is the 0.4s the dialog takes to go. */
@keyframes dlg-in    { 0%, 20.9% { opacity: 0 } 21%, 51.9% { opacity: 1 } 52%, 100% { opacity: 0 } }
@keyframes dlg-press { 0%, 45.9% { background: var(--live) } 46%, 51.9% { background: #004E7A } 52%, 100% { background: var(--live) } }
@keyframes viewer-in { 0%, 55.9% { opacity: 0 } 56%, 96.9% { opacity: 1 } 97%, 100% { opacity: 0 } }
.fig:hover .dlg, .fig:hover .viewer, .fig:hover .dlg__btn--go { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) {
  .dlg { display: none; }
  .viewer { animation: none; opacity: 1; }
}
.fig--export .fig__win { min-height: 12.5rem; }

/* Every figure wears the same frame. The export one needed its own while a
   file strip sat outside the window; now the dialog and the viewer are over
   the window, so it is just a window like the other three. */
/* The file is a thumbnail of the page, not the page. `zoom`, not a transform:
   a transform leaves the box its full size, so the strip below the window has
   to guess a height and clips whatever it guessed wrong. */

/* A `\` at the end of a line does not vanish when it renders: the editor
   prints a dim return mark in its place (tutorial.pakka, "Units"). */
.brk { color: var(--muted); padding-left: 0.3em; font-size: 0.85em; }

/* A picture and its caption are ONE thing in the stack, so the caption sits by
   its own margin and the stack's gap falls outside the pair. Loose in the
   stack they were 1.9rem apart - further than the picture stood from the
   paragraph above it - and the caption read as the next paragraph.
   `<figure>` also carries the UA's 1em/40px margin, which has to go. */
.fig-wrap { margin: 0; }
.fig__cap { margin-top: 0.4rem; color: var(--muted); font-size: 0.875rem; }
.mono, .fig .num { font-variant-numeric: tabular-nums; }

/* Hovering freezes every figure, the same bargain the home page's demo makes. */
.fig:hover .fig__slide, .fig:hover .fig__tab,
.fig:hover .fig__tab > span, .fig:hover .fig__tab::before,
.fig:hover .fig__ring, .fig:hover .fig__caret,
.fig:hover .fx-fa, .fig:hover .fx-fb, .swap:hover .fx-fa, .swap:hover .fx-fb,
.fig:hover .fx-a, .fig:hover .fx-b, .fig:hover .fx-c,
.swap:hover .fx-a, .swap:hover .fx-b { animation-play-state: paused; }

/* Reduced motion gets the before and the after as two blocks, which is what
   the page showed before these figures moved. A NUMBER cannot show two values
   at once, so there only the changed one stays. */
@media (prefers-reduced-motion: reduce) {
  .fx-a, .fx-b, .fx-c, .fx-fa, .fx-fb { animation: none; opacity: 1; }
  .num .fx-a, .num .fx-b { display: none; }
  .fig__slide { animation: none; transform: none; }
  .fig__tab, .fig__tab > span, .fig__tab::before { animation: none; }
  .fig__tab--l, .fig__tab--r { color: var(--text); }
  .fig__tab--l > span, .fig__tab--r > span { font-weight: 700; }
  .fig__tab--l::before, .fig__tab--r::before { opacity: 1; }
  .fig__ring, .fig__caret { display: none; }
  .swap { display: block; }
  .swap > * + * { margin-top: 0.75rem; }
  .num.fx-a, .fig__row .fx-a { display: none; }
}
/* BELOW 44rem THE WINDOW STOPS BEING A WINDOW.
   It needs 641px: two 28px rails, two 10.1rem margins, a 16.4rem sheet. A
   390px phone gives it 342, so the sheet was clipped on both edges, the right
   rail fell outside the frame, and a panel that overlays the MARGIN on a
   desktop landed on the words instead — step three, whose caption says the
   number on the page matches the number in the panel, covered the page
   completely. Narrowing the panel cannot fix arithmetic.
   So on a phone the panel becomes a band above or below the page, the rails go
   (a tab has nothing to stand beside once nothing slides), and the sheet takes
   the width it has. Step one loses its slide and shows the panel open, which
   is the honest trade: a still picture that can be read beats an animation of
   something hidden. */
@media (max-width: 43.99rem) {
  .fig__win { flex-direction: column; }
  .fig__rail { display: none; }
  .fig__win .fig__page { order: 0; padding: 0.6rem; }
  .fig__sheetwrap { width: auto; }
  .fig__slide {
    position: static;
    width: auto;
    transform: none !important;
    animation: none !important;
    box-shadow: none;
  }
  .fig__slide--l { order: -1; }
  .fig__slide--r { order: 1; }
  .fig__panel { border: 0; }
  .fig__slide--l .fig__panel { border-bottom: 1px solid var(--border); }
  .fig__slide--r .fig__panel { border-top: 1px solid var(--border); }
  /* The two windows that stand OVER the editor keep doing that - they are
     other programs - but they need the room a phone has. */
  /* The desktop dialog is centred with `left: 50%` and a translate that pulls
     it back by half its width. Giving it two edges without clearing the X
     translate leaves it pulled half a width off the left. */
  .dlg { left: 4%; right: 4%; width: auto; transform: translateY(-50%); }
  .viewer { left: 4%; right: 4%; top: 4%; bottom: 4%; }
  .viewer__body .fig__sheetwrap--out { zoom: 1; }
  .viewer__body { justify-content: flex-start; }
}

/* ── typing inside the home page's editor box ──────────────────────────
   The shell is `.demo`, unchanged: same bar, body, gutter, line and caret the
   home page uses, so the two pages show one program. Only the timing below is
   this page's, and it types rather than swaps — a zone renders at the moment
   the caret leaves it, which is the only way to show that the reveal is per
   ZONE: the last line holds two, and the first renders while the second is
   still being typed.

   Mono spans are measured in `ch`, which is exact. Thai prose is not
   monospaced, so those widths were measured in the browser and are carried
   here in `em` - re-measure with scratchpad/measure-type.py if the text
   changes. */
.type .demo__line { min-height: 2em; }
/* A rendered heading is BIGGER but keeps a plain line's box: 1.44em x 1.39 and
   1.2em x 1.67 both come to the 2em the editor gives every other line. Every
   row is then the same height, so the rhythm holds and nothing has to reserve
   room for a state it is not in. */
.type--md .rendered__h1 { line-height: 1.39; }
.type--md .rendered__h2 { line-height: 1.67; }
.type .rendered__h2 { margin-top: 0; }
.type .t, .type .o, .type .u { display: inline-block; vertical-align: bottom; }
.type .t, .type .u { overflow: hidden; white-space: nowrap; }
.type .t, .type .o, .type .u, .type .caret {
  animation-duration: var(--type-t);
  animation-iteration-count: infinite;
}
.type .t, .type .u { animation-timing-function: steps(var(--n), end); }
.type .o { animation-timing-function: steps(1); }

/* the zone block: three lines, four zones, 20 s */
/* The block is prose. Only a zone's own source wears the code face - the
   unit typed after the closing backtick is prose and must look like it. */
.type--z { --type-t: 10s; }
.type--z .t1, .type--z .t2, .type--z .t3a, .type--z .t3c { font-family: var(--f-mono); }
.type--z .u { display: inline-block; overflow: hidden; white-space: nowrap;
  vertical-align: bottom; font-family: var(--f-sans); }
.type--z .t1  { --n: 17; width: 17ch; animation-name: ty1; }
.type--z .u1  { --n: 6; width: 3.25em; animation-name: ty1u;
  animation-duration: var(--type-t); animation-iteration-count: infinite;
  animation-timing-function: steps(6, end); }
.type--z .t2  { --n: 16; width: 16ch; animation-name: ty2; }
.type--z .u2  { --n: 3; width: 1.75em; animation-name: ty2u;
  animation-duration: var(--type-t); animation-iteration-count: infinite;
  animation-timing-function: steps(3, end); }
.type--z .t3a { --n: 23; width: 23ch; animation-name: ty3a; }
.type--z .t3b { --n: 3;  width: 1.2em; animation-name: ty3b; font-family: var(--f-sans); }
.type--z .t3c { --n: 12; width: 12ch; animation-name: ty3c; }
.type--z .t3d { --n: 5;  width: 2.7em; animation-name: ty3d; font-family: var(--f-sans); }
.type--z .o1  { animation-name: sh1; }
.type--z .o2  { animation-name: sh2; }
.type--z .o3a { animation-name: sh3a; }
.type--z .o3c { animation-name: sh3c; }
/* The prose that follows a zone renders on its OWN event - the caret leaving
   the line - not on the zone's. That is why it is a second pair of spans. */
.type--z .q1  { animation-name: sq1; }
.type--z .q2  { animation-name: sq2; }
.type--z .c1  { animation: cr1 10s steps(1) infinite, blink 1.06s steps(1) infinite; }
.type--z .c2  { animation: cr2 10s steps(1) infinite, blink 1.06s steps(1) infinite; }
.type--z .c3  { animation: cr3 10s steps(1) infinite, blink 1.06s steps(1) infinite; }
.type--z .c4  { animation: cr4 10s steps(1) infinite, blink 1.06s steps(1) infinite; }

/* A ZONE RENDERS WHEN THE CARET LEAVES ITS CLOSING BACKTICK, NOT WHEN THE LINE
   ENDS. Typing the closing backtick puts the caret past the zone, so the zone
   is done and shows its result while the prose after it is still being typed.
   The old timing held every zone as source until the whole line was finished,
   which is a different program. The 1.5% after each zone completes is the beat
   a reader needs to see the finished source, not a wait for anything. */
@keyframes ty1  { 0% { width: 0 } 11%, 12.5% { width: 17ch } 12.6%, 100% { width: 0 } }
@keyframes sh1  { 0%, 12.5% { font-size: 0 } 12.6%, 100% { font-size: 1em } }
@keyframes ty1u { 0%, 12.6% { width: 0 } 15.5%, 16.9% { width: 3.25em } 17%, 100% { width: 0 } }
@keyframes sq1  { 0%, 16.9% { font-size: 0 } 17%, 100% { font-size: 1em } }

@keyframes ty2  { 0%, 18% { width: 0 } 27%, 28.5% { width: 16ch } 28.6%, 100% { width: 0 } }
@keyframes sh2  { 0%, 28.5% { font-size: 0 } 28.6%, 100% { font-size: 1em } }
@keyframes ty2u { 0%, 28.6% { width: 0 } 30.5%, 32.9% { width: 1.75em } 33%, 100% { width: 0 } }
@keyframes sq2  { 0%, 32.9% { font-size: 0 } 33%, 100% { font-size: 1em } }

@keyframes ty3a { 0%, 34% { width: 0 } 50%, 51.5% { width: 23ch } 51.6%, 100% { width: 0 } }
@keyframes sh3a { 0%, 51.5% { font-size: 0 } 51.6%, 100% { font-size: 1em } }
@keyframes ty3b { 0%, 51.6% { width: 0 } 54%, 100% { width: 1.2em } }
@keyframes ty3c { 0%, 54% { width: 0 } 63%, 64.5% { width: 12ch } 64.6%, 100% { width: 0 } }
@keyframes sh3c { 0%, 64.5% { font-size: 0 } 64.6%, 100% { font-size: 1em } }
@keyframes ty3d { 0%, 64.6% { width: 0 } 68%, 100% { width: 2.7em } }
@keyframes cr1 { 0%, 16.9% { opacity: 1 } 17%, 100% { opacity: 0 } }
@keyframes cr2 { 0%, 17.9% { opacity: 0 } 18%, 32.9% { opacity: 1 } 33%, 100% { opacity: 0 } }
@keyframes cr3 { 0%, 33.9% { opacity: 0 } 34%, 72.9% { opacity: 1 } 73%, 100% { opacity: 0 } }
@keyframes cr4 { 0%, 72.9% { opacity: 0 } 73%, 100% { opacity: 1 } }

/* the Markdown block: four lines, each rendering as the caret leaves it, 20 s */
.type--md {
  --type-t: 10s;
  /* measured in the browser by scratchpad/measure-type.py */
  --w1: 14.89em; --w2: 6.13em; --w3: 10.33em; --w4: 12.17em;
}
/* Prose is prose even while its markers show: the editor reveals `#`, it does
   not change the face. Only a zone's source is code. */
.type--md .t { font-family: var(--f-sans); }
.type--z .o { font-family: var(--f-sans); }
.type--md .m1 { --n: 34; width: var(--w1); animation-name: md1; }
.type--md .m2 { --n: 15; width: var(--w2); animation-name: md2; }
.type--md .m3 { --n: 22; width: var(--w3); animation-name: md3; }
.type--md .m4 { --n: 28; width: var(--w4); animation-name: md4; }
.type--md .r1 { animation-name: mo1; }
.type--md .r2 { animation-name: mo2; }
.type--md .r3 { animation-name: mo3; }
.type--md .r4 { animation-name: mo4; }
.type--md .k1 { animation: mc1 10s steps(1) infinite, blink 1.06s steps(1) infinite; }
.type--md .k2 { animation: mc2 10s steps(1) infinite, blink 1.06s steps(1) infinite; }
.type--md .k3 { animation: mc3 10s steps(1) infinite, blink 1.06s steps(1) infinite; }
.type--md .k4 { animation: mc4 10s steps(1) infinite, blink 1.06s steps(1) infinite; }
@keyframes md1 { 0% { width: 0 } 19%, 21.9% { width: var(--w1) } 22%, 100% { width: 0 } }
@keyframes md2 { 0%, 23% { width: 0 } 32%, 34.9% { width: var(--w2) } 35%, 100% { width: 0 } }
@keyframes md3 { 0%, 36% { width: 0 } 49%, 51.9% { width: var(--w3) } 52%, 100% { width: 0 } }
@keyframes md4 { 0%, 53% { width: 0 } 69%, 71.9% { width: var(--w4) } 72%, 100% { width: 0 } }
@keyframes mo1 { 0%, 21.9% { font-size: 0 } 22%, 100% { font-size: 1.44em } }
@keyframes mo2 { 0%, 34.9% { font-size: 0 } 35%, 100% { font-size: 1.2em } }
@keyframes mo3 { 0%, 51.9% { font-size: 0 } 52%, 100% { font-size: 1em } }
@keyframes mo4 { 0%, 71.9% { font-size: 0 } 72%, 100% { font-size: 1em } }
@keyframes mc1 { 0%, 21.9% { opacity: 1 } 22%, 100% { opacity: 0 } }
@keyframes mc2 { 0%, 22.9% { opacity: 0 } 23%, 34.9% { opacity: 1 } 35%, 100% { opacity: 0 } }
@keyframes mc3 { 0%, 35.9% { opacity: 0 } 36%, 51.9% { opacity: 1 } 52%, 100% { opacity: 0 } }
@keyframes mc4 { 0%, 52.9% { opacity: 0 } 53%, 71.9% { opacity: 1 } 72%, 100% { opacity: 0 } }

.type:hover .t, .type:hover .o, .type:hover .caret { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) {
  /* WHAT IS LEFT MUST BE THE FINISHED LINE. Hiding every `.t` hides the PROSE
     spans too - the ` = ` and ` kN·m` that sit between two zones on line 3 -
     and leaves `M = w·L²/890`. And the unit source has to go with them, or the
     line shows the typed `kN/m\` beside the rendered `kN/m↵`. */
  .type .t, .type .caret, .type .u { display: none; }
  .type .o { animation: none; font-size: 1em; }
  .type--z .t3b, .type--z .t3d { display: inline-block; width: auto; animation: none; }
  .type--md .r1 { font-size: 1.44em; }
  .type--md .r2 { font-size: 1.2em; }
  /* The bar keeps ONE button. `.fig__bar .fig__pdf` outranks the `.fx-*` reset,
     so the press state has to be removed, not just un-animated. */
  .fig__bar .fig__pdf { animation: none; }
  .fig__bar .fx-fb { display: none; }
}

/* em, not rem: a hidden line is hidden with font-size 0, and the indent has
   to vanish with it or the caret waiting on that line sits inside it. */
.rev__bullet { padding-left: 1.35em; text-indent: -0.9em; }
.rev__bullet::before { content: "•"; color: var(--muted); padding-right: 0.48em; }

