/* Kotoba console — single stylesheet, no build step, no external font services.
   Everything is served from 'self' so the CSP needs no exceptions. */

/* ── The Thai face ─────────────────────────────────────────────────────────
   Until now no Thai font was served at all: the stack named Noto Sans Thai
   first and nothing shipped it, so what a Thai reader saw was whichever family
   their machine happened to carry — Sukhumvit Set on a Mac, Leelawadee UI on
   Windows, something else again on Android. Asked directly, Chrome was drawing
   the landing headings in Sukhumvit Set, the *fourth* name in the list, at
   `font-weight: 900` — a weight that face does not have, so the browser was
   synthesising it by thickening the outlines. On a script that stacks tone
   marks over its consonants, a faked weight is not only a cosmetic problem.

   26 KB for the whole variable face, 100–900, so every weight the design asks
   for is a real one. `unicode-range` keeps it to Thai: Latin still falls
   through to the system UI font, which is what this design has been drawn
   against all along, and the file is never fetched by a reader who never sees
   a Thai glyph.

   `swap`, not the `block` the lettering fonts use: those are typeset INTO the
   customer's page and a reflow moves their work, while this is chrome, where
   text that cannot be read for even a moment is the worse failure. Served from
   `static/`, which `font-src 'self'` already allows. OFL, licence beside it. */
@font-face {
  font-family: "Noto Sans Thai";
  src: url("../fonts/noto-sans-thai-variable.1089c9bdc330.woff2") format("woff2");
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
  unicode-range: U+02D7, U+0303, U+0331, U+0E01-0E5B, U+200C-200D, U+25CC;
}

/* ── The band's two other scripts ──────────────────────────────────────────
   The claim in the marquee is said in the four languages this product actually
   works between, and two of them need glyphs no system font can be assumed to
   carry at weight 900. The whole faces are 5.1 MB and 10 MB; subset to the
   seven characters the band prints, they are 2.2 KB and 1.2 KB — the words are
   fixed in the markup, so the subset can be too. Re-cut them if the copy
   changes, or the missing glyph will simply not draw.

   `unicode-range` on each, so a page that never prints these characters never
   asks for the file, and neither face can capture a Latin or Thai glyph from
   the families above. */
@font-face {
  font-family: "KL Band JP";
  src: url("../fonts/noto-sans-jp-band.0d4bbc3c6ee7.woff2") format("woff2");
  font-weight: 900;
  font-style: normal;
  font-display: swap;
  unicode-range: U+3042-3093, U+8AAD;
}
@font-face {
  font-family: "KL Band SC";
  src: url("../fonts/noto-sans-sc-band.d2b08e0c9176.woff2") format("woff2");
  font-weight: 900;
  font-style: normal;
  font-display: swap;
  unicode-range: U+901A, U+987A;
}

/* ── The hero balloon's four faces ─────────────────────────────────────────
   The balloon on the landing hero says one sentence in each of the seven
   languages we sell, and it is set in the face the *pipeline itself* would
   choose for that language — `fonts.choices(<lang>)`, first entry:

     en · th · es · pt-br  →  Mali Bold
     ko                    →  KomaGothicA1 Bold
     zh-hans               →  KomaNotoSansSC Bold
     zh-hant               →  KomaNotoSansTC Bold

   Setting the shop window in some other face would be showing work we do not
   do. Whole, the four are 7.8 MB — the Traditional Chinese one alone is 4.6 —
   so they are cut down to the characters those seven sentences print, the same
   way the band's faces above are, and for the same reason. 11.5 KB for all
   four. **Re-cut them when the sentences change** or the new characters will
   simply not draw; the source list lives beside the subsetting call in
   `docs/mockups` and the sentences themselves in `landing.html`.

   `swap`, not `block`: this is chrome, and a balloon that is briefly empty is
   worse than a balloon briefly set in the fallback. */
@font-face {
  font-family: "KL Hero Mali";
  src: url("../fonts/hero-mali.f9bfb3c1ec5a.woff2") format("woff2");
  font-weight: 700; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "KL Hero KR";
  src: url("../fonts/hero-kr.2e6b3d3b0e7e.woff2") format("woff2");
  font-weight: 700; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "KL Hero SC";
  src: url("../fonts/hero-sc.a4d9ac5a4e8a.woff2") format("woff2");
  font-weight: 700; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "KL Hero TC";
  src: url("../fonts/hero-tc.a4be5fa69c3e.woff2") format("woff2");
  font-weight: 700; font-style: normal; font-display: swap;
}

:root {
  --bg: #0d0e14;
  /* สีประจำเครดิต — Top เคาะ 16 ส.ค.: ทุกเลขเครดิตทั้งเว็บเป็นน้ำเงินนี้
     จะได้อ่านออกทันทีว่าตัวไหนคือเครดิต ไม่ปนกับทอง/แดงของอย่างอื่น */
  --credit: #5db5e8;
  --surface: #14161f;
  --surface-2: #1b1e2a;
  --surface-3: #232736;
  --border: #2a2f40;
  --border-soft: #21253200;
  --text: #e8eaf2;
  --text-muted: #9aa1b8;
  --text-faint: #6b7186;
  --accent: #7c6cf5;
  --accent-soft: #7c6cf522;
  --accent-hover: #8f81ff;
  --ok: #3fbf8f;
  --warn: #e3a552;
  --danger: #ef5f6b;
  --adult: #e0567f;
  /* ค่าจริงถูกประกาศทับโดยบล็อก design system ข้างล่าง — แก้ที่นั่น */
  --radius: 10px;
  --radius-lg: 14px;
  --shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 8px 24px rgba(0, 0, 0, .25);
  --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
  --sans: system-ui, -apple-system, "Segoe UI", "Noto Sans Thai", sans-serif;
}

:root[data-theme="light"] {
  --bg: #f6f7fb;
  --credit: #1c6ea4;
  --surface: #ffffff;
  --surface-2: #f1f2f8;
  --surface-3: #e7e9f2;
  --border: #dcdfeb;
  --text: #1a1c25;
  --text-muted: #5b6178;
  --text-faint: #868ca1;
  --accent-soft: #7c6cf518;
  --shadow: 0 1px 2px rgba(20, 22, 40, .06), 0 8px 24px rgba(20, 22, 40, .06);
}

/* ═══════════════════════════════════════════════════════════════════════════
   KomaLume — the design system, bridged onto this stylesheet's token names.

   The two branches grew two vocabularies: this file says `--border` and
   `--text-muted` and paints in violet; the design system says `--line2` and
   `--mid` and paints in vermilion. Almost none of the names overlap, and the
   handful that do (`--surface`, `--radius`) disagree about their values.

   So the palette is not pasted over the rules — it is defined once here, in its
   own names, and this file's names are then declared to BE those values. Every
   one of the 1,554 lines below keeps working, unedited, and renders in the new
   system. Screens this branch added since the fork — the editor, the legal
   pages — come along without being touched at all.

   Where the two disagree about how many colours a product needs, the design
   system wins and the loss is stated rather than hidden. See `--ok` below.
   ═══════════════════════════════════════════════════════════════════════════ */

:root {
  --thai: "Noto Sans Thai", "Noto Sans Thai Looped", "IBM Plex Sans Thai",
          "Sukhumvit Set", "Leelawadee UI", "Thonburi", "Tahoma";
  /* Thai first, not buried mid-stack: this console is read in Thai, and the
     fallback order decides what a Thai page actually renders in. */
  --sans: var(--thai), ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;

  /* 12 and 16, overturning this block's own "nothing rounds past 4" rule:
     Top ruled the other way on 2026-08-16, with /jobs/new/ as the reference —
     every screen wears the same rounding — staff included; the operator
     asked for it and the operator is the staff. */
  --radius: 12px;
  --radius-lg: 16px;

  /* Every control is one of two heights and nothing else. A control's height is
     a system decision, not a component's, which is why it is a token. */
  --control-h: 2.5rem;
  --control-h-sm: 1.875rem;
}

/* ── KomaLume, dark ─────────────────────────────────────────────────────── */
:root, :root[data-theme="dark"] {
  --ink: #08070A;
  --ink2: #0C0A10;
  --km-surface: #131019;
  --line: #1D1926;
  --line2: #2A2434;
  --hi: #F7F4EE;
  --mid: #B9B2C0;
  --low: #7C7488;

  --verm: #FF3B1F;
  /* The fill is its own token because the accent cannot carry a label: #FF3B1F
     under #FBF7EF measures 3.33:1 — legal only above 23px, and every primary
     here is 14–16px. #D92B12 is 4.57:1, and hover deepens rather than
     brightens, because the label is fixed. */
  --verm-fill: #D92B12;
  --verm-fill-h: #B32410;
  --verm-ink: #FF3B1F;

  --acid: #FFD400;
  --acid-ink: #FFD400;
  /* The one cold note, reserved for source material — the original language,
     the raw scan, the thing that is not ours. Nothing else may spend it. */
  --cold: #00B4E6;
  /* Ink for the one cold note. The same value in both themes because the note
     itself is the same brightness in both — it is a reserved hue, not a
     surface that follows the page. */
  --on-cold: #08070A;
  --paper: #FBF7EF;
  --focus: #FFD400;
  --num: #FFD400;
  --km-card-edge: var(--line2);
  --km-lift: none;
  color-scheme: dark;
}

/* ── KomaLume, light ────────────────────────────────────────────────────── */
:root[data-theme="light"] {
  /* Warm, the same hex as --paper — not the cool #F7F7F9 this shipped with,
     which read as flat gray rather than "light" (Top flagged this 23 ส.ค.,
     against the public header's own dropdown sheet, but the sheet was only
     the messenger: it correctly mirrors --ink everywhere it is used, so the
     canvas itself was what needed to change, sitewide). */
  --ink: #FBF7EF;
  --ink2: #F0F0F3;
  --km-surface: #FFFFFF;
  --line: #ECECF0;
  --line2: #E2E2E8;
  --hi: #14131A;
  --mid: #4A4A56;
  /* Measured on `--ink2`, the faintest ground faint text lands on: 4.83. */
  --low: #686874;

  --verm: #D92B12;
  --verm-fill: #D92B12;
  --verm-fill-h: #B32410;
  /* 5.67 on the light surfaces, where --verm would have been 4.18. */
  --verm-ink: #B32410;

  --acid: #E5BE00;
  --acid-ink: #6B5300;
  --cold: #0090BB;
  /* Ink for the one cold note. The same value in both themes because the note
     itself is the same brightness in both — it is a reserved hue, not a
     surface that follows the page. */
  --on-cold: #08070A;
  --paper: #FBF7EF;
  --focus: #D92B12;
  /* เหลืองทองแบบเดียวกับ dark แต่เข้มพอให้อ่านบนพื้นขาว — Top ขอ 16 ส.ค. */
  --num: #AD8A00;
  /* A hairline is a machine's answer on a lit page; here the panel lifts. */
  --km-card-edge: transparent;
  --km-lift: 0 1px 2px rgb(20 18 26 / .05), 0 6px 16px -8px rgb(20 18 26 / .10);
  color-scheme: light;
}

/* The same palette again, for the reader who chose neither.
 *
 * "System" is the absence of `data-theme`, and until now the stylesheet had
 * no answer for it: the light tokens existed only under an explicit choice,
 * so a machine set to light got the dark console anyway. `:not([data-theme=
 * "dark"])` is what keeps an explicit dark choice winning over the machine —
 * without it, a reader who asked for dark on a light laptop would be overruled
 * by their own hardware.
 *
 * Duplicated rather than shared because CSS cannot put a media query into a
 * selector list. **If the block above changes, this one changes with it.**
 * Same shape as `.hero-photo` further down, which had this problem first. */
@media (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]) {
    --ink: #FBF7EF;
    --ink2: #F0F0F3;
    --km-surface: #FFFFFF;
    --line: #ECECF0;
    --line2: #E2E2E8;
    --hi: #14131A;
    --mid: #4A4A56;
    /* Measured on `--ink2`, the faintest ground faint text lands on: 4.83. */
    --low: #686874;

    --verm: #D92B12;
    --verm-fill: #D92B12;
    --verm-fill-h: #B32410;
    /* 5.67 on the light surfaces, where --verm would have been 4.18. */
    --verm-ink: #B32410;

    --acid: #E5BE00;
    --acid-ink: #6B5300;
    --cold: #0090BB;
    /* Ink for the one cold note. The same value in both themes because the note
       itself is the same brightness in both — it is a reserved hue, not a
       surface that follows the page. */
    --on-cold: #08070A;
    --paper: #FBF7EF;
    --focus: #D92B12;
    /* เหลืองทองแบบเดียวกับ dark แต่เข้มพอให้อ่านบนพื้นขาว — Top ขอ 16 ส.ค. */
    --num: #AD8A00;
    /* A hairline is a machine's answer on a lit page; here the panel lifts. */
    --km-card-edge: transparent;
    --km-lift: 0 1px 2px rgb(20 18 26 / .05), 0 6px 16px -8px rgb(20 18 26 / .10);
    color-scheme: light;
  }
}


/* ── The bridge ─────────────────────────────────────────────────────────────
   This file's names, declared as the design system's values. Written once for
   both themes, because every value on the right is itself theme-aware. */
:root, :root[data-theme="dark"], :root[data-theme="light"] {
  --bg: var(--ink);
  --surface: var(--km-surface);
  --surface-2: var(--ink2);
  --surface-3: var(--line);
  --border: var(--line2);
  --border-soft: var(--line);
  --text: var(--hi);
  --text-muted: var(--mid);
  --text-faint: var(--low);

  /* Accent as a LABEL, not as a fill — `--verm-ink` is the contrast-checked
     one. Anything that fills with the accent and prints on it uses
     `--accent-fill`, which is the darker value with a measured pair. */
  --accent: var(--verm-ink);
  --accent-fill: var(--verm-fill);
  --accent-hover: var(--verm-fill-h);
  --accent-soft: color-mix(in srgb, var(--verm) 12%, transparent);

  /* Six semantic hues collapse to three, and this is the deliberate loss:
     the design system spends exactly vermilion, acid and one cold blue, and
     signals "done" by WEIGHT rather than by hue — a column of green ticks says
     five things need you when none of them do. `--ok` therefore lands on the
     muted ink. Anywhere that reads as having lost meaning wants the state
     spelled in words, which is the system's answer to it. */
  --ok: var(--mid);
  --warn: var(--acid-ink);
  --danger: var(--verm-ink);
  /* Content rating is not an error. It takes the cold note — the only other
     reserved hue — rather than a second red nobody can tell from danger. */
  --adult: var(--cold);

  --shadow: var(--km-lift);
  --card-edge: var(--km-card-edge);
}


* { box-sizing: border-box; }

/* `hidden` has to win.
 *
 * The attribute's `display: none` comes from the browser's own stylesheet, which
 * any class carrying a `display` beats — so `class="message" hidden` rendered an
 * empty red box on the add-chapter form and a permanently visible size warning on
 * the drop screen. Both were markup the page hides and shows, and both looked
 * like the CSS was fine. `!important` is what outranks a class here, and an
 * element carrying `hidden` has exactly one correct rendering.
 */
[hidden] { display: none !important; }


body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font-family: var(--sans);
  font-size: 15px;
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
}

a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hover); }
h1, h2, h3, h4 { margin: 0 0 .5rem; line-height: 1.25; font-weight: 650; letter-spacing: -.01em; }
h1 { font-size: 1.6rem; }
h2 { font-size: 1.2rem; }
h3 { font-size: 1rem; }
p { margin: 0 0 1rem; }
small { font-size: .82rem; }
code, kbd { font-family: var(--mono); font-size: .85em; }

/* ---------- layout ---------- */
.shell { display: grid; grid-template-columns: 236px 1fr; min-height: 100vh; }

.sidebar {
  background: var(--surface);
  border-right: 1px solid var(--border);
  padding: 1.1rem .8rem;
  display: flex;
  flex-direction: column;
  gap: 1.2rem;
  position: sticky;
  top: 0;
  height: 100vh;
  overflow-y: auto;
}

.brand { display: flex; align-items: center; gap: .6rem; padding: .25rem .5rem; }
/* Only the staff shell still draws a lettered tile, and `.staff-mark` supplies
   its own background — so the gradient that used to end in `#4ac3d9`, a colour
   in none of this project's palettes, has no caller left. */
.brand-mark {
  width: 30px; height: 30px; border-radius: var(--radius);
  background: var(--surface-3);
  display: grid; place-items: center;
  font-weight: 700; color: var(--text); font-size: .95rem;
}
/* ── The wordmark ──────────────────────────────────────────────────────────
   Two axes meet on this element — theme and size — and CSS specificity between
   two two-class rules is decided by source order, which is how both marks once
   rendered side by side as "KL KL". So one axis is a class on the image
   (`.on-dark` / `.on-light`) and the other is a wrapper (`.brand-full` /
   `.brand-min`). Exactly one of the four is ever displayed.

   The size axis is opt-in per caller, and it did not used to be switched at
   all: `.brand-min` was declared `display: none` here and no rule anywhere ever
   turned it back on, so the compact mark shipped for months without rendering
   once — measured at 0 of 204 (page × theme × width) combinations. Both PNGs
   were still downloaded on every page load. The wrapper classes come from
   `partials/_brand.html`; see it for what each caller asks for. */
/* The subtitle belongs UNDER the mark, not beside it: `.brand` is a flex row,
   so a third child lands on the same line and the sidebar reads as one long
   strip of type. `flex-basis: 100%` puts it on its own line without changing
   the row the mark and the wordmark share. */
.brand { flex-wrap: wrap; row-gap: .1rem; }
.brand-sub { flex-basis: 100%; }
.brand-wordmark, .brand-mark-img { display: block; }
.glyph { vertical-align: -0.125em; }
/* The credit gem, wherever the glyph partial draws it: outline follows the
   text colour, the core is the acid gold — one shape for the header chip,
   the sidebar entry and the pricing cards alike. */
.glyph .g-out { fill: none; stroke: currentColor; stroke-width: 2; }
.glyph .g-in { fill: var(--credit); }
.badge.credit { color: var(--credit); font-weight: 700; font-variant-numeric: tabular-nums; }
/* ── Job new v2 — ตาม docs/mockups/job-new-v2.html ── */
.jobnew-cols { display: grid; grid-template-columns: 1fr 330px; gap: 16px; align-items: start; }
@media (max-width: 980px) { .jobnew-cols { grid-template-columns: 1fr; } }
/* topbar ของคอนโซล sticky สูง 4.5rem อยู่ชั้นบน — การ์ดต้องเกาะใต้มัน
   ไม่งั้นเลื่อนแล้วหัว "ใบสรุปงาน" มุดเข้าไปหลังแถบ */
.jobnew-sum { position: sticky; top: calc(4.5rem + 14px); display: grid; gap: 14px;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius-lg); padding: 18px; }
.jobnew-sum-title { margin: 0; font-size: .9rem; }
.jobnew-sum .startbar-row { display: block; }
.jobnew-modes { display: grid; gap: 8px; margin-top: 4px; }
.jn-mo { position: relative; display: flex; gap: 10px; align-items: center; padding: 11px 12px;
  background: var(--surface-2); border: 1px solid var(--border); border-radius: 10px; cursor: pointer; }
.jn-mo input { position: absolute; inset: 0; opacity: 0; cursor: pointer; margin: 0; }
.jn-mo .r { width: 15px; height: 15px; border-radius: 50%; border: 2px solid var(--text-faint); flex: none; }
.jn-mo b { font-size: .86rem; font-weight: 600; }
.jn-mo .pr { margin-left: auto; font: 700 .74rem var(--mono); color: var(--text-muted); white-space: nowrap; }
.jn-mo:has(input:checked) { border-color: var(--credit);
  background: color-mix(in srgb, var(--credit) 6%, var(--surface-2)); }
.jn-mo:has(input:checked) .r { border-color: var(--credit); box-shadow: inset 0 0 0 3.5px var(--credit); }
.jn-mo:has(input:checked) .pr { color: var(--credit); }
.jn-mo:has(input:focus-visible) { outline: 2px solid var(--focus); outline-offset: 2px; }
.jobnew-cost { border-top: 1px solid var(--border); padding-top: 12px; display: grid; gap: 7px; }
.jobnew-cost .jr { display: flex; justify-content: space-between; align-items: baseline;
  font-size: .8rem; color: var(--text-muted); }
.jobnew-cost .jr b { font-family: var(--mono); color: var(--text); font-weight: 600; }
.jobnew-cost .jr.total span { font-size: .84rem; }
.jn-total { font: 700 1.65rem var(--mono) !important; color: var(--credit) !important; }
.jn-total i { font-style: normal; font-family: var(--sans); font-size: .72rem; font-weight: 400;
  color: var(--text-muted); margin-left: 5px; }
.jn-after { margin: 0; font-size: .74rem; color: var(--text-faint); }
.jobnew-go { width: 100%; padding-block: .8rem; font-size: .95rem; justify-content: center; }
.jn-note { margin: 0; font-size: .72rem; color: var(--text-faint); text-align: center; }
/* ปลดมรดกของ layout เก่าออกจากหน้านี้: dropzone เคยถูกสั่งให้สูงเต็มจอตอน
   ยังว่าง (สมัยมันเป็นคอลัมน์เดียว) และ startbar เคยเป็นตารางกรอบหนามี
   เส้นแบ่งช่อง — ในผัง mock ทั้งคู่ต้องเบาลงเหลือการ์ดเดียวกับเพื่อน */
.jobnew-cols { max-width: 1120px; }
/* หน้านี้มนกว่ามาตรฐานเว็บหนึ่งขั้นตามที่ Top ขอ — ค่าเดียวคุมทั้งชุด */
.jobnew-cols { --jn-round: 16px; }
.jobnew-left .dropzone { border-radius: calc(var(--jn-round) + 4px); border-width: 2px; }
.jobnew-sum.startbar { border-radius: var(--jn-round); }
.jobnew-sum input, .jobnew-sum select { border-radius: 10px; }
.jn-mo { border-radius: 12px; }
.startbar-go.jobnew-go { border-radius: 12px; }
/* ── Jobs list v2 — ตารางภาษาเดียวกับ dashboard ── */
.jobs-table .jl-name a { font-weight: 600; color: var(--text); text-decoration: none; }
.jobs-table .jl-name a:hover { color: var(--accent); }
.jobs-table .jl-name small { display: block; color: var(--text-faint); font-size: .72rem; margin-top: 2px; }
/* Any table whose rows are links (data-rowlink) tells you so on hover — the
   jobs list had this and the support lists did not, so identical clickable
   rows behaved differently between two screens. One rule, keyed off the same
   attribute the click handler uses, so the promise and the behavior cannot
   drift apart. */
tbody[data-rowlink] tr:hover td { background: var(--surface-2); }
.jobs-table tr.is-draft td { opacity: .55; }
.jchip { font-size: .72rem; font-weight: 600; padding: 3px 10px; border-radius: 999px;
  background: var(--surface-3); color: var(--text-muted); white-space: nowrap; }
.jchip.pro { background: color-mix(in srgb, var(--credit) 14%, var(--surface-2));
  color: var(--credit); }
/* อัลตร้า = ชั้นพรีเมียม ใส่ทอง (โทนเดียวกับตัวเลข USD) ล้อกับโปรที่เป็นฟ้า */
.jchip.ultra { background: color-mix(in srgb, var(--num) 14%, var(--surface-2));
  color: var(--num); }
.jst { font-size: .8rem; color: var(--text-muted); }
.jst.ok { color: #2e9e68; }
.jst.bad { color: var(--accent); }
.jst-why { display: block; color: var(--text-faint); font-size: .72rem; margin-top: 2px; }
/* ── Credits page v2 ── */
.lg-delta { font-weight: 600; }
.lg-delta.up { color: #2e9e68; }
.pager { display: flex; align-items: center; justify-content: space-between;
  gap: 12px; margin-top: 12px; }
.pager-at { font-size: .78rem; color: var(--text-faint); font-variant-numeric: tabular-nums; }
.pager .is-off { opacity: .35; pointer-events: none; }
/* ── Pricing page v2 ── */
.price-wallet { display: flex; align-items: center; gap: 10px; padding: 14px 18px; }
.price-wallet .glyph { width: 22px; height: 22px; }
.price-wallet b { font: 700 1.35rem var(--mono); color: var(--credit); font-variant-numeric: tabular-nums; }
.price-wallet > span { color: var(--text-muted); font-size: .85rem; }
.price-wallet .btn { margin-left: auto; }
.plan-grid { align-items: start; }
/* ใบเดียวสองโหมด — แถวในไม่มีกรอบของตัวเอง มีเส้นบางคั่นกลางแทน */
.rate-info { display: grid; gap: 0; margin: 14px 0 0; padding: 6px 16px; }
.ri { margin: 0; padding: 11px 0; font-size: .84rem; line-height: 1.7; }
.ri + .ri { border-top: 1px solid var(--border); }
.ri.pro { border-color: var(--border); }
.ri b { font-weight: 700; margin-right: 8px; }
.ri.pro b { color: var(--credit); }
.ri.pro { border-color: color-mix(in srgb, var(--credit) 30%, var(--border)); }
.ri-price { font: 700 .72rem var(--mono); color: var(--text-muted);
  background: var(--surface-2); border: 1px solid var(--border);
  padding: 2px 9px; border-radius: 999px; white-space: nowrap; }
.ri-desc { display: block; color: var(--text-muted); margin-top: 2px; }
/* ── ขั้นตอนซื้อ 1-2-3 ── */
.buysteps { display: flex; align-items: center; gap: 10px; margin: 4px 0 18px; flex-wrap: wrap; }
.bs { display: inline-flex; align-items: center; gap: 8px; font-size: .82rem;
  color: var(--text-faint); text-decoration: none; }
.bs i { width: 22px; height: 22px; border-radius: 50%; display: grid; place-content: center;
  font: 700 .72rem var(--mono); font-style: normal;
  border: 1.5px solid var(--border); color: var(--text-faint); }
.bs.now { color: var(--text); font-weight: 600; }
.bs.now i { border-color: var(--accent); background: var(--accent); color: #fff; }
.bs.done { color: var(--text-muted); }
.bs.done i { border-color: #2e9e68; color: #2e9e68; }
.bs-line { flex: none; width: 26px; height: 1.5px; background: var(--border); }
.bs-line.done { background: #2e9e68; }
.buy-sum { max-width: 460px; }
.buy-sum .wallet-n .wn-unit { color: var(--text-muted); font-size: .85rem; }
.pk-bonus.is-inline { position: static; margin-left: 6px; }
.buy-lines { border-top: 1px solid var(--border); margin-top: 10px; padding-top: 10px;
  display: grid; gap: 7px; }
.buy-lines .jr { display: flex; justify-content: space-between; align-items: baseline;
  font-size: .84rem; color: var(--text-muted); }
.buy-lines .jr b { font-family: var(--mono); color: var(--text); font-weight: 700; }
.buy-lines .jr b.gold { color: var(--num); font-size: 1.15rem; }
.buy-lines .jr.total b.blue { color: var(--credit); font-size: 1.25rem; }
.buy-confirm { width: 100%; justify-content: center; padding-block: .75rem; margin-top: 6px; }
.pay-what { display: flex; align-items: center; gap: 9px; margin-bottom: 16px;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius); padding: 10px 14px; width: fit-content; }
.pay-what .glyph { width: 18px; height: 18px; }
.pay-what b { font: 700 1.05rem var(--mono); color: var(--credit); }
.pay-what span { color: var(--text-muted); font-size: .82rem; }
.pay-what em { font: 700 .95rem var(--mono); font-style: normal; color: var(--num); margin-left: 6px; }
.pay-grid { align-items: stretch; }
.provider-card { display: grid; gap: 6px; align-content: start; justify-items: start; }
.provider-card .pv-ic .glyph { width: 26px; height: 26px; color: var(--text-muted); }
.provider-card h3 { margin: 0; font-size: .95rem; }
.provider-card .pv-amt { margin: 0; font: 700 1.25rem var(--mono); color: var(--num); }
.provider-card .pv-fee { margin: 0 0 6px; font-size: .74rem; color: var(--text-faint); min-height: 2.2em; }
.provider-card form { width: 100%; margin-top: auto; }
.pv-go { justify-content: center; }
/* ── ซื้อแพ็กหน้าเดียว ── */
.buy-cols { display: grid; grid-template-columns: 1fr 330px; gap: 16px; align-items: start; margin-top: 18px; }
/* หัว "แพ็กเครดิต" ต้องขึ้นบรรทัดเดียวกับหัวแผงขวา — ตัด margin บนของมัน
   เฉพาะตอนอยู่ในผังสองคอลัมน์ */
.buy-cols .row.between { margin-top: 0; margin-bottom: 12px; }
@media (max-width: 980px) { .buy-cols { grid-template-columns: 1fr; } }
.buy-side { position: sticky; top: calc(4.5rem + 14px); display: grid; gap: 10px; }
.buy-side-title { margin: 0; font-size: .9rem; }
.bs-methods { display: grid; gap: 8px; margin-top: 4px; }
.bs-methods form { margin: 0; }
.bs-method { display: flex; align-items: center; gap: 10px; width: 100%;
  background: var(--surface-2); border: 1px solid var(--border);
  border-radius: var(--radius); padding: 10px 12px; cursor: pointer;
  color: var(--text); font: inherit; text-align: left;
  transition: border-color .15s; }
.bs-method:hover { border-color: var(--accent); }
.bs-method .mi .glyph { width: 20px; height: 20px; color: var(--text-muted); }
.bs-method .ml { font-size: .84rem; font-weight: 600; }
.bs-method .ma { margin-left: auto; display: grid; justify-items: end; line-height: 1.2; }
.bs-method .ma b { font: 700 .84rem var(--mono); color: var(--num); }
.bs-method .ma small { font-size: .68rem; color: var(--text-faint); }
.bs-slip { display: grid; gap: 8px; justify-items: center; border-top: 1px solid var(--border);
  padding-top: 12px; }
.bs-slip .qr { width: 200px; height: 200px; border-radius: var(--radius); background: #fff; padding: 8px; }
.bs-slip-amt { margin: 0; text-align: center; display: grid; gap: 2px; }
.bs-slip-amt span { font: 700 1.15rem var(--mono); color: var(--num); }
.bs-slip-amt small { color: var(--text-faint); font-size: .72rem; }
.bs-slip form { display: grid; gap: 8px; width: 100%; }
.bs-slip input[type="file"] { font-size: .8rem; color: var(--text-muted); }
.bs-slip-msg { margin: 0; font-size: .8rem; color: var(--accent); text-align: center; }
.bs-slip-msg.ok { color: #2e9e68; }
/* ── ผิว v3: ราง-ใบเสร็จ-จานQR (docs/mockups/buy-flow-v3.html) ── */
.tp-head { display: flex; align-items: baseline; gap: 16px; flex-wrap: wrap; margin-bottom: 4px; }
.tp-sub { margin: 0; color: var(--text-muted); font-size: .84rem; max-width: 62ch; }
.tp-bal { margin: 0 0 0 auto; font-size: .8rem; color: var(--text-muted); white-space: nowrap; }
.tp-bal b { font: 800 1.35rem var(--mono); color: var(--credit); font-variant-numeric: tabular-nums; }

.tp-steps { position: relative; display: grid; grid-template-columns: repeat(3, 1fr);
  margin: 16px 0 22px; padding: 0; list-style: none; }
.tp-steps::before { content: ""; position: absolute; top: 14px; left: 16.6%; right: 16.6%;
  height: 2px; background: var(--border); }
.tp-steps::after { content: ""; position: absolute; top: 14px; left: 16.6%;
  width: 0; height: 2px; background: #2e9e68; transition: width .4s ease; }
.tp-steps[data-fill="1"]::after { width: 33.3%; }
.tp-steps[data-fill="2"]::after { width: 66.6%; }
.tp-steps li { position: relative; display: grid; justify-items: center; gap: 7px;
  font-size: .78rem; color: var(--text-faint); }
.tp-steps li b { width: 30px; height: 30px; border-radius: 50%; display: grid; place-content: center;
  font: 800 .8rem var(--mono); background: var(--bg);
  border: 2px solid var(--border); color: var(--text-faint); position: relative; z-index: 1;
  transition: border-color .2s, background .2s, color .2s; }
.tp-steps li.is-on { color: var(--text); font-weight: 600; }
.tp-steps li.is-on b { border-color: var(--accent); background: var(--accent); color: #fff;
  box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent) 11%, transparent); }
.tp-steps li.is-done { color: var(--text-muted); }
.tp-steps li.is-done b { border-color: #2e9e68; color: #2e9e68; }
@media (prefers-reduced-motion: reduce) { .tp-steps::after { transition: none; } }

.tp-pane { display: none; }
.tp-pane.is-on { display: block; animation: tpRise .25s ease; }
@keyframes tpRise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .tp-pane.is-on { animation: none; } }
.tp-sec { margin: 0 0 12px; font-size: 1rem; }

.tp-pays { display: grid; gap: 10px; }
.tp-pay { display: flex; align-items: center; gap: 13px; width: 100%;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius-lg); padding: 15px 17px; cursor: pointer;
  color: var(--text); font: inherit; text-align: left; transition: border-color .15s, background .15s; }
.tp-pay:hover { border-color: var(--accent); }
.tp-pay[aria-pressed="true"] { border-color: var(--credit);
  background: color-mix(in srgb, var(--credit) 8%, var(--surface)); }
.tp-pay .pv-ic .glyph { width: 26px; height: 26px; color: var(--text-muted); }
.tp-pay[aria-pressed="true"] .pv-ic .glyph { color: var(--credit); }
.tp-pay-b { display: grid; gap: 2px; }
.tp-pay-b b { font-size: .9rem; }
.tp-pay-b small { color: var(--text-muted); font-size: .74rem; line-height: 1.55; }
.tp-amt { margin-left: auto; font: 800 .88rem var(--mono); color: var(--num); white-space: nowrap; }
.tp-foot { display: flex; justify-content: space-between; margin-top: 16px; }

.tp-paybox { background: var(--surface); border: 1px solid var(--border);
  border-radius: 18px; padding: 26px 22px; display: grid; gap: 12px; justify-items: center; }
.tp-paybox .tp-slipdrop, .tp-paybox .btn.full, .tp-paybox [data-s-send],
.tp-paybox [data-s-cancel] { max-width: 380px; }
.tp-paybox .qr { width: 220px; height: 220px; background: #fff; border-radius: 14px;
  padding: 12px; box-shadow: 0 0 0 1px #00000030, 0 6px 40px -6px #ff8a3c33; }
.tp-ppid { margin: 0; font: 700 .78rem var(--mono); color: var(--text-muted);
  background: var(--surface-2); border: 1px solid var(--border); padding: 5px 13px; border-radius: 999px; }
.bs-slip-amt { margin: 0; text-align: center; display: grid; gap: 2px; }
.bs-slip-amt span { font: 900 1.5rem var(--mono); color: var(--num); font-variant-numeric: tabular-nums; }
.bs-slip-amt small { color: var(--text-faint); font-size: .72rem; }
.tp-slipdrop { display: grid; place-content: center; gap: 4px; width: 100%; min-height: 88px;
  border: 2px dashed var(--border); border-radius: var(--radius);
  color: var(--text-muted); font-size: .84rem; cursor: pointer; text-align: center;
  transition: border-color .15s; }
.tp-slipdrop:hover { border-color: var(--accent); }
.tp-preview { margin: 0; display: grid; gap: 8px; justify-items: center; }
.tp-preview img { max-width: 220px; border-radius: 10px; border: 1px solid var(--border); }
.tp-preview[hidden], .tp-pane[hidden], .bs-slip[hidden],
[data-bs-body][hidden], [data-s-upload][hidden] { display: none !important; }

/* ใบเสร็จ: กระดาษขอบล่างปรุ */
.buy-side { position: sticky; top: calc(4.5rem + 14px); display: grid; gap: 8px;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: 14px 14px 0 0; padding: 18px 18px 24px;
  -webkit-mask: linear-gradient(#000 0 0) top / 100% calc(100% - 9px) no-repeat,
    radial-gradient(circle at 6px 0, transparent 5px, #000 5.5px) bottom / 12px 9px repeat-x;
  mask: linear-gradient(#000 0 0) top / 100% calc(100% - 9px) no-repeat,
    radial-gradient(circle at 6px 0, transparent 5px, #000 5.5px) bottom / 12px 9px repeat-x; }
.buy-side-title { margin: 0; font-size: .78rem; letter-spacing: .12em; text-transform: uppercase; }
.buy-lines { border-top: 1px dashed var(--border); margin-top: 6px; padding-top: 8px;
  display: grid; gap: 2px; }
.buy-lines .jr { display: flex; justify-content: space-between; align-items: baseline;
  font-size: .82rem; color: var(--text-muted); padding: 3px 0; }
.buy-lines .jr b { font: 700 .9rem var(--mono); color: var(--text); font-variant-numeric: tabular-nums; }
.buy-lines .jr b.blue { color: var(--credit); }
.buy-lines .jr b.green { color: #2e9e68; }
.buy-lines .jr b.gold { color: var(--num); }
.buy-lines .jr.total { border-top: 1px dashed var(--border); margin-top: 6px; padding-top: 10px; }
.buy-lines .jr.total b { font-size: 1.3rem; color: var(--num); }
.bs-pay-w { display: grid; justify-items: end; line-height: 1.3; }
.bs-pay-w small { font: 600 .72rem var(--mono); color: var(--text-muted); }
.buy-lines .jr .jr-i { font-style: normal; font-family: var(--sans); font-size: .72rem;
  font-weight: 400; color: var(--text-muted); }

.tp-div { border: 0; border-top: 1px solid var(--border); margin: 28px 0 20px; }
.tp-pend { margin-top: 0; }
.tp-ord { display: flex; align-items: center; gap: 12px; padding: 10px 0;
  border-top: 1px solid var(--border); flex-wrap: wrap; }
.tp-ord .jchip { color: var(--num); }
.tp-ord-amt { font-family: var(--mono); color: var(--num); }
.tp-ord-coin { color: var(--credit); font-size: .84rem; font-weight: 600; }
.tp-ord-when { color: var(--text-faint); font-size: .76rem; }
.tp-ord .btn { margin-left: auto; }
/* dialog ยืนยัน — ภาษาใบเสร็จเดียวกับแผงขวา ไม่ใช่กล่องโล่ง */
/* ขนาดต้องตั้งที่ตัว dialog ไม่ใช่กล่องข้างใน — dialog กว้าง 560 แต่กล่องใน
   360 ทำให้กล่องชิดซ้ายของกรอบที่มองไม่เห็น = "modal ไม่ตรงกลาง" */
/* .sheetbox-dialog.narrow (สองคลาส) แต้มสูงกว่า .tp-confirm เดี่ยวๆ เลยชนะ
   ไปกว้าง 560 — ผูกสองคลาสให้แต้มเท่าแล้วอยู่ท้ายกว่า */
.sheetbox-dialog.narrow.tp-confirm { width: min(360px, 94vw); }
/* หน้า order เดี่ยว — การ์ดชุด v3 กลางหน้า */
.ord-box { max-width: 440px; margin: 8px auto 0; }
.ord-box .jchip.ok { background: #2e9e6820; color: #2e9e68; }
.ord-box .jchip.bad { background: color-mix(in srgb, var(--accent) 14%, var(--surface-2)); color: var(--accent); }
.ord-ok { width: 52px; height: 52px; border-radius: 50%; display: grid; place-content: center;
  background: #2e9e6820; color: #2e9e68; font-size: 1.5rem; font-weight: 800; }
.ord-msg { margin: 0; text-align: center; color: var(--text-muted); font-size: .88rem; line-height: 1.7; }
.ord-bank { margin: 0; font-size: .78rem; color: var(--text-faint); text-align: center; }
.ord-slip-form { display: grid; gap: 10px; width: 100%; justify-items: center; }
.ord-slip-form .tp-slipdrop input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.ord-slip-form .tp-slipdrop { position: relative; }
.ord-box .btn.full { width: 100%; max-width: 380px; justify-content: center; }
.plan-price.browse { pointer-events: none; }
.plan-price.browse .pp-main { margin-left: 0; }

/* ── รางเลือกโทน (ชั้นอัลตร้า) — ทรง "รางไล่สี หัวแก้ว" ที่ Top เคาะจาก
   tone-lab (18 ส.ค.): แทร็กไล่จากฟ้า (นุ่ม) ไปส้มแดง (รุนแรง) เห็นสเปกตรัม
   ตลอดเวลา หัวโปร่งเลื่อนทับ — ข้างใต้เป็น radio จริง no-JS/คีย์บอร์ดใช้ได้ */
.jn-tone { margin-top: 12px; }
.jn-tone .k { display: block; margin-bottom: 8px; }
.tone-slider { position: relative; display: grid;
  grid-template-columns: repeat(3, 1fr); width: min(360px, 100%);
  height: 40px; padding: 4px; border-radius: 999px;
  border: 1px solid var(--line);
  background: linear-gradient(90deg,
    color-mix(in srgb, var(--credit) 26%, var(--surface-2)) 0%,
    var(--surface-2) 50%,
    color-mix(in srgb, var(--accent) 30%, var(--surface-2)) 100%); }
.ts-stop { position: relative; z-index: 1; display: flex; align-items: center;
  justify-content: center; cursor: pointer; border-radius: 999px;
  font-size: .82rem; color: var(--text-muted); transition: color .15s;
  user-select: none; }
.ts-stop input { position: absolute; opacity: 0; pointer-events: none; }
.ts-stop:hover { color: var(--text); }
.ts-stop:has(input:checked) { color: var(--text); font-weight: 700; }
.ts-thumb { position: absolute; z-index: 0; top: 4px; bottom: 4px; left: 4px;
  width: calc((100% - 8px) / 3); border-radius: 999px;
  background: color-mix(in srgb, var(--text) 9%, transparent);
  border: 1.5px solid color-mix(in srgb, var(--text) 35%, transparent);
  backdrop-filter: blur(2px);
  transition: transform .2s cubic-bezier(.3, 1.4, .5, 1); }
.tone-slider:has(.ts-stop:nth-of-type(1) input:checked) .ts-thumb { transform: translateX(0); }
.tone-slider:has(.ts-stop:nth-of-type(2) input:checked) .ts-thumb { transform: translateX(100%); }
.tone-slider:has(.ts-stop:nth-of-type(3) input:checked) .ts-thumb { transform: translateX(200%); }
.ts-stop:has(input:focus-visible) { outline: 2px solid var(--num); outline-offset: 2px; }

/* ── "ให้เนื้อหาตัดสิน" (auto) — ค่าตั้งต้นของชั้นอัลตร้าตั้งแต่ 30 ส.ค. 2026
   สองปุ่มนี้ตอบคำถามคนละข้อกับราง: *ใครเลือก* ไม่ใช่ *โทนไหน* จึงไม่เอา auto
   ไปต่อท้าย "รุนแรง" บนราง ซึ่งจะอ่านว่าแรงกว่ารุนแรง (Top เคาะจาก mockup
   docs/mockups/2026-08-30-tone-auto.html) รางเดิมไม่ถูกแตะเลยแม้แต่บรรทัดเดียว.
   ทั้งบล็อกนี้เป็น CSS ล้วน — ไม่มี JS แปลว่ายังกดได้ครบ: ปุ่มทุกใบเป็น radio
   ชื่อ `tone` กลุ่มเดียวกัน มันจึงตัดกันเอง */
.tone-mode { display: inline-grid; grid-auto-flow: column; gap: 4px; margin-bottom: 10px;
  background: var(--surface-2); border: 1px solid var(--line); border-radius: 999px; padding: 4px; }
.tm-opt { position: relative; padding: 6px 14px; border-radius: 999px; font-size: .82rem;
  color: var(--text-muted); cursor: pointer; user-select: none; white-space: nowrap;
  transition: color .15s, background .15s; }
.tm-opt input { position: absolute; opacity: 0; pointer-events: none; }
.tm-opt:hover { color: var(--text); }
.tm-opt:has(input:checked),
.jn-tone:has(.ts-stop input:checked) .tm-manual { background: var(--surface);
  color: var(--text); font-weight: 700; box-shadow: inset 0 0 0 1px var(--line2); }
.tm-opt:has(input:focus-visible) { outline: 2px solid var(--num); outline-offset: 2px; }
/* หรี่รางเมื่อ auto ถูกเลือก แต่ยังกดได้ — การกดจุดบนรางคือวิธีเปลี่ยนใจ */
.jn-tone:has(.tone-auto:checked) .tone-slider { opacity: .4; filter: saturate(.4); }
.tone-auto-note { display: none; margin: 10px 0 0; max-width: 360px; font-size: .8rem;
  line-height: 1.6; color: var(--text-muted);
  border-left: 2px solid var(--credit); padding: 8px 12px; border-radius: 0 8px 8px 0;
  background: color-mix(in srgb, var(--credit) 8%, transparent); }
.jn-tone:has(.tone-auto:checked) .tone-auto-note { display: block; }


.pay-closed { border-left: 3px solid var(--num); max-width: 720px; margin-bottom: 18px; }
.pay-closed b { font-size: .95rem; }
.pay-closed p { margin-top: 4px; color: var(--text-muted); font-size: .85rem; line-height: 1.7; }

/* ── หน้า order ทรง palworld: ซ้ายสถานะ+ไทม์ไลน์ / ขวาลงมือทำ ── */
.ordv2 { display: grid; grid-template-columns: 1fr 380px; gap: 16px; align-items: start; max-width: 980px; }
@media (max-width: 900px) { .ordv2 { grid-template-columns: 1fr; } }
.ordv2-l { display: grid; gap: 14px; }
.ordv2-status b { font-size: 1.05rem; }
.ordv2-status p { margin: 4px 0 10px; color: var(--text-muted); font-size: .86rem; line-height: 1.7; }
.ordv2-status { border-left: 3px solid var(--border); }
.ordv2-status.is-ok { border-left-color: #2e9e68; }
.ordv2-status.is-bad { border-left-color: var(--accent); }
.ordv2-status.is-wait { border-left-color: var(--num); }
.ordv2-r { display: grid; gap: 11px; justify-items: center; align-content: start; padding: 22px; }
.ordv2-r .qr { width: 200px; height: 200px; background: #fff; border-radius: 14px; padding: 10px;
  box-shadow: 0 0 0 1px #00000030, 0 6px 40px -6px #ff8a3c33; }
.ordv2-r .btn.full { width: 100%; justify-content: center; }
.tpc { display: grid; gap: 12px; justify-items: center; padding: 22px; }
.tpc-ic { width: 44px; height: 44px; border-radius: 50%; display: grid; place-content: center;
  background: color-mix(in srgb, var(--credit) 12%, var(--surface-2));
  border: 1px solid color-mix(in srgb, var(--credit) 35%, var(--border)); }
.tpc-ic .glyph { width: 22px; height: 22px; }
.tpc-title { margin: 0; font-size: 1.05rem; }
.tpc-lines { width: 100%; }
.tpc-note { margin: 0; font-size: .72rem; color: var(--text-faint); text-align: center; line-height: 1.7; }
.tpc-acts { display: flex; gap: 10px; width: 100%; }
.tpc-acts .btn { flex: 1; justify-content: center; }
.rate-btns { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 12px; }
.ri-btn { display: inline-flex; align-items: center; gap: 10px; cursor: pointer;
  font: inherit; color: var(--text); background: var(--surface);
  border: 1px solid var(--border); border-radius: 999px; padding: 9px 16px;
  transition: border-color .15s; }
.ri-btn:hover { border-color: var(--accent); }
.ri-btn b { font-size: .86rem; }
.ri-btn.pro b { color: var(--credit); }
.ri-btn.pro { border-color: color-mix(in srgb, var(--credit) 30%, var(--border)); }
.mode-detail { display: grid; gap: 6px; justify-items: center; text-align: center; }
.mode-detail[hidden] { display: none !important; }
.mode-detail-desc { margin: 4px 0 6px; color: var(--text-muted); font-size: .88rem; line-height: 1.8; max-width: 30ch; }
.tp-railrow { display: flex; align-items: flex-start; gap: 14px; }
.tp-railrow .tp-steps { flex: 1; }
.tp-amt { display: grid; justify-items: end; line-height: 1.3; }
.tp-amt small { font: 600 .68rem var(--mono); color: var(--text-muted); }
form:has([data-picked][hidden]) .jobnew-left .dropzone { min-height: min(480px, 58dvh); }
.jobnew-sum.startbar { margin-top: 0; border: 1px solid var(--border); }
.jobnew-sum .startbar-row { grid-template-columns: 1fr; }
.jobnew-sum .startbar-row + .startbar-row { border-top: 0; }
.jobnew-sum .startbar-cell { padding: 0; }
.jobnew-sum .startbar-cell + .startbar-cell { border-left: 0; }
.jobnew-sum .startbar-cell .k { margin-bottom: 2px; }
.jn-mo b { font-size: .84rem; line-height: 1.35; }
/* .startbar-go เดิมสั่ง border-radius: 0 ไว้ (ปุ่มมุมฉากของตารางเก่า) */
.jobnew-go { font-size: .95rem; }
/* ── Dashboard v2 — ตาม docs/mockups/console-body-v2.html ── */
.dash-hi-sub { margin: 0 0 2px; font-size: .78rem; color: var(--text-faint); }
.dash-hi { margin: 0 0 18px; font-size: 1.35rem; letter-spacing: -.01em; }
.dash-hi small { color: var(--text-muted); font-weight: 400; font-size: .85rem; margin-left: 10px; }
.dash-stats .stat .value { font-family: var(--mono); font-variant-numeric: tabular-nums; }
.dash-stats .stat .sub { font-size: .74rem; color: var(--text-muted); margin-top: 3px; min-height: 1em; }
.dash-stats .stat.credit { border-color: color-mix(in srgb, var(--credit) 40%, var(--border)); }
.dash-stats .stat.credit .value { color: var(--credit); }
.dash-stats .stat .value.ok { color: #2e9e68; }
.mode-price { position: absolute; top: 12px; right: 12px; font: 700 11.5px var(--mono);
  color: var(--text-muted); background: var(--surface-2); border: 1px solid var(--border);
  padding: 3px 9px; border-radius: 999px; }
.mode-price.pro { color: var(--credit); border-color: color-mix(in srgb, var(--credit) 40%, var(--border)); }
.mode-card { position: relative; }
.mode-go { display: inline-block; margin-top: 8px; font-size: .8rem; font-weight: 600; color: var(--accent); }
.dash-cols { display: grid; grid-template-columns: 1fr 300px; gap: 16px; align-items: start; }
.dash-cols.no-rail { grid-template-columns: 1fr; }
@media (max-width: 980px) { .dash-cols { grid-template-columns: 1fr; } }
.jrun { display: flex; align-items: center; gap: 8px; font-family: var(--mono); font-size: .76rem; color: var(--text-muted); min-width: 120px; }
.jbar { flex: 1; height: 4px; border-radius: 999px; background: var(--surface-3); overflow: hidden; }
.jbar i { display: block; height: 100%; background: var(--credit); }
.wallet-card { border-color: color-mix(in srgb, var(--credit) 35%, var(--border)); }
.wallet-n { display: flex; align-items: center; gap: 9px; margin: 2px 0 6px; }
.wallet-n .glyph { width: 24px; height: 24px; }
.wallet-n b { font: 700 1.6rem var(--mono); color: var(--credit); font-variant-numeric: tabular-nums; }
/* ป้ายมุมแบบ absolute เหลือบ้านเดียว: การ์ดแพ็กบน landing — หน้า pricing
   เปลี่ยนเป็นแถวเทียบราคาแล้ว ป้ายของมันเป็น pill ในบรรทัด (กฎท้ายไฟล์) */
.pack .pk-bonus { position: absolute; top: 10px; right: 10px;
  font: 700 11.5px var(--mono); color: #2e9e68; background: #2e9e6814;
  border: 1px solid #2e9e6845; padding: 3px 8px; border-radius: 999px; }
.brand-full { display: flex; align-items: center; gap: .4rem; }
/* The mark is drawn at 66px but must not grow the header row: negative
   vertical margins shrink its layout box back to ~30px and the glow simply
   overhangs, which transparent pixels are allowed to do. The negative right
   margin eats the halo's transparent border so the lantern sits close to the
   wordmark. */
.brand-full .brand-mark-img { display: block; margin: -18px -14px -18px -6px; }
.brand-min .brand-mark-img { margin: -18px -8px; }
/* The mark carries a baked-in warm halo; this only breathes it, so a
   reduced-motion viewer still sees the lit lantern, just holding still. */
@keyframes brand-glow-breathe {
  0%, 100% { filter: drop-shadow(0 0 1px rgba(232, 64, 43, 0)); }
  50% { filter: drop-shadow(0 0 6px rgba(232, 64, 43, 0.55)); }
}
.brand-mark-img { animation: brand-glow-breathe 3.6s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
  .brand-mark-img { animation: none; }
}
.brand-min { display: none; }
/* A footer that already prints the name in the line beside it takes the mark at
   every width. Two classes beat the one above, so this holds without
   `!important` and without depending on source order. */
.brand-markonly .brand-full { display: none; }
.brand-markonly .brand-min { display: block; }
:root[data-theme="light"] .on-dark,
:root:not([data-theme="dark"]) .on-dark { display: none; }
:root[data-theme="dark"] .on-light { display: none; }
@media (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]) .on-dark { display: none; }
  :root:not([data-theme="dark"]) .on-light { display: block; }
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .on-light { display: none; }
  :root:not([data-theme="light"]) .on-dark { display: block; }
}
.brand-sub { display: block; font-size: .68rem; color: var(--text-faint); letter-spacing: .04em; text-transform: uppercase; }

.nav { display: flex; flex-direction: column; gap: 2px; }
.nav a {
  display: flex; align-items: center; gap: .65rem;
  padding: .5rem .6rem; border-radius: var(--radius);
  color: var(--text-muted); font-size: .9rem; text-decoration: none;
}
.nav a:hover { background: var(--surface-2); color: var(--text); }
/* The balance rides the Credits entry's right edge — the sidebar answers the
   question before the page does. Same figure as the header chip; both read
   `header_credits` from site_chrome, so they cannot disagree. */
.nav-balance { margin-left: auto; font-family: var(--mono); font-size: .78rem;
  font-weight: 700; color: var(--credit); font-variant-numeric: tabular-nums; }
.nav a.active { background: var(--accent-soft); color: var(--accent); font-weight: 550; }
.nav-icon { width: 1.1rem; text-align: center; opacity: .85; }

.sidebar-foot { margin-top: auto; display: flex; flex-direction: column; gap: .5rem; }

/* The account menu. `position: relative` on the <details> is what the list
   below hangs off, so the sidebar never grows when the menu opens. */
.user-menu { position: relative; }

.user-chip {
  display: flex; align-items: center; gap: .6rem;
  padding: .5rem; border-radius: 10px; background: var(--surface-2);
  border: 1px solid var(--border);
}
/* <summary> ships a disclosure triangle in every engine; both selectors are
   needed to be rid of it, and `display: flex` alone would drop the marker in
   Chrome but not Safari. */
.user-menu > summary { cursor: pointer; list-style: none; }
.user-menu > summary::-webkit-details-marker { display: none; }
.user-menu > summary:hover { background: var(--surface-3); }
.user-menu > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.user-menu[open] > summary { border-color: var(--accent); }

.user-chip-caret {
  margin-left: auto; flex: none;
  font-size: .7rem; color: var(--text-faint);
  transition: transform .12s ease;
}
.user-menu[open] .user-chip-caret { transform: rotate(180deg); }

/* Opens upward: the chip sits at the bottom of the sidebar. */
.user-menu-list {
  position: absolute; z-index: 30;
  bottom: calc(100% + .4rem); left: 0; right: 0;
  display: flex; flex-direction: column; gap: 2px;
  padding: .3rem; border-radius: var(--radius);
  background: var(--surface-2); border: 1px solid var(--border);
  box-shadow: 0 12px 28px rgb(0 0 0 / .28);
}
.user-menu-list a {
  display: flex; align-items: center; gap: .65rem;
  padding: .45rem .55rem; border-radius: var(--radius);
  color: var(--text-muted); font-size: .88rem; text-decoration: none;
}
.user-menu-list a:hover { background: var(--surface-3); color: var(--text); }
.avatar {
  width: 30px; height: 30px; border-radius: 50%; flex: none;
  background: var(--surface-3); color: var(--text-muted);
  display: grid; place-items: center; font-size: .72rem; font-weight: 650;
  object-fit: cover;
}
.user-chip .meta { min-width: 0; }
.user-chip .meta strong { display: block; font-size: .82rem; font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.user-chip .meta span { font-size: .7rem; color: var(--text-faint); }

.main { min-width: 0; display: flex; flex-direction: column; }

/* One height on every screen. The bar had none of its own, so it was as tall as
   whatever that page happened to put in it: a title alone (Translate manga) made
   it ~58px, a title with a button (Dashboard, Jobs) ~72px, and a title under a
   breadcrumb (Your credits) ~77px. Nothing moved between those screens except
   the bar, so the whole page stepped up and down as you walked the sidebar.
   `min-height` is the floor for the sparse case; the two line-heights below keep
   the crumbs-plus-title stack inside the same 2.5rem of content box the tallest
   ordinary child — a full-size `.btn` — already needs, so the crumb costs
   nothing. Still `min-`, not `height`: a topbar with wrapped actions grows
   rather than overflowing. */
.topbar {
  display: flex; align-items: center; justify-content: space-between; gap: 1rem;
  padding: 1rem 1.6rem; min-height: 4.5rem; border-bottom: 1px solid var(--border);
  background: color-mix(in srgb, var(--bg) 85%, transparent);
  position: sticky; top: 0; z-index: 20; backdrop-filter: blur(8px);
}
.topbar .page-title { font-size: 1.05rem; font-weight: 620; line-height: 1.25; }
.topbar .crumbs { font-size: .78rem; color: var(--text-faint); line-height: 1.2; }
.topbar .crumbs a { color: var(--text-muted); }

.content { padding: 1.6rem; max-width: 1180px; width: 100%; }
.content.wide { max-width: none; }
/* A single readable column. For pages that are read rather than operated:
   the account-closing confirmation and its farewell. */
.content.narrow { max-width: 46rem; }

/* ---------- generic pieces ---------- */
.card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: 1.15rem;
}
/* Stacked cards in ordinary flow need their own spacing. Inside a grid or a
   flex stack the container's `gap` already provides it, and the extra margin
   pushes every card *except the first* down by 1rem — which is why a row of
   cards that should have been level was not. */
.card + .card { margin-top: 1rem; }
.grid > .card + .card,
.plan-grid > .card + .card,
.job-folds > .card + .card,
.stack > .card + .card { margin-top: 0; }
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: .9rem; }
.card-head h2, .card-head h3 { margin: 0; }
.card-sub { color: var(--text-muted); font-size: .85rem; margin: -.35rem 0 .9rem; }

.grid { display: grid; gap: 1rem; }
/* ── `minmax(min(100%, Npx), 1fr)`, everywhere ─────────────────────────────
   The idiom, stated once here because eighteen grids in this file use it and
   the reason is the same for all of them.

   `repeat(auto-fit, minmax(280px, 1fr))` reads as "as many 280px columns as
   fit", and it is — until the container is narrower than 280px. The floor is a
   hard minimum, not a preference: below it the track stays 280px wide and the
   grid overflows its own parent, which on a phone means the whole page scrolls
   sideways. A 330px floor on the extension guide's FAQ did exactly that on a
   320px screen, and it is the width where every one of these breaks.

   `min(100%, 280px)` makes the floor "280px, or the whole container, whichever
   is smaller", so a narrow container gets one full-width column instead of one
   column too wide to sit in it. Nothing changes at any width above the floor.
   `.packs` was already written this way; the rest have been brought into line. */
.grid.cols-2 { grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); }
.grid.cols-3 { grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr)); }
.grid.cols-4 { grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); }

/* Cards in a row whose action must stay level with its neighbours. The copy
   above it is not the same length in each — one of the storage cards also
   explains a plan cap — so the button has to be pinned to the bottom rather
   than left to follow the text down. */
.provider-card { display: flex; flex-direction: column; }
.provider-card > :last-child { margin-top: auto; }

.row { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; }
.row.between { justify-content: space-between; }
.spacer { flex: 1; }
.stack { display: flex; flex-direction: column; gap: 1rem; }

.stat { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: .9rem 1rem; }
.stat .label { font-size: .72rem; text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); }
.stat .value { font-size: 1.7rem; font-weight: 650; letter-spacing: -.02em; margin-top: .15rem; }

/* ---------- buttons ---------- */
.btn {
  display: inline-flex; align-items: center; gap: .45rem;
  padding: .5rem .9rem; border-radius: var(--radius);
  border: 1px solid var(--border); background: var(--surface-2);
  color: var(--text); font: inherit; font-size: .875rem; font-weight: 520;
  cursor: pointer; text-decoration: none; white-space: nowrap;
}
.btn:hover { background: var(--surface-3); text-decoration: none; color: var(--text); }

/* ── ช่องเลือกไฟล์ ───────────────────────────────────────────────────────────
   เบราว์เซอร์วาดปุ่ม "Choose File" ให้เองด้วยสไตล์ของระบบปฏิบัติการ — สีเทา
   มุมเหลี่ยม ฟอนต์คนละตัวกับทั้งหน้า มันจึงเป็นชิ้นเดียวบนจอที่ดูเหมือนหลุดมาจาก
   โปรแกรมอื่น `::file-selector-button` คือส่วนที่ CSS แตะได้ และรับสไตล์เดียวกับ
   `.btn` ได้ทั้งดุ้น ไม่ต้องซ่อน input จริงแล้ววาดปุ่มปลอมทับด้วย JS ซึ่งเป็นวิธี
   เก่าที่พังกับคีย์บอร์ดและสกรีนรีดเดอร์เสมอ */
input[type="file"] {
  font: inherit; font-size: .875rem; color: var(--text-muted);
  max-width: 100%;
}
input[type="file"]::file-selector-button {
  margin-right: .7rem;
  padding: .5rem .9rem; border-radius: var(--radius);
  border: 1px solid var(--border); background: var(--surface-2);
  color: var(--text); font: inherit; font-size: .875rem; font-weight: 520;
  cursor: pointer;
}
input[type="file"]::file-selector-button:hover { background: var(--surface-3); }
input[type="file"]:focus-visible { outline: 2px solid var(--num); outline-offset: 2px; }

/* ภาพที่แนบมาในเธรด — ย่อให้เท่ากันเป็นแถว กดเพื่อเปิดเต็ม
   `object-fit: cover` เพราะภาพหน้าจอมีสัดส่วนไม่เหมือนกันเลยสักภาพ และแถวที่สูง
   ไม่เท่ากันอ่านยากกว่าภาพที่ถูกครอบตัดนิดหน่อย */
.shots { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.shots img {
  width: 120px; height: 90px; object-fit: cover;
  border-radius: var(--radius); border: 1px solid var(--border);
}
.shots a:hover img { border-color: var(--accent); }
/* `--accent-fill`, not `--accent`. The accent is the LABEL colour — the value
   chosen to be legible *as* text on a page — and this is the one rule in the
   product that turns it into a field and prints on it. White on `#FF3B1F`
   measures 3.56:1 where AA wants 4.5 at this size; `--accent-fill` is `#D92B12`
   and measures 4.88:1. The token was declared for exactly this case and
   referenced nowhere, which is why the note above its declaration reads like an
   instruction nobody followed.
   Hover already used the deeper value, so until now the button became legible
   only while the pointer was on it — and a label cannot depend on a pointer. */
.btn.primary { background: var(--accent-fill); border-color: var(--accent-fill); color: #fff; }
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: #fff; }
.btn.danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); }
.btn.danger:hover { background: color-mix(in srgb, var(--danger) 14%, var(--surface-2)); }
.btn.ghost { background: transparent; }
.btn.small { padding: .3rem .6rem; font-size: .8rem; }
.btn[disabled], .btn.disabled { opacity: .45; pointer-events: none; }
/* A form already on its way. Styled rather than disabled: a disabled control is
   dropped from the form's entry list, so a button that carries the data — the
   language switcher's does — would submit without it. The submit handler is
   what refuses the second send. */
form[data-submitting] button[type="submit"],
form[data-submitting] input[type="submit"] { opacity: .45; pointer-events: none; }
/* Somebody else's screen, opened from the console — see `staff.views.job_full`.
   Every action a customer has is drawn and none of them work.
   Actions only: the tabs, the page cells and the lines panel are reads, and an
   operator who cannot open the failed page is looking at a screenshot rather
   than at the screen the customer is describing on the phone. */
[data-read-only] .btn, [data-read-only] .cell-edit { opacity: .45; pointer-events: none; }
/* A download being prepared. The same treatment as a form on its way and for
   the same reason — the buttons carry no name or value, but a flag on the form
   is what refuses the second press, and this only says so. All three
   containers, not just the one pressed: the box is building one file for this
   person, and "ZIP now, CBZ after" is a wait, not a race. */
form[data-downloading] .btn { opacity: .45; pointer-events: none; }
/* Square, for a button whose whole label is its glyph. The padding is the one
   thing that has to change: `.btn`'s horizontal padding on a captionless icon
   makes a lozenge rather than a square. */
.btn.icon-only { padding: .5rem; gap: 0; }
.btn.discord-link { color: var(--text-muted); }
.btn.discord-link:hover { color: #5865f2; }  /* Discord's own blurple, on hover only */
.btn-row { display: flex; gap: .5rem; flex-wrap: wrap; }

/* ---------- badges ---------- */
.badge {
  display: inline-flex; align-items: center; gap: .3rem;
  padding: .12rem .5rem; border-radius: 999px;
  font-size: .72rem; font-weight: 550; letter-spacing: .01em;
  background: var(--surface-3); color: var(--text-muted);
  border: 1px solid var(--border);
}
.badge.ok { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 35%, transparent); background: color-mix(in srgb, var(--ok) 12%, transparent); }
.badge.warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 35%, transparent); background: color-mix(in srgb, var(--warn) 12%, transparent); }
.badge[class*="tone-"] { margin-left: 6px; font-size: .68rem; vertical-align: middle; }
.badge.tone-soft { color: var(--credit); border-color: color-mix(in srgb, var(--credit) 35%, transparent); background: color-mix(in srgb, var(--credit) 10%, transparent); }
.badge.tone-medium { color: var(--text-muted); }
.badge.tone-intense { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 35%, transparent); background: color-mix(in srgb, var(--accent) 10%, transparent); }
.badge.danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 35%, transparent); background: color-mix(in srgb, var(--danger) 12%, transparent); }
.badge.accent { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 35%, transparent); background: var(--accent-soft); }
.badge.adult { color: var(--adult); border-color: color-mix(in srgb, var(--adult) 40%, transparent); background: color-mix(in srgb, var(--adult) 12%, transparent); }

/* ---------- forms ---------- */
.field { margin-bottom: 1rem; }
.field > label { display: block; font-size: .82rem; font-weight: 550; color: var(--text-muted); margin-bottom: .3rem; }
.field .help { font-size: .77rem; color: var(--text-faint); margin-top: .3rem; }
.field .errors { font-size: .8rem; color: var(--danger); margin-top: .3rem; list-style: none; padding: 0; }
/* Django's own markup for `{{ form.field.errors }}`. It had no rule at all, so
   every error rendered straight off a form was a grey bulleted list that did not
   read as a refusal — including the one telling a customer why their chapter was
   not queued. Matched to `.field .errors` rather than invented, because they are
   the same thing arriving by two routes. */
.errorlist { font-size: .8rem; color: var(--danger); margin: 0; list-style: none; padding: 0; }
.message.error .errorlist { color: inherit; }

input[type="text"], input[type="email"], input[type="password"], input[type="date"],
input[type="number"], input[type="url"], input[type="search"], select, textarea {
  width: 100%; padding: .5rem .65rem;
  background: var(--surface-2); color: var(--text);
  border: 1px solid var(--border); border-radius: var(--radius);
  font: inherit; font-size: .9rem;
}
input:focus, select:focus, textarea:focus {
  outline: none; border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft);
}
textarea { resize: vertical; min-height: 70px; }
input[type="file"] { font-size: .85rem; color: var(--text-muted); }

.check { display: flex; align-items: flex-start; gap: .55rem; margin-bottom: .6rem; font-size: .87rem; }
.check input { margin-top: .25rem; flex: none; accent-color: var(--accent); }
.check label { color: var(--text); font-weight: 450; }

.field-inline { display: flex; gap: .75rem; align-items: flex-end; flex-wrap: wrap; }
.field-inline .field { flex: 1 1 180px; margin-bottom: 0; }

fieldset { border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; margin: 0 0 1rem; }
legend { padding: 0 .4rem; font-size: .8rem; color: var(--text-muted); font-weight: 550; }

/* ---------- tables ---------- */
.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius); }
table { width: 100%; border-collapse: collapse; font-size: .875rem; }
th, td { padding: .6rem .8rem; text-align: left; border-bottom: 1px solid var(--border); }
th { font-size: .74rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-faint); font-weight: 600; background: var(--surface-2); }
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover { background: var(--surface-2); }
/* แถวที่พาไปหน้าของมันเอง (ดูบล็อก data-rowlink ใน app.js) — เคอร์เซอร์บอกว่า
   กดได้ทั้งแถว ไม่ใช่แค่ตัวลิงก์ */
[data-rowlink] > tr { cursor: pointer; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }

/* ── ตารางเรื่องที่ลูกค้าเปิดไว้ ─────────────────────────────────────────────
   เต็มคอลัมน์เนื้อหาเหมือนตารางงานแปล — เพดานความกว้างเพื่อการอ่านมีอยู่แล้วที่
   .content (1180px) การตั้งเพดานซ้อนอีกชั้นทำให้เกิดแถบว่างข้างขวาที่ดูเหมือน
   ของพัง ไม่ใช่การตั้งใจ (Top ทัก 2 ก.ย. 2026 — สองรอบ) */
.tickets-table { table-layout: fixed; }
/* ความกว้างอยู่ที่นี่ ไม่ใช่ style="" บนแท็ก — CSP ของเว็บใช้ nonce ซึ่งไม่ครอบ
   สไตล์ที่เขียนติดแท็ก เบราว์เซอร์จะทิ้งมันเงียบๆ แล้วตารางกลับไปยืดเต็มจอเหมือนเดิม */
.tickets-table .c-subject { width: 42%; }
.tickets-table .c-about   { width: 22%; }
.tickets-table .c-status  { width: 14%; }
.tickets-table .c-when    { width: 22%; }
.tickets-table td { vertical-align: top; }
.tickets-table a { font-weight: 600; color: var(--text); text-decoration: none; }
.tickets-table a:hover { color: var(--accent); }
.tickets-table small { display: block; color: var(--text-faint); font-size: .72rem; margin-top: 2px; }

/* ── หน้าเรื่องหนึ่งเรื่อง ────────────────────────────────────────────────────
   บทสนทนากับช่องตอบอยู่ในกล่องเดียว ไม่ใช่ชิ้นส่วนลอยบนพื้นหน้า — หน้าที่มีการ์ด
   สามใบกับฟอร์มลอยๆ อ่านเหมือนของสามอย่างที่ไม่เกี่ยวกัน ทั้งที่มันคือบทสนทนาเดียว

   กว้าง 860px: เกินกว่านี้บรรทัดยาวจนตาหาต้นบรรทัดถัดไปไม่เจอ น้อยกว่านี้ภาพที่แนบ
   มาสามสี่รูปเรียงไม่พอในแถวเดียว */
.back-link { margin: 0 0 .6rem; font-size: .85rem; }
.back-link a { color: var(--text-muted); text-decoration: none; }
.back-link a:hover { color: var(--accent); }

.ticket-meta { display: flex; flex-wrap: wrap; gap: .4rem .6rem; align-items: center;
  margin: 0 0 .9rem; font-size: .78rem; color: var(--text-muted); }
.ticket-meta .ref { font-family: var(--mono); color: var(--text-faint); }

.ticket-panel {
  border: 1px solid var(--border); border-radius: var(--radius-lg);
  background: var(--surface); overflow: hidden;
}
.thread { display: grid; }
.msg { padding: .85rem 1rem; border-left: 3px solid transparent; }
.msg + .msg { border-top: 1px solid var(--border); }
/* ทีมงานพูด = แถบสีแบรนด์ทางซ้าย + พื้นเข้มขึ้นหนึ่งขั้น ลูกค้าพูด = พื้นเรียบ
   ตำแหน่งเท่ากันทั้งคู่ เพราะเรื่องหนึ่งมักมีข้อความของฝั่งเดียวติดกันหลายอัน และ
   การสลับซ้ายขวาทำให้หน้ากลายเป็นกล่องลอยกับพื้นที่ว่างครึ่งจอ */
.msg.from-team { border-left-color: var(--accent); background: var(--surface-2); }
.msg-who { margin: 0 0 .35rem; font-size: .72rem; color: var(--text-faint); }
.msg.from-team .msg-who { color: var(--accent); font-weight: 600; }
.msg-body { margin: 0; font-size: .9rem; line-height: 1.65; overflow-wrap: anywhere; }

.thread-reply { border-top: 1px solid var(--border); background: var(--surface-2);
  padding: 1rem; display: grid; gap: .7rem; }
.thread-reply .message.error { margin: 0; }
/* ช่องกรอกทั้งเว็บทาสี --surface-2 อยู่แล้ว พอวางบนแถบตอบกลับที่เป็นสีเดียวกัน
   ช่องกับพื้นจึงกลืนกันเหลือแค่เส้นบางๆ — มองไม่ออกว่าตรงไหนพิมพ์ได้ (Top เจอบน
   ธีมสว่าง 2 ก.ย. 2026) สลับให้ช่องเป็น --surface: พื้นเทา ช่องขาว อ่านออกทันที */
.thread-reply input:not([type="file"]), .thread-reply textarea, .thread-reply select {
  background: var(--surface);
}
/* ช่องแนบไฟล์ไม่ใช่ช่องพิมพ์ — ทาพื้นให้มันได้กล่องขาวเหลี่ยมรอบปุ่ม+ชื่อไฟล์
   ซึ่งโผล่เฉพาะธีมสว่าง (Top เจอ 3 ก.ย. 2026) ปุ่มมีสไตล์ของตัวเองอยู่แล้ว */
/* แต่ตัวปุ่ม Choose file ต้องสลับชั้นสีเหมือนช่องพิมพ์: ปุ่มปกติพื้น --surface-2
   วางบนแถบตอบกลับที่เป็น --surface-2 เท่ากันจึงเหลือแต่เส้นขอบ (Top ทัก 3 ก.ย.) */
.thread-reply input[type="file"]::file-selector-button { background: var(--surface); }
.thread-reply input[type="file"]::file-selector-button:hover { background: var(--surface-3); }

/* กล่องขยายรูปที่แนบ — ภาพเต็มจอเท่าที่จอให้ ฉากหลังมืดแบบเดียวกับ sheetbox */
.shotbox-dialog { border: none; padding: 0; background: none;
  max-width: 96vw; max-height: 96vh; }
.shotbox-dialog::backdrop { background: rgb(6 7 12 / .78); }
.shotbox-dialog img { display: block; max-width: 96vw; max-height: 92vh;
  border-radius: var(--radius); }
.shotbox-dialog .close { position: absolute; top: .4rem; right: .4rem;
  border: none; background: rgb(6 7 12 / .55); color: #fff; font-size: 1rem;
  width: 2.2rem; height: 2.2rem; border-radius: 999px; cursor: pointer; }
.shotbox-dialog .close:hover { background: rgb(6 7 12 / .8); }

/* เรื่องที่ปิดแล้ว: บรรทัดเดียวบอกสถานะกับทางไปต่อ — ไม่มีช่องให้พิมพ์ */
.thread-closed { margin: 0; padding: .85rem 1rem; font-size: .85rem;
  color: var(--text-muted); border-top: 1px solid var(--border); }

/* การกระทำรอง: ตัวเล็ก อยู่นอกกล่อง พร้อมประโยคบอกว่าปิดแล้วยังตอบได้ */
.ticket-resolve { margin-top: .9rem;
  display: flex; align-items: center; gap: .7rem; flex-wrap: wrap; }

/* ---------- messages ---------- */
/* Toasts, not a page banner. Pinned to the corner so a one-line confirmation
   never pushes the page down, and out of flow so `_messages.html` can stay
   where it is at the top of the content block. */
.messages {
  position: fixed; z-index: 60;
  right: 1rem; bottom: 1rem;
  width: min(340px, calc(100vw - 2rem));
  display: flex; flex-direction: column; gap: .5rem;
  pointer-events: none;
}
.message {
  padding: .6rem .8rem; border-radius: var(--radius);
  border: 1px solid var(--border); background: var(--surface-2);
  box-shadow: var(--shadow);
  font-size: .85rem; display: flex; gap: .55rem; align-items: flex-start;
  /* Enters fast, sits, then leaves on its own. Doing it in CSS means the
     dismissal survives app.js failing to load. `forwards` holds the end
     state, and `visibility` is in the keyframes so a faded toast stops
     swallowing nothing — `pointer-events` above already handles clicks. */
  animation: toast-in .16s ease-out both, toast-out .5s ease-in 6s forwards;
}
.message.success { border-color: color-mix(in srgb, var(--ok) 45%, transparent); background: color-mix(in srgb, var(--ok) 12%, var(--surface-2)); }
.message.error { border-color: color-mix(in srgb, var(--danger) 45%, transparent); background: color-mix(in srgb, var(--danger) 12%, var(--surface-2)); }
.message.warning { border-color: color-mix(in srgb, var(--warn) 45%, transparent); background: color-mix(in srgb, var(--warn) 12%, var(--surface-2)); }

/* An error is the one message worth reading after six seconds — so it never
   fades on its own, and so it is the one that has to be closeable by hand. */
.message.error { animation: toast-in .16s ease-out both; }

/* The ✕, and only the ✕, takes clicks. The container stays
   `pointer-events: none` so a toast in the corner never turns its own patch of
   the screen into a dead zone over the page beneath it — which is also the
   reason this button did not work before it existed: everything in here
   inherited "not clickable".

   Dismissal is a checkbox and a `:has()`, with no script involved, for the same
   reason the fade is CSS: the message that most needs closing is the one that
   appears when something has already gone wrong. */
.message-off, .message-close { pointer-events: auto; }
.message-close {
  margin-inline-start: auto; align-self: flex-start;
  cursor: pointer; color: var(--text-faint);
  padding: 0 .15rem; border-radius: 4px; line-height: 1.25;
}
.message-close:hover { color: var(--text); }
/* The input is clipped, so the ring goes on the thing that can be seen. */
.message-off:focus-visible + .message-close { outline: 2px solid var(--focus); outline-offset: 2px; color: var(--text); }
.message:has(.message-off:checked) { display: none; }

@keyframes toast-in {
  from { opacity: 0; transform: translateY(6px); }
  to { opacity: 1; transform: none; }
}
@keyframes toast-out {
  to { opacity: 0; visibility: hidden; transform: translateY(4px); }
}
@keyframes toast-vanish {
  to { opacity: 0; visibility: hidden; }
}

/* Still self-dismissing, just without the travel — "reduce motion" is not
   "keep this on screen forever". Both selectors are listed because
   `.message.error` outranks a bare `.message` even from inside a query. */
@media (prefers-reduced-motion: reduce) {
  .message { animation: toast-vanish .01s linear 6s forwards; }
  .message.error { animation: none; }
}

.notice {
  border: 1px solid var(--border); border-left: 3px solid var(--accent);
  background: var(--surface-2); border-radius: var(--radius);
  padding: .8rem 1rem; font-size: .875rem; color: var(--text-muted);
}
.notice.adult { border-left-color: var(--adult); }
.notice.warn { border-left-color: var(--warn); }
/* A refusal, as a block the page shows and hides. `.notice` sets no
   `display`, so it obeys `hidden` — unlike `.message`, which is why
   `test_nothing_relies_on_hidden_while_wearing_a_display_class` exists. */
.notice.danger { border-left-color: var(--danger); color: var(--danger); }
.notice.danger strong, .notice.danger .errorlist { color: var(--danger); }
.notice strong { color: var(--text); }

/* ---------- job mode cards ---------- */
.mode-grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr)); }
.mode-card {
  position: relative; display: flex; flex-direction: column; gap: .5rem;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius-lg); padding: 1.15rem;
  text-decoration: none; color: inherit;
}
.mode-card:hover { border-color: var(--accent); text-decoration: none; color: inherit; transform: translateY(-1px); box-shadow: var(--shadow); }
.mode-card .icon { font-size: 1.6rem; line-height: 1; }
.mode-card h3 { margin: 0; font-size: 1rem; }
.mode-card .tagline { color: var(--text-muted); font-size: .84rem; }
.mode-card .facts { margin-top: auto; padding-top: .7rem; display: flex; gap: .4rem; flex-wrap: wrap; }
.mode-card.locked { opacity: .72; }
.mode-card.locked:hover { border-color: var(--border); transform: none; box-shadow: none; }
.mode-card.adult { border-color: color-mix(in srgb, var(--adult) 35%, var(--border)); }
.mode-card.adult:hover { border-color: var(--adult); }

.pick-bar .count.credits { color: var(--accent); font-variant-numeric: tabular-nums; }

/* ---------- series covers ---------- */
.series-cover {
  border: 1px solid var(--border); border-radius: var(--radius);
  overflow: hidden; background: var(--surface-2); aspect-ratio: 2 / 3;
  display: grid; place-items: center; flex: 0 0 72px;
}
.series-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* Placeholder for a series with no cover, so the row keeps its shape rather
   than collapsing and shifting every card beside it. */
.series-cover .ph { font-size: 1.5rem; opacity: .45; }
.series-cover.lg { flex-basis: 132px; }
.series-cover.lg .ph { font-size: 2.4rem; }

.series-card { display: flex; gap: .9rem; align-items: flex-start; }
.series-card .series-meta { min-width: 0; flex: 1; }
/* The title can be long and the card is narrow — wrap instead of overflowing. */
.series-card .card-head { margin-bottom: .5rem; }
.series-card .card-head h3 { overflow-wrap: anywhere; }

.series-detail-head { display: flex; gap: 1rem; align-items: flex-start; }
.series-detail-head .kv { flex: 1; min-width: 0; margin: 0; }

/* ---------- page thumbnails ---------- */
/* Bigger tiles, tighter gutters: the artwork is the content here, and at
   130px with .8rem of air the page read as a scattering of stamps. */
.page-grid { display: grid; gap: .5rem; grid-template-columns: repeat(auto-fill, minmax(min(100%, 172px), 1fr)); }

/* The same tiles, laid out as rows. One DOM, two stylesheets — switching views
   is a class on the container, so nothing is re-fetched and no image reloads. */
.page-list { display: flex; flex-direction: column; gap: .35rem; }
/* Which shape is showing. Both are in the DOM; this only picks one, so the
   toggle costs no request — and the hidden grid's lazy images are inside a
   `display: none` box, which browsers never fetch. */
[data-page-view="list"] .page-grid { display: none; }
[data-page-view="grid"] [data-page-view-table] { display: none; }

/* The page index reads as a table because that is what it is: one row per
   page, sortable columns, and nothing decorative between the reader and the
   numbers. */
.page-table .datatable tbody tr:nth-child(even) { background: color-mix(in srgb, var(--surface-2) 45%, transparent); }
.page-table .datatable tbody tr:hover { background: var(--surface-2); }
.page-table .datatable td { padding: .4rem .8rem; }
/* Shrink every column to its content and let one absorb the slack. Without
   this the browser splits the width evenly and a six-column table on a wide
   card reads as numbers marooned in whitespace. */
.page-table .datatable th,
.page-table .datatable td { width: 1%; white-space: nowrap; }
.page-table .datatable .col-flex { width: auto; white-space: normal; }
/* A right-aligned number under a left-aligned heading looks like a mistake. */
.page-table .datatable th.num { text-align: right; }
.page-table .datatable th.num .sort-link { justify-content: flex-end; }
.page-table .datatable .idx { color: var(--text); font-weight: 550; }
/* The header stays put while a long chapter scrolls under it. */
.page-table .datatable thead th { position: sticky; top: 0; z-index: 1; }

.sort-link {
  display: inline-flex; align-items: center; gap: .3rem;
  color: inherit; text-decoration: none;
}
.sort-link:hover { color: var(--text); text-decoration: none; }
.sort-link.active { color: var(--accent); }
/* Reserve the arrow's width so a column does not jump when it becomes the
   sorted one. */
.sort-arrow { opacity: .55; font-size: .8em; min-width: .8em; }
.sort-link.active .sort-arrow { opacity: 1; }

.page-table .tile-delete {
  position: static; opacity: 1; width: 1.4rem; height: 1.4rem;
  background: transparent; color: var(--text-faint);
  border: none; border-radius: var(--radius); cursor: pointer;
  font: inherit; font-size: .72rem; line-height: 1;
}
.page-table .tile-delete:hover { background: var(--danger); color: #fff; }

/* A tile that opens the preview. The button is the frame, so the whole
   thumbnail is the hit area rather than a corner of it. */
.tile-open {
  display: block; width: 100%; height: 100%; padding: 0;
  border: none; background: none; cursor: zoom-in;
}
.tile-missing {
  display: grid; place-items: center; height: 100%;
  color: var(--text-faint); font-size: .78rem;
}

.preview { border: none; padding: 0; background: none; max-width: 92vw; max-height: 92vh; }
.preview::backdrop { background: rgb(6 7 12 / .82); }
/* `align-items: center`, and it is load-bearing.
 *
 * A column flex container stretches its children on the cross axis, which for
 * a column *is the width* — so the image was pulled out to the figure's full
 * width while its height stayed where the intrinsic ratio put it, and the page
 * came out visibly wide. It only showed when the figure was wider than the
 * image, which depends on the page's shape against the viewport: some pages
 * looked right and some looked stretched, on the same screen.
 *
 * `height: auto` for the same reason from the other side — never let one
 * dimension be set by the box while the other comes from the file. */
.preview figure { margin: 0; display: flex; flex-direction: column; align-items: center; gap: .5rem; }
.preview img {
  max-width: 92vw; max-height: 84vh; width: auto; height: auto; display: block;
  border-radius: var(--radius); background: var(--surface-2);
}
.preview figcaption { color: var(--text-muted); font-size: .82rem; text-align: center; }
.preview-close {
  position: absolute; top: .4rem; right: .4rem;
  width: 2rem; height: 2rem; border: none; border-radius: var(--radius); cursor: pointer;
  background: rgb(0 0 0 / .55); color: #fff; font: inherit; font-size: .85rem;
}
.preview-close:hover { background: var(--danger); }

/* The marker that pulls in the next batch. A real link, so it still works
   without JavaScript; app.js follows it automatically when it scrolls in. */
.lazy-more {
  display: block; text-align: center; padding: .7rem;
  color: var(--text-faint); font-size: .82rem;
}
.lazy-more[data-lazy-busy] { color: var(--text-muted); pointer-events: none; }

/* The same shell as `.tabs` and the language switch, and its radius from the
   token rather than from an 8 typed here — the bridge can restate what
   `--radius` means, and it cannot reach a number baked into a rule. */
.view-toggle { display: flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.view-option {
  padding: 0 .6rem; height: var(--control-h-sm); border: none; border-radius: 0;
  background: transparent; color: var(--text-faint);
  font: inherit; font-size: .75rem; font-weight: 600; cursor: pointer;
}
.view-option + .view-option { border-left: 1px solid var(--border); }
.view-option:hover { color: var(--text); }
.view-option.active { background: var(--text); color: var(--bg); }
.page-tile {
  position: relative; border: 1px solid var(--border); border-radius: var(--radius);
  overflow: hidden; background: var(--surface-2); aspect-ratio: 2 / 3;
}
.page-tile img { width: 100%; height: 100%; object-fit: cover; display: block; }
.page-tile .idx {
  position: absolute; left: .35rem; top: .35rem;
  background: rgba(0, 0, 0, .65); color: #fff;
  font-size: .7rem; padding: .05rem .35rem; border-radius: 5px;
}
.page-tile .stage { position: absolute; right: .35rem; bottom: .35rem; }

/* Delete sits in the corner and stays out of the way until the tile is hovered
   or the button itself is focused — it must never be a stray tap on a phone,
   and it must still be reachable from the keyboard, where there is no hover. */
.page-tile .tile-delete {
  position: absolute; right: .35rem; top: .35rem;
  width: 1.5rem; height: 1.5rem; padding: 0;
  display: grid; place-items: center;
  border: none; border-radius: var(--radius); cursor: pointer;
  background: rgba(0, 0, 0, .65); color: #fff;
  font-family: inherit; font-size: .72rem; line-height: 1;
  opacity: 0; transition: opacity .12s ease, background .12s ease;
}
.page-tile:hover .tile-delete,
.page-tile .tile-delete:focus-visible { opacity: 1; }
.page-tile .tile-delete:hover { background: var(--danger); }
.page-tile .tile-delete:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

/* Coarse pointers have no hover to reveal it with. */
@media (hover: none) {
  .page-tile .tile-delete { opacity: 1; }
}

/* Blur explicit thumbnails until the reader asks for them. */
.page-tile.blurred img { filter: blur(18px) saturate(.6); transform: scale(1.08); }
.page-tile .reveal {
  position: absolute; inset: 0; display: grid; place-items: center;
  background: rgba(10, 11, 16, .45); color: #fff; font-size: .78rem;
  border: none; cursor: pointer; font-family: inherit; gap: .3rem;
}
.page-tile:not(.blurred) .reveal { display: none; }

/* ---------- job wizard page picker ---------- */
/* Picking pages is looking at pages, so this is a grid of artwork rather than
   the list of filenames it used to be. One DOM and two layouts, the way
   `.page-list` above always meant to be used: the tile carries the checkbox,
   so it cannot be sent twice the way library/_page_rows.html sends its batch. */
.picker-head { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; margin-bottom: .8rem; }
.picker-head select { flex: 0 1 20rem; min-width: 0; }

/* The picker is bounded, and the Options card below it is why. A 300-page
   chapter is 50 rows of thumbnails; laid out down the page that is ten
   thousand pixels the reader has to cross to reach "Queue job" — and since
   scrolling is also what pulls in the next batch, reaching the button means
   loading every last page first.

   Sized so the whole card — heading, switcher, grid and count — fits one
   screen. The count below it is `position: sticky`, and a card taller than the
   viewport would leave it floating over the last row of tiles it describes. */
.pick-scroll { max-height: min(56vh, 620px); overflow-y: auto; overscroll-behavior: contain; }
.pick-grid { display: grid; gap: .5rem; grid-template-columns: repeat(auto-fill, minmax(min(100%, 150px), 1fr)); }
/* Shift-selecting a range drags a text selection across every tile it passes
   otherwise, which reads as a glitch. */
.pick-tile { user-select: none; -webkit-user-select: none; }

[data-page-view="list"] .pick-grid { display: flex; flex-direction: column; gap: .3rem; }
[data-page-view="list"] .pick-tile { aspect-ratio: auto; display: flex; align-items: center; gap: .6rem; padding: .3rem .5rem; }
[data-page-view="list"] .pick-tile .pick-box { position: static; flex: none; }
[data-page-view="list"] .pick-tile .pick-face { position: static; display: flex; align-items: center; gap: .7rem; }
[data-page-view="list"] .pick-tile .pick-face img { width: 34px; height: 48px; flex: none; }
[data-page-view="list"] .pick-tile .idx { position: static; background: none; color: var(--text); font-size: .82rem; }
[data-page-view="list"] .pick-tile .reveal { position: static; inset: auto; background: none; color: var(--text-faint); margin-left: auto; }

/* Stacking, and it is load-bearing: the box sits above the blur veil, so an
   18+ page can be ticked without unblurring it on a screen someone else can
   see. face < reveal < box. */
.pick-tile .pick-face { position: absolute; inset: 0; z-index: 1; cursor: pointer; display: block; }
.pick-tile .pick-face img { width: 100%; height: 100%; object-fit: cover; display: block; }
.pick-tile .reveal { z-index: 2; }
.pick-tile .pick-box {
  position: absolute; right: .35rem; top: .35rem; z-index: 3;
  width: 1.15rem; height: 1.15rem; margin: 0; accent-color: var(--accent); cursor: pointer;
}
.pick-tile:has(.pick-box:checked) { outline: 2px solid var(--accent); outline-offset: -2px; }
.pick-tile .pick-box:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* The count follows the reader down a five-hundred-page chapter. Without it
   the one number that decides whether the job can be submitted scrolls away. */
.pick-bar {
  position: sticky; bottom: 0; z-index: 10;
  display: flex; flex-wrap: wrap; align-items: center; gap: .6rem;
  padding: .6rem .8rem; margin-top: .8rem;
  border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--surface-2);
}
.pick-bar .count { font-weight: 600; }
.pick-bar .note { color: var(--text-muted); font-size: .82rem; }
.pick-tray { display: flex; align-items: center; gap: .4rem; font-size: .82rem; }

/* The marker is a <button> here, not a link, because a GET would drop the
   pages already ticked. It still has to read as the library's quiet row, and
   it lives inside the grid, so it has to span it. */
button.lazy-more { width: 100%; border: none; background: none; font: inherit; cursor: pointer; }
.pick-grid .lazy-more { grid-column: 1 / -1; }

/* ---------- misc ---------- */
.empty {
  text-align: center; padding: 2.5rem 1rem; color: var(--text-muted);
  border: 1px dashed var(--border); border-radius: var(--radius-lg);
}
.empty .icon { font-size: 2rem; display: block; margin-bottom: .5rem; opacity: .7; }

/* Native <progress> so the value rides on an attribute — a width set via
   style="" would be blocked by the nonce-based CSP. */
progress.progress {
  -webkit-appearance: none; appearance: none;
  width: 100%; height: 6px; border: none;
  background: var(--surface-3); border-radius: 999px; overflow: hidden;
}
progress.progress::-webkit-progress-bar { background: var(--surface-3); border-radius: 999px; }
progress.progress::-webkit-progress-value { background: var(--accent); border-radius: 999px; }
progress.progress::-moz-progress-bar { background: var(--accent); border-radius: 999px; }

.timeline { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .7rem; }
.timeline li { display: flex; gap: .7rem; font-size: .85rem; }
.timeline .when { color: var(--text-faint); font-size: .78rem; white-space: nowrap; min-width: 7.5rem; }

.kv { display: grid; grid-template-columns: max-content 1fr; gap: .4rem 1rem; font-size: .875rem; }
.kv dt { color: var(--text-faint); }
.kv dd { margin: 0; }

/* The artefact picker, on the same shell as every other segmented control.

   It was an underlined tab bar, and it appears in exactly two places — the job
   screen and the chapter screen — where in BOTH it sits in the same card head as
   `.view-toggle`, which is a segmented pill group. One choice between three
   views drawn one way, one choice between two layouts drawn another, six inches
   apart. The segmented control is what this console uses for an exclusive
   choice — `.view-toggle`, the language switch — so the outlier is the tab bar.

   The type is deliberately NOT shared with `.view-option`: that one carries
   two-word micro-labels, these carry phrases somebody reads ("Inpainted",
   "ภาพต้นฉบับ"). Small caps on a phrase is a label pretending to be a heading,
   and `text-transform` does nothing to Thai anyway — it would have split the two
   languages rather than joined the two controls. */
.tabs {
  display: inline-flex; margin: 0 0 1.1rem;
  border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden;
}
.tabs a {
  display: inline-flex; align-items: center; height: var(--control-h-sm);
  padding: 0 .85rem; font-size: .84rem; color: var(--text-faint); white-space: nowrap;
}
.tabs a + a { border-left: 1px solid var(--border); }
.tabs a:hover { color: var(--text); text-decoration: none; }
.tabs a.active { background: var(--text); color: var(--bg); font-weight: 700; }
.tabs a.active:hover { color: var(--bg); }

/* ---------- auth pages ---------- */
/* ── The entrance screens ──────────────────────────────────────────────────
   Seven screens render through this shell — sign in, sign up, the provider's
   own signup, password reset, email verification, and the two extension-connect
   pages — so whatever stands behind the card has to hold for a one-button form
   and a four-field one alike.

   It used to be speed lines turning slowly on a flat ground. That was fine and
   it was nobody's: the screen where somebody decides whether to trust us with
   their files had nothing of this product on it but a logo. It is the lantern
   street now — the same picture the drop target stands on further down the
   landing, so arriving here reads as walking further into the same place.

   **Dark in both themes, unlike the old ground.** The picture is a dusk street
   lit by paper lanterns; a light veil over it greys out the one thing it is
   for. The card keeps the theme's own `--surface` and ink, so every form,
   input, error and link inside it stays exactly as correct as it was — only
   translucent enough for the lanterns to come through. */
.auth-shell {
  position: relative; isolation: isolate; overflow: hidden;
  min-height: 100vh; display: grid; place-items: center; padding: 2rem 1rem;
  background: url("../landing/upload-street.9a02002a6671.webp") center / cover no-repeat var(--bg);
}
/* The veil is the condition for reading the card, not decoration: an ellipse
   that keeps the middle open where the lanterns are, and closes the corners
   where a form's edge would otherwise fight the fence posts. */
.auth-art {
  position: absolute; inset: 0; z-index: 0; pointer-events: none;
  background:
    radial-gradient(ellipse 54% 62% at 50% 50%, rgb(8 7 10 / .30), rgb(8 7 10 / .80) 78%),
    linear-gradient(180deg, rgb(8 7 10 / .45) 0%, rgb(8 7 10 / 0) 22%,
                    rgb(8 7 10 / 0) 74%, rgb(8 7 10 / .55) 100%);
}

/* ── The card is dark in both themes, and that is the whole point ──────────
   It followed the theme at first: `color-mix(--surface 78%)`, so light theme
   got a white panel at the same 78% the dark one had. Same transparency, and
   it read as a solid slab — because **a translucent panel only looks like
   glass when what is behind it is brighter than the panel is.** White at 78%
   over a night street mixes to rgb(224,214,214); the lanterns are darker than
   that and cannot come through. Lowering the alpha does not fix it either, it
   only turns the panel grey and takes the small print below AA with it (at 65%
   the muted line falls to 3.6:1).

   So the panel is dark whatever the page's theme, and the dark theme's tokens
   are re-declared on it rather than each piece of ink being pinned by hand.
   Everything inside already reads `--text`, `--border`, `--surface-2` and the
   rest, so the heading, the lead, the small print, form labels, inputs, help
   text, errors and links all land correct on all seven screens this shell
   renders, with nothing per-element to keep in step.

   Keep this list in step with the `:root, :root[data-theme="dark"]` block —
   it is a copy, and a copy is a thing that drifts. */
.auth-card {
  --surface: #131019;
  --surface-2: #0C0A10;
  --surface-3: #1D1926;
  --border: #2A2434;
  --text: #F7F4EE;
  --text-muted: #B9B2C0;
  --text-faint: #6E667C;
  --accent: #FF6A52;
  --accent-hover: #FF8B78;
  --danger: #FF6A52;

  /* **โปรเจกต์นี้มี token สองตระกูล และต้องปักทั้งคู่**

     `--text/--text-muted/--surface/--border` เป็นชุดเดิม ส่วน `--hi/--mid/--low/
     --ink/--line` เป็นชุดของ design system ที่ประกาศทีหลังในไฟล์นี้ ทั้งสองชุด
     ยังถูกใช้อยู่จริง และบางคลาสถูกเขียนสองรอบด้วยคนละชุด — `.muted` มีทั้ง
     `color: var(--text-muted)` ที่บรรทัด ~1838 และ `color: var(--mid)` ที่
     ~5369 อันหลังอยู่ทีหลังจึงชนะ

     ปักแค่ชุดเดียวแล้วจะได้การ์ดที่หัวเรื่องสว่างถูกต้องแต่บรรทัดรองยังเป็นสี
     ของธีมสว่าง — วัดได้ 1.9:1 บนกระจกมืด ซึ่งอ่านไม่ออกจริง ๆ

     `--mid` กับ `--low` ตั้งสว่างกว่าค่าธีมมืดหนึ่งขั้น เพราะพื้นข้างหลังไม่ใช่
     สีเรียบ มันคือถนนที่มีโคมสว่างเป็นจุด ๆ ตัวเลขคอนทราสต์คิดบนพื้นเรียบเสมอ
     จึงมองโลกในแง่ดีเกินจริงเมื่อพื้นมีลาย */
  --ink: #08070A;
  --ink2: #0C0A10;
  --km-surface: #131019;
  --line: #1D1926;
  --line2: #2A2434;
  --hi: #F7F4EE;
  --mid: #D2CBD8;
  --low: #ABA2B6;
  --verm: #FF6A52;
  --focus: #FFD400;

  position: relative; z-index: 1; width: 100%; max-width: 400px;
  color: var(--text);
  background: rgb(19 16 25 / .62);
  border: 1px solid rgb(247 244 238 / .12);
  border-radius: var(--radius-lg); padding: 1.8rem;
  box-shadow: 0 24px 60px rgb(8 7 10 / .5);
  -webkit-backdrop-filter: blur(18px) saturate(1.25);
  backdrop-filter: blur(18px) saturate(1.25);
}
/* กลาง — เหมือนที่เคยเป็นมาก่อนหน้านี้ ผมเคยย้ายไปชิดซ้ายตอนออกแบบใหม่โดย
   คิดว่ามันจะอ่านเป็น "ชื่อของที่ที่เดินเข้ามา" แต่บนการ์ดที่จัดกลางทั้งใบ
   โลโก้ชิดซ้ายอ่านเป็นของที่วางไม่ตรง ไม่ใช่ของที่ตั้งใจ */
.auth-card .brand { justify-content: center; margin-bottom: 1.35rem; }
/* โลโก้มีสองเวอร์ชันตามธีม (`.on-dark` / `.on-light`) และการ์ดนี้เป็นมืดเสมอ
   จึงต้องบังคับให้ใช้เวอร์ชันสำหรับพื้นมืด ไม่ใช่ตามธีมของหน้า */
:root[data-theme="light"] .auth-card .on-dark,
:root:not([data-theme="dark"]) .auth-card .on-dark { display: revert; }
:root[data-theme="light"] .auth-card .on-light,
:root:not([data-theme="dark"]) .auth-card .on-light { display: none; }
.sso-list { display: flex; flex-direction: column; gap: .5rem; }
.sso-btn { width: 100%; justify-content: center; }
.sso-btn .sso-ic { flex: none; }

/* The email/password signup, folded away. Most people sign up with a provider
   and never open this; it stays reachable for anyone who will not link one. */
.alt-signup > summary {
  cursor: pointer; list-style: none;
  padding: .5rem 0; font-size: .85rem; color: var(--text-muted);
}
.alt-signup > summary::-webkit-details-marker { display: none; }
.alt-signup > summary::before { content: "＋ "; color: var(--text-faint); }
.alt-signup[open] > summary::before { content: "− "; }
.alt-signup > summary:hover { color: var(--text); }
.alt-signup > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.divider { display: flex; align-items: center; gap: .7rem; color: var(--text-faint); font-size: .78rem; margin: 1.1rem 0; }
.divider::before, .divider::after { content: ""; flex: 1; height: 1px; background: var(--border); }

/* ---------- landing: shared chrome ----------
   `.landing-nav` stays because the signed-out pricing page borrows it. The rest
   of the old landing — `.hero`, `.landing-section`, `.section-head`,
   `.feature-grid`, `.landing-foot` — is gone with the page it dressed; the
   replacement brings its own, further down the file. `.hero` in particular had
   to go rather than be overridden: it set `max-width: 780px`, which the new
   full-bleed hero would have inherited as an invisible corset. */
/* The nav links are in-page anchors. Smooth scrolling and the offset that keeps
   a heading clear of the sticky bar are done here rather than in JS, so the
   page needs no script and the CSP stays nonce-only. */
html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }

.landing-nav {
  display: flex; align-items: center; justify-content: space-between; gap: 1rem;
  padding: .9rem 1.5rem; border-bottom: 1px solid var(--border);
  position: sticky; top: 0; z-index: 30;
  background: color-mix(in srgb, var(--bg) 88%, transparent);
  backdrop-filter: blur(10px);
}
.landing-links { display: flex; gap: 1.2rem; }
.landing-links a { color: var(--text-muted); font-size: .88rem; text-decoration: none; }
.landing-links a:hover { color: var(--text); }
@media (max-width: 860px) { .landing-links { display: none; } }

/* Limits inside a plan card: a two-column list that stays readable when the
   card is narrow, which a table would not. */
.plan-limits {
  display: grid; grid-template-columns: auto minmax(0, 1fr); gap: .18rem .8rem;
  margin: 0; padding: .7rem 0 0; font-size: .8rem;
  align-content: start;
  /* Top rule only. The price row below draws its own top border, so a bottom
     rule here would land a second line a gap's width away from it. */
  border-top: 1px solid var(--border);
}
/* The label is the fixed part and must not be squeezed onto two lines by a
   long value — that is what made "Pages per job" wrap mid-phrase. */
.plan-limits dt { color: var(--text-faint); white-space: nowrap; }
.plan-limits dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; }

/* The free tier's single cell is not a link — nothing to buy. */
.plan-price.static { cursor: default; }
.plan-price.static:hover { border-color: var(--border); background: var(--surface-2); }

/* The console shell's own answer to this width now lives in ONE place, at the
   foot of this file under "The phone". What stood here was the first half of a
   sidebar-as-horizontal-strip layout that a second, identical block further
   down finished — two media queries for one component, source order deciding
   between them, and the pair of them describing a strip the drawer replaced.

   These two rules are not part of that and stay: `collapse` is asked for by the
   landing header as well as by the sidebar, and the landing header still has to
   fit its own row on a phone. The wordmark is 104px of a 390px screen — 27% —
   in a row that also carries the language switch and Sign in, and swapping it
   for the 30px mark is what makes that row fit. Only callers that ask swap: a
   centred sign-in card has the row to itself and keeps the wordmark. */
@media (max-width: 860px) {
  .brand-collapses .brand-full { display: none; }
  .brand-collapses .brand-min { display: block; }
}

/* Utility classes — inline style="" attributes are blocked by the nonce-based
   CSP, so anything one-off still lives here. */
.full { width: 100%; justify-content: center; }
.muted { color: var(--text-muted); }
.faint { color: var(--text-faint); }
.nowrap { white-space: nowrap; }
.mono { font-family: var(--mono); font-size: .85em; }
.mt-0 { margin-top: 0; }
.mt-1 { margin-top: .5rem; }
.mt-2 { margin-top: 1rem; }
.mb-0 { margin-bottom: 0; }
.mb-2 { margin-bottom: 1rem; }
.text-right { text-align: right; }
.over-limit { color: var(--danger); font-weight: 600; }

/* ---------- plans & pricing ---------- */
/* ---------- plan cards ---------- */
/* Cards sit side by side and are compared row by row, so every band has to
   line up: heading with heading, price with price. Flexbox cannot do that —
   `margin-top: auto` only pins the *last* child, and everything above it
   drifts by however tall that card's mode list happens to be.

   Subgrid can: each card spans the same six row lines of the shared grid, so
   the rows are measured once across all four cards. Browsers without it fall
   back to the old flex column, which is merely uneven rather than broken. */
.plan-grid {
  display: grid; gap: 1rem;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr));
}
.plan-card { display: flex; flex-direction: column; gap: .6rem; }
.plan-current { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent-soft); }

@supports (grid-template-rows: subgrid) {
  /* head · tagline · modes · limits · prices · description */
  .plan-grid > .plan-card {
    display: grid;
    grid-row: span 6;
    grid-template-rows: subgrid;
    gap: .6rem;
  }
  .plan-grid.packs > .plan-card { grid-row: span 2; }
}

.plan-modes { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .3rem; }
.plan-modes li { font-size: .85rem; color: var(--text-muted); display: flex; gap: .5rem; align-items: center; }

/* แพ็กเป็นแถวเทียบราคาแนวตั้ง (ทรงเลือกแพ็กมือถือ) — Top สั่งรื้อ 17 ส.ค.
   หลังการ์ดตาราง 4 ใบแตกแถว 3+1 ในคอลัมน์แคบ ทรงแถวทำให้เลขเครดิตเรียง
   ตรงกันเทียบง่าย และไม่มีใบเคว้ง ฉบับนี้คือฉบับเดียวของ .plan-price */
.plan-price-row { display: grid; grid-template-columns: 1fr; gap: 10px; }
.plan-price {
  display: flex; align-items: center; gap: 14px;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius-lg); padding: 15px 18px;
  color: inherit; text-decoration: none; cursor: pointer;
  transition: border-color .15s, background .15s; }
.plan-price:hover { border-color: var(--accent); }
.plan-price:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.plan-price .pp-dot { flex: none; width: 16px; height: 16px; border-radius: 50%;
  border: 2px solid var(--text-faint); transition: border-color .15s, box-shadow .15s; }
.plan-price .pp-main { display: grid; gap: 2px; min-width: 0; }
.plan-price .term { font: 800 1.3rem var(--mono); color: var(--credit); line-height: 1.2;
  letter-spacing: -.01em; font-variant-numeric: tabular-nums;
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.plan-price .term i { font: 600 .76rem var(--sans); color: var(--text-muted); font-style: normal; }
.plan-price .per { font-size: .76rem; font-weight: 600; color: #2e9e68; }
.plan-price .rate { font-size: .74rem; color: var(--text-faint); }
.plan-price .amount-wrap { margin-left: auto; display: grid; justify-items: end; line-height: 1.25; }
.plan-price .amount { font: 800 1.2rem var(--mono); color: var(--num); white-space: nowrap; }
.plan-price .amount-thb { font: 600 .72rem var(--mono); color: var(--text-muted); }
.pk-bonus { font: 700 .68rem var(--mono); color: #2e9e68; background: #2e9e6814;
  border: 1px solid #2e9e6845; padding: 2px 8px; border-radius: 999px; }
.pk-best { font: 700 .68rem var(--sans); color: #fff; background: var(--credit);
  padding: 2px 9px; border-radius: 999px; }
.plan-price.best { border-color: color-mix(in srgb, var(--credit) 40%, var(--border)); }
.plan-price.on { border-color: var(--credit);
  background: color-mix(in srgb, var(--credit) 8%, var(--surface)); }
.plan-price.on .pp-dot { border-color: var(--credit);
  box-shadow: inset 0 0 0 4px var(--credit); }

/* ---------- staff console ---------- */
.staff-mark { background: linear-gradient(135deg, #e3a552, #e0567f); }

/* ---------- language switcher ---------- */
/* ตัวสลับภาษา — เดิมเป็นแถวปุ่มสองภาษา พอมีจีนเป็นภาษาที่สามเลยแน่นเกิน
   เปลี่ยนเป็น dropdown กลไก <details> เดียวกับเมนูบัญชี (Top, 3 ก.ย. 2026) */
.lang-menu { position: relative; }
.lang-menu > summary {
  display: flex; align-items: center; gap: .4rem;
  padding: .45rem .7rem; border: 1px solid var(--border);
  border-radius: var(--radius); background: var(--surface-2);
  font-size: .8rem; font-weight: 550; color: var(--text-muted);
  cursor: pointer; list-style: none; white-space: nowrap;
}
.lang-menu > summary::-webkit-details-marker { display: none; }
.lang-menu > summary:hover { background: var(--surface-3); color: var(--text); }
.lang-menu > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.lang-menu[open] > summary { border-color: var(--accent); }
.lang-globe { display: grid; place-items: center; font-size: 1.15rem; color: var(--text-faint); }
.lang-menu > summary:hover .lang-globe { color: inherit; }
.lang-caret { font-size: .6rem; color: var(--text-faint); transition: transform .12s ease; }
.lang-menu[open] .lang-caret { transform: rotate(180deg); }
.lang-menu-list {
  position: absolute; z-index: 30;
  top: calc(100% + .4rem); right: 0; min-width: 8.5rem;
  display: flex; flex-direction: column; gap: 2px; margin: 0;
  padding: .3rem; border-radius: var(--radius);
  background: var(--surface-2); border: 1px solid var(--border);
  box-shadow: 0 12px 28px rgb(0 0 0 / .28);
}
/* ใน sidebar เมนูอยู่ล่างสุดของจอ — เปิดขึ้นบนแบบเดียวกับ user-menu */
.sidebar-foot .lang-menu-list { top: auto; bottom: calc(100% + .4rem); left: 0; right: auto; }
.lang-flag { width: 1.15rem; height: auto; border-radius: 2px; flex: none;
  outline: 1px solid color-mix(in srgb, var(--text) 14%, transparent); outline-offset: -1px; }
.lang-option {
  display: flex; align-items: center; gap: .5rem; text-align: left; width: 100%;
  padding: .45rem .55rem; border: 0; border-radius: var(--radius);
  background: none; color: var(--text-muted);
  font: inherit; font-size: .85rem; cursor: pointer;
}
.lang-option:hover { color: var(--text); background: var(--surface-3); }
.lang-option.active { background: var(--accent-soft); color: var(--accent); }

/* ---------- utilities ---------- */
/* Reachable by a screen reader, invisible on screen — used for a column
   header whose cells carry a button rather than a word. An empty <th> would
   leave the column unnamed in the accessibility tree. */
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* Feature cards on the landing page: the tier badge sits with the heading, and
   the headings must not drift when one wraps to two lines and its neighbour
   does not. */
.feature-grid .card { display: flex; flex-direction: column; }
.feature-grid .card-head { align-items: flex-start; margin-bottom: .6rem; }
.feature-grid .card-head h3 { margin: 0; }
.feature-grid .badge { text-decoration: none; white-space: nowrap; }
@supports (grid-template-rows: subgrid) {
  .feature-grid { grid-auto-rows: auto; }
  .feature-grid > .card {
    display: grid; grid-row: span 2; grid-template-rows: subgrid; gap: .6rem;
  }
}

/* ── Job flow ─────────────────────────────────────────────────────────────
   The five steps a customer watches a job move through. Drawn as panels with
   gutters, the way a page of the thing being translated is drawn — the step
   they are on is inked, the ones ahead are held back in outline. */
.flow { margin-bottom: 1.5rem; }

.flow-now {
  margin: 0 0 .5rem;
  font-size: .95rem;
  font-weight: 600;
  color: var(--accent, #24407a);
}

.flow-panels {
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  gap: .6rem;
  list-style: none;
  margin: 0;
  padding: 0;
}

.flow-panels li {
  display: flex;
  flex-direction: column;
  gap: .2rem;
  padding: .55rem .6rem;
  border: 1.5px solid var(--border, #d5dae2);
  background: var(--surface, #fff);
  min-width: 0;
}

.flow-n {
  font-family: var(--mono, ui-monospace, monospace);
  font-size: .68rem;
  letter-spacing: .05em;
  color: var(--text-muted);
}

.flow-name {
  font-size: .82rem;
  font-weight: 600;
  line-height: 1.3;
  color: var(--text-muted);
}

.flow-panels li.done { border-color: var(--text); }
.flow-panels li.done .flow-name { color: var(--text); }

.flow-panels li.now {
  border-color: var(--accent, #24407a);
  border-width: 2.5px;
  background: var(--accent-soft);
}
.flow-panels li.now .flow-name,
.flow-panels li.now .flow-n { color: var(--accent, #24407a); }

.flow-panels li.todo { border-style: dashed; }

/* <progress> rather than a div with a width, so no inline style is needed and
   the CSP stays nonce-only. Both engines need their own pseudo-element. */
progress.flow-bar {
  display: block;
  width: 100%;
  height: 5px;
  margin-top: .5rem;
  border: 0;
  appearance: none;
  background: var(--border, #d5dae2);
}
progress.flow-bar::-webkit-progress-bar { background: var(--border, #d5dae2); }
progress.flow-bar::-webkit-progress-value { background: var(--accent, #24407a); }
progress.flow-bar::-moz-progress-bar { background: var(--accent, #24407a); }

/* A job that stopped shows why, not a strip of empty panels — five blanks read
   as progress that stalled rather than as an ending. */
.flow-stopped {
  display: grid;
  gap: .3rem;
  padding: .8rem 1rem;
  border: 1.5px solid var(--danger, #9b3226);
  background: color-mix(in srgb, var(--danger) 12%, transparent);
}
.flow-stopped-label { font-weight: 700; color: var(--danger, #9b3226); }
.flow-stopped-why { font-size: .88rem; }

@media (max-width: 720px) {
  .flow-panels { grid-template-columns: repeat(3, 1fr); }
}

/* Jobs still moving, above the archive on the list screen. */
.running { display: grid; gap: 1rem; }
.running-job {
  padding: 1rem 1.1rem;
  border: 2px solid var(--accent, #24407a);
  background: var(--surface, #fff);
}
.running-name {
  display: block;
  font-weight: 700;
  font-size: 1.05rem;
  margin-bottom: .55rem;
  text-decoration: none;
  color: inherit;
}
.running-name:hover { text-decoration: underline; }
.running-job .flow { margin-bottom: 0; }
.block { display: block; }

/* ── Quick start ──────────────────────────────────────────────────────── */
.visually-hidden {
  position: absolute; width: 1px; height: 1px;
  clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap;
}

.dropzone {
  display: grid; justify-items: center; align-content: center; gap: .5rem;
  padding: 3.5rem 1.5rem; text-align: center; cursor: pointer;
  border: 2.5px dashed var(--border);
  background: var(--surface);
}
/* Unpacking. Seconds of nothing otherwise, on a control whose whole job is to
   respond to a drop — and a target that looks like it ignored you is a target
   you drop on again. The stripes move so it reads as work rather than as a
   state the page got stuck in. */
.dropzone.is-busy {
  cursor: progress; border-style: solid; border-color: var(--credit);
  background-image: repeating-linear-gradient(
    -45deg,
    color-mix(in srgb, var(--credit) 8%, transparent) 0 10px,
    transparent 10px 20px);
  background-size: 200% 100%;
  animation: dropzone-busy 1.1s linear infinite;
}
@keyframes dropzone-busy { to { background-position: -28px 0; } }
@media (prefers-reduced-motion: reduce) { .dropzone.is-busy { animation: none; } }

/* With nothing picked yet this screen holds one control and 540px of nothing
   under it, and the one control is a drag target — the thing a bigger target is
   unambiguously better at. So the empty state gives the target the page.
   `:has()` rather than a new class: the script already keeps `[data-picked]`
   hidden in exactly this state, and a second source of truth for "has anything
   been picked" is a thing to get out of sync. Where `:has()` is missing the box
   keeps its old height, which is today's screen. It shrinks the moment pages
   arrive — from then on the start bar is what has to be reachable. */
form:has([data-picked][hidden]) .dropzone { min-height: calc(100dvh - 16rem); }
.dropzone:focus-within,
.dropzone.is-over { border-color: var(--accent); background: var(--accent-soft); }
.dropzone-mark { font-size: 2.4rem; line-height: 1; color: var(--accent, #24407a); }
.dropzone-lead { font-size: 1.25rem; font-weight: 700; }
.dropzone-sub { color: var(--text-muted); font-size: .92rem; }
.dropzone-or { color: var(--text-muted); font-size: .82rem; }

.picked { margin-top: 1.6rem; }
.picked-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: .4rem 1rem; }
.picked-head h2 { margin: 0; font-size: 1.15rem; }
.picked-grid {
  display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 96px), 1fr));
  gap: .8rem; margin-top: .8rem;
}
.picked-cell {
  position: relative; aspect-ratio: 2 / 3;
  border: 1.5px solid var(--border, #d5dae2); background: var(--surface, #fff);
}
.picked-cell img { width: 100%; height: 100%; object-fit: cover; display: block; }
.picked-seq {
  position: absolute; top: 0; left: 0; padding: 1px 6px;
  font-size: .7rem; font-weight: 700;
  background: var(--text); color: var(--bg);
}
.picked-name {
  position: absolute; left: 0; right: 0; bottom: 0; padding: 2px 4px;
  font-size: .62rem; text-align: center; background: rgba(0, 0, 0, .55); color: #fff;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* A page too long to translate cleanly as one image — same tile size and grid
   as every other page (deliberately not a wider card), a warning border in
   place of the ordinary one, and a ribbon naming what it becomes instead of
   a page number. No split control lives here; see `.strip-banner-row`. */
.picked-cell.is-strip { border-color: var(--danger); }
.picked-warn {
  position: absolute; top: 0; left: 0; z-index: 2; line-height: 1.4;
  padding: 1px 6px; font-size: .78rem; background: var(--danger); color: #fff;
}
.picked-hint {
  position: absolute; left: 0; right: 0; bottom: 0; padding: 2px 4px;
  font-size: .6rem; text-align: center; color: #fff;
  background: color-mix(in srgb, var(--danger) 80%, black 20%);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

.strip-banner-row { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }

.startbar { margin-top: 1.8rem; border: 1.5px solid var(--text); background: var(--surface); }
.startbar-row { display: grid; grid-template-columns: 1fr 1fr; }
/* Four things sit on this row — mode, credits, size, and the button that spends
   them — and it declared three columns. The button wrapped to a second line and
   took only the first, so the action ran the width of the mode select while the
   two numbers it depends on floated above and to the right of it. It read as a
   broken panel because it was one; the state is only reachable with files
   actually picked, which is why it went unseen. */
.startbar-row.go { grid-template-columns: 1fr auto auto auto; align-items: stretch; }
.startbar-row + .startbar-row { border-top: 1.5px solid var(--border, #d5dae2); }
.startbar-cell { display: grid; gap: .25rem; align-content: center; padding: .9rem 1.1rem; }
.startbar-cell + .startbar-cell { border-left: 1.5px solid var(--border, #d5dae2); }
.startbar-cell .k {
  font-size: .68rem; letter-spacing: .08em; text-transform: uppercase;
  color: var(--text-muted);
}
.startbar-cell .small { font-size: .75rem; }
/* Every field in the bar takes the token, not whatever its own padding came to
   — 43px, which agreed with nothing else in the product. The Queue button is
   deliberately not in this rule: it is the panel's terminal cell and stretches
   to the row like the bordered cells beside it rather than sitting inside one.
   That is the one control here taller than the row's own control height, and it
   is on purpose. */
.startbar-cell input,
.startbar-cell select { height: var(--control-h); }
.startbar-cost { font-size: 1.3rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.startbar-cost small { font-size: .72rem; font-weight: 400; color: var(--text-muted); }
.startbar-go { border-radius: 0; padding-inline: 2rem; font-size: 1.05rem; }

@media (max-width: 760px) {
  .startbar-row, .startbar-row.go { grid-template-columns: 1fr; }
  .startbar-cell + .startbar-cell { border-left: 0; border-top: 1.5px solid var(--border, #d5dae2); }
}

/* The language pair reads as one sentence, so the two selects sit on one line
   with the arrow between them. They share the row's width rather than each
   claiming a column, so a long language name does not push the arrow around. */
.startbar-cell.langs { min-width: 0; }
.lang-pair {
  display: flex;
  align-items: center;
  gap: .5rem;
}
.lang-pair select {
  flex: 1 1 0;
  min-width: 0;
}
.lang-arrow {
  flex: none;
  color: var(--text-muted);
}

/* Export scope picker on a chapter. "All" or a range, and the range is two
   <select>s of real page numbers — nothing to type, so nothing to get wrong.
   The selects stay enabled with JS off: submitting with "All" checked ignores
   them server-side, so the control is honest without a script to dim them. */
.scope {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 1rem;
  border: 0;
  padding: 0;
  margin: 0;
}
.scope legend { padding: 0; margin-bottom: 0.35rem; }
.scope .pick { display: inline-flex; align-items: center; gap: 0.4rem; }
.scope .range { display: inline-flex; align-items: center; gap: 0.5rem; }
.scope .range select { min-width: 4.5rem; }

/* --- Drive backup panel --------------------------------------------------
   Shown inside a <dialog> on the library page. Styled for both states the
   element can be in: `open` (rendered by the server, no script) and modal
   (upgraded by app.js), which is why the backdrop rules are separate. */
.grow { flex: 1 1 auto; min-width: 0; }
.bad { color: var(--danger); }
.pending { color: var(--warn); }

dialog.modal {
  width: min(920px, calc(100vw - 2rem));
  max-height: calc(100vh - 3rem);
  overflow: auto;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: 16px;
  background: var(--surface);
  color: var(--text);
}
dialog.modal::backdrop { background: rgb(0 0 0 / .6); }
/* Server-rendered `open` is not a modal, so it needs its own placement. */
dialog.modal[open]:not(:modal) { margin: 1.5rem auto; }

.backup-panel { padding: 1.1rem 1.25rem 1.4rem; }

.backup-head { display: flex; gap: .9rem; align-items: flex-start; }
.backup-head h2 { margin: 0; font-size: 1.15rem; }
.backup-head .backup-close { margin-left: auto; font-size: 1rem; line-height: 1; }
.backup-icon {
  display: grid; place-items: center; flex: none;
  width: 44px; height: 44px; border-radius: var(--radius);
  background: var(--accent-soft); color: var(--accent); font-size: 1.3rem;
}
.backup-icon.small { width: 34px; height: 34px; font-size: 1rem; }

.backup-status {
  display: flex; gap: .6rem; align-items: center; flex-wrap: wrap;
  margin: 1rem 0 0; padding: .6rem .8rem; border-radius: var(--radius);
}
.backup-status.ok {
  color: var(--ok);
  background: color-mix(in srgb, var(--ok) 12%, transparent);
  border: 1px solid color-mix(in srgb, var(--ok) 30%, transparent);
}
.backup-status form { margin-left: auto; }

.backup-quota { margin-top: .9rem; font-size: .88rem; color: var(--text-muted); }
progress.quota-bar {
  display: block; width: 100%; height: 6px; margin-top: .4rem;
  border: 0; border-radius: 999px; overflow: hidden;
  background: var(--surface-3); color: var(--ok);
}
progress.quota-bar::-webkit-progress-bar { background: var(--surface-3); border-radius: 999px; }
progress.quota-bar::-webkit-progress-value { background: var(--ok); border-radius: 999px; }
progress.quota-bar::-moz-progress-bar { background: var(--ok); border-radius: 999px; }
progress.quota-bar.warn { color: var(--warn); }
progress.quota-bar.warn::-webkit-progress-value { background: var(--warn); }
progress.quota-bar.warn::-moz-progress-bar { background: var(--warn); }
progress.quota-bar.danger { color: var(--danger); }
progress.quota-bar.danger::-webkit-progress-value { background: var(--danger); }
progress.quota-bar.danger::-moz-progress-bar { background: var(--danger); }

.backup-settings {
  display: flex; gap: .8rem; align-items: center; flex-wrap: wrap;
  margin-top: 1rem; padding: .8rem; border-radius: var(--radius);
  border: 1px solid var(--border); background: var(--surface-2);
}

.backup-list-head { margin-top: 1.4rem; }
.backup-list-head h3 { font-size: 1rem; }
.backup-list-head small { font-weight: 400; margin-left: .4rem; }

/* ---------- linked account row ---------- */
/* One connected identity inside the Profile card — mark, name, state, action.
   Wraps rather than squashes: the action buttons drop to their own line on a
   narrow screen instead of crushing the username to an ellipsis. */
.link-row {
  display: flex; gap: .7rem; align-items: center; flex-wrap: wrap;
  padding: .7rem .8rem; border: 1px solid var(--border);
  border-radius: 12px; background: var(--surface-2);
}
.link-mark {
  flex: none; width: 34px; height: 34px; border-radius: var(--radius);
  display: grid; place-items: center;
}
/* Discord's blurple, and the only place the brand colour is used at full
   strength — this row is the one that says which service it is. */
.link-mark[data-net="discord"] { background: color-mix(in srgb, #5865f2 14%, transparent); color: #5865f2; }
.link-body { min-width: 0; flex: 1 1 12rem; display: flex; flex-direction: column; }
.link-body strong { font-size: .9rem; }
.link-body small { color: var(--text-muted); font-size: .78rem; }
.link-note { color: var(--text-muted); font-size: .78rem; margin: .5rem 0 0; }
.link-row form { margin: 0; }
/* Stacked rows (the connected-browsers list) need air between them; the rule
   is sibling-scoped so the single-row uses (Discord on Settings, the connect
   page) keep their tight fit. */
.link-row + .link-row { margin-top: .5rem; }

.backup-rows { list-style: none; margin: .6rem 0 0; padding: 0; display: grid; gap: .5rem; }
@media (min-width: 760px) { .backup-rows { grid-template-columns: 1fr 1fr; } }
.backup-row {
  display: flex; gap: .7rem; align-items: center; justify-content: space-between;
  padding: .6rem .7rem; border: 1px solid var(--border);
  border-radius: 12px; background: var(--surface-2);
}
.backup-row-main { display: flex; gap: .7rem; align-items: center; min-width: 0; flex: 1 1 auto; }
.backup-row strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.backup-thumb {
  flex: none; width: 42px; height: 56px; border-radius: 6px; overflow: hidden;
  background: var(--surface-3); display: grid; place-items: center;
}
.backup-thumb img { width: 100%; height: 100%; object-fit: cover; }

.dot {
  display: inline-block; width: 7px; height: 7px; margin-right: .35rem;
  border-radius: 50%; background: var(--text-faint); vertical-align: middle;
}
.dot.ok { background: var(--ok); }
.dot.busy { background: var(--accent); }
.dot.bad { background: var(--danger); }

/* The Refresh control on the backup panel. The glyph spins from the moment it
   is pressed until the new document replaces this one, and app.js presses it on
   a timer while a backup is still running — so the spin is also how a customer
   can tell the panel is watching for them rather than sitting still. */
.spin-icon { display: inline-block; }
.is-spinning .spin-icon { animation: spin 900ms linear infinite; }
.is-spinning { pointer-events: none; opacity: .75; }

@keyframes spin { to { transform: rotate(360deg); } }

/* Reduced motion still needs to show that something is happening; it just must
   not rotate. Fading in place carries the same "busy" without the movement. */
@media (prefers-reduced-motion: reduce) {
  .is-spinning .spin-icon { animation: spin-fade 1.2s ease-in-out infinite; }
  @keyframes spin-fade { 50% { opacity: .3; } }
}

/* "Translated" corner marker on a page tile. Only the translated state is
   marked: a raw page is the ordinary case and tagging every tile would leave a
   grid where nothing stands out. */
.page-tile .tile-tag {
  position: absolute;
  bottom: 0.35rem;
  left: 0.35rem;
  padding: 0.1rem 0.4rem;
  border-radius: 4px;
  font-size: 0.68rem;
  font-weight: 600;
  background: #2e7d32;
  color: #fff;
}

/* A rendered page the engine found no text on. Amber rather than the green
   above because it is not the same outcome: the tile looks finished and the
   picture is unchanged, which is the one case worth a second look. Dark text —
   `--warn` is light enough that white on it fails contrast. */
.page-tile .tile-tag.warn {
  background: var(--warn);
  color: #241a08;
}

/* A note the page writes next to a field: same weight and colour as the
   server-rendered `.field .errors`, deliberately with no `display` of its own so
   `hidden` keeps working. `.message` is the toast style — fixed to a corner and it
   animates itself away — which is wrong for something that has to stay put. */
.form-note { margin: .35rem 0 0; font-size: .8rem; color: var(--danger); }
/* บอกจำนวนที่เลือกไว้ ไม่ใช่คำเตือน — สีของ error สงวนไว้สำหรับสิ่งที่ผิดจริง */
.form-note.is-quiet { color: var(--text-muted); }

/* ---------------------------------------------------------------------------
   Lettering fonts. The same files the engine renders with, served from here so
   the browser can measure the same widths — a different file means the customer
   wraps a line one place and the finished page wraps it another, with nothing on
   screen to hint at it. Kept as .ttf rather than converted: these bytes have to
   stay byte-identical to what Pillow opens on the server.
   --------------------------------------------------------------------------- */
@font-face {
  font-family: "Choei";
  src: url("../fonts/MN_Op_Choei-hearts.be06caddbc8a.ttf") format("truetype");
  font-display: block; /* not swap: a bubble laid out in a fallback face and
                          then reflowed is the customer's work moving under
                          their hands */
}
/* The two we have a commercial licence for, and the ones the studio offers
   first. Their OFL.txt sits beside the .ttf in `static/fonts/`, which the
   licence requires. */
/* เกาหลี · จีนตัวย่อ · จีนตัวเต็ม — ชุดย่อยตัวเดียวกับที่ worker วาดจริง
   (~1–4.6 MB ต่อไฟล์ จึง `font-display: swap` และโหลดเฉพาะเมื่อมีบล็อกที่ใช้)

   **สามตัวนี้เคยไม่มี @font-face เลย** ทั้งที่ถูกลงทะเบียนใน `fonts.FONTS`
   เรียบร้อย `familyOf()` จึงคืนชื่อ "KomaLumeKR" ให้เบราว์เซอร์ที่ไม่เคยรู้จัก
   มัน แล้วตกไปใช้ฟอนต์ระบบ — สตูดิโอวาดเกาหลีกับจีนด้วยคนละฟอนต์กับ worker
   ตั้งแต่วันแรกที่เปิดขาย ซึ่งทำให้พรีวิวกับไฟล์ที่โหลดไปไม่มีทางตรงกัน */
@font-face {
  font-family: "KomaLumeKR";
  src: url("../fonts/KomaGothicA1-Bold.56ffc2ce9ce8.ttf") format("truetype");
  font-weight: 700;
  font-display: swap;
}

@font-face {
  font-family: "KomaLumeSC";
  src: url("../fonts/KomaNotoSansSC-Bold.b8ab9d456957.ttf") format("truetype");
  font-weight: 700;
  font-display: swap;
}

@font-face {
  font-family: "KomaLumeTC";
  src: url("../fonts/KomaNotoSansTC-Bold.5f02c20d9c47.ttf") format("truetype");
  font-weight: 700;
  font-display: swap;
}

@font-face {
  font-family: "Mali";
  src: url("../fonts/Mali-Bold.18983c725f84.ttf") format("truetype");
  font-display: block;
}
@font-face {
  font-family: "ComicNeue";
  src: url("../fonts/ComicNeue-Bold.a4a97c1734d2.ttf") format("truetype");
  font-display: block;
}
/* The rest of the menu: three more for balloons in each language and the
   shout faces. Every one is SIL OFL 1.1 or Apache 2.0 with its licence file
   beside the .ttf in `static/fonts/`, and every one is declared here because
   the preview has to draw with the same file Pillow will. */
@font-face {
  font-family: "Itim";
  src: url("../fonts/Itim-Regular.ee6755ae411f.ttf") format("truetype");
  font-display: block;
}
@font-face {
  font-family: "Kodchasan";
  src: url("../fonts/Kodchasan-Bold.557684ecd156.ttf") format("truetype");
  font-display: block;
}
@font-face {
  font-family: "Kanit";
  src: url("../fonts/Kanit-Black.4051e01b837f.ttf") format("truetype");
  font-display: block;
}
@font-face {
  font-family: "Mitr";
  src: url("../fonts/Mitr-Bold.675f110a52a8.ttf") format("truetype");
  font-display: block;
}
@font-face {
  font-family: "PatrickHand";
  src: url("../fonts/PatrickHand-Regular.5771ceaf1296.ttf") format("truetype");
  font-display: block;
}
@font-face {
  font-family: "Kalam";
  src: url("../fonts/Kalam-Bold.024d7424e770.ttf") format("truetype");
  font-display: block;
}
@font-face {
  font-family: "Bangers";
  src: url("../fonts/Bangers-Regular.d78984da4c0f.ttf") format("truetype");
  font-display: block;
}
@font-face {
  font-family: "LuckiestGuy";
  src: url("../fonts/LuckiestGuy-Regular.fd7340fce24c.ttf") format("truetype");
  font-display: block;
}
@font-face {
  font-family: "PermanentMarker";
  src: url("../fonts/PermanentMarker-Regular.3e9e8a239b6b.ttf") format("truetype");
  font-display: block;
}

/* The fallback chain, in the order `fonts.FALLBACK_FAMILIES` names it. Not
   offered in the menu: these are what a chosen face falls back *to* when it has
   no glyph for a character — ♥ above all, which appears 227 times in the
   translations on record and is in none of the display faces. */
@font-face {
  font-family: "NotoSansThaiFallback";
  src: url("../fonts/NotoSansThai.bf70020c73d6.ttf") format("truetype");
  font-display: block;
}
@font-face {
  font-family: "NotoSansSymbols2Fallback";
  src: url("../fonts/NotoSansSymbols2.441f29f816f3.ttf") format("truetype");
  font-display: block;
}
@font-face {
  font-family: "Iannnnn";
  src: url("../fonts/2005_iannnnnJPG.779859e4c0f0.ttf") format("truetype");
  font-display: block;
}
@font-face {
  font-family: "AnimeAce";
  src: url("../fonts/anime_ace.44fb30abbd59.ttf") format("truetype");
  font-display: block;
}

/* ---------------------------------------------------------------------------
   Page editor.

   Four zones that never move — rail, canvas, panel, and the top bar the base
   template already draws. Fixed positions are the whole point: this is a screen
   somebody uses for an hour at a stretch, and a control that migrates costs a
   look every time.

   The canvas owns the height. `.content.bleed` drops the reading-width cap and
   the padding, because a page you cannot see all of is a page you cannot lay
   text out on.
   --------------------------------------------------------------------------- */

/* `.shell` is a 100vh grid and `.main` a column flexbox, so claiming the
   leftover row here needs no arithmetic — and none of the guesswork that a
   hardcoded header height turns into a scrollbar the day somebody adds a line
   to the top bar. `min-height: 0` is the part flexbox will not do for you: an
   overflowing child grows its flex item unless it is told not to, which would
   push the whole document taller and take the top bar off screen. */
/* **The height chain, and why both zoom bugs were the same bug.**
   `.shell` is `min-height: 100vh`, not `height`. A minimum does not give the
   grid row a definite size, so `.main` has an auto height, so `.content.bleed`
   has an auto height, so `.editor`'s `height: 100%` resolves against auto — and
   a percentage of auto is auto. Nothing below it ever had a real height.

   Everything followed from that. `.editor-stageWrap` grew to fit the artwork
   instead of scrolling it, so a zoomed page had no vertical overflow to scroll
   *at all* (sideways still worked: the width was definite). And `.editor-canvas`
   grew with it, which put the zoom bar — pinned to the bottom of a container now
   several thousand pixels tall — far below the viewport.

   Two symptoms, one missing number. `:has()` scopes the fix to the one screen
   that owns the viewport, so every other page keeps growing with its content. */
.shell:has(.content.bleed) { height: 100vh; }
.shell:has(.content.bleed) .main { min-height: 0; }

.content.bleed { max-width: none; padding: 0; flex: 1 1 auto; min-height: 0; }

.editor {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) 320px;
  height: 100%;
  /* No `min-height` here. One taller than the space available makes the whole
     document scrollable, and from then on every click that focuses a text box
     scrolls the page to reveal it — which reads as the page jumping away the
     moment you touch a bubble. The canvas scrolls inside itself instead. */
  overflow: hidden;
}

/* งานหน้าเดียวไม่มีแถบหน้า (rail ของหนึ่งหน้าคือปุ่มที่กดไปก็ไม่ไปไหน) ตารางจึง
   ต้องเหลือสองคอลัมน์ด้วย ไม่งั้นเวทีไปนั่งคอลัมน์ `auto` แล้วหดจนเท่ารูปย่อ
   ส่วนแผงบานเต็มจอ — ซึ่งเป็นสิ่งที่งานหน้าเดียวทุกงานเห็นมาตั้งแต่ 13 ส.ค. 2026 */
.editor.is-railless { grid-template-columns: minmax(0, 1fr) 320px; }

/* ---------- the workspace shell ----------
   The Studio is a screen for working on one document, and the site's own
   navigation was standing beside it: 236px of Dashboard / New job / Jobs /
   Credits next to the artwork, none of which is about the page in front of the
   customer. Measured on a 1440px display, the picture they came to work on got
   794px — 55% of their screen. Behind the drawer it gets 1030px, 72%.

   **The mechanism is the phone's drawer, at every width.** The rules below are
   the ones from the ≤860px block, written again for this one shell — small
   enough to read in one screen, and kept apart on purpose: that block also
   moves the top bar, unsticks it, restacks the brand and turns on the mobile
   strip, none of which should happen here. Sharing it would mean the Studio
   changing shape whenever the phone layout is tuned. */
.shell.workspace { grid-template-columns: 1fr; }

/* Clipped, never `display: none` — which is what it is everywhere else above
   860px. A hidden input takes no focus, so the drawer would open by mouse and
   be unreachable by keyboard: the site's whole navigation, behind a control
   that Tab cannot land on. */
.shell.workspace .drawer-toggle {
  display: block; position: absolute; width: 1px; height: 1px;
  clip-path: inset(50%); overflow: hidden; white-space: nowrap;
}

.shell.workspace > .sidebar {
  position: fixed; inset-block: 0; inset-inline-start: 0; z-index: 60;
  width: min(19rem, 84vw); height: 100dvh; max-height: 100dvh;
  overflow-y: auto; overscroll-behavior: contain;
  /* Composited, like the phone's: a transform does not relayout the editor
     behind it, which on this screen is a canvas mid-render. */
  transform: translateX(-100%); visibility: hidden;
  transition: transform .22s ease, visibility .22s;
}
.shell.workspace .drawer-toggle:checked ~ .sidebar { transform: none; visibility: visible; }
.shell.workspace .drawer-scrim {
  display: block; position: fixed; inset: 0; z-index: 55;
  background: rgb(0 0 0 / .5);
  opacity: 0; pointer-events: none; transition: opacity .22s ease;
}
.shell.workspace .drawer-toggle:checked ~ .drawer-scrim { opacity: 1; pointer-events: auto; }
@media (prefers-reduced-motion: reduce) {
  .shell.workspace > .sidebar, .shell.workspace .drawer-scrim { transition: none; }
}

/* The bar loses a row: with the way back beside the page number rather than
   above it, 4.5rem of a 900px laptop screen becomes 3rem. */
.shell.workspace .topbar { min-height: 3rem; padding: .4rem 1rem; }
.shell.workspace .topbar > div:first-child {
  display: flex; align-items: center; gap: .7rem; min-width: 0;
}
.shell.workspace .topbar .page-title { font-size: .95rem; white-space: nowrap; }

.editor-home { display: flex; align-items: center; gap: .45rem; }
/* The three bars are the console's own, drawn by `.drawer-bars`. This is a
   second button for the same checkbox, not a second menu. */
.workspace-menu {
  flex: none; display: grid; place-items: center;
  width: 2rem; height: 2rem; border-radius: var(--radius);
  border: 1px solid var(--border); background: var(--surface-2);
  color: var(--text-muted); cursor: pointer;
}
.workspace-menu:hover { color: var(--text); border-color: var(--text-faint); }
.shell:has(.drawer-toggle:focus-visible) .workspace-menu {
  outline: 2px solid var(--accent); outline-offset: 2px;
}

/* The way back. A chip rather than a breadcrumb because it is a control — the
   only one on this screen that leaves it — and because it has to say *where*
   back is: the job, and the chapter the customer knows it by. */
.editor-back {
  display: flex; align-items: center; gap: .35rem; min-width: 0;
  padding: .25rem .6rem .25rem .45rem; border-radius: 999px;
  border: 1px solid var(--border); background: var(--surface-2);
  color: var(--text-muted); font-size: .78rem; text-decoration: none;
}
.editor-back:hover { color: var(--text); border-color: var(--text-faint); }
.editor-back .arrow { color: var(--text-faint); }
.editor-back .what { font-family: var(--mono); font-size: .72rem; }
.editor-back .sep { color: var(--text-faint); }
.editor-back .where { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 16rem; }

/* ---------- left rail: the rest of the run ---------- */

.editor-rail {
  /* `contain`: reaching the end of the rail must not hand the scroll on to the
     document behind it. Chained scroll on a screen whose page never moves reads
     as the whole thing bouncing. */
  overscroll-behavior: contain;
  width: 5.5rem;
  border-inline-end: 1px solid var(--border);
  background: var(--surface);
  overflow-y: auto;
  padding: .5rem .45rem;
  display: flex;
  flex-direction: column;
  gap: .4rem;
}
/* Folded: the strip keeps every page — losing one is what the rail was fixed to
   stop — but shows them small enough to read as a position rather than as a
   picture. Worth ~54px of canvas on a 1440px display, which is why it is offered
   at all, and why it is not the default: a strip you cannot recognise a page in
   is a strip you cannot navigate by. */
.editor[data-rail="folded"] .editor-rail { width: 2.4rem; padding-inline: .25rem; }
.editor[data-rail="folded"] .editor-thumb .n { display: none; }
.editor[data-rail="folded"] .editor-thumb { border-radius: 3px; }

/* Sticky at the top of the strip it folds, and drawn as a shape rather than a
   glyph: at 12px a font's ‹ is a different weight on every platform. */
.rail-fold {
  position: sticky; inset-block-start: 0; z-index: 2;
  flex: none; align-self: stretch;
  display: grid; place-items: center;
  height: 1.5rem; margin-bottom: .15rem;
  border: 0; border-radius: 4px; cursor: pointer;
  background: var(--surface); color: var(--text-faint);
}
.rail-fold:hover { background: var(--surface-2); color: var(--text); }
.rail-fold-mark {
  width: 0; height: 0;
  border-block: 4px solid transparent;
  border-inline-end: 5px solid currentColor;
}
.editor[data-rail="folded"] .rail-fold-mark {
  border-inline-end: 0; border-inline-start: 5px solid currentColor;
}

.editor-thumb {
  position: relative;
  display: block;
  border: 1px solid var(--border);
  border-radius: 6px;
  overflow: hidden;
  background: #fff;
  line-height: 0;
  /* Not optional, and not cosmetic. The rail is a *column* flex container, so
     flex's main axis here is height and every entry is shrinkable by default.
     With thirty-three pages in a rail the height of the window, each one gets
     squeezed to a fraction of its own height — the page turns into a wide flat
     strip with the art squashed out of it. `flex: none` makes each entry keep
     the height its picture asks for and lets the rail scroll, which is what
     `overflow-y: auto` two rules up was always for. */
  flex: none;
}
.editor-thumb img { width: 100%; height: auto; display: block; }
.editor-thumb .n {
  position: absolute; inset-block-end: 2px; inset-inline-start: 2px;
  background: rgba(13, 14, 20, .78); color: #fff;
  font-family: var(--mono); font-size: .6rem;
  padding: 0 .25rem; border-radius: 3px; line-height: 1.5;
}
.editor-thumb:hover { border-color: var(--accent); }
/* A page in the run that cannot be opened: still here, still recognisable,
   plainly not a link. Greyed rather than hidden — the rail used to leave these
   out, and a strip numbered 1, 2, 4, 5 reads as pages having gone missing. */
.editor-thumb.is-locked { opacity: .45; filter: grayscale(.65); cursor: not-allowed; }
.editor-thumb.is-locked:hover { border-color: var(--border); }
.editor-thumb.is-current {
  border-color: var(--accent);
  box-shadow: 0 0 0 2px var(--accent-soft);
}

/* ---------- the tools ----------
   Over the canvas rather than in the top bar: a tool changes what a click on the
   *page* does, so it belongs beside the page. Floating rather than docked in the
   left rail because that rail is the chapter's pages — a different subject. */
.editor-tools {
  position: absolute; inset-block-start: .7rem; inset-inline-start: .7rem; z-index: 5;
  display: flex; flex-direction: column; gap: .15rem;
  padding: .25rem;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: 8px; box-shadow: 0 6px 22px rgb(0 0 0 / .28);
}
.editor-tool {
  position: relative;
  width: 2.1rem; height: 2.1rem;
  display: grid; place-items: center; cursor: pointer;
  border: 1px solid transparent; border-radius: 5px;
  background: transparent; color: var(--text-muted);
}
.editor-tool:hover { background: var(--surface-2); color: var(--text); }
.editor-tool[aria-pressed="true"] {
  color: var(--text); border-color: var(--accent); background: var(--accent-soft);
}
.editor-tool .k {
  position: absolute; inset-block-end: 1px; inset-inline-end: 3px;
  font-family: var(--mono); font-size: .5rem; color: var(--text-faint);
}
/* A tool that places something is a tool you aim: the pointer says so, and the
   boxes underneath stop taking the click. */
.editor-stage[data-tool="text"], .editor-stage[data-tool="bubble"] { cursor: crosshair; }
.editor-stage[data-tool="text"] .tbox,
.editor-stage[data-tool="bubble"] .tbox { pointer-events: none; }

/* ---------- centre: the page ---------- */

/* The frame that does not move: it owns the zoom bar's position and clips
   nothing, while the scroller inside it takes all the overflow. Before this the
   bar lived in the scroller and slid away with the artwork, so zooming in was
   the exact moment the zoom control left the screen. */
.editor-canvas {
  position: relative;
  overflow: hidden;
  background: var(--bg);
  min-width: 0;
}

.editor-stageWrap {
  overscroll-behavior: contain;
  width: 100%;
  height: 100%;
  overflow: auto;
  display: flex;
  padding: 1.1rem;
}
.editor-stage {
  position: relative;
  background: #fff;
  box-shadow: 0 2px 24px rgba(0, 0, 0, .35);
  user-select: none;
  /* No `touch-action: none` here — that belongs on the boxes, which are the only
     thing that needs the browser to keep its hands off the gesture. On the stage
     it also killed two-finger panning, so a zoomed page could not be moved at
     all on a trackpad or a tablet. */
  flex: 0 0 auto;
  /* `margin: auto`, and not `align-items`/`justify-content: center` on the
     parent. Both centre a page that fits, but they part company the moment one
     does not: centring pushes the overflow *before* the scroll origin, and no
     scroll position reaches a negative offset, so the top and left of a zoomed
     page become unreachable. An auto margin resolves to zero when there is no
     free space to share, which start-aligns the page and leaves every pixel of
     it scrollable. `safe center` is meant to do the same and does — on the main
     axis. On the cross axis it left this pannable sideways and stuck
     vertically, which is worse than either behaviour on its own. */
  margin: auto;
}
.editor-stage img.plate { display: block; width: 100%; height: auto; }
/* `display` on a class beats the browser's rule for `[hidden]`, and the two
   pictures — the plate and the finished page — take turns by exactly that
   attribute. Without this both would be laid out, one under the other. */
.editor-stage img.plate[hidden] { display: none; }
/* The rail's 320px picture, standing in until the ~2.9 MB plate arrives. It is
   the page with its lettering still on, so it is briefly the *wrong* artwork to
   edit against — softened and dimmed so nobody starts placing a bubble on it,
   and it never lasts longer than the download. */
.editor-stage img.plate.is-provisional { filter: blur(3px) brightness(.82); }

/* Floating, because zoom is a view control and every row above the page is a
   row of page nobody can see. */
.editor-zoom {
  position: absolute;
  inset-block-end: .9rem;
  inset-inline-start: 50%;
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  gap: .5rem;
  padding: .3rem .5rem;
  border-radius: 999px;
  background: color-mix(in srgb, var(--surface) 92%, transparent);
  border: 1px solid var(--border);
  box-shadow: var(--shadow);
  backdrop-filter: blur(6px);
  white-space: nowrap;
}
.editor-sep { width: 1px; height: 1.1rem; background: var(--border); }
.editor-toggle {
  display: inline-flex; align-items: center; gap: .35rem;
  font-size: .74rem; color: var(--text-muted); cursor: pointer;
}
/* `display` on a class beats the browser's own rule for `[hidden]`, so the
   compare control stayed on screen for pages that have nothing to compare
   against. Written as a pair because the separator has a `display` of its own
   for the same reason. */
.editor-toggle[hidden], .editor-sep[hidden] { display: none; }
.zoom-readout {
  font-family: var(--mono); font-size: .72rem; color: var(--text-faint);
  font-variant-numeric: tabular-nums; min-width: 2.7rem; text-align: center;
}

.editor-nav { display: inline-flex; align-items: center; gap: .3rem; }
.editor-count {
  font-family: var(--mono); font-size: .78rem; color: var(--text-muted);
  font-variant-numeric: tabular-nums; padding-inline: .2rem;
}
.editor-count .of { color: var(--text-faint); }
.editor-nav [aria-disabled="true"] { opacity: .35; pointer-events: none; }

/* ---------- the text boxes ---------- */

.tbox {
  position: absolute;
  display: flex; flex-direction: column; justify-content: center;
  cursor: move;
  transform-origin: 50% 50%;
  /* Only here: dragging a box is a gesture the script owns completely, and the
     browser scrolling the canvas at the same time would fight it. Everywhere
     else on the stage the browser keeps panning, which is how a zoomed page is
     moved around. */
  touch-action: none;
}
/* `position: relative` so the script can nudge the block by the half-leading
   CSS adds above the first line and Pillow does not — see `halfLeading` in
   page-editor.js. Nudged with `top` rather than a margin because a margin would
   change the box the flex centring above measures, and the centring would eat
   half the correction back. */
.tbox .lines { width: 100%; pointer-events: none; position: relative; }
.tbox .line {
  white-space: pre; display: block;
  /* The stroke set by the script is twice the engine's width and straddles the
     glyph contour; painting the fill after it covers the inner half and leaves
     exactly the outer half, which is what Pillow draws. Without this the stroke
     lands on top of the fill and every letter comes out bold and closed up. */
  paint-order: stroke fill;
}
/* A bubble object fills its own box exactly — no padding, no owner, nothing to
   overflow. `pointer-events: none` so the drag handler sees the box rather than
   whichever ellipse happens to be under the cursor. */
.tbox .bubble {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  display: block;
  pointer-events: none;
}
.tbox .frame {
  position: absolute; inset: 0;
  border: 1px dashed rgba(124, 108, 245, .5);
  opacity: 0; pointer-events: none;
}
.editor-stage[data-frames="on"] .tbox .frame { opacity: 1; }
.tbox:hover .frame { opacity: 1; }
.tbox[aria-selected="true"] .frame {
  opacity: 1; border: 1.5px solid var(--accent); border-style: solid;
}

.tbox .handle {
  position: absolute; width: 10px; height: 10px;
  background: #fff; border: 1.5px solid var(--accent); border-radius: 2px;
  display: none;
}
.tbox[aria-selected="true"] .handle { display: block; }
.h-nw { top: -5px; left: -5px; cursor: nwse-resize; }
.h-ne { top: -5px; right: -5px; cursor: nesw-resize; }
.h-se { bottom: -5px; right: -5px; cursor: nwse-resize; }
.h-sw { bottom: -5px; left: -5px; cursor: nesw-resize; }
.h-n  { top: -5px; left: calc(50% - 5px); cursor: ns-resize; }
.h-s  { bottom: -5px; left: calc(50% - 5px); cursor: ns-resize; }
.h-e  { top: calc(50% - 5px); right: -5px; cursor: ew-resize; }
.h-w  { top: calc(50% - 5px); left: -5px; cursor: ew-resize; }
.h-rot {
  top: -26px; left: calc(50% - 6px);
  width: 12px; height: 12px; border-radius: 50%; cursor: grab;
}
.h-rot::before {
  content: ""; position: absolute; top: 11px; left: 4px;
  width: 1.5px; height: 14px; background: var(--accent);
}

/* ---------- leaving with unsaved work ---------- */

/* Width is set on the <dialog>, not on the box inside it. `.sheetbox-dialog`
   centres its child in a frame you cannot see, so a narrow box inside a wide
   dialog sits off to one side and reads as "the modal is not centred" — the
   top-up confirm learned this the same way. Two classes so it outscores
   `.sheetbox-dialog.narrow` rather than tying with it. */
.sheetbox-dialog.narrow.leave-confirm { width: min(400px, 94vw); }

.leavec { display: grid; gap: 14px; justify-items: center; padding: 24px 22px; }
.leavec-ic {
  width: 44px; height: 44px; border-radius: 50%; display: grid; place-content: center;
  color: var(--warn);
  background: color-mix(in srgb, var(--warn) 12%, var(--surface-2));
  border: 1px solid color-mix(in srgb, var(--warn) 35%, var(--border));
}
.leavec-ic .glyph { width: 22px; height: 22px; }
.leavec-title { margin: 0; font-size: 1.05rem; text-align: center; line-height: 1.45; }
.leavec-note {
  margin: 0; font-size: .78rem; color: var(--text-muted);
  text-align: center; line-height: 1.65; text-wrap: balance;
}
.leavec-acts { display: grid; gap: 8px; width: 100%; margin-top: 4px; }
.leavec-alts { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.leavec-acts .btn { justify-content: center; }

/* Comparing against the finished page: the text is already drawn into it, so
   the boxes come off. Both visible would show every line twice, a few pixels
   apart, which reads as a rendering fault rather than a comparison. */
.editor-stage[data-compare="on"] .tbox { display: none; }
/* While the server is working out where the engine actually drew the text.
   `visibility` rather than `display`, so the boxes keep their size and the
   script can measure and lay them out while they are out of sight — they appear
   already correct rather than appearing and then jumping. */
.editor-stage[data-fitting="on"] .tbox { visibility: hidden; }

.snapline { position: absolute; background: var(--ok); pointer-events: none; z-index: 5; }
.snapline.v { width: 1px; top: 0; bottom: 0; }
.snapline.h { height: 1px; left: 0; right: 0; }

/* ---------- right: the inspector ---------- */

.editor-panel {
  overscroll-behavior: contain;
  border-inline-start: 1px solid var(--border);
  background: var(--surface);
  overflow-y: auto;
  display: flex; flex-direction: column;
}
.editor-panel .group {
  padding: .8rem .9rem;
  border-block-end: 1px solid var(--border);
  display: flex; flex-direction: column; gap: .55rem;
}
.editor-panel .group > h2 {
  margin: 0; font-size: .66rem; text-transform: uppercase; letter-spacing: .09em;
  color: var(--text-faint); font-weight: 700;
  display: flex; align-items: center; gap: .45rem;
}
.editor-panel .group > h2 .count {
  font-family: var(--mono); letter-spacing: 0; text-transform: none;
  color: var(--text-muted);
}
.editor-field { display: flex; flex-direction: column; gap: .28rem; }
.editor-field > label {
  font-size: .72rem; color: var(--text-muted);
  display: flex; justify-content: space-between; align-items: baseline; gap: .5rem;
}
.editor-field > label .val {
  font-family: var(--mono); font-size: .68rem; color: var(--text-faint);
  font-variant-numeric: tabular-nums;
}
.editor-row { display: flex; gap: .45rem; }
.editor-row > * { flex: 1 1 0; min-width: 0; }
.editor-source {
  background: var(--bg); border: 1px solid var(--border); border-radius: 7px;
  padding: .35rem .5rem; font-size: .82rem; color: var(--text-muted);
  line-height: 1.6; max-height: 5rem; overflow-y: auto;
}
.editor-empty {
  padding: 1.1rem .2rem; text-align: center; color: var(--text-faint);
  font-size: .76rem; line-height: 1.7;
}

/* The list of every balloon on the page. Always on screen, never behind a
   selection: a translator reads a page as a script, and having the lines in
   order is how you notice the one that is wrong. */
/* The bubble templates. A row of small toggles rather than a select: there are
   five of them, the difference between them is a shape, and a dropdown hides
   four of the five behind a click. */
/* `bubbleGroup` hands back three groups in one node, and they have to sit in the
   panel as though they were siblings. */
.editor-groups { display: contents; }

.editor-shapes { display: flex; flex-wrap: wrap; gap: .25rem; }
.editor-shape {
  flex: 1 0 auto;
  font: inherit; font-size: .72rem;
  padding: .25rem .45rem;
  color: var(--text-muted);
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: 5px;
  cursor: pointer;
}
.editor-shape:hover { border-color: var(--accent); color: var(--text); }
.editor-shape[aria-pressed="true"] {
  color: var(--text);
  border-color: var(--accent);
  background: var(--accent-soft);
}

.editor-list { display: flex; flex-direction: column; gap: .15rem; }
/* A row is no longer one button: picking the object and moving it up or down the
   stack are three separate controls, so the row is a container and the button is
   inside it. Nesting them would have been a button inside a button, which is
   invalid and which the keyboard cannot reach. */
.editor-listrow {
  display: flex; align-items: center; gap: .15rem;
  border-radius: var(--radius); border: 1px solid transparent;
}
.editor-listrow:hover { background: var(--bg); }
.editor-listrow[aria-current="true"] {
  background: var(--accent-soft); border-color: #7c6cf544;
}
.editor-listpick {
  display: flex; align-items: center; gap: .45rem;
  flex: 1 1 auto; min-width: 0; text-align: start;
  padding: .3rem .45rem; border-radius: var(--radius);
  background: none; border: 0;
  color: var(--text-muted); font-size: .78rem; cursor: pointer;
}
.editor-listrow[aria-current="true"] .editor-listpick { color: var(--text); }
.editor-listpick svg { flex: 0 0 auto; width: .82rem; height: .82rem; color: var(--text-faint); }
.editor-listrow[aria-current="true"] .editor-listpick svg { color: var(--accent); }
.editor-listpick .t {
  flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.editor-listpick .dot {
  flex: 0 0 auto; width: 5px; height: 5px; border-radius: 50%;
  background: var(--warn); visibility: hidden;
}
.editor-listpick[data-changed="true"] .dot { visibility: visible; }
.editor-stackbtn {
  flex: 0 0 auto; width: 1.15rem; height: 1.35rem;
  display: grid; place-items: center; cursor: pointer;
  background: none; border: 0; border-radius: 3px;
  color: var(--text-faint); font-size: .72rem; line-height: 1;
}
.editor-stackbtn:hover { background: var(--surface-2); color: var(--text); }

@media (max-width: 960px) {
  .editor,
  .editor.is-railless { grid-template-columns: 1fr; height: auto; }
  .editor-rail {
    width: auto; flex-direction: row; overflow-x: auto;
    border-inline-end: 0; border-block-end: 1px solid var(--border);
  }
  .editor-thumb { flex: 0 0 3.4rem; }
  .editor-stageWrap { min-height: 60vh; }
  .editor-panel { border-inline-start: 0; border-block-start: 1px solid var(--border); }
}

/* Legal notices in the sidebar foot. Small and quiet, but on every page: the
   Privacy Notice carries the only route to several data-subject rights. */
/* ── Prose ──────────────────────────────────────────────────────────────────
   `.prose` was in the markup of the privacy notice and the terms and had no
   rule anywhere in this stylesheet, so both pages set their paragraphs to the
   full content column — measured at 1,129px, about 143 characters a line
   against the 45–75 a reader tracks comfortably. Twice the comfortable maximum,
   on the two pages somebody reads to decide whether to trust the service with
   their files.

   `ch` rather than a pixel width, so the measure follows the type: it stays
   right when the font size changes and when the reader zooms.

   60, not 68, and the number was measured rather than picked. `ch` is the width
   of the digit zero, and Thai glyphs are narrower than that — 68ch rendered 78
   Thai characters a line, still over the band. 60ch lands it inside. Counted by
   dividing each paragraph's length by the lines it actually wrapped to, not by
   estimating from the font size, which is how the first number came out wrong.

   The 45–75 band is a Latin convention and Thai sets no spaces between words,
   so it has no word-space rivers to break up a long line — this sits at the top
   of the band deliberately rather than in the middle.

   The measure goes on the TEXT, not on the article. Constraining `.prose`
   itself narrowed everything inside it — including the "not ready to publish"
   banner, whose label then broke across four lines — and `max-width: none` on a
   child cannot widen it past a parent that is already narrow. A banner is not
   prose; it speaks about the document rather than being part of it, so it keeps
   the full column and only the reading matter is measured. */
.prose > p, .prose > ul, .prose > ol, .prose > h2, .prose > h3 { max-width: 60ch; }
.prose h2 { margin: 2rem 0 .6rem; }
.prose h3 { margin: 1.4rem 0 .4rem; }
.prose p, .prose li { line-height: 1.85; }

.legal-links {
  display: flex;
  gap: .75rem;
  margin: .5rem 0 0;
  font-size: .72rem;
}
.legal-links a { color: var(--text-muted); text-decoration: none; }
.legal-links a:hover { color: var(--text); text-decoration: underline; }

/* ---------- the job screen: two legs, then the contact sheet ----------
 *
 * One job is two messages in the broker, so it is two panels here, with the
 * handoff between them drawn as its own cell — that gap is where a run
 * actually sits waiting, and giving it the same weight as a dot in a strip is
 * what made "slow" and "nobody picked it up" look identical.
 */
.rail { display: grid; grid-template-columns: 1fr auto 1fr; }
.leg {
  padding: .95rem 1.1rem;
  background: var(--surface);
  border: 1px solid var(--border);
}
.leg:first-child { border-radius: var(--radius-lg) 0 0 var(--radius-lg); border-inline-end: 0; }
.leg:last-child { border-radius: 0 var(--radius-lg) var(--radius-lg) 0; border-inline-start: 0; }
.leg[data-leg-state="idle"] { opacity: .5; }
.leg-top { display: flex; align-items: baseline; justify-content: space-between; gap: .6rem; }
.leg-name { font-size: .9rem; font-weight: 600; margin: 0; }
.leg-n { color: var(--text-faint); font-weight: 500; margin-inline-end: .4rem; font-family: var(--mono); font-size: .8rem; }
.leg-note { font-size: .76rem; color: var(--text-faint); }
.leg-sub { font-size: .76rem; color: var(--text-faint); margin: .4rem 0 0; }
.leg-bar {
  appearance: none; -webkit-appearance: none;
  display: block; width: 100%; height: 6px; margin-top: .6rem;
  border: 0; border-radius: 3px; background: var(--surface-3); overflow: hidden;
}
.leg-bar::-webkit-progress-bar { background: var(--surface-3); border-radius: 3px; }
.leg-bar::-webkit-progress-value { background: var(--accent); border-radius: 3px; transition: width .5s ease; }
.leg-bar::-moz-progress-bar { background: var(--accent); border-radius: 3px; }
.leg[data-leg-state="done"] .leg-bar::-webkit-progress-value,
.leg[data-leg-state="skipped"] .leg-bar::-webkit-progress-value { background: var(--ok); }
.leg[data-leg-state="done"] .leg-bar::-moz-progress-bar,
.leg[data-leg-state="skipped"] .leg-bar::-moz-progress-bar { background: var(--ok); }
.leg[data-leg-state="skipped"] .leg-bar::-webkit-progress-value { background: var(--text-faint); }
.leg[data-leg-state="skipped"] .leg-bar::-moz-progress-bar { background: var(--text-faint); }
.leg[data-leg-state="dead"] .leg-bar::-webkit-progress-value { background: var(--text-faint); }
.leg[data-leg-state="dead"] .leg-bar::-moz-progress-bar { background: var(--text-faint); }

.rail-join {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: .25rem; min-width: 3.2rem; padding: 0 .5rem;
  background: var(--surface-2);
  border-block: 1px solid var(--border);
  color: var(--text-faint);
}
.rail-join-arrow { font-size: .95rem; line-height: 1; }
.rail-join small { font-size: .62rem; letter-spacing: .02em; font-variant-numeric: tabular-nums; }
.rail-join[data-state-handoff="active"] { color: var(--accent); }
/* Waiting is reported as a duration, not as a fault: with one GPU a long wait
   here is usually a queue behind somebody else's chapter. */
.rail-join[data-state-handoff="waiting"] {
  color: var(--warn);
  background: color-mix(in srgb, var(--warn) 10%, var(--surface-2));
}

@media (max-width: 720px) {
  .rail { grid-template-columns: 1fr; }
  .leg:first-child { border-radius: var(--radius-lg) var(--radius-lg) 0 0; border-inline-end: 1px solid var(--border); border-block-end: 0; }
  .leg:last-child { border-radius: 0 0 var(--radius-lg) var(--radius-lg); border-inline-start: 1px solid var(--border); border-block-start: 0; }
  .rail-join { flex-direction: row; border-block: 0; border-inline: 1px solid var(--border); padding: .3rem; }
  .rail-join-arrow { transform: rotate(90deg); }
}

.job-grid { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(300px, .95fr); gap: 1rem; align-items: start; }
.job-side { display: grid; gap: .75rem; position: sticky; top: 1rem; }
/* `align-items: start` so a closed fold is the height of its own header. Grid
   stretches by default, which made the shorter of the two cards as tall as
   whichever one happened to be open beside it. */
.job-folds {
  display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; margin-top: 1rem;
  align-items: start;
}
@media (max-width: 1000px) { .job-grid { grid-template-columns: 1fr; } .job-side { position: static; } }
@media (max-width: 800px) { .job-folds { grid-template-columns: 1fr; } }

/* A card that is a <details>. The header is the <summary>, and an <h2> inside
   one is a block box — so the disclosure triangle sat on a line of its own with
   the heading underneath it, and the two cards' headings came out at different
   heights. A flex row puts the marker and the heading on one line, at the
   height every other `.card-head` on the screen uses. */
.fold > summary {
  display: flex; align-items: center; gap: .5rem;
  cursor: pointer; list-style: none;
}
.fold > summary::-webkit-details-marker { display: none; }
.fold > summary::before {
  content: "▸"; display: inline-block; flex: none;
  color: var(--text-faint); font-size: .85em;
  transition: transform .15s ease;
}
.fold[open] > summary::before { transform: rotate(90deg); }
.fold > summary:hover::before { color: var(--text-muted); }
.fold > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: var(--radius); }
.fold h2.inline { margin: 0; font-size: 1rem; }
/* Both of these grow while the job runs, and neither is worth handing the page
   over to: the log is our record and the term list can be a hundred names. */
.fold-body { max-height: 46vh; overflow-y: auto; }

/* The contact sheet. Every page of the chapter at once, which is the shape the
   question "how far has it got" is actually answered in.
 *
 * Bounded, and it is a height cap rather than a rule about page counts: a
 * twelve-page job is shorter than the cap and never grows a scrollbar, while a
 * 200-page chapter on a laptop no longer runs for several screens with the job's
 * own details stranded underneath it.
 *
 * Deliberately *no* `overscroll-behavior: contain`. Scrolling has to chain to
 * the page at either end, or a sheet that fills the window becomes a box a
 * trackpad cannot get out of — which is the real objection to nesting a scroll
 * box inside a page, and the reason this used not to have one. */
.sheet {
  display: grid; gap: .5rem; grid-template-columns: repeat(auto-fill, minmax(min(100%, 96px), 1fr));
  align-items: start;
  max-height: min(68vh, 860px); overflow-y: auto;
  /* Room for the scrollbar so the last column is not shaved by it. */
  padding-inline-end: .35rem;
  scrollbar-width: thin;
}
/* Four steps rather than one switch. A single threshold at 120 made a
   120-page chapter taller than a 500-page one, which is a step nobody would
   design on purpose. Short chapters are most chapters, so they get the biggest
   pages. */
.sheet.roomy { grid-template-columns: repeat(auto-fill, minmax(min(100%, 140px), 1fr)); }
.sheet.tight { grid-template-columns: repeat(auto-fill, minmax(min(100%, 68px), 1fr)); gap: .38rem; }
.sheet.dense { grid-template-columns: repeat(auto-fill, minmax(min(100%, 46px), 1fr)); gap: .3rem; }
/* No fixed aspect ratio. Every real page measures between 0.70 and 0.715, the
   box was 2/3 = 0.667, and `object-fit: cover` cut the difference off the sides
   of every page on the screen — and off far more than that for a spread. The
   `width`/`height` attributes on the image carry the page's own ratio, so each
   cell is the shape of the page in it and nothing is cropped or stretched. */
.cell {
  position: relative; overflow: hidden;
  border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--surface-2);
  transition: border-color .15s, transform .12s;
  /* Off-screen cells are not laid out or painted, so a 500-page sheet costs
     about what the visible rows cost. `auto` in the intrinsic size means the
     browser remembers each cell's real height once it has been rendered, so
     scrolling back up does not jump. Content is still found by the browser's
     own find and still reachable by tab — this skips the work, not the page. */
  content-visibility: auto;
  contain-intrinsic-size: auto 180px;
}
.sheet.tight .cell { contain-intrinsic-size: auto 96px; }
.sheet.dense .cell { contain-intrinsic-size: auto 65px; }
.sheet.roomy .cell { contain-intrinsic-size: auto 200px; }
/* Held back by the feed until the sheet is scrolled near them — see app.js.
   Only ever set by the script, so with it off the whole chapter is on screen. */
.cell[hidden] { display: none; }
/* The line the feed watches for, spanning the grid so it sits below the last
   revealed row rather than inside it. */
.sheet-more {
  grid-column: 1 / -1; height: 1px; margin: 0;
}
.cell:hover { border-color: var(--accent); transform: translateY(-1px); }
.cell-face {
  display: block; width: 100%; padding: 0;
  border: 0; background: none; cursor: pointer; color: inherit; font: inherit;
}
.cell-face:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; }
/* Quiet until the cell is hovered or the link itself has focus — five hundred
   of these shouting at once is the wallpaper the label change was avoiding. */
.cell-edit {
  position: absolute; inset-inline-end: 3px; inset-block-end: 3px; z-index: 3;
  font-size: .64rem; padding: .05rem .3rem; border-radius: 5px;
  background: color-mix(in srgb, var(--bg) 85%, transparent);
  color: var(--text-muted); text-decoration: none;
  opacity: 0; transition: opacity .15s;
}
.cell:hover .cell-edit, .cell-edit:focus-visible { opacity: 1; color: var(--text); }
.sheet.tight .cell-edit, .sheet.dense .cell-edit { display: none; }
.cell-img { display: block; width: 100%; height: auto; transition: filter .55s ease; }
/* Blurred because there is nothing else true to show yet — and the upload
   rather than a grey box, because the shape of the page is what makes the
   sheet readable at this size. */
.cell[data-state="pending"] .cell-img { filter: blur(5px) saturate(.55) brightness(.8); }
.cell[data-state="failed"] .cell-img { filter: blur(4px) grayscale(1) brightness(.5); }
/* The raw tab is the file the customer sent us. It is complete from the moment
   they sent it, and dimming it would claim we are still working on it. */
.sheet[data-view="raw"] .cell-img { filter: none; }
.cell-n {
  position: absolute; inset-inline-start: 4px; inset-block-start: 4px; z-index: 2;
  font-size: .66rem; font-variant-numeric: tabular-nums;
  padding: .05rem .3rem; border-radius: 5px;
  background: color-mix(in srgb, var(--bg) 85%, transparent); color: var(--text-muted);
}
.cell-mark {
  position: absolute; inset-inline-end: 4px; inset-block-start: 4px; z-index: 2;
  width: 14px; height: 14px; border-radius: 50%; opacity: 0; transform: scale(.6);
  transition: opacity .25s, transform .25s cubic-bezier(.34, 1.56, .64, 1);
}
.cell[data-state="done"] .cell-mark { opacity: 1; transform: scale(1); background: var(--ok); }
.cell[data-state="failed"] .cell-mark { opacity: 1; transform: scale(1); background: var(--danger); }
.sheet.tight .cell-n, .sheet.dense .cell-n { font-size: .55rem; padding: 0 .18rem; }
.sheet.tight .cell-mark, .sheet.dense .cell-mark { width: 10px; height: 10px; }

/* The lines under the page in the preview dialog, and anywhere else a
   source/translation pair is listed. */
.preview-lines {
  margin-top: .7rem; max-height: 30vh; overflow-y: auto;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius); padding: .2rem .9rem;
}
.preview-lines:empty { display: none; }
.preview figure { min-height: 0; }
/* The panel beside the sheet, when a page is picked. */
.panel-line { padding: .55rem 0; border-block-end: 1px solid var(--border); }
.panel-line:last-child { border-block-end: 0; }
.panel-line .src { font-size: .9rem; color: var(--text-muted); }
.panel-line .to { font-size: .97rem; margin-top: .12rem; }
.panel-line .to:empty::before { content: "—"; color: var(--text-faint); }

.small-text { font-size: .79rem; }
.sub-h { font-size: .78rem; text-transform: uppercase; letter-spacing: .07em; color: var(--text-faint); margin: .9rem 0 .35rem; }
.kv.tight { gap: .2rem .8rem; font-size: .85rem; }

@media (prefers-reduced-motion: reduce) {
  .cell, .cell-img, .cell-mark, .cell-edit, .leg-bar::-webkit-progress-value { transition: none; }
}

/* ---------- sending a chapter, while the page stays put ----------
 *
 * The job form submits over XHR so a refusal cannot take the customer's files
 * away with the page. That removes the browser's own loading indicator, and an
 * upload of a couple of hundred pages is minutes long — so this is not
 * decoration, it is the only thing on screen saying the button worked.
 */
.sending { display: flex; align-items: center; gap: .7rem; margin-top: .7rem; }
/* `width: 100%`, not `flex: 1`.
 *
 * It was written as a flex child when it sat in a row under the button. In the
 * dialog it is a plain block child, so `flex` does nothing and `<progress>`
 * falls back to its own intrinsic width — about 160px inside a 1100px panel.
 * The bar was full the whole time; it just looked a third full, which is a
 * worse lie than showing nothing.
 *
 * `progress.` prefix to match `.flow-bar` and `.quota-bar` in this file: the
 * pseudo-elements need to out-rank the browser's own, and every other bar here
 * already earns that the same way. */
progress.sending-bar {
  appearance: none; -webkit-appearance: none;
  display: block; width: 100%; height: 8px;
  border: 0; border-radius: 4px; overflow: hidden;
  background: var(--surface-3); color: var(--accent);
}
progress.sending-bar::-webkit-progress-bar { background: var(--surface-3); border-radius: 4px; }
progress.sending-bar::-webkit-progress-value { background: var(--accent); border-radius: 4px; }
progress.sending-bar::-moz-progress-bar { background: var(--accent); border-radius: 4px; }
.sending-note {
  font-size: .85rem; color: var(--text); margin: .6rem 0 0;
  font-variant-numeric: tabular-nums;
}

/* ---------- the sheet's two dialogs ----------
 *
 * A panel with a head that stays put and a body that scrolls, so a chapter's
 * worth of lines cannot push the page's own title off the top of the screen.
 */
.sheetbox-dialog {
  border: none; padding: 0; background: none;
  width: min(1600px, 96vw); max-width: 96vw; max-height: 96vh;
}
.sheetbox-dialog.narrow { width: min(560px, 94vw); }
.sheetbox-dialog::backdrop { background: rgb(6 7 12 / .72); }
.sheetbox {
  display: flex; flex-direction: column; max-height: 96vh; overflow: hidden;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius-lg); box-shadow: var(--shadow);
}
.sheetbox-head {
  display: flex; align-items: center; justify-content: space-between;
  gap: 1rem; padding: .85rem 1.1rem; border-bottom: 1px solid var(--border);
}
.sheetbox-head h2 { margin: 0; font-size: 1rem; }
.sheetbox-body { padding: 1.1rem; overflow: auto; }
.close {
  background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius);
  color: var(--text-muted); cursor: pointer; font: inherit; font-size: .9rem;
  padding: .25rem .55rem; line-height: 1;
}
.close:hover { color: var(--text); border-color: var(--accent); }
/* Shut while a file is being built — the download dialog is the only place that
   build is visible, and closing it loses the one thing the customer is
   watching. Greyed rather than hidden: a control that vanishes mid-task reads
   as a bug, one that is plainly unavailable reads as "not yet". */
.close:disabled { opacity: .4; cursor: default; }
.close:disabled:hover { color: var(--text-muted); border-color: var(--border); }

/* The page, and what was read off it. */
.pageview {
  display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, 26rem);
  gap: 1.2rem; align-items: start;
}
@media (max-width: 860px) { .pageview { grid-template-columns: 1fr; } }
.pageview-plate {
  display: flex; justify-content: center;
  border: 1px solid var(--border); border-radius: var(--radius);
  overflow: hidden; background: var(--surface-2);
}
/* Bounded by the height of the screen rather than the width of the column: a
   manga page is portrait, so a bigger dialog has to mean a taller page and not
   more empty space beside it. Both bounds are maxima and neither sets a
   dimension outright, which is what keeps the ratio the file's own — the exact
   thing `.preview` got wrong. */
.pageview-plate img {
  display: block; width: auto; height: auto;
  max-width: 100%; max-height: 78vh;
}
.pageview-lines { max-height: 78vh; overflow: auto; }

/* Taking one page back out of a picked batch. Quiet until the tile is hovered
   or the button itself has focus: thirty-two of these shouting at once turns a
   preview of the chapter into a row of controls. */
.picked-cell { position: relative; }
.picked-drop {
  position: absolute; inset-inline-end: 3px; inset-block-start: 3px; z-index: 3;
  width: 1.35rem; height: 1.35rem; padding: 0; line-height: 1;
  border: 1px solid var(--border); border-radius: 6px;
  background: color-mix(in srgb, var(--bg) 85%, transparent);
  color: var(--text-muted); font: inherit; font-size: .7rem; cursor: pointer;
  opacity: 0; transition: opacity .15s;
}
.picked-cell:hover .picked-drop, .picked-drop:focus-visible { opacity: 1; }
.picked-drop:hover { background: var(--danger); border-color: var(--danger); color: #fff; }
@media (prefers-reduced-motion: reduce) { .picked-drop { transition: none; } }

/* ---------- staff ops board ----------
   หน้า ops ตาม mockup (docs/superpowers/specs/2026-08-15-worker-ops-dashboard-mockup.html):
   หัวข้อ mono ตัวพิมพ์ใหญ่ · ชื่อเครื่อง/รหัสงานเป็น mono · log pane โทนคอนโซล
   สีทุกตัวมาจาก token กลาง จะได้รอดทั้งธีมมืด/สว่างเหมือนส่วนอื่นของแอป */
.ops-h {
  font-family: var(--mono); font-size: .76rem; font-weight: 650;
  letter-spacing: .18em; text-transform: uppercase; color: var(--text-faint);
  margin: 1.7rem 0 .7rem;
}
.ops-h .count { color: var(--accent); letter-spacing: normal; }
.ops-name { font-family: var(--mono); font-weight: 700; font-size: 1.02rem; }
.ops-worker p { margin: .45rem 0 0; font-size: .84rem; }

.ops-log { border: 1px solid var(--border); border-radius: 8px; overflow: hidden;
  background: color-mix(in srgb, var(--bg) 82%, black); }
.ops-log-head { display: flex; align-items: center; gap: .7rem; flex-wrap: wrap;
  padding: .45rem .85rem; border-bottom: 1px solid var(--border);
  font-family: var(--mono); font-size: .72rem; color: var(--text-faint); }
.ops-live { color: var(--ok); }
.ops-log pre { margin: 0; padding: .55rem 0; max-height: 460px; overflow: auto;
  font-family: var(--mono); font-size: .74rem; line-height: 1.75; }
.ops-log .ln { display: block; padding: 0 .9rem; white-space: pre-wrap; }
.ops-log .ln.warn { color: var(--warn); }
.ops-log .ln.bad { color: var(--danger); }
.ops-log .ln.ok { color: var(--ok); }

.ops-cfg pre { margin: 0; font-family: var(--mono); font-size: .74rem;
  line-height: 1.6; max-height: 260px; overflow: auto; }
.ops-credit td { font-variant-numeric: tabular-nums; }
/* ── The jobs list ──────────────────────────────────────────────────────────
   One row shape, one line each, and the columns are FIXED rather than `auto`.
   That is the whole point of the shape: `auto` lets every row size its columns
   to its own text, so fifty rows have fifty different column positions and the
   eye has nothing to run down. Fixed tracks mean row 1 and row 50 rule up.

   It replaces two things — a stack of five-step flow panels for every running
   job, and a table of everything underneath in a different row language. */
.joblist { display: grid; gap: .3rem; }

.jobrow {
  position: relative; overflow: hidden;
  display: grid;
  grid-template-columns: 10px minmax(0, 1fr) 9rem 13rem 3.5rem 1rem;
  gap: .9rem; align-items: center;
  padding: .5rem .95rem; min-height: 2.5rem;
  text-decoration: none; color: inherit;
  border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--surface);
}
.jobrow:hover { border-color: var(--accent); text-decoration: none; color: inherit; }
.jobrow:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }

/* One shape; colour is the only variable. Five badge treatments down one column
   was the loudest thing on the old page and said nothing the word beside it did
   not. */
.jobdot { width: 10px; height: 10px; border-radius: 99px; border: 1.5px solid var(--text-faint); background: transparent; }
.is-live    .jobdot { border-color: var(--accent); background: var(--accent); }
.is-stopped .jobdot { border-color: var(--accent); background: transparent; }
.is-done    .jobdot { border-color: var(--text-muted); background: var(--text-muted); }
.is-draft   .jobdot { border-color: var(--text-faint); background: transparent; }

/* Every cell truncates rather than wraps: a row that grows to fit its longest
   field stops ruling up with its neighbours, which is the one thing this shape
   exists to prevent. */
.jobname { font-weight: 700; font-size: .88rem; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.jobmode { font-size: .78rem; color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.jobwhen { font-size: .74rem; color: var(--text-faint); text-align: right; white-space: nowrap; }
.jobsep { color: var(--text-faint); opacity: .55; }

/* Two facts in one cell so the row stays one line: what state it is in, and —
   only where it is the reason somebody came — why it stopped. */
.jobstate { display: flex; align-items: baseline; gap: .4rem; min-width: 0; }
.jobstate-k { font-size: .8rem; color: var(--text-muted); white-space: nowrap; }
.jobwhy { font-size: .74rem; color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.is-live .jobstate-k { color: var(--text); font-weight: 700; }
/* The one group that is asking for something gets the one accent. */
.is-stopped .jobstate-k { color: var(--accent); font-weight: 700; }

.jobgo { color: var(--border); text-align: right; }
.jobrow:hover .jobgo { color: var(--accent); }

/* The row's own bottom edge, full width — not a cell competing for column
   space. Drawn there it costs the row no height and no column. */
.jobbar-fill {
  -webkit-appearance: none; appearance: none;
  position: absolute; left: 0; right: 0; bottom: 0;
  width: 100%; height: 3px; border: none; background: var(--border);
}
.jobbar-fill::-webkit-progress-bar { background: var(--border); }
.jobbar-fill::-webkit-progress-value { background: var(--accent); }
.jobbar-fill::-moz-progress-bar { background: var(--accent); }

/* ── The bar above the list ─────────────────────────────────────────────── */
.jobbar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: .9rem; }
/* One line. The selects each took the full width and pushed the button onto a
   third row, so one question asked twice read as three controls stacked. */
.jobfilter { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
.jobfilter select, .jobfilter .btn { height: var(--control-h); }
.jobfilter select { width: auto; min-width: 9rem; }
.jobfilter-clear { font-size: .8rem; color: var(--text-faint); white-space: nowrap; }
.jobfilter-clear:hover { color: var(--text); }
/* Nothing on this page polls, so it says when it read the figures. */
.jobbar-as { margin: 0; font-size: .78rem; color: var(--text-faint); }
.jobbar-as a { color: var(--text-muted); margin-left: .4rem; }
.jobnote { margin: 0 0 .8rem; font-size: .8rem; color: var(--text-muted); }
.jobnote a { margin-left: .5rem; text-decoration: underline; text-underline-offset: 2px; }

@media (max-width: 900px) {
  /* The fixed tracks are what make the wide screen scan, and below this there
     is not room for them. Mode goes — it is on the job's own page — and the
     state, which is what the reader came for, takes the second line. */
  .jobrow { grid-template-columns: 10px minmax(0, 1fr) auto; row-gap: .2rem; }
  .jobmode, .jobwhen, .jobgo { display: none; }
  .jobstate { grid-column: 2 / -1; }
}


/* ══════════════════════════════════════════════════════════════════════════
   THE LANDING PAGE
   Ported from the `ux-ui` branch, which is the designer's own draft of this
   screen. Rewritten against this branch rather than merged: the draft sat on
   a base three weeks behind and its `app.css` had diverged past merging.
   It needed almost no translation — it was written in `--verm`, `--acid`,
   `--cold`, `--hi`, `--mid`, `--low`, `--line2`, and the token bridge already
   declares every one of them here. Two exceptions: `--lift` is this file's
   `--shadow` under another name (1 uses, swapped), and `--on-cold` was added
   above. `--ctl-h`, `--split` and `--grip` are declared by the rules that use
   them and travel with them.
   ══════════════════════════════════════════════════════════════════════════ */
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }
.landing [id], .landing { scroll-margin-top: 70px; }

.landing { display: block; position: relative; container-type: inline-size; }

/* One warm light source, its temperature scrubbed by the scroll. Sticky with
   zero height so it costs no layout: it paints behind and never pushes. */
.warmth { position: sticky; top: 0; height: 0; z-index: 0; pointer-events: none; }
.warmth span {
  position: absolute; left: 0; right: 0; top: 0; height: 100vh; display: block;
  background:
    radial-gradient(120% 78% at 74% -14%, color-mix(in srgb, var(--verm) 32%, transparent), transparent 62%),
    radial-gradient(90% 60% at 6% 6%, color-mix(in srgb, var(--acid) 13%, transparent), transparent 60%);
}
.warmth span.cool {
  background:
    radial-gradient(120% 80% at 20% 106%, color-mix(in srgb, var(--cold) 26%, transparent), transparent 60%),
    radial-gradient(80% 55% at 94% 90%, color-mix(in srgb, var(--verm) 14%, transparent), transparent 60%);
  opacity: .55;
}
:root[data-theme="light"] .warmth span { opacity: .45; }

/* ---------- landing primitives ---------- */
/* The pointer position, published once by hero.js on the root and read by every
   layer that wants to sit at its own distance — the hero's three, and the mascot
   beside the drop target.

   Registered rather than declared on a selector. A plain `.landing { --px: 0 }`
   would *shadow* what the script writes on the root for everything inside it,
   which is the whole page; `@property` gives the pair an initial value without
   any element claiming to own it, so the untouched page is the composed one and
   the script still reaches every reader. Same device as `--split` above. */
@property --px { syntax: "<number>"; inherits: true; initial-value: 0; }
@property --py { syntax: "<number>"; inherits: true; initial-value: 0; }

.landing .wrap, .hdr .wrap, .ftr .wrap {
  width: min(1180px, calc(100% - 96px)); margin-inline: auto; position: relative; z-index: 2;
}
@media (max-width: 900px) { .landing .wrap { width: calc(100% - 40px); } }

.eyebrow {
  font-size: .75rem; font-weight: 700; line-height: 1.6;
  letter-spacing: .22em; text-transform: uppercase;
  color: var(--verm-ink); margin: 0 0 18px;
  display: flex; align-items: center; gap: 12px;
}
.eyebrow::before { content: ""; width: 26px; height: 2px; background: var(--verm); display: block; flex: none; }
.eyebrow.cold { color: var(--cold); }
.eyebrow.cold::before { background: var(--cold); }

.landing .h1 { font-weight: 900; font-size: clamp(34px, 4.5cqi, 66px); line-height: 1.3; letter-spacing: -.005em; margin: 0; }

/* ── Thai does not take negative tracking ──────────────────────────────────
   Tightening the space between letters is a Latin display device: it closes
   gaps between shapes that sit side by side on one line. Thai stacks — vowels
   above and below, a tone mark above those — and the marks are positioned
   against their own consonant, not against the tracking. Pull the consonants
   together and the marks do not come with them; at 47px and weight 900 that
   drift is what a reader sees first, and it reads as broken spelling rather
   than as tight typography.

   `:lang(th)` rather than deleting the tracking outright, because the English
   page still wants it and the two languages share every one of these classes.
   The value goes back to `normal`, not to zero: `normal` lets the face apply
   its own kerning, and this face has some. */
:lang(th) h1, :lang(th) h2, :lang(th) h3, :lang(th) h4,
:lang(th) .h1, :lang(th) .h2, :lang(th) .h3,
:lang(th) .dz-lead, :lang(th) .band .row span, :lang(th) .mark b { letter-spacing: normal; }
.landing .h2 { font-weight: 900; font-size: clamp(27px, 3.3cqi, 47px); line-height: 1.38; margin: 0; }
.landing .h3 { font-weight: 800; font-size: clamp(20px, 1.8cqi, 27px); line-height: 1.55; margin: 0; }
.landing .em { color: var(--verm-ink); }
.lede { font-size: clamp(15px, 1.25cqi, 19px); line-height: 1.95; color: var(--mid); margin: 0; max-width: 62ch; }
.lede b { color: var(--hi); font-weight: 800; }
.small { font-size: 14px; line-height: 1.9; color: var(--low); }

/* The self-drawing brush rule: the outer window sweeps one way, the inner
   mark counter-moves, so the stroke appears to be laid down rather than to
   slide in. Both halves are transforms — never a paint or layout property. */
.draw { display: block; overflow: hidden; max-width: 420px; margin-top: 18px; }
.draw > span { display: block; }
.draw svg { display: block; width: 100%; height: auto; }

.landing .btn {
  padding: 19px 30px; font-size: 1rem; font-weight: 800; border-radius: 2px; gap: 12px;
}
/* The header button is chrome, not a call. It sits beside the language switch
   and has to be the same height as it — at the hero button's size it was the
   loudest thing on a bar whose job is to stay out of the way, and it read as
   the page's action when the page's action is the drop target below. */
.hdr .btn { padding: 9px 16px; font-size: .8rem; font-weight: 700; border-radius: 2px; }
.landing .btn.full { width: 100%; }
.btn-ghost {
  display: inline-flex; align-items: center; gap: 10px;
  font-size: .94rem; font-weight: 700; color: var(--hi); text-decoration: none;
  padding: 19px 4px; border-bottom: 2px solid var(--line2);
  transition: border-color .2s, color .2s;
}
.btn-ghost:hover { border-color: var(--verm); color: var(--verm-ink); text-decoration: none; }

/* ---------- header ---------- */
.hdr {
  position: sticky; top: 0; z-index: 40;
  display: flex; align-items: center; gap: 24px;
  padding: 16px clamp(20px, 4cqi, 48px);
  background: var(--ink); border-bottom: 1px solid var(--line);
}
.mark { display: flex; align-items: center; gap: 11px; text-decoration: none; color: var(--hi); flex: none; }
.mark:hover { color: var(--hi); text-decoration: none; }
.mark b { font-size: 1.2rem; font-weight: 900; letter-spacing: -.01em; }
/* ลิงก์ห้ามหักบรรทัด: ป้ายอังกฤษยาวกว่าไทย ("Using it & answers") เคยหักสองแถว
   จนแถบทั้งเส้นเบี้ยว — พื้นที่ไม่พอให้ gap หดแทน แล้วค่อยไปหักที่ระดับแถบ
   ทั้งแถวตาม media query เดิม */
.hdr nav { display: flex; gap: clamp(12px, 2vw, 26px); margin-left: 14px; }
.hdr nav a { white-space: nowrap; }
.hdr nav a { font-size: .875rem; font-weight: 600; color: var(--mid); text-decoration: none; padding: 6px 0; border-bottom: 2px solid transparent; }
.hdr nav a:hover { color: var(--hi); border-color: var(--verm); text-decoration: none; }
/* The download button, wherever a page puts one.
   Larger than the buttons around it on purpose: every other control on the
   guide moves a reader further into the text, and this one ends the errand
   they came with. The mark does the naming, so the label can stay short.

   `inline-flex` with a `gap` rather than a margin on the svg, so the icon and
   the words wrap and centre as one thing — a `full`-width copy of this button
   centres both halves together. */
.btn.btn-get {
  display: inline-flex; align-items: center; justify-content: center; gap: 10px;
  /* Every button on this page is already 16px/800, so a point of difference is
     no difference. This one is a fifth larger and stands a head taller than its
     neighbours — the size *is* the signal that it ends the errand. */
  font-size: 1.25rem; font-weight: 800; letter-spacing: -.015em;
  padding-inline: 1.6rem; min-height: 3.4rem;
}
.btn.btn-get .dl-ic { flex: none; }
.btn.btn-get.full { display: flex; width: 100%; }

/* ── Downloads ────────────────────────────────────────────────────────────
   A shelf, not a pitch, and the spacing has to say so. Wearing the landing
   page's hero and section bands unaltered, this page spent 528px of padding
   framing 311px of card: a hero sized to sell sits over a heading that only
   labels, and a band sized for a wall of features sits around one item.

   Scoped to `.dl-shelf` rather than edited in place — the guide and the landing
   page still want the big version, and they are the reason those numbers are
   what they are. */
/* The heading labels a shelf; it is not the first line of an argument. The
   landing page's display size is earning attention this page already has —
   somebody arrived here to fetch something. */
.dl-shelf .guide-hero .h1 { font-size: clamp(22px, 2.1cqi, 30px); }
.dl-shelf .guide-hero .lede { margin-top: .6rem; max-width: 58ch; }
/* The band's rule goes with its top padding.

   `.ext-sec` carries `border-top` because on the landing page and in the guide
   it divides one band from the next, with `clamp(3rem, 7vw, 6rem)` of air
   underneath it doing the dividing. This page takes that padding to zero — the
   shelf starts straight after its heading — and the rule was left behind, so it
   drew a full-width line one pixel above the card's own top border. Two edges a
   hair apart, one of them running off past the card on both sides: it reads as
   a stray line through the top of the card rather than as a division of
   anything.

   Nothing to divide here in any case. There is one band on this page, its
   heading is above it, and the card underneath carries its own border and its
   own ground. */
.dl-shelf .ext-sec { padding-block: 0 3rem; border-top: 0; }

/* ── Downloads: one card per thing you can install ────────────────────────
   A grid rather than a stack, so the second item lands beside the first and
   the page does not have to be redesigned the day there is one. `auto-fit`
   with a floor means a single card still fills the measure instead of sitting
   half-width beside nothing.

   Borrowed from `.guide-feats` on purpose — same surface, same border, same
   corner. Two card treatments on two public pages of one product is how a
   design system starts disagreeing with itself. */
.dl-list {
  list-style: none; margin: 0; padding: 0;
  display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
  /* Capped, because a lone card was inheriting the full 1180px measure and a
     paragraph 1180px wide is a paragraph nobody finishes a line of. Two cards
     still fit side by side inside this; a third wraps, which is the right
     answer at that count anyway. */
  max-width: 880px;
}
.dl-card {
  padding: 22px 24px 24px;
  border: 1px solid var(--line2); border-radius: var(--radius-lg);
  background: var(--surface);
  scroll-margin-top: 90px;
}
/* The heading and what it runs on, on one line while there is room. `baseline`
   rather than `center`: the platform note is small type and should sit on the
   heading's line, not float in the middle of its height. */
.dl-card-head {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 14px;
  margin-bottom: 10px;
}
.dl-card-head .h2 { margin: 0; }
.dl-for {
  font: 600 .78rem/1 var(--mono); letter-spacing: .04em; text-transform: uppercase;
  color: var(--low); border: 1px solid var(--line2); border-radius: 999px;
  padding: 5px 10px;
}
.dl-what { color: var(--mid); font-size: .94rem; line-height: 1.8; margin: 0 0 16px; }
.dl-actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
/* The step people miss, under the button that makes them need it. */
.dl-note { color: var(--low); font-size: .86rem; line-height: 1.7; margin: 12px 0 0; }
.dl-more { color: var(--low); font-size: .88rem; margin: 20px 0 0; }

/* ── The route that is not the recommended one ────────────────────────────
   Once the extension is on the Chrome Web Store, the zip stops being *the*
   way in and becomes the way in for people the store does not serve: an
   offline machine, a browser that is not signed in, a reviewer holding a
   build, every developer on this repo. That is a real audience and a small
   one, which is exactly what a `<details>` is for — one line closed, the whole
   manual route open.

   The same gesture as `.alt-signup` on the signup page, in the landing page's
   tokens rather than the console's: this appears on `core:downloads` and
   inside the guide's install aside, both of which are dressed from the landing
   palette. Sharing the class and not the colours is the only way both pages
   keep looking like themselves.

   `list-style: none` plus the `::-webkit-details-marker` rule because Safari
   needs both — one of them alone leaves the triangle drawn. */
.dl-alt { margin-top: 18px; border-top: 1px solid var(--line2); padding-top: 6px; }
.dl-alt > summary {
  cursor: pointer; list-style: none;
  padding: 10px 0; font-size: .88rem; font-weight: 600; color: var(--low);
}
.dl-alt > summary::-webkit-details-marker { display: none; }
.dl-alt > summary::before { content: "+ "; font-family: var(--mono); color: var(--verm-ink); }
.dl-alt[open] > summary::before { content: "− "; }
.dl-alt > summary:hover { color: var(--hi); }
.dl-alt > summary:focus-visible { outline: 2px solid var(--verm-ink); outline-offset: 3px; border-radius: 4px; }
/* The steps inside are an aside, not the page's main procedure — `.craft-list`
   sizes itself for the latter and arrives with 38px above it and a rule under
   every row, which inside a fold reads as a second section rather than as the
   contents of the one that was just opened. */
.dl-alt .craft-list { margin-top: 10px; }
.dl-alt .craft-list li { padding: 14px 0; }
.dl-alt .craft-list b { font-size: .98rem; }
.dl-alt .dl-actions { margin-top: 12px; }

/* A list of connected browsers, with a lid.
   Six rows is the point where the card stops being a card and becomes the
   page — and this list has no natural bound, because it grows by one every
   time somebody signs in from somewhere new and never shrinks on its own.
   `max-height` and nothing else: a short list does not scroll, so the card
   keeps its shape for the account that has two. */
.link-scroll { max-height: 19rem; overflow-y: auto; overscroll-behavior: contain; }
/* The gutter is reserved rather than overlaid, so the Remove button on the
   right does not shift sideways the moment the seventh browser is added. */
.link-scroll { scrollbar-gutter: stable; }

.hdr .sp { flex: 1; }
/* Every control on this bar is the same height, and it is stated once rather
   than arrived at from three different paddings. Padding plus a border plus a
   line-height is three numbers per control and they will not agree — which is
   how a 38px language switch ended up beside a 44px button. */
/* One height for everything on this bar, declared on the bar rather than on the
   group of controls at the end of it — the menu button is a child of `.hdr` and
   needs the same number, and a custom property only reaches what it is declared
   above. */
.hdr { --ctl-h: 38px; }
.ctrls { display: flex; align-items: center; gap: 10px; }
.ctrls .lang-menu > summary, .ctrls .seg, .hdr .btn, .ctrls .hdr-credits { height: var(--ctl-h); flex: none; }
.ctrls .seg button { padding-block: 0; }
.ctrls .lang-option { padding-inline: 13px; font-size: .72rem; letter-spacing: .06em; }

/* Segmented control — the same component as the console's language switch and
   the library's view toggle, and the same contract: pressed is a full
   inversion, never a tint. */
.seg { display: flex; border: 1px solid var(--line2); border-radius: 2px; overflow: hidden; }
.seg button {
  display: flex; align-items: center; justify-content: center; gap: 6px;
  background: transparent; border: 0; cursor: pointer; color: var(--low);
  font-size: .72rem; font-weight: 700; letter-spacing: .06em; padding-inline: 11px;
  transition: color .15s, background-color .15s;
}
.seg button + button { border-left: 1px solid var(--line2); }
.seg button:hover { color: var(--hi); }
.seg button[aria-pressed="true"] { background: var(--hi); color: var(--ink); }
.seg svg { display: block; }
/* ── The public header's own menu ───────────────────────────────────────────
   `.hdr nav` used to be `display: none` below 1120px and nothing took its
   place. What went with it was the product's own table of contents — The work,
   Pricing, Using it & answers, Extension, Downloads — on the page a visitor
   arrives at first, leaving the footer as the only route to any of them. A
   phone reader had to scroll a landing page to the bottom to learn that there
   was a price list.

   So the nav does not disappear now; it changes shape. Wide, it is the row of
   links in the bar. Narrow, it is a sheet under the bar that a button opens,
   and it is the same `<nav>` either way — two lists would be two things to keep
   in step, and the one that fell behind would be the phone's.

   The theme control and the language switch travel with it. They were taking a
   third of a 390px bar between them and neither is what a visitor came here to
   press; worse, the theme select was rendering as "Match ..." with the word cut
   off, which is a control whose state cannot be read. In the sheet they get
   their full width back, and the three-way theme control can be the segmented
   row again instead of the select that only ever existed to fit the bar.

   What stays on the bar is the mark, the balance and the one button this screen
   exists for. A call to action behind a menu is a call to action nobody hears.

   The mechanism is the console drawer's, and the long note in `base.html` is
   the argument for it: a checkbox and a `<label>`, so the menu opens with the
   stylesheet alone. `app.js` adds Escape and closing on the way to a link.

   ── Above the breakpoint ───────────────────────────────────────────────────
   `display: contents` so the wrapper has no box at all and its two children lay
   themselves out in the bar exactly as they did before it existed. `order` puts
   them back where they were: the spacer between the nav and the controls is
   what pushes the controls right, and in source order the wrapper now sits
   before it. */
.hdr-menu { display: contents; }
.hdr .sp { order: 1; }
.hdr-quiet { order: 2; }
.hdr-act { order: 3; }
/* The controls used to be one `.ctrls` with a 10px gap running through it.
   Split in two, the gap between the language switch and the credit chip becomes
   the bar's own 24px — this is that difference, given back, so the row is the
   same row it was. */
.hdr-quiet { margin-inline-end: -14px; }
/* Only the sheet draws these: the invite by its name (the bar has the icon),
   and the toggle itself. */
.hdr-sheet-only, .hdr-toggle, .hdr-burger, .hdr-scrim { display: none; }

@media (max-width: 1350px) {
  /* 1350px, not 1120px — measured live (23 ส.ค.) against the signed-in bar,
     logo through the Dashboard button, on every page in this nav: the row's
     own content needs ~1292px before anything is cut. That number moved once
     already the same day — a first fix landed at 1230px against a row with no
     `aria-current` styling, and then the very next commit (a different session,
     same afternoon) made the current page's own link bold, which is wider than
     the regular weight it was measured with. `1230px` was correct for the row
     that existed when it was written and wrong for the row one commit later.
     Re-measured against the row as it stands now, worst case across
     `/`, `/help/`, `/how-it-works/` (the longest label, bold, on its own page).
     A breakpoint sized to content should not need to keep changing by hand —
     see `.hdr` for the day this becomes a `container-type` query instead.
     Until then: 1350px is the current measured width plus margin, and the
     next person changing what lives in this row should re-measure rather
     than assume the margin still covers it. */
  /* No `position: relative` on `.hdr` — it is already `position: sticky`, which
     establishes both the containing block the sheet is positioned against and
     the stacking context it and the scrim sit inside. That second part is the
     useful half: the scrim can never paint over the bar it belongs to, so the
     button that closes it stays pressable. */
  .hdr-menu {
    display: flex; flex-direction: column; align-items: stretch; gap: 0;
    position: absolute; inset-inline: 0; top: 100%; z-index: 70;
    padding: 4px clamp(20px, 4cqi, 48px) 12px;
    background: var(--ink); border-bottom: 1px solid var(--line);
    box-shadow: 0 18px 40px rgb(0 0 0 / .35);
    transform: translateY(-8px); opacity: 0; visibility: hidden;
    transition: transform .18s ease, opacity .18s ease, visibility .18s;
  }
  .hdr-toggle:checked ~ .hdr-menu { transform: none; opacity: 1; visibility: visible; }

  /* A column of full-width targets — the same links, read as a list rather than
     as a row. 44px is the smallest a thumb hits reliably. */
  .hdr nav { display: flex; flex-direction: column; align-items: stretch; gap: 0; }
  .hdr nav a {
    display: flex; align-items: center; min-height: 46px; padding: 4px 2px;
    font-size: .95rem; border-bottom: 1px solid var(--line);
  }
  .hdr-sheet-only { display: flex; }

  /* The quiet controls, on one row under the links. `.ctrls` is pushed to the
     right of the bar by `justify-content` and an auto margin, which is right
     where it is a group at the end of a row and wrong where it is a row of its
     own in a sheet. */
  .hdr .hdr-quiet {
    margin: 12px 0 0; order: 0;
    flex-wrap: wrap; justify-content: flex-start; margin-inline-start: 0;
  }
  /* The invite is in the list above with its name printed; the icon here would
     be the same room twice. */
  .hdr-quiet .discord-link { display: none; }

  /* The theme control goes back to being the segmented row. The select exists
     only because three buttons could not fit the bar beside the language switch
     and Sign in — in the sheet they can, and three targets one press apart beat
     one target that opens a picker. It is also the version whose state can be
     read: the select was showing "Match ..." with the word cut off. */
  /* `.hdr .` on the front for weight, not for reach: `.ctrls .seg { display:
     none }` under the 560px query is written later in this file, and between two
     selectors of equal weight the later one wins. */
  .hdr .hdr-quiet .seg { display: flex; }
  .hdr .hdr-quiet .theme-sel { display: none; }

  .hdr-toggle {
    display: block; position: absolute; width: 1px; height: 1px;
    clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap;
  }

  /* Square, and the same three bars the console's button draws — `.drawer-bars`
     is shared rather than redrawn, so the two menus in this product open under
     the same mark. Sized to `--ctl-h` so it sits level with the button beside it
     rather than making the bar taller than it is. */
  .hdr-burger {
    display: grid; place-items: center; flex: none; order: 4;
    width: var(--ctl-h); height: var(--ctl-h);
    border: 1px solid var(--line2); border-radius: 2px;
    cursor: pointer; color: var(--hi); -webkit-tap-highlight-color: transparent;
  }
  .hdr-burger:active { background: var(--ink2); }
  .hdr:has(.hdr-toggle:focus-visible) .hdr-burger {
    outline: 2px solid var(--verm); outline-offset: 2px;
  }

  /* The page behind an open sheet. A `<label>`, so a tap outside closes it with
     no script — the dismissal people try first. */
  .hdr-scrim {
    display: block; position: fixed; inset: 0; z-index: 60;
    background: rgb(0 0 0 / .45);
    opacity: 0; pointer-events: none; transition: opacity .18s ease;
  }
  .hdr-toggle:checked ~ .hdr-scrim { opacity: 1; pointer-events: auto; }
  /* The scrim covers the viewport, and `.hdr` is the stacking context both it
     and the bar's own contents live in — so a positioned child at z-60 paints
     over a static sibling however far down the markup it is, and the mark, the
     button and the balance were all being dimmed along with the page. They are
     what the reader is meant to still be able to press. */
  .hdr .mark, .hdr-act, .hdr-burger { position: relative; z-index: 80; }

  @media (prefers-reduced-motion: reduce) {
    .hdr-menu, .hdr-scrim { transition: none; }
  }
}

/* The header row at 390px has 350px of usable width and wants 374. The theme
   control and the language switch both stay — the page follows the operating
   system until somebody says otherwise, and a Thai/English product cannot hide
   the language. The Discord invite is what goes: it is an icon-only link to a
   chat room, this screen exists to get somebody signed in, and the invite is on
   every console screen behind that. It stays in the markup — `display: none`,
   not removed — so it is back the moment there is room. */
@media (max-width: 720px) {
  .ctrls .discord-link { display: none; }
  /* 24px of header gap twice over is a quarter of a phone's width. */
  .hdr { gap: 10px; }
}
/* One control, two shapes — see `landing.html`. The wrapper carries no box of
   its own so `.seg` and the select stay direct flex children of `.ctrls`. */
.theme-ctl { display: contents; }
.theme-sel { display: none; }
.theme-sel select {
  height: var(--ctl-h); padding: 0 8px; border: 1px solid var(--line2);
  border-radius: 2px; background: var(--ink2); color: var(--hi);
  font: inherit; font-size: .72rem; font-weight: 700; letter-spacing: .06em;
  /* A select is as wide as its longest option, and `Match system` is wider than
     `ตามระบบ` by enough to push the row over. The budget is what the row has
     left after the logo, the language switch and Sign in — about 113px at 390
     and 83 at 360 — so the cap is tied to the viewport rather than fixed. The
     closed control shows as much of the current choice as fits; the picker the
     platform opens shows every option in full whatever this says. */
  max-width: min(7rem, 22vw); text-overflow: ellipsis;
}
@media (max-width: 560px) {
  .ctrls .seg { display: none; }
  .theme-sel { display: block; }
}
/* The eyebrow's rule is a flex item that never wraps, so once the words beside
   it take two lines it sits centred against the pair — a dash floating on its
   own at the left margin, which is what it looks like on a phone. It is
   decoration and the words read without it, so below the width where they wrap
   it goes. */
@media (max-width: 560px) { .eyebrow::before { display: none; } }


/* ---------- hero ---------- */
.hero { position: relative; padding: clamp(20px, 2.4cqi, 34px) 0 clamp(56px, 7cqi, 104px); overflow: hidden; }
/* Landing hero over the generated artwork. The section commits to the dark
   look in both themes — a manga page glowing in space cannot sit on a cream
   ground — so the ink tokens are pinned here rather than inherited. Two
   gradients keep it legible: left-to-right shade under the headline, and a
   bottom fade into the page background so the photo has no hard edge. */
.hero-photo {
  /* **Dark in both themes, on purpose.** This used to follow the theme, with a
     purpose-made light render (`hero-bg-light.jpg`) under a near-white veil.
     Both renders were replaced by one photograph on 9 Sep 2026 and the light
     veil was never re-cut for it: 82% of `246 247 251` over a dusk platform
     bleached the picture to nothing while the band below it stayed a lit
     street, and the two sections stopped looking like one site.

     The original note above this block said it best before the light render
     existed — a manga page glowing in space cannot sit on a cream ground. So
     the veil is one triplet now, the ink below is pinned light to match, and
     the bottom stop still blends into var(--bg) so the photo ends on whatever
     ground the page stands on. */
  --hero-img: url("../landing/hero-station.c332eb48ac02.webp");
  --hero-veil: 8 7 10;
  --hero-line: 234 230 221;
  min-height: min(88svh, 900px);
  display: grid; align-items: center;
  padding-block: clamp(28px, 3cqi, 48px);
  background:
    linear-gradient(180deg, transparent 58%, var(--bg) 100%),
    linear-gradient(180deg, rgb(var(--hero-veil) / .55) 0%, rgb(var(--hero-veil) / .15) 30%,
      rgb(var(--hero-veil) / .05) 58%, transparent 72%),
    linear-gradient(90deg, rgb(var(--hero-veil) / .82) 0%, rgb(var(--hero-veil) / .45) 46%,
      rgb(var(--hero-veil) / .18) 100%),
    var(--hero-img) center / cover no-repeat var(--bg);
}
/* The writing rides the photograph in both themes, so it is pinned rather than
   inherited — the page's own ink would be near-black here under a light theme
   and vanish into the platform. Same reasoning as `.upload-band` below. */
.hero-photo, .hero-photo .h1 { color: #F7F4EE; }
.hero-photo .hero-head .lede { color: #C9C2D0; }
/* ── The hero's third dimension ────────────────────────────────────────────
   The platform behind is the section's own background image, so this layer
   carries only what stands in front of it: the character, and the balloon she
   is speaking. Both are finished without script — the <img> is in the markup
   and the balloon already holds a sentence, the reader's own language, picked
   server-side. `hero.js` only makes that sentence change and makes the layers
   move apart under the pointer.

   Distance is a multiplier on one shared pointer position (`--px`/`--py`),
   not a camera. Three flat pictures with nothing between them do not need one,
   and a vendored 3D library on this page in particular would undo the promise
   two comments above make about paints and requests. `--px` stays 0 until the
   script writes it, so the untouched state is the composed one. */
/* `--px`/`--py` are declared on `.landing`, not here: the drop target's band
   reads the same pair, and a local declaration would shadow what the script
   writes on the root. One pointer, published once. */
.hero-stage {
  position: absolute; inset: 0; z-index: 0;
  pointer-events: none; overflow: hidden;
}
/* Same box as `.wrap`, so every percentage inside is a share of the content
   column and not of the viewport. The two stop tracking each other above
   1276px, where `.wrap` reaches its 1180px cap and the screen keeps growing —
   position against the viewport and the whole scene walks out of the column
   on a wide monitor. */
.hero-stage-in {
  position: absolute; top: 0; bottom: 0; left: 50%;
  width: min(1180px, calc(100% - 96px));
  transform: translateX(-50%);
}
.hero-stage-in > * { position: absolute; will-change: transform; }

/* The headline's grid cell ends at half the wrap, so the free column is the
   right half and she is centred on 75% of it. The veil above is heaviest on
   the left where the headline sits and lightest here, which is why the artwork
   survives the shading in exactly the place she stands. */
.hero-char {
  left: 57%; bottom: -2%;
  width: 36%; height: auto;
  transform: translate3d(calc(var(--px) * -26px), calc(var(--py) * -10px), 0);
  filter: drop-shadow(0 18px 40px rgb(8 7 10 / .5));
}

/* Two balloons, one either side of her head, and **both tails point at her
   mouth**. That is the whole rule for a tail: it aims at whoever is speaking,
   not at the side of the page the balloon happens to sit on. Mirror them
   outward and a reader is told there are two speakers, one of them off-panel.

   So the right-hand balloon's drawing is flipped — the path's tail leaves the
   lower right, and flipping puts it at the lower left, where it can reach back
   across to her. The text is a sibling of the <svg>, not a child, so it is not
   flipped with it. */
.hero-balloon { width: 21%; aspect-ratio: 540 / 380; top: 23%; }
.hero-balloon-a {
  right: 0;
  transform: translate3d(calc(var(--px) * -46px), calc(var(--py) * -18px), 0);
}
.hero-balloon-a .hero-balloon-skin { transform: scaleX(-1); }
.hero-balloon-b {
  left: 49%;
  transform: translate3d(calc(var(--px) * -62px), calc(var(--py) * -24px), 0);
}

/* Below the cap the column is too narrow to hold two balloons and her between
   them; the left one goes rather than letting the pair shrink to unreadable. */
@media (max-width: 1279px) {
  .hero-balloon-b { display: none; }
}

/* She walks on. Enters from off the right edge, decelerates, and tips back a
   degree or so as she stops — the settle is what makes it read as someone who
   just stepped up rather than a picture sliding into place. Chosen from three
   in `docs/mockups/2026-09-09-hero-character-entrances.html`.

   **`backwards`, never `both`.** `.hero-char` carries the pointer parallax on
   its own `transform`, and this animation ends on `transform: none`; a forwards
   fill would hold that last frame for good and the parallax would be dead from
   the moment she landed. `backwards` fills only the wait before it starts, and
   hands the property back afterwards. The balloons can use `both` because they
   loop — an infinite animation never reaches a final frame to hold. */
@keyframes hero-char-enter {
  0%   { opacity: 0; transform: translateX(46%) rotate(3.5deg); }
  55%  { opacity: 1; }
  72%  { transform: translateX(-2.5%) rotate(-1.4deg); }
  88%  { transform: translateX(.8%) rotate(.5deg); }
  100% { opacity: 1; transform: none; }
}
.hero-stage.is-ready .hero-char {
  animation: hero-char-enter 1.15s cubic-bezier(.16, .72, .24, 1) backwards;
}

/* The pair's whole cycle, 4.4s of it: the right balloon pops, the left follows,
   both hold for two seconds once the second one has landed, **both leave
   together**, and a second later it starts again.

   Two keyframe sets rather than one with a delay. A delay on an infinite
   animation is a phase offset — it moves the exit as far as it moves the
   entrance, so the pair would leave as staggered as it arrived. Only the
   in-points differ here; from 68% on the two are identical, which is what makes
   them vanish on the same frame.

   Against 4.4s: right lands at 11% (0.48s), left at 23% (1.0s), both hold to
   68% (3.0s — two seconds after the second one arrived), both gone by 76%
   (3.35s), and the last quarter is the beat before it repeats.

   The pop overshoots, undershoots and settles rather than easing to size once —
   a single ease reads as a panel sliding in; what is wanted is a balloon
   filling. `transform-origin` is each balloon's own tail, so it inflates from
   the end nearest her mouth rather than from the middle of the shape. */
@keyframes hero-balloon-pop-a {
  0%       { opacity: 0; transform: scale(.2) translateY(1.1em); }
  4.5%     { opacity: 1; transform: scale(1.14) translateY(-.14em); }
  7%       { transform: scale(.94) translateY(.04em); }
  9%       { transform: scale(1.04); }
  11%, 68% { opacity: 1; transform: none; }
  76%, 100%{ opacity: 0; transform: scale(.2) translateY(.5em); }
}
@keyframes hero-balloon-pop-b {
  0%, 11%  { opacity: 0; transform: scale(.2) translateY(1.1em); }
  15.5%    { opacity: 1; transform: scale(1.14) translateY(-.14em); }
  18%      { transform: scale(.94) translateY(.04em); }
  20%      { transform: scale(1.04); }
  23%, 68% { opacity: 1; transform: none; }
  76%, 100%{ opacity: 0; transform: scale(.2) translateY(.5em); }
}
/* The same delay on both, so she has walked on and stopped before either of
   them speaks. It has to be the same number on each: a delay shifts an infinite
   animation's whole phase, so two different ones would pull the shared exit
   apart again and they would stop leaving together. The stagger between them
   lives in the keyframes, not here — that is the point of there being two
   keyframe sets. Only the first cycle is affected in any visible way; after
   that a shifted loop is just a loop. Keep at or past the 1.15s the character's
   entrance takes. */
.hero-stage.is-ready .hero-balloon-a,
.hero-stage.is-ready .hero-balloon-b {
  animation-duration: 4.4s;
  animation-timing-function: cubic-bezier(.22, .68, .3, 1);
  animation-iteration-count: infinite;
  animation-fill-mode: both;
  animation-delay: 1.3s;
}
.hero-stage.is-ready .hero-balloon-a { animation-name: hero-balloon-pop-a; transform-origin: 12% 92%; }
.hero-stage.is-ready .hero-balloon-b { animation-name: hero-balloon-pop-b; transform-origin: 88% 92%; }
/* The shape is a path, not a rounded box. It was `border-radius` plus a
   `clip-path` triangle first, and that reads as an ellipse with a spike taped
   to it — the radii land too near 50% to look drawn, and a straight-edged tail
   never blends into a curved body. The path carries the wobble and the tail's
   two curves as one drawing, and the numbers in the markup come from the same
   equations the mock rendered to canvas, so the two cannot drift.
   `preserveAspectRatio="none"` is safe here: the box is locked to the path's
   own ratio above, so nothing ever stretches. */
.hero-balloon-skin {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  filter: drop-shadow(0 14px 26px rgb(8 7 10 / .38));
}
/* The list *is* the balloon's contents, not a data island beside it — the same
   sentence written twice is the pair that drifts. With no script the reader's
   own language is the one `li` that shows; `hero.js` then flattens the list
   into letters and cycles the seven. */
/* Centred on the blob's own middle (40% of the viewBox, where the path puts
   it) rather than on the box, whose lower third is the tail's room. Centred on
   the box the line sits low and a long one crosses the tail.

   `padding: 0` was load-bearing while this was a <ul> — the UA's default
   `padding-inline-start` survives `list-style: none` and pushed every line
   20px right of the middle. It is a <p> now; the reset stays because the next
   person to change the element should not have to rediscover that. */
.hero-balloon-text {
  position: absolute; margin: 0; padding: 0;
  left: 9%; right: 9%; top: 40%;
  transform: translateY(-50%);
  text-align: center; text-wrap: balance;
  color: #17161C;
  font-size: clamp(13px, 1.5cqi, 20px);
  line-height: 1.42;
  font-family: "KL Hero Mali", var(--sans); font-weight: 700;
  /* The engine strokes its lettering white on every non-Latin script it sets —
     see any `static/scene/p1-out-<lang>.jpg`. Same here, for the same reason:
     it holds the letters legible against a warm ground. */
  paint-order: stroke fill;
  -webkit-text-stroke: .12em #FFFFFF;
}

/* The face is chosen by the language the page is being *read* in, because the
   greeting is the mascot talking to this reader — not a sample of our output.
   Each one is the face `fonts.choices(<lang>)` would pick for that language, so
   the shop window is lettered with what the shop sells. Latin and Thai share
   Mali, which is the default above; only these three need saying. */
/* `keep-all` for Korean only. Browsers break Hangul between any two syllables
   by default, which split 오신 across two lines here; Korean wants its breaks
   at the spaces it already has. Chinese has no spaces to break at, so the same
   rule there would push the line straight out of the balloon. */
html[lang="ko"] .hero-balloon-text      { font-family: "KL Hero KR", var(--sans); word-break: keep-all; }
html[lang="zh-hans"] .hero-balloon-text { font-family: "KL Hero SC", var(--sans); }
html[lang="zh-hant"] .hero-balloon-text { font-family: "KL Hero TC", var(--sans); }

/* Below this the centred column owns the whole width and she would be behind
   it rather than beside it. The platform photograph carries the hero alone,
   which is what it did before any of this existed. */
@media (max-width: 860px) { .hero-stage { display: none; } }
/* The entrance is the only motion left up here that nobody asked for, so it is
   the only one to withdraw. `is-ready` is what carries it — see the keyframes
   above — and without the animation the pair is simply already there. */
@media (prefers-reduced-motion: reduce) {
  .hero-stage.is-ready .hero-balloon-a,
  .hero-stage.is-ready .hero-balloon-b,
  .hero-stage.is-ready .hero-char { animation: none; }
}

/* ── The curtain that rules its own panels ─────────────────────────────────
   A landing page whose product is manga can afford one arrival that nobody
   else can: the page ruling itself into panels before it lets you read it.
   Six of them are struck in the order a Japanese page is read — **right
   before left, top before bottom** — and each one holds its own piece of the
   very photograph the hero stands on. When the gutters close, the six pieces
   meet and the picture is whole; the curtain then dissolves into the real
   hero, which is the same photograph at very nearly the same scale, so the
   last frame of the entrance and the first frame of the page are one image.

   **Nothing here is in the template, and that is the point.** `loader.js`
   builds the whole thing and appends it, so a reader with no JavaScript never
   receives a curtain and therefore can never be left sitting under one. The
   opposite arrangement — ship it in the markup and have script take it away —
   is one failed request away from a landing page that is a black rectangle.

   Chosen from three in `docs/mockups/2026-09-10-landing-loader-v2.html`, where
   it is the second. The other two were a screen-tone field resolving into the
   photograph and a line of Japanese being wiped and rewritten; this one won
   because it is the only one of the three that a reader has to *know manga* to
   read, and knowing manga is the thing this audience has in common.

   Why the close is layout and not a transform: it has to move each panel's
   *box* while the picture inside it stays nailed to the same spot of the
   photograph. A scale would drag the picture along and the six pieces would
   never line up on the seam. So the gutter is a single animated length,
   `--kl-gut`, inherited by all six; left/top/width/height and
   background-position are each written against it, and the browser lays out
   six boxes for half a second. That is what closing the seam actually costs,
   and six boxes on a page with nothing else moving is a price worth paying.

   `.kl-curtain.is-playing`, not `.kl-curtain .is-playing` — the class lands on
   the same element that already carries `.kl-curtain`. A space in there is a
   descendant selector matching nothing, and it fails silently: no error, no
   warning, just a curtain that sits still forever.

   `both` fill is safe under these selectors and would not be up on the hero.
   Nothing here carries the pointer parallax, so there is no `transform` for a
   forwards fill to freeze on its last frame — see the note on `.hero-char`
   above for the version of this that bites. */
@property --kl-gut { syntax: "<length>"; inherits: true; initial-value: 0px; }

/* ── หมึกก่อนเฟรมแรก ────────────────────────────────────────────────────────
   ม่านข้างล่างถูกสร้างด้วยสคริปต์ `defer` แปลว่ามันมาถึงช้ากว่าเฟรมแรกเสมอ และภาพ
   hero ที่อยู่ในมาร์กอัปมาถึงก่อน คนดูจึงเห็นตัวละครแล้วค่อยถูกปิดทับ ซึ่งอ่าน
   เหมือนหน้าเว็บพัง ไม่ใช่เหมือนม่าน แผ่นนี้คือหมึกผืนเดียวกับพื้นม่าน วาดด้วย CSS
   ล้วน จึงมีอยู่ตั้งแต่เฟรมแรกโดยไม่ต้องรอสคริปต์ใดทั้งสิ้น

   `kl-armed` ติดจาก inline script ใน `<head>` ของ `core/landing.html` ซึ่งเช็ค
   เงื่อนไขชุดเดียวกับ `loader.js` แล้ว `loader.js` ถอดมันทิ้งทันทีที่ `appendChild`
   ม่านจริงลง body พื้นทั้งสองเป็น `rgb(8 7 10)` เท่ากันเป๊ะ รอยต่อจึงไม่มีให้เห็น
   — ถ้าวันไหนสีหนึ่งขยับ อีกสีต้องขยับตาม ไม่งั้นจะเห็นแฟลชหนึ่งเฟรมตรงส่งไม้ต่อ

   z-index 89 คือใต้ม่านหนึ่งชั้นพอดี ไม่ใช่เลขสวย ๆ: ถ้าทั้งคู่บังเอิญอยู่บนจอ
   พร้อมกันหนึ่งเฟรม ม่านต้องเป็นฝ่ายอยู่บน เพราะมันคือตัวที่กำลังจะเคลื่อนไหว
   ไม่มี `contain` ที่ระดับ html หรือ body ทั้งสองชั้นจึงอยู่ใน stacking context
   เดียวกันจริง และเลขสองตัวนี้เทียบกันตรง ๆ ได้ */
html.kl-armed::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 89;
  background: rgb(8 7 10);
}

/* ── หน้าที่ยังไม่ให้เลื่อน ────────────────────────────────────────────────────
   ม่านเป็น `position: fixed` มันจึงบังจอ แต่ไม่เคยหยุดหน้าเว็บที่อยู่ใต้มัน การปัด
   หนึ่งครั้งเคยได้สองอย่างพร้อมกัน — ม่านถูกข้าม *และ* หน้าเลื่อนลงไปแล้ว พอม่านหาย
   คนดูจึงไม่ได้อยู่บนสุด และพลาดมาสคอตเดินเข้าฉาก ซึ่งเป็นสิ่งเดียวที่ม่านเปิดออกมา
   ให้ดู ตอนนี้การปัดไม่ทำอะไรเลย ทางออกมีสามทาง: Space, Esc, หรือรอ 2.9 วิ

   คนละคลาสกับ `kl-armed` เพราะคนละอายุ: หมึกถูกถอดทันทีที่ม่านจริงลง body ส่วนล็อก
   ต้องอยู่ต่อจนม่านออกจากจอ แต่ทั้งคู่ติดพร้อมกันจาก inline script ใน `<head>` เพื่อ
   ให้ช่วงหมึกดำก่อน `loader.js` จะรัน ถูกล็อกไปด้วย — ช่วงนั้นก็คือ "ตอน loading"
   เหมือนกัน และเป็นช่วงที่ปัดแล้วหน้าเลื่อนโดยไม่มีใครเห็นว่ามันเลื่อน

   `scrollbar-gutter` ไม่ใช่ของแถม มันต้องมาคู่กับ `overflow: hidden` เสมอ: การเอา
   สกอลล์บาร์ออกทำให้หน้าเว็บกว้างขึ้น 15px บนเครื่องที่สกอลล์บาร์กินที่จริง
   (Windows/Linux) แล้วหดกลับตอนปลดล็อก — กระตุกสองครั้งกลางฉากที่ทั้งไฟล์นี้มีไว้
   เพื่อไม่ให้กระตุก ช่องที่จองไว้ตอนล็อกคือช่องเดียวกับที่สกอลล์บาร์จริงยืนตอนปลด
   ความกว้างจึงเท่ากันทั้งสองสถานะ (บน macOS สกอลล์บาร์ลอยอยู่แล้ว บรรทัดนี้ไม่มีผล) */
html.kl-still { overflow: hidden; scrollbar-gutter: stable; }

/* คนที่ขอลดการเคลื่อนไหวไม่ได้ม่าน จึงต้องไม่ได้หมึกด้วย — inline script กันไว้
   ชั้นหนึ่งแล้ว นี่คือชั้นที่สอง สำหรับคนที่สลับค่านี้ระหว่างหน้ากำลังโหลด คู่กับ
   กฎเดียวกันของ `.kl-curtain` ที่ท้ายบล็อกนี้ */
@media (prefers-reduced-motion: reduce) {
  html.kl-armed::before { display: none; }
  /* ล็อกที่ไม่มีม่านคุมอยู่ คือหน้าแรกที่เลื่อนไม่ลงเฉย ๆ ซึ่งแย่กว่าทุกอย่างที่ม่าน
     นี้แก้ ถ้ากฎท้ายบล็อกซ่อนม่านให้คนที่สลับค่ากลางคัน บรรทัดนี้ต้องคืนหน้าให้เขา
     เลื่อนได้ในจังหวะเดียวกันเสมอ */
  html.kl-still { overflow: visible; scrollbar-gutter: auto; }
}

.kl-curtain {
  position: fixed; inset: 0; z-index: 90;
  /* The same ink as `--hero-veil` on `.hero-photo`, spelled out rather than
     read from it: that property is declared on the section and this element
     is a child of <body>, outside its inheritance. Both are `8 7 10`, and if
     one of them ever moves the other has to move with it. */
  background: rgb(8 7 10);
  /* Six boxes are relaid out every frame of the close. Containment keeps that
     work from reaching the document under it, which is a whole landing page. */
  contain: layout paint;
  /* The groove at rest, before `kl-close` takes it to nothing. Proportional,
     because a 14px gutter that reads right on a laptop is a canyon on a phone
     — `vmin` so it answers to the short side in either orientation. */
  --kl-gut: 1.2vmin;
}
.kl-panel {
  position: absolute;
  /* Declared here rather than handed in from the template, so the path exists
     in one place — and `loader.js` reads it straight back off the computed
     style to know what to preload, instead of being told twice. Resolved
     against this stylesheet, exactly like `--hero-img`. */
  background-image: url("../landing/hero-station.c332eb48ac02.webp");
  background-repeat: no-repeat;
  /* A panel that has not been drawn yet: no ink, and clipped away from all
     four sides at once, so the stroke opens outward from the middle the way a
     pen box does rather than wiping in from one edge like a slide. */
  opacity: 0;
  clip-path: inset(50% 50%);
  box-shadow: 0 0 0 1px rgb(247 244 238 / .5);
}
@keyframes kl-panel-draw {
  from { opacity: 0; clip-path: inset(50% 50%); }
  to   { opacity: 1; clip-path: inset(0 0); }
}
/* The rule around a panel is the thing that makes it a panel. Once the gutters
   have closed there is nothing left for it to separate, so it leaves with
   them and the six pieces read as one photograph again. */
@keyframes kl-panel-rule { to { box-shadow: 0 0 0 0 rgb(247 244 238 / 0); } }
@keyframes kl-close { to { --kl-gut: 0px; } }
@keyframes kl-fade  { to { opacity: 0; } }
/* The same two frames as `kl-fade` and deliberately not the same name. When a
   reader skips, the animation list on this element goes from two entries to
   one, and a browser keeps the *start time* of an animation whose name it
   still recognises in the new list — so a `kl-fade` that had been sitting in
   its 2.5s delay for six hundred milliseconds was handed a 0.28s duration,
   found itself long past the end of it, and finished on the frame it was
   asked to begin. The curtain vanished instead of leaving. A name that was
   not in the old list cannot be mistaken for anything already running. */
@keyframes kl-leave { to { opacity: 0; } }

/* `--d` is written per panel by the script — reading order × 0.23s. The whole
   sequence is 2.85s: last panel struck at 1.57s, half a second of quiet to
   read the page as a page, gutters shut at 2.55s, gone by 2.85s. `loader.js`
   holds the same numbers for its failsafe, and the two have to move together. */
.kl-curtain.is-playing .kl-panel {
  animation:
    kl-panel-draw .42s cubic-bezier(.2, .8, .3, 1) var(--d) both,
    kl-panel-rule .5s cubic-bezier(.6, 0, .3, 1) 2.05s both;
}
.kl-curtain.is-playing {
  animation:
    kl-close .5s cubic-bezier(.6, 0, .3, 1) 2.05s both,
    kl-fade .35s ease 2.5s both;
}
/* Somebody who pressed Space or Escape has said they would rather be reading,
   and an entrance that argues with that is an obstacle. This wins
   over the shorthand above by coming after it at equal specificity, and takes
   the curtain out in 0.28s from wherever it had got to. The panels keep
   animating underneath for those 0.28s; at that speed nobody sees it, and
   stopping them would snap them back to undrawn, which everybody would. */
.kl-curtain.is-gone { animation: kl-leave .28s ease both; }

/* Belt and braces. The script never builds a curtain while this is set, so the
   only way to arrive at this rule is to switch reduced motion on while one is
   already on screen — and the answer to that request is the page, right now. */
@media (prefers-reduced-motion: reduce) {
  .kl-curtain { display: none; }
}

.hero-photo .hero-art { display: none; }
.hero-art { position: absolute; inset: -6% -10% -10% -10%; z-index: 0; pointer-events: none; }
.hero-art svg { position: absolute; display: block; }
.hero-art .slash { top: -20%; right: -10%; width: min(620px, 46%); opacity: .8; }
.hero-art .splat { bottom: -10%; left: -11%; width: min(360px, 32%); opacity: .35; }
.hero-art .grid-note { top: 22%; left: -1%; width: min(340px, 32%); opacity: .24; }

/* The hero is the caption and the picture; the drop target is a band of its
   own below it (`.upload-band`), and moved there on 9 Sep 2026. What used to
   stand in the right-hand column now stands in the artwork: the character and
   the balloon she is speaking. The two-column grid below is kept for exactly
   that reason — the right cell is deliberately empty, and `.hero-stage` fills
   it. Collapsing it to one column would drag the headline back to the middle
   of a picture that is composed around her being there. */
.hero-inner { display: block; }
.hero-head { width: min(100%, 660px); margin-inline: auto; text-align: center; }
.hero-head .eyebrow { justify-content: center; margin-bottom: 12px; }
.hero-head .lede { margin-inline: auto; font-size: clamp(14px, 1.1cqi, 16px); line-height: 1.85; max-width: 52ch; margin-top: 16px; }
.hero-head .draw { margin-inline: auto; }
.hero .h1 { font-size: clamp(24px, 2.8cqi, 39px); line-height: 1.28; }
/* On the photo hero the two halves split: copy rides the dark left of the
   artwork, the drop target stands on the right. Below 900px the photo crops
   toward the middle anyway, so the stacked centred layout returns. */
@media (min-width: 900px) {
  .hero-photo .hero-inner { display: grid; grid-template-columns: 1.05fr .95fr;
    gap: clamp(28px, 4cqi, 64px); align-items: center; }
  .hero-photo .hero-head { width: auto; margin-inline: 0; text-align: left; }
  .hero-photo .hero-head .eyebrow { justify-content: flex-start; }
  .hero-photo .hero-head .lede { margin-inline: 0; }
  .hero-photo .hero-head .draw { margin-inline: 0; }
}
/* ── The drop target's band ────────────────────────────────────────────────
   It used to sit in the hero's right-hand column over the platform, and moved
   down here on 9 Sep 2026 to give the scene above its room back. It stands on
   a picture again now — the KomaLume lantern street — so the glass treatment
   comes with it: a dark translucent pane for artwork to glow through rather
   than a solid slab. `backdrop-filter` needs no request, so the CSP is
   untouched.

   The section commits to the dark look in both themes. That is not laziness:
   the picture is a dusk street lit by warm lanterns, and a near-white veil over
   it would grey out the one thing the picture is for. Only the two rgb triplets
   below carry the theme, and every rule here reads them, so the deck's glass
   and its borders cannot drift apart from the veil they sit on.

   Top and bottom fade into `--bg` so the band has no hard edge against the
   sections either side of it. */
.upload-band {
  --upload-veil: 8 7 10;
  --upload-line: 234 230 221;
  position: relative; overflow: hidden;
  /* Named because the figure below cancels it — she stands on the street in
     the picture, and the picture runs to the section's edge. */
  --band-pad-b: clamp(44px, 6cqi, 88px);
  /* The top is deliberately deeper than the bottom. Her balloon rises about
     13% of her height above her, and `overflow: hidden` above will shear the
     top off the blob if the section does not leave that much room. Shrinking
     the balloon instead is not a fix: a smaller one cannot both sit left of her
     and reach her mouth — see the solved numbers on `.upload-band .hero-balloon`. */
  padding: clamp(64px, 8cqi, 132px) 0 var(--band-pad-b);
  background:
    linear-gradient(180deg, var(--bg) 0%, rgb(var(--upload-veil) / 0) 16%,
                    rgb(var(--upload-veil) / 0) 84%, var(--bg) 100%),
    linear-gradient(180deg, rgb(var(--upload-veil) / .5), rgb(var(--upload-veil) / .34)),
    url("../landing/upload-street.9a02002a6671.webp") center / cover no-repeat var(--bg);
  color: #F7F4EE;
}

/* Her on the left, the drop target on the right — the direction she is already
   facing. Two even columns: the card is the subject and gets no more room than
   the picture, because the picture is what carries the eye into it. */
/* `end`, not `center`: she belongs on the ground the photograph already draws,
   and the card is a UI object that wants its margin. Centring gave them the
   same treatment and left her floating a padding's height above the street. */
.upload-grid {
  display: grid; grid-template-columns: 1fr 1fr;
  gap: clamp(20px, 3cqi, 56px); align-items: end;
}
/* She and her balloon travel together, so they share one positioned box.

   The negative margin cancels the section's bottom padding exactly, which puts
   her feet on the section's own edge rather than a padding's height above it.
   Tied to the same custom property so the two cannot drift; `overflow: hidden`
   on the band is what keeps the sums honest if they ever do. */
/* She and her balloon drift together under the pointer, as one plate — they are
   at the same distance from the eye, unlike the hero's layers. Putting it on
   the wrapper also keeps it clear of the children's own entrance animations,
   which own their `transform` while they run. */
.upload-figure {
  position: relative; margin-bottom: calc(-1 * var(--band-pad-b));
  transform: translate3d(calc(var(--px) * -20px), calc(var(--py) * -9px), 0);
}
.upload-char { display: block; width: 100%; height: auto;
               filter: drop-shadow(0 22px 44px rgb(8 7 10 / .55)); }

/* Above and left of her head, tail down-right at her — the un-mirrored path
   already leaves its lower right, so this one needs no flip. Kept clear of the
   deck's column: it would be over the artwork either way, but a balloon that
   overlaps the drop target reads as a label for it.

   The three numbers are solved, not eyeballed. Her mouth sits at 55.2% across
   and 16.7% down her own image; the tail leaves its box at (90%, 96%); the box
   keeps a 540:380 ratio. Writing the tail's tip in the figure's own units —

     x = left + tipX x width
     y = 1.287 x top + 0.704 x tipY x width   (1.287 = her height / her width)

   where tipX/tipY are where the tail leaves its box. This band uses the
   **short** tail (`short_tail=True` on the include), tip at (83.6%, 79.1%);
   the hero's long one is at (90%, 96%). The box stays where it is either way —
   these three numbers place the *balloon*, and the tail simply reaches out of
   it as far as it reaches.

   — puts the tip level with her mouth and a little to its left, which is where
   a tail should stop: pointing at the mouth, not landing on it.

   **Moving it left and raising the tail pull against each other.** Left moves
   the tip left with the box, away from her; the width is what buys it back. Do
   not nudge one of the three alone. */
.upload-band .hero-balloon { position: absolute; left: -4%; top: -13%; width: 56%; }

/* She comes in from the left and settles — the direction she is already facing
   and leaping, so the movement finishes pointing at the drop target. Held back
   until the band is actually on screen (see hero.js): this section sits well
   below the fold, and an entrance that plays on load has finished before anyone
   has scrolled far enough to see it. */
@keyframes upload-char-enter {
  0%   { opacity: 0; transform: translateX(-24%) rotate(-4deg); }
  60%  { opacity: 1; }
  76%  { transform: translateX(2%) rotate(1.4deg); }
  90%  { transform: translateX(-.6%) rotate(-.5deg); }
  100% { opacity: 1; transform: none; }
}
.upload-band.is-seen .upload-char {
  animation: upload-char-enter 1.05s cubic-bezier(.16, .72, .24, 1) backwards;
}
/* The same 4.4s cycle the hero's pair runs, so the page keeps one pulse rather
   than two: pop, hold, leave, a beat, again. The percentages differ from the
   hero's only because there is one balloon here and no second one to wait for.

   The delay clears her own entrance (1.05s) before she says anything. Unlike
   the hero's it is a single number on a single element, so there is no shared
   exit to keep in step. */
@keyframes upload-balloon-pop {
  0%       { opacity: 0; transform: scale(.2) translateY(.9em); }
  6%       { opacity: 1; transform: scale(1.13) translateY(-.12em); }
  9%       { transform: scale(.95); }
  12%      { transform: scale(1.03); }
  15%, 68% { opacity: 1; transform: none; }
  76%, 100%{ opacity: 0; transform: scale(.2) translateY(.5em); }
}
.upload-band .hero-balloon { opacity: 0; }
.upload-band.is-seen .hero-balloon {
  animation: upload-balloon-pop 4.4s cubic-bezier(.22, .68, .3, 1) 1.15s infinite both;
  transform-origin: 88% 92%;
}
@media (prefers-reduced-motion: reduce) {
  .upload-band.is-seen .upload-char { animation: none; }
  .upload-band .hero-balloon { opacity: 1; }
  .upload-band.is-seen .hero-balloon { animation: none; }
}
.upload-deck { width: 100%; max-width: 560px; margin-block: auto; }

/* One column below this, and she would be a picture stacked above the thing
   being asked for rather than pointing at it. The street stays as the ground. */
@media (max-width: 900px) {
  .upload-grid { grid-template-columns: 1fr; }
  .upload-char { display: none; }
  .upload-deck { max-width: 560px; margin-inline: auto; }
}

.upload-band .deck {
  background: rgb(var(--upload-veil) / .62);
  -webkit-backdrop-filter: blur(10px) saturate(1.1);
  backdrop-filter: blur(10px) saturate(1.1);
  border-color: rgb(var(--upload-line) / .18);
}
.upload-band .dz { background: rgb(var(--upload-veil) / .4); }
/* The picture is dark in both themes, so the writing on top of it is light in
   both — the page's own ink would vanish into the street under a light theme. */
.upload-band .deck-head b,
.upload-band .dz-lead { color: #F7F4EE; }
.upload-band .dz-sub,
.upload-band .dz-note,
.upload-band .cta .btn-ghost { color: #C9C2D0; }

/* Half the width, so the type grows to keep its weight in the composition. */
.hero-photo .h1 { font-size: clamp(27px, 3.2cqi, 46px); line-height: 1.25; }
.hero-photo .hero-head .lede { font-size: clamp(14px, 1.2cqi, 17px); max-width: 48ch; }
.hero-photo .hero-head .eyebrow { font-size: .8rem; }
/* The one secondary link sits BELOW the drop target, so nothing at all stands
   between the headline and the thing being asked for. */
/* Followed the drop target down into `.upload-band` — it is the one secondary
   link, and it belongs under the thing it is secondary to. Left on `.hero` the
   rule matched nothing and the link fell back to the flow, hard against the
   left edge of an 880px card. */
.upload-band .cta { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 22px; margin-top: 20px; }
.upload-band .cta .btn-ghost { padding: 12px 4px; font-size: .875rem; }

/* ---------- the deck: estimate before there is an account ---------- */
.deck {
  position: relative; background: var(--ink2); border: 1px solid var(--line2);
  padding: clamp(20px, 2.4cqi, 30px);
  box-shadow: 0 40px 90px -34px rgb(0 0 0 / .75);
}
:root[data-theme="light"] .deck { box-shadow: 0 28px 64px -34px rgb(0 0 0 / .3); }
.deck-head { display: flex; align-items: baseline; justify-content: center; gap: 14px; margin-bottom: 16px; }
.deck-head b { font-size: .7rem; font-weight: 800; letter-spacing: .2em; text-transform: uppercase; color: var(--low); }

/* The drop target: the widest, tallest, heaviest-bordered thing in the hero,
   and the only element on the page set at display size. */
.dz {
  position: relative; display: block; cursor: pointer;
  padding: clamp(36px, 4.6cqi, 70px) clamp(20px, 3cqi, 44px);
  background: var(--ink); border: 3px dashed var(--line2);
  transition: border-color .18s, background-color .18s;
}
.dz:hover { border-color: var(--verm); }
.dz:has(input:focus-visible) { outline: 2px solid var(--focus); outline-offset: 3px; }
.dz.over { border-color: var(--verm); border-style: solid; }
.dz input[type="file"] { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
.dz-mark { display: block; margin: 0 auto clamp(12px, 1.4cqi, 18px); width: clamp(62px, 6.6cqi, 92px); height: auto; }
.dz-lead { margin: 0; text-align: center; font-size: clamp(22px, 3cqi, 40px); font-weight: 900; line-height: 1.45; color: var(--hi); letter-spacing: -.01em; }
.dz-sub { margin: 12px 0 0; text-align: center; font-size: clamp(14px, 1.3cqi, 17px); line-height: 1.85; color: var(--mid); }
.dz-note { margin: 14px 0 0; text-align: center; font-size: 13px; line-height: 1.8; color: var(--mid); }
/* Once files are in, the target steps back to a slim bar so the estimate —
   the thing being read now — takes the room. */
.has-files .dz { padding: 16px 18px; border-width: 2px; }
.has-files .dz-mark { width: 28px; margin: 0 auto 8px; }
.has-files .dz-lead { font-size: .94rem; font-weight: 700; letter-spacing: 0; }
.has-files .dz-sub, .has-files [data-est-note] { display: none; }

.est { margin-top: 22px; }
.est-count { display: flex; align-items: baseline; gap: 12px; border-top: 2px solid var(--hi); padding-top: 14px; }
.est-count b { font-size: clamp(30px, 3cqi, 44px); font-weight: 900; line-height: 1; color: var(--num); font-family: var(--mono); }
.est-count span { font-size: 14px; color: var(--mid); }
.est-count button {
  margin-left: auto; background: none; border: 0; cursor: pointer; padding: 4px 0;
  font-size: 13px; font-weight: 700; color: var(--mid); border-bottom: 1px solid var(--line2);
}
.est-count button:hover { color: var(--verm-ink); border-color: var(--verm); }
.est-files {
  margin: 12px 0 0; padding: 0; list-style: none; max-height: 104px; overflow: auto;
  font-family: var(--mono); font-size: 12px; line-height: 2; color: var(--mid);
  -webkit-mask-image: linear-gradient(#000 70%, transparent 100%);
          mask-image: linear-gradient(#000 70%, transparent 100%);
}
.est-files li { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

.fset { border: 0; margin: 20px 0 0; padding: 0; min-width: 0; }
.fset > legend {
  padding: 0; margin: 0 0 10px; font-size: .7rem; font-weight: 800; line-height: 1.6;
  letter-spacing: .18em; text-transform: uppercase; color: var(--mid);
}
.fset > legend i { font-style: normal; letter-spacing: 0; text-transform: none; font-weight: 600; }

/* ---------- the option chips ----------
   One bordered box per option and nothing bordered or filled inside it.
   Selection is carried by three cues at once, only one of which is colour:
   the field inverts, the label steps 700 → 800, and a solid square appears in
   a slot that is reserved whether or not the mark is drawn — so nothing
   shifts, and the mark's PRESENCE rather than its hue is the signal.

   No `opacity` anywhere inside the box. Opacity composites after the colour
   is resolved, so a computed-style reading still reports the right value
   while the pixels on screen are washed out — a defect only a screenshot
   catches. Every colour in here is stated outright. */
.opts { display: flex; flex-wrap: wrap; gap: 8px; }
.opt { position: relative; }
.opt input { position: absolute; inset: 0; opacity: 0; margin: 0; cursor: pointer; }
.opt > span {
  display: flex; align-items: baseline; gap: 10px; cursor: pointer;
  padding: 12px 14px; border: 1px solid var(--line2); background: var(--ink);
  color: var(--mid); transition: color .15s, border-color .15s, background-color .15s;
}
.opt > span::before {
  content: ""; flex: none; width: 8px; height: 8px; align-self: center;
  background: transparent; transition: background-color .15s;
}
.opt .nm { font-size: .94rem; font-weight: 700; line-height: 1.5; color: inherit; }
.opt .rt { margin-left: auto; font-style: normal; font-family: var(--mono); font-size: 12px; font-weight: 600; white-space: nowrap; color: inherit; }
.opt:hover input:not(:checked) + span { color: var(--hi); border-color: var(--verm); }
/* Selected: 2px border and 1px less padding, so the box does not resize. */
.opt input:checked + span { background: var(--hi); color: var(--ink); border: 2px solid var(--hi); padding: 11px 13px; }
.opt input:checked + span::before { background: var(--verm); }
.opt input:checked + span .nm { font-weight: 800; }
.opt:hover input:checked + span { border-color: var(--verm); }
.opt input:focus-visible + span { outline: 2px solid var(--focus); outline-offset: 3px; }
.opts.modes { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }
@media (max-width: 640px) { .opts.modes { grid-template-columns: 1fr; } }

.cap-note {
  margin: 18px 0 0; padding: 10px 12px; border-left: 3px solid var(--verm);
  background: color-mix(in srgb, var(--verm) 8%, transparent);
  font-size: 14px; line-height: 1.75; color: var(--hi);
}
.deck .total {
  display: flex; align-items: baseline; justify-content: space-between; gap: 14px; flex-wrap: wrap;
  margin-top: 22px; padding-top: 16px; border-top: 1px solid var(--line2);
}
.deck .total .lb { font-size: 14px; font-weight: 700; color: var(--mid); }
.deck .total .vl { display: flex; align-items: baseline; gap: 9px; }
.deck .total .vl b { font-family: var(--mono); font-size: clamp(32px, 3.4cqi, 48px); font-weight: 900; line-height: 1; color: var(--num); letter-spacing: -.02em; }
.deck .total .vl span { font-size: 13px; font-weight: 700; color: var(--mid); }
.est .btn { margin-top: 18px; }

/* ---------- handoff ribbon: files kept for the next screen ----------
   Same left-rule-and-tint language as `.cap-note` above, reused rather than
   invented: it is already how this page marks "read this, it is not an
   error". `.bad` swaps the rule and tint to `--verm`, the one colour every
   warning here uses, for the cases where the browser could not hold the
   files at all (too large, or storage refused outright) — everything else
   about the shape stays put. */
/* ริบบิ้นเดียวกันยืนอยู่สองแบบ และระยะห่างข้างล่างต้องต่างกันตามนั้น
   บนหน้าแรกมันเป็นลูกตัวสุดท้ายใน `.deck` — padding ของแผงให้ที่ว่างข้างล่าง
   อยู่แล้ว margin อีกชั้นจะบวกทับกลายเป็นก้นแผงหนากว่าหัวแผง
   บนหน้าสมัคร/ล็อกอิน/เริ่มงาน มันยืนเดี่ยวเหนือฟอร์ม ไม่มีอะไรดันฟอร์มลง
   เลยติดกันสนิท (Top เจอเองบน production 1 ก.ย.) */
.handoff {
  display: flex; align-items: center; gap: 12px; margin: 18px 0;
  padding: 12px 14px; border-left: 3px solid var(--acid);
  background: color-mix(in srgb, var(--acid) 9%, transparent);
}
.deck .handoff:last-child { margin-bottom: 0; }
.handoff .glyph { flex: none; font-size: 17px; color: var(--acid); }
.handoff-tx { display: flex; flex-direction: column; gap: 2px; font-size: 13px; line-height: 1.7; }
.handoff-tx b { font-weight: 800; color: var(--hi); }
.handoff-tx em { font-style: normal; color: var(--mid); }
/* บรรทัดเสริมที่โผล่เฉพาะตอน ok (data-msg-ok-extra) — จางกว่า em หลักเล็กน้อย
   เพื่อให้ตาอ่านลำดับถูก: "มีของรออยู่" ก่อน แล้วค่อย "ไม่ต้องเลือกใหม่" */
.handoff-extra { font-size: 12px; color: var(--low); }
.handoff-drop {
  margin-left: auto; flex: none; background: none; border: 0; cursor: pointer;
  padding: 0 0 2px; font-size: 12.5px; color: var(--mid); border-bottom: 1px solid var(--line2);
}
.handoff-drop:hover { color: var(--verm-ink); border-color: var(--verm); }
.handoff.bad { border-left-color: var(--verm); background: color-mix(in srgb, var(--verm) 9%, transparent); }
.handoff.bad .glyph { color: var(--verm); }

/* ---------- band: the one claim ---------- */
.band {
  position: relative; z-index: 2; border-block: 1px solid var(--line);
  background: var(--ink2); padding: clamp(46px, 6cqi, 86px) 0; overflow: hidden;
}
/* Fade both edges so a word leaving the frame reads as intentional rather
   than as a clipped one. Thai has no inter-word spaces, so a hard cut
   mid-word is read as a spelling mistake, not as a marquee. The mask sits on
   the fixed-width viewport and not on the travelling row: the row is
   max-content wide, so a mask on the row would put its fade far off screen
   and the reader would still see a hard cut. */
.band-rows {
  overflow: hidden;
  -webkit-mask-image: linear-gradient(90deg, transparent 0, #000 11%, #000 89%, transparent 100%);
          mask-image: linear-gradient(90deg, transparent 0, #000 11%, #000 89%, transparent 100%);
}
.band .row { display: flex; gap: .6em; width: max-content; white-space: nowrap; flex-wrap: nowrap; }
.band .row span { font-size: clamp(46px, 7.2cqi, 116px); font-weight: 900; line-height: 1.34; letter-spacing: -.01em; color: var(--hi); padding-inline: .12em; }
.band .row span.o { color: transparent; -webkit-text-stroke: 2px var(--line2); }
.band .row span.v { color: var(--verm-ink); }

/* ── The band cycles its languages ─────────────────────────────────────────
   Four words stacked in one grid cell, one visible at a time. `inline-grid`
   with every child in `1/1` means the slot is as wide as its widest word and
   never resizes as they swap — a marquee whose slots breathe would drag the
   whole row with them on every turn.

   Crossfade, not a blink: each word is up for a fifth of the cycle and takes a
   beat to arrive and to leave, so two are briefly present together the way a
   scene change is. The slots are offset by a third of a turn from each other,
   so the row never changes as one piece.

   The faces are declared with `unicode-range`, so a slot showing Thai never
   fetches the Japanese subset and the two CJK files are only asked for on the
   turn that needs them. */
.cyc { display: inline-grid; vertical-align: baseline; }
.cyc i {
  grid-area: 1 / 1; font-style: normal; text-align: center;
  opacity: 0; animation: bandcycle 16s linear infinite;
}
.cyc i:nth-child(2) { animation-delay: -12s; }
.cyc i:nth-child(3) { animation-delay: -8s; }
.cyc i:nth-child(4) { animation-delay: -4s; }
/* The wave. Every slot in the row cycles, each a step behind the one before
   it, so the change travels along the row instead of happening to it. The
   outlined slots cycle too: left in one language they would sit there in Thai
   for an English reader forever, which is the thing this band was changed to
   stop saying. They read as the background because they are outlined, not
   because they are frozen.
   Keyed on `data-slot`, not `nth-of-type`, which counts every span in the row
   and matched nothing. Negative delays, so nothing waits to start. */
.cyc[data-slot="1"] i { animation-delay: -0.0s; }
.cyc[data-slot="1"] i:nth-child(2) { animation-delay: -12.0s; }
.cyc[data-slot="1"] i:nth-child(3) { animation-delay: -8.0s; }
.cyc[data-slot="1"] i:nth-child(4) { animation-delay: -4.0s; }
.cyc[data-slot="2"] i { animation-delay: -0.55s; }
.cyc[data-slot="2"] i:nth-child(2) { animation-delay: -12.55s; }
.cyc[data-slot="2"] i:nth-child(3) { animation-delay: -8.55s; }
.cyc[data-slot="2"] i:nth-child(4) { animation-delay: -4.55s; }
.cyc[data-slot="3"] i { animation-delay: -1.1s; }
.cyc[data-slot="3"] i:nth-child(2) { animation-delay: -13.1s; }
.cyc[data-slot="3"] i:nth-child(3) { animation-delay: -9.1s; }
.cyc[data-slot="3"] i:nth-child(4) { animation-delay: -5.1s; }
.cyc[data-slot="4"] i { animation-delay: -1.65s; }
.cyc[data-slot="4"] i:nth-child(2) { animation-delay: -13.65s; }
.cyc[data-slot="4"] i:nth-child(3) { animation-delay: -9.65s; }
.cyc[data-slot="4"] i:nth-child(4) { animation-delay: -5.65s; }
.cyc[data-slot="5"] i { animation-delay: -2.2s; }
.cyc[data-slot="5"] i:nth-child(2) { animation-delay: -14.2s; }
.cyc[data-slot="5"] i:nth-child(3) { animation-delay: -10.2s; }
.cyc[data-slot="5"] i:nth-child(4) { animation-delay: -6.2s; }
.cyc[data-slot="6"] i { animation-delay: -2.75s; }
.cyc[data-slot="6"] i:nth-child(2) { animation-delay: -14.75s; }
.cyc[data-slot="6"] i:nth-child(3) { animation-delay: -10.75s; }
.cyc[data-slot="6"] i:nth-child(4) { animation-delay: -6.75s; }
.cyc[data-slot="7"] i { animation-delay: -3.3s; }
.cyc[data-slot="7"] i:nth-child(2) { animation-delay: -15.3s; }
.cyc[data-slot="7"] i:nth-child(3) { animation-delay: -11.3s; }
.cyc[data-slot="7"] i:nth-child(4) { animation-delay: -7.3s; }
.cyc i[lang="ja"] { font-family: "KL Band JP", var(--sans); }
.cyc i[lang="zh-Hans"] { font-family: "KL Band SC", var(--sans); }
/* Four scripts at one font-size are four different widths — `reads well` runs
   about three times `通顺`, and a slot sized to the widest of them leaves the
   short ones floating in a hole. Sized per script instead, so each word fills
   roughly the same measure and the row keeps its rhythm whichever language is
   up. This is what a type designer would do by hand; the sizes are optical, not
   arithmetic. */
.cyc i[lang="en"] { font-size: .58em; letter-spacing: -.02em; }
.cyc i[lang="ja"] { font-size: .82em; }
.cyc i[lang="zh-Hans"] { font-size: 1.06em; letter-spacing: .04em; }
/* Not a crossfade — a cut. The word arrives from below, slightly small and out
   of focus, snaps to size, holds, then lifts away and blurs out. It is the
   title-card move: the frame settles on the word for a beat and then leaves it
   behind. `blur` and `translate` only — both are composited, so seven of these
   running at once cost the compositor and not the layout engine, and the slot
   never changes size while it happens. */
@keyframes bandcycle {
  0%      { opacity: 0; transform: translateY(.16em) scale(.94); filter: blur(6px); }
  4%      { opacity: 1; transform: translateY(0) scale(1.012); filter: blur(0); }
  6%, 20% { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
  24%     { opacity: 0; transform: translateY(-.13em) scale(1.02); filter: blur(5px); }
  25%, 100% { opacity: 0; transform: translateY(.16em) scale(.94); filter: blur(6px); }
}
/* Asked to hold still, it holds still — on the word the reader can read. The
   others are not merely un-animated but gone, or four words would print on top
   of each other. */
@media (prefers-reduced-motion: reduce) {
  /* The transform and the blur have to be cleared as well as the animation, or
     the resting word keeps whatever frame it was stopped on. */
  .cyc i { animation: none; opacity: 0; transform: none; filter: none; }
  .cyc i:first-child { opacity: 1; }
  :lang(en) .cyc i:first-child { opacity: 0; }
  :lang(en) .cyc i[lang="en"] { opacity: 1; }
}
.band .row.b span { font-size: clamp(20px, 2.4cqi, 34px); color: var(--low); font-weight: 800; }
.band .row.b span.k { color: var(--acid-ink); }
.band-cap { margin-top: 30px; }

/* ---------- the comparison ----------
   Not a claim about the translation: the translation, next to what it came
   from, with the reader in charge of how much of each they see.

   One custom property, `--split`, holds the divide, and it is declared here
   with a real resting value — so with no script at all this is a finished
   half-and-half comparison rather than an empty box waiting to be filled.

   `--split` is *registered* rather than merely declared, and that is what lets
   the two end buttons exist. An unregistered custom property is a string to the
   engine: it can be swapped but not travelled through, so a jump to either end
   would be a cut. Registered as a `<percentage>`, it interpolates, and pressing
   an end sweeps the divider there — which reads as the same control moving,
   not as a second control that replaced the first. */
@property --split { syntax: "<percentage>"; inherits: true; initial-value: 50%; }

.scene { position: relative; padding: clamp(72px, 10cqi, 150px) 0; }
.scene-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 520px); gap: clamp(28px, 4.4cqi, 76px); align-items: start; }
@media (max-width: 1000px) { .scene-grid { grid-template-columns: 1fr; gap: 40px; } }
.scene-copy .h2 { margin-bottom: 22px; }
.scene-copy .lede { max-width: 46ch; margin-bottom: 36px; }
.scene-meta { display: grid; gap: 15px; }
.scene-meta div { border-top: 1px solid var(--line2); padding-top: 12px; }
.scene-meta b { display: block; font-size: 12px; font-weight: 800; line-height: 1.8; letter-spacing: .14em; text-transform: uppercase; }
/* The one cold note is spent here and on the divider's left half, and nowhere
   else in the section: it marks the material that is not ours. */
.scene-meta b.src { color: var(--cold); }
.scene-meta b.out { color: var(--verm-ink); }
.scene-meta span { font-size: 13px; color: var(--low); line-height: 1.8; }
/* The same field, still being answered. It is set smaller rather than
   fainter because `--low` is already the faintest text on the page that
   clears contrast — below it the honesty would cost legibility. */
.scene-meta i { display: block; margin-top: 8px; font-size: 12px; line-height: 1.75; color: var(--low); font-style: normal; max-width: 34ch; }
.scene-hint { margin-top: 30px; display: flex; align-items: center; gap: 12px; font-size: 11px; font-weight: 700; letter-spacing: .2em; text-transform: uppercase; color: var(--low); }
.scene-hint i { display: block; width: 44px; height: 1px; background: var(--line2); }

/* The rail: the same chapter, three pages of it. One page proves the sentences
   were translated; three prove the names and the voices held between them,
   which is the claim the lede actually makes. */
.scene-rail { margin-top: 34px; }
.scene-rail .rail { display: flex; gap: 12px; }
.scene-rail button { position: relative; padding: 0; width: 78px; flex: none; cursor: pointer; background: var(--km-surface); border: 1px solid var(--line2); }
.scene-rail button img { display: block; width: 100%; height: auto; }
.scene-rail button[aria-current="true"] { border-color: var(--acid); }
.scene-rail button[aria-current="true"]::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; background: var(--acid); }
.scene-rail button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.scene-rail p { margin: 13px 0 0; font-size: 13px; color: var(--low); line-height: 1.8; max-width: 38ch; }

.pagewrap { position: relative; justify-self: center; width: min(100%, 520px, 46vh); }
@media (max-width: 1000px) { .pagewrap { justify-self: start; width: min(100%, 470px); } }

/* The two ends of the divider, as buttons. They are the legend as well: each
   sits over the side it names and carries that side's colour, so the strip that
   used to label the halves is gone rather than duplicated. Between them a
   dashed rule stands in for the travel the divider makes. */
.cmp-ends { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
.cmp-ends .thread { flex: 1; height: 1px; background: repeating-linear-gradient(90deg, var(--line2) 0 5px, transparent 5px 10px); }
.cmp-end { font: 800 11px/1 var(--sans); letter-spacing: .16em; text-transform: uppercase; cursor: pointer; padding: 10px 13px; border: 1px solid var(--line2); border-radius: 2px; background: transparent; color: var(--mid); }
.cmp-end[aria-pressed="true"] { color: var(--on-cold); background: var(--cold); border-color: var(--cold); }
.cmp-end.out[aria-pressed="true"] { color: var(--paper); background: var(--verm-fill); border-color: var(--verm-fill); }
.cmp-end:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }

.cmp { --split: 50%; --grip: 44px; position: relative; touch-action: none; }
/* Only while an end button is driving. Dragging must not be smoothed: a divider
   that lags the pointer stops being a divider you are holding. */
.cmp.is-gliding { transition: --split .5s cubic-bezier(.22, .7, .25, 1); }
@media (prefers-reduced-motion: reduce) { .cmp.is-gliding { transition: none; } }
.cmp-stack {
  position: relative; overflow: hidden; background: var(--surface);
  border: 1px solid var(--line2); aspect-ratio: 1200 / 1660;
  box-shadow: 0 40px 90px -30px rgb(0 0 0 / .75);
}
:root[data-theme="light"] .cmp-stack { box-shadow: 0 30px 70px -34px rgb(0 0 0 / .35); }
/* Every page is stacked in the same box and the box holds its own aspect ratio,
   so switching pages cannot move the divider, the handle, or the fold. */
.cmp-stack img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; user-select: none; -webkit-user-select: none; }
/* `clip-path`, not a width: the translated page keeps its own geometry, so the
   two halves line up panel for panel however far the divider travels. */
.cmp-stack .out { clip-path: inset(0 0 0 var(--split)); }
/* Cold on the left of the line, vermilion on the right — the divider states the
   rule the caption underneath it repeats, so the colours have to agree. */
.cmp-line {
  position: absolute; top: 0; bottom: 0; left: var(--split); width: 4px; margin-left: -2px;
  pointer-events: none; background: linear-gradient(90deg, var(--cold) 0 50%, var(--verm) 50% 100%);
}

/* The range input IS the handle — restyled, never re-invented. So the divider
   is dragged with a pointer and moved with the arrow keys, and neither path
   needed inventing: the element already does both. */
.cmp-range {
  position: absolute; top: 50%; left: calc(-1 * var(--grip) / 2);
  width: calc(100% + var(--grip)); height: var(--grip); translate: 0 -50%;
  margin: 0; padding: 0; border: 0; background: transparent;
  -webkit-appearance: none; appearance: none;
  touch-action: none; cursor: ew-resize;
}
.cmp-range::-webkit-slider-runnable-track { height: var(--grip); background: transparent; border: 0; }
.cmp-range::-moz-range-track { height: var(--grip); background: transparent; border: 0; }
.cmp-range::-webkit-slider-thumb {
  -webkit-appearance: none; appearance: none; cursor: ew-resize;
  width: var(--grip); height: var(--grip); border: 0; border-radius: 2px; background: var(--verm-fill);
  background-image: linear-gradient(var(--paper), var(--paper)), linear-gradient(var(--paper), var(--paper));
  background-repeat: no-repeat; background-size: 2px 14px; background-position: 17px 15px, 25px 15px;
  box-shadow: 0 8px 20px -8px rgb(0 0 0 / .75);
}
.cmp-range::-moz-range-thumb {
  cursor: ew-resize; width: var(--grip); height: var(--grip); border: 0; border-radius: 2px; background: var(--verm-fill);
  background-image: linear-gradient(var(--paper), var(--paper)), linear-gradient(var(--paper), var(--paper));
  background-repeat: no-repeat; background-size: 2px 14px; background-position: 17px 15px, 25px 15px;
  box-shadow: 0 8px 20px -8px rgb(0 0 0 / .75);
}
.cmp-range:focus-visible { outline: none; }
.cmp-range:focus-visible::-webkit-slider-thumb { outline: 2px solid var(--focus); outline-offset: 3px; }
.cmp-range:focus-visible::-moz-range-thumb { outline: 2px solid var(--focus); outline-offset: 3px; }
/* The input is deliberately half a grip wider than the box it sits on, so the
   handle can be taken all the way to either edge without the browser holding it
   back. That overhang has room to spend while the wrap has margins; at 390px the
   wrap is the screen less 40px and the 22px on the right became the page's only
   horizontal scroll. Inside the box below that width: the handle still travels
   0–100, it simply stops overhanging, and a finger does not need the last 22px
   of reach that a pointer was being given. */
@media (max-width: 720px) { .cmp-range { left: 0; width: 100%; } }

/* The way in to the full-size view, in the corner of the artwork rather than
   on it: a click anywhere else on the picture already means "move the divider",
   and a control that competes with an existing gesture is a control that will
   sometimes lose. Shipped `hidden`; `app.js` reveals it, because a dialog needs
   script and a button that opens nothing is worse than no button. */
.cmp-open {
  position: absolute; right: 10px; bottom: 10px; z-index: 3;
  display: flex; align-items: center; gap: 7px;
  padding: 8px 11px; border: 1px solid rgb(251 247 239 / .22); border-radius: 2px;
  background: rgb(8 7 10 / .72); color: var(--paper);
  font: 700 11px/1 var(--sans); letter-spacing: .12em; text-transform: uppercase;
  cursor: pointer; backdrop-filter: blur(6px);
}
.cmp-open:hover { background: rgb(8 7 10 / .88); border-color: rgb(251 247 239 / .4); }
.cmp-open:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
@media (max-width: 480px) { .cmp-open { font-size: 0; gap: 0; padding: 9px; letter-spacing: 0; } }

/* ---------- the comparison, full size ----------
   A portrait page cannot be zoomed by fitting it to the screen: at 92vh of a
   laptop it comes out about 550px wide, which is what the section already gives
   it. So the dialog shows the page at its natural width and scrolls, and the
   two things that must stay reachable while it scrolls — the languages and the
   way out — sit in a bar that sticks to the top. */
.cmp-zoom {
  width: min(1200px, 94vw); max-width: none; max-height: 94vh;
  padding: 0; border: 1px solid var(--line2); border-radius: 2px;
  background: var(--ink); color: var(--hi); overflow: auto; overscroll-behavior: contain;
}
.cmp-zoom::backdrop { background: rgb(6 7 12 / .82); }
.cmp-zoom-bar {
  position: sticky; top: 0; z-index: 4;
  display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
  padding: 12px 14px; border-bottom: 1px solid var(--line2);
  background: color-mix(in srgb, var(--ink) 92%, transparent); backdrop-filter: blur(10px);
}
.cmp-zoom-bar .cmp-ends { margin-bottom: 0; }
/* The ends stop stretching in here: the dashed thread between them is a stand-in
   for the divider's travel, and next to the picture at full size the picture
   makes that point by itself. */
.cmp-zoom-bar .cmp-ends .thread { display: none; }
.cmp-zoom-credit { margin-left: auto; font-size: 12px; line-height: 1.7; color: var(--low); }
/* Only the credit survives the move — the sentence about which side is which is
   already told by the divider itself, and the bar has no room to say it twice.
   The plate keeps its own element in here, so the rule has to reach through it
   rather than at it. */
.cmp-zoom-credit .cmp-plate { margin: 0; display: block; font-size: inherit; }
.cmp-zoom-credit .cmp-plate > span:not(.src-of) { display: none; }
.cmp-zoom-credit .cmp-plate .src-of { margin-left: 0; text-align: left; }
.cmp-zoom-credit .src-of a { color: var(--mid); text-decoration: none; border-bottom: 1px solid var(--line2); }
.cmp-zoom-credit .src-of .lic { font-family: var(--mono); font-size: 11px; }
.cmp-zoom-close {
  flex: none; width: 34px; height: 34px; padding: 0; cursor: pointer;
  border: 1px solid var(--line2); border-radius: 2px; background: transparent;
  color: var(--mid); font: 600 15px/1 var(--sans);
}
.cmp-zoom-close:hover { color: var(--hi); border-color: var(--low); }
.cmp-zoom-close:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.cmp-zoom-page { padding: 14px; }
.cmp-zoom-page .cmp-stack { border: 0; box-shadow: none; }
.cmp-zoom-page .cmp-open { display: none; }
/* Scrolled to the bottom of a 1660px page, a handle pinned to the middle of it
   is a handle nobody can reach. So in here the input is the whole picture: a
   range still tracks the pointer's x anywhere inside its box, `pan-y` leaves the
   vertical scroll to the browser, and `.cmp-line` shows where the divide landed
   — which is what the thumb was drawing anyway. */
.cmp-zoom-page .cmp-range {
  top: 0; height: 100%; translate: none; left: 0; width: 100%;
  touch-action: pan-y; cursor: ew-resize;
}
.cmp-zoom-page .cmp-range::-webkit-slider-thumb { background: transparent; background-image: none; box-shadow: none; }
.cmp-zoom-page .cmp-range::-moz-range-thumb { background: transparent; background-image: none; box-shadow: none; }
.cmp-zoom-page .cmp-range:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.cmp-zoom-page .cmp-range:focus-visible::-webkit-slider-thumb { outline: none; }
.cmp-zoom-page .cmp-range:focus-visible::-moz-range-thumb { outline: none; }

/* Below this width the page cannot be made bigger by giving it the screen — the
   screen is the thing that is small. So it is given more than the screen and
   panned instead, which is the only magnification that means anything on a
   phone, and the divider steps aside for it: dragging sideways is how you pan
   here, and a control that also wants sideways drags would take every one of
   them. The two ends in the bar keep the comparison — a tap swaps the whole
   page, which is the same answer the divider gives at either end of its travel. */
@media (max-width: 760px) {
  .cmp-zoom { width: 94vw; }
  .cmp-zoom-bar { left: 0; width: 94vw; max-width: 100%; }
  .cmp-zoom-page { padding: 0; }
  .cmp-zoom-page .cmp { width: min(1200px, max(100%, 190vw)); }
  .cmp-zoom-page .cmp-range { display: none; }
}

/* Two small lines under the artwork, weighted the same: what the divider means
   on the left, where the pages come from on the right. The credit is set as a
   plate under the picture rather than a footnote at the end of the page,
   because CC BY asks for attribution where the work is used — and a line the
   design makes room for is one nobody is tempted to delete later. */
.cmp-plate { margin-top: 15px; display: flex; justify-content: space-between; align-items: baseline; gap: 18px; flex-wrap: wrap; font-size: 13px; line-height: 1.85; color: var(--low); }
.cmp-plate .src-of { margin-left: auto; text-align: right; }
.cmp-plate .src-of a { color: var(--mid); text-decoration: none; border-bottom: 1px solid var(--line2); }
.cmp-plate .src-of a:hover { color: var(--hi); border-bottom-color: var(--low); }
.cmp-plate .src-of .lic { font-family: var(--mono); font-size: 11px; letter-spacing: .04em; }

/* ---------- craft ---------- */
.craft { position: relative; padding: clamp(90px, 12cqi, 180px) 0; overflow: hidden; }
.craft-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: clamp(30px, 5cqi, 90px); align-items: start; }
@media (max-width: 960px) { .craft-grid { grid-template-columns: 1fr; } }
.craft .h2 { max-width: 15ch; }
.craft .small { margin-top: 26px; max-width: 34ch; }
.craft-body { padding-top: clamp(0px, 2cqi, 26px); }
.craft-list { list-style: none; margin: 38px 0 0; padding: 0; }
/* `minmax(0, 1fr)`, never a bare `1fr`. A grid track's automatic minimum is its
   content's min-content width, and Thai is set without spaces between words —
   so a heading like "ดาวน์โหลดแล้วแตกไฟล์" measures 293px as one unbreakable
   run, the track grows to hold it, and the list ends up wider than the column
   it sits in. On a 360px screen that is what took the whole guide page
   sideways. `minmax(0, …)` lets the track be narrower than its contents; the
   wrapping rule below is what then lets the contents fit inside it. */
.craft-list li { display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 16px; padding: 22px 0; border-top: 1px solid var(--line); }
.craft-list b, .craft-list span { overflow-wrap: anywhere; }
.craft-list li:last-child { border-bottom: 1px solid var(--line); }
.craft-list .n { font-family: var(--mono); font-size: 13px; font-weight: 900; line-height: 1.7; color: var(--verm-ink); letter-spacing: .06em; }
.craft-list b { display: block; font-size: 1.05rem; font-weight: 800; line-height: 1.75; color: var(--hi); margin-bottom: 4px; }
.craft-list span { display: block; font-size: .94rem; line-height: 1.95; color: var(--mid); }
.craft-art { position: absolute; right: -8%; top: 5%; width: min(620px, 50%); opacity: .15; pointer-events: none; z-index: 0; }
.craft-art svg { width: 100%; height: auto; display: block; }

/* The specimen. A run of lines rather than a screenshot: the point is the
   rhythm of the Thai, and the source line is set in --cold because it is
   material that is not ours. */
.spec { margin-top: clamp(60px, 8cqi, 110px); border-top: 2px solid var(--hi); padding-top: 34px; position: relative; z-index: 2; }
.spec-head { display: flex; justify-content: space-between; align-items: baseline; gap: 20px; flex-wrap: wrap; margin-bottom: 34px; }
.spec-run { display: grid; gap: clamp(22px, 2.6cqi, 38px); }
.spec-run .item { display: grid; grid-template-columns: auto 1fr; gap: clamp(16px, 2cqi, 34px); align-items: start; }
.spec-run .who { font-family: var(--mono); font-size: 11px; font-weight: 800; line-height: 1.8; letter-spacing: .14em; text-transform: uppercase; color: var(--low); padding-top: .9em; min-width: 70px; }
.spec-run .src { font-size: 14px; line-height: 1.7; color: var(--cold); margin: 0 0 6px; letter-spacing: .04em; }
.spec-run .out { font-size: clamp(18px, 2.05cqi, 29px); font-weight: 700; line-height: 1.9; color: var(--hi); margin: 0; }
.spec-run .item.r .out { color: var(--verm-ink); }
.spec-foot { margin-top: 36px; padding-top: 20px; border-top: 1px solid var(--line); }

/* ---------- price ---------- */
.price { position: relative; background: var(--ink2); border-block: 1px solid var(--line); padding: clamp(80px, 10cqi, 150px) 0; }
.price-top { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: clamp(28px, 5cqi, 80px); align-items: end; margin-bottom: clamp(46px, 6cqi, 84px); }
@media (max-width: 960px) { .price-top { grid-template-columns: 1fr; } }
.free { position: relative; padding-left: 26px; }
.free::before { content: ""; position: absolute; left: 0; top: .3em; bottom: .3em; width: 5px; background: var(--acid); }
.free b { display: block; font-size: clamp(26px, 3cqi, 42px); font-weight: 900; line-height: 1.4; }
.free span { display: block; margin-top: 12px; color: var(--mid); font-size: 1rem; line-height: 1.95; }

/* Credit packs: discrete amounts, so cards. */
.packs { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 210px), 1fr)); gap: clamp(12px, 1.6cqi, 20px); margin-top: 22px; }
.pack {
  position: relative;
  background: var(--surface); border: 1px solid var(--card-edge); box-shadow: var(--shadow);
  padding: clamp(20px, 2.2cqi, 28px); display: flex; flex-direction: column; gap: 6px;
  text-decoration: none; color: inherit;
  transition: border-color .12s ease;
}
.pack:hover { border-color: var(--verm); color: inherit; text-decoration: none; }
.pack .cr { font-family: var(--mono); font-size: clamp(28px, 3.2cqi, 44px); font-weight: 900; line-height: 1.1; color: var(--credit); letter-spacing: -.02em; }
.pack .cr i { font-style: normal; font-family: var(--sans); font-size: 13px; font-weight: 700; color: var(--low); margin-left: 8px; letter-spacing: 0; }
.pack .amt { display: flex; align-items: baseline; gap: 7px; margin-top: 6px; flex-wrap: wrap; }
.pack .amt b { font-family: var(--mono); font-size: clamp(22px, 2.3cqi, 30px); font-weight: 900; line-height: 1; color: var(--num); }
.pack .amt span { font-size: 12px; font-weight: 700; color: var(--low); }
.pack .unit { margin: 10px 0 0; padding-top: 10px; border-top: 1px solid var(--line); font-family: var(--mono); font-size: 12px; font-weight: 600; line-height: 1.7; color: var(--low); }

/* Per-mode rates: a rate card, not a plan chooser. A mode is picked per job,
   so three cards side by side would wrongly read as "choose your plan". */
.ratewrap { margin-top: clamp(46px, 6cqi, 80px); }
table.ratecard { width: 100%; border-collapse: collapse; margin-top: 20px; text-align: left; }
table.ratecard caption { text-align: left; font-size: 14px; line-height: 1.9; color: var(--low); margin-bottom: 14px; }
table.ratecard th, table.ratecard td { padding: 16px 16px 16px 0; border-bottom: 1px solid var(--line); vertical-align: baseline; background: transparent; }
table.ratecard thead th {
  font-size: 11px; font-weight: 800; line-height: 1.6; letter-spacing: .16em; text-transform: uppercase; color: var(--low);
  border-bottom: 2px solid var(--hi); padding-bottom: 12px;
}
table.ratecard tbody th { font-size: 1.05rem; font-weight: 800; line-height: 1.7; color: var(--hi); white-space: nowrap; text-transform: none; letter-spacing: 0; }
table.ratecard tbody th i { display: block; font-style: normal; font-family: var(--mono); font-size: 11px; font-weight: 600; line-height: 1.6; letter-spacing: .14em; text-transform: uppercase; color: var(--low); }
table.ratecard tbody tr:hover { background: transparent; }
table.ratecard td.use { color: var(--mid); font-size: .94rem; line-height: 1.9; padding-right: 0; }
table.ratecard td.num, table.ratecard th.num { text-align: right; white-space: nowrap; padding-right: 0; font-family: inherit; }
table.ratecard td.num b { font-family: var(--mono); font-size: clamp(24px, 2.4cqi, 32px); font-weight: 900; line-height: 1; color: var(--num); }
table.ratecard td.num span { display: block; font-size: 12px; font-weight: 600; line-height: 1.7; color: var(--low); margin-top: 4px; }
@media (max-width: 760px) {
  table.ratecard thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); }
  table.ratecard tr { display: grid; grid-template-columns: 1fr auto; gap: 2px 16px; padding: 18px 0; border-bottom: 1px solid var(--line); }
  table.ratecard th, table.ratecard td { border: 0; padding: 0; }
  table.ratecard td.use { grid-column: 1 / -1; margin-top: 8px; }
}
.price-foot { display: flex; gap: 30px; flex-wrap: wrap; margin-top: 34px; align-items: center; justify-content: space-between; }

/* ---------- closing call ---------- */
.cta-sec { position: relative; padding: clamp(100px, 14cqi, 200px) 0; overflow: hidden; }
.cta-art { position: absolute; inset: 0; z-index: 0; pointer-events: none; }
.cta-art svg { position: absolute; display: block; }
.cta-slash { right: -8%; top: -6%; width: min(660px, 48%); opacity: .85; }
.cta-dots { right: 6%; bottom: 8%; width: min(240px, 22%); opacity: .45; }
.cta-inner { width: min(760px, 66%); }
@media (max-width: 820px) { .cta-inner { width: 100%; } }
.cta-sec .h1 { font-size: clamp(36px, 5.6cqi, 80px); }
.cta-sec .row { gap: 24px; margin-top: 44px; }
.cta-sec .fine { margin-top: 26px; max-width: 46ch; }

/* Standalone public documents (terms, privacy): one readable column under
   the landing chrome. 74ch, because a legal line that spans a monitor is a
   legal line nobody reads. */
.pub-main { background: var(--bg); }
.pub-doc { max-width: 74ch; margin: 0 auto; padding: clamp(28px, 5vw, 56px) 20px 90px; }
.pub-title { margin: 0 0 18px; }
/* The balance as Top picked it: the two-layer gem (bone outline, gold core),
   the label over the number, and a dashed + that firms up vermilion on
   hover — the whole thing is one link to the credits page. */
.hdr-credits { display: inline-flex; align-items: center; gap: 9px;
  color: var(--hi); text-decoration: none; white-space: nowrap;
  background: var(--ink); border: 1px solid var(--line2);
  border-radius: 7px; padding: 0 12px; }
.hdr-credits:hover { color: var(--hi); border-color: var(--verm); }
.hdr-credits .glyph { width: 17px; height: 17px; }
/* The sparks were cut, then missed — back by request. The gem leans and two
   four-point sparks take turns catching light; reduced motion stills it all
   and leaves one spark lit. */
.hdr-credits .hc-ic { position: relative; display: grid; place-content: center; }
.hdr-credits .hc-ic .glyph { animation: hcGemTilt 2.6s ease-in-out infinite; }
.hc-spark { position: absolute; fill: var(--acid); opacity: 0;
  animation: hcSparkTw 2.6s ease-in-out infinite; }
.hc-spark.s1 { top: -3px; right: -6px; }
.hc-spark.s2 { bottom: -2px; left: -6px; animation-delay: 1.3s; }
@keyframes hcSparkTw {
  0%, 100% { opacity: 0; transform: scale(.4) rotate(0deg); }
  50% { opacity: 1; transform: scale(1) rotate(90deg); }
}
@keyframes hcGemTilt {
  0%, 100% { transform: rotate(0deg); }
  30% { transform: rotate(-6deg); }
  60% { transform: rotate(5deg); }
}
@media (prefers-reduced-motion: reduce) {
  .hdr-credits .hc-ic .glyph, .hc-spark { animation: none; }
  .hc-spark.s1 { opacity: .8; }
}
.hdr-credits .hc-num { display: flex; flex-direction: column; align-items: flex-end; line-height: 1.15; }
.hdr-credits .hc-num { line-height: 1.1; }
.hdr-credits .hc-num small { font-size: 9px; letter-spacing: .16em; text-transform: uppercase; color: var(--low); }
.hdr-credits .hc-num b { font-family: var(--mono); font-size: 13px; font-weight: 700; color: var(--credit); }
.hdr-credits .hc-plus { width: 20px; height: 20px; border-radius: 50%; font-size: 12px;
  border: 1px dashed var(--line2); display: grid; place-content: center;
  color: var(--low); font-weight: 800; transition: border-color .15s, color .15s; }
.hdr-credits:hover .hc-plus { border-color: var(--verm); color: var(--verm); border-style: solid; }
.ftr { border-top: 1px solid var(--line); padding: 48px 0 40px; }
.ftr-grid { display: grid; grid-template-columns: 1.5fr 1fr 1fr 1fr; gap: 38px; align-items: start; }
.ftr-brand { display: grid; gap: 10px; justify-items: start; }
.ftr nav { display: grid; gap: 11px; justify-items: start; align-content: start; }
.ftr-h { font-size: .7rem; font-weight: 800; letter-spacing: .16em; text-transform: uppercase; color: var(--low); }
.ftr-bottom { margin-top: 38px; padding-top: 18px; border-top: 1px solid var(--line);
  display: flex; justify-content: space-between; gap: 18px; flex-wrap: wrap; }
@media (max-width: 800px) { .ftr-grid { grid-template-columns: 1fr 1fr; } }
@media (max-width: 440px) { .ftr-grid { grid-template-columns: 1fr; } }
.ftr a { font-size: 14px; color: var(--low); text-decoration: none; }
.ftr a:hover { color: var(--hi); }

/* ---------- motion ----------
   Continuous and scroll-linked only. No entrance fades and no
   translate-up-on-appear: the resting state above is already the finished
   design, and everything here only re-times it. Transform and opacity only. */
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .hero-art .slash { animation: driftA linear both; animation-timeline: scroll(nearest block); animation-range: 0 120vh; }
    .hero-art .splat { animation: driftB linear both; animation-timeline: scroll(nearest block); animation-range: 0 120vh; }
    .hero-art .grid-note { animation: driftC linear both; animation-timeline: scroll(nearest block); animation-range: 0 120vh; }
    .hero-head { animation: driftText linear both; animation-timeline: scroll(nearest block); animation-range: 0 120vh; }
    @keyframes driftA { from { translate: 0 0; rotate: 0deg; } to { translate: -5% -15%; rotate: -2.2deg; } }
    @keyframes driftB { from { translate: 0 0; } to { translate: 8% 24%; } }
    @keyframes driftC { from { translate: 0 0; } to { translate: -10% 10%; } }
    @keyframes driftText { from { translate: 0 0; } to { translate: 0 -7%; } }

    .warmth span { animation: warmOut linear both; animation-timeline: scroll(nearest block); }
    .warmth span.cool { animation: coolIn linear both; animation-timeline: scroll(nearest block); }
    @keyframes warmOut { 0% { opacity: 1; } 55% { opacity: .3; } 100% { opacity: .95; } }
    @keyframes coolIn { 0% { opacity: 0; } 45% { opacity: .75; } 100% { opacity: .12; } }

    .draw { animation: winOut linear both; animation-timeline: view(); animation-range: entry 40% cover 48%; }
    .draw > span { animation: winIn linear both; animation-timeline: view(); animation-range: entry 40% cover 48%; }
    @keyframes winOut { from { transform: translateX(-100%); } to { transform: translateX(0); } }
    @keyframes winIn { from { transform: translateX(100%); } to { transform: translateX(0); } }

    .band .row.a { animation: pushL linear both; animation-timeline: view(); animation-range: cover 0% cover 100%; }
    .band .row.b { animation: pushR linear both; animation-timeline: view(); animation-range: cover 0% cover 100%; }
    @keyframes pushL { from { transform: translateX(4%); } to { transform: translateX(-34%); } }
    @keyframes pushR { from { transform: translateX(-30%); } to { transform: translateX(2%); } }

    .craft-art { animation: craftDrift linear both; animation-timeline: view(); animation-range: cover 0% cover 100%; }
    @keyframes craftDrift { from { translate: 6% 14%; rotate: 3deg; } to { translate: -4% -12%; rotate: -2deg; } }

    .spec-run .item { animation: specDrift linear both; animation-timeline: view(); animation-range: cover 0% cover 100%; }
    @keyframes specDrift { from { translate: -1.2% 0; } to { translate: 1.2% 0; } }

    .pack .amt { animation: amtDrift linear both; animation-timeline: view(); animation-range: cover 0% cover 100%; }
    @keyframes amtDrift { from { translate: 0 10%; } to { translate: 0 -10%; } }
    /* The rate card never drifts — a table of numbers has to hold still. */

    .cta-slash { animation: ctaA linear both; animation-timeline: view(); animation-range: cover 0% cover 100%; }
    .cta-dots { animation: ctaB linear both; animation-timeline: view(); animation-range: cover 0% cover 100%; }
    @keyframes ctaA { from { translate: -8% 16%; rotate: -3deg; } to { translate: 3% -10%; rotate: 1.5deg; } }
    @keyframes ctaB { from { translate: 10% 22%; } to { translate: -6% -14%; } }
  }
}

/* Limits inside a plan card: a two-column list that stays readable when the
   card is narrow, which a table would not. */
.plan-limits {
  display: grid; grid-template-columns: auto minmax(0, 1fr); gap: .18rem .8rem;
  margin: 0; padding: .7rem 0 0; font-size: .8rem;
  align-content: start;
  /* Top rule only. The price row below draws its own top border, so a bottom
     rule here would land a second line a gap's width away from it. */
  border-top: 1px solid var(--line);
}
/* The label is the fixed part and must not be squeezed onto two lines by a
   long value — that is what made "Pages per job" wrap mid-phrase. */
.plan-limits dt { color: var(--low); white-space: nowrap; }
.plan-limits dd { margin: 0; text-align: right; font-family: var(--mono); }

/* The free tier's single cell is not a link — nothing to buy. */
.plan-price.static { cursor: default; }
.plan-price.static:hover { border-color: var(--line2); background: var(--ink2); }

/* The second of the two strip blocks. Same story as the first — see the note
   there, and "The phone" at the foot of this file for what replaced both. */

/* Utility classes — inline style="" attributes are blocked by the nonce-based
   CSP, so anything one-off still lives here. */
.full { width: 100%; justify-content: center; text-align: center; }
.muted { color: var(--mid); }
.faint { color: var(--low); }
.nowrap { white-space: nowrap; }
.mono { font-family: var(--mono); font-size: .86em; }
.mt-0 { margin-top: 0; }
.mt-1 { margin-top: .5rem; }
.mt-2 { margin-top: 1rem; }
.mt-3 { margin-top: 1.6rem; }
.mb-0 { margin-bottom: 0; }
.mb-2 { margin-bottom: 1rem; }
.text-right { text-align: right; }
.block { display: block; }
.over-limit { color: var(--verm-ink); font-weight: 700; }
/* A figure the page counts in. Anything wearing this is a quantity. */
.num { font-family: var(--mono); }
/* Nothing polls, so no surface may look live. The time a figure was read and
   a way to read it again — both honest, both free. */


/* มิเตอร์บนการ์ดเครื่องหน้า ops — ความกว้างเป็นคลาสขั้นละ 5% (w0-w20)
   เพราะ CSP ห้าม style= และตัวเลขจาก Prometheus ก็ไม่ต้องละเอียดกว่านี้ */
.ops-meters { display: grid; gap: .3rem; margin-top: .6rem; font-size: .72rem; }
.ops-meter { display: grid; grid-template-columns: 3.2em 1fr auto; gap: .5rem;
  align-items: center; color: var(--text-faint); font-variant-numeric: tabular-nums; }
.ops-track { height: 5px; background: color-mix(in srgb, var(--border) 55%, transparent);
  border-radius: 3px; overflow: hidden; }
.ops-fill { display: block; height: 100%; border-radius: 3px; background: var(--accent); }
.ops-fill.hot { background: var(--danger); }
.ops-fill.w0 { width: 0%; }
.ops-fill.w1 { width: 5%; }
.ops-fill.w2 { width: 10%; }
.ops-fill.w3 { width: 15%; }
.ops-fill.w4 { width: 20%; }
.ops-fill.w5 { width: 25%; }
.ops-fill.w6 { width: 30%; }
.ops-fill.w7 { width: 35%; }
.ops-fill.w8 { width: 40%; }
.ops-fill.w9 { width: 45%; }
.ops-fill.w10 { width: 50%; }
.ops-fill.w11 { width: 55%; }
.ops-fill.w12 { width: 60%; }
.ops-fill.w13 { width: 65%; }
.ops-fill.w14 { width: 70%; }
.ops-fill.w15 { width: 75%; }
.ops-fill.w16 { width: 80%; }
.ops-fill.w17 { width: 85%; }
.ops-fill.w18 { width: 90%; }
.ops-fill.w19 { width: 95%; }
.ops-fill.w20 { width: 100%; }

/* ---------- landing: the browser extension ----------
   Its own section rather than a fourth item in `craft`: the others describe
   what the translation is like, and this describes a different way of handing
   work over. Mixing them would read as another quality claim. */
.ext-sec { padding: clamp(3rem, 7vw, 6rem) 0; border-top: 1px solid var(--line); }
.ext-sec .lede { max-width: 62ch; }
.ext-steps {
  list-style: none; margin: 2rem 0 1.6rem; padding: 0;
  display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr));
  counter-reset: ext;
}
.ext-steps li {
  position: relative; padding: 1rem 1.1rem 1.1rem;
  border: 1px solid var(--line2); border-radius: var(--radius-lg);
  background: var(--surface);
}
/* Numbered because the order is real — nothing can be collected before the
   extension is installed, and nothing sent before an account is connected. */
.ext-steps li::before {
  counter-increment: ext; content: counter(ext);
  display: block; font: 700 .7rem/1 var(--mono); letter-spacing: .1em;
  color: var(--verm-ink); margin-bottom: .5rem;
}
.ext-steps b { display: block; font-size: .98rem; margin-bottom: .3rem; }
.ext-steps span { display: block; font-size: .87rem; color: var(--text-muted); line-height: 1.6; }
.ext-install {
  margin-top: 2rem; padding: 1.2rem 1.3rem;
  border: 1px solid var(--line2); border-left: 3px solid var(--cold);
  border-radius: var(--radius-lg); background: var(--surface-2);
}
.ext-howto { margin: .7rem 0 .9rem; padding-left: 1.2rem; font-size: .9rem; line-height: 1.9; }
.ext-howto code { font-family: var(--mono); font-size: .85em; }
/* The caveat under the buttons. It used to sit inside `.ext-install`, which
   gave it the box's padding; with the install steps moved to the guide it
   follows the button row directly and was landing on the ghost button's
   underline. */
.ext-sec .row + .fine { margin-top: 1.4rem; }

/* ---------- the extension guide (/extension/guide/) ----------
   The landing page's shell, not the legal notices'. This page began on
   `base_public.html` — the 60ch column the Privacy Notice uses — and read like
   a legal page inside a product that is trying to sell the thing it documents.
   It is read by somebody deciding whether to install, so it gets the full-bleed
   sections, the warm light and the type scale the rest of the public site has,
   and reuses `.wrap`, `.eyebrow`, `.h1`/`.h2`, `.craft-list` and `.ext-sec`
   rather than growing a second set of the same rules.

   The measure still has to be held somewhere: a 1,180px line is unreadable
   whatever the page is selling. It is held on the TEXT — `.lede`, `.small`,
   `.craft-list span` all carry their own — never on the section, so the cards
   and the two-column splits keep the full column. */
/* The band under the bar. It used to open at 120px on a wide screen — a figure
   borrowed from the landing page's opening section, where the first thing a
   visitor meets should have room around it. These two pages are not that: a
   reader arrives at the guide with a question and at Downloads with an errand,
   and both had to scroll before the heading answered them. */
.guide-hero { padding: clamp(28px, 3.4cqi, 52px) 0 clamp(22px, 2.6cqi, 34px); }
.guide-hero .h1 { max-width: 18ch; }
.guide-hero .lede { margin-top: 20px; }
.guide-row { margin-top: 28px; flex-wrap: wrap; gap: 12px; }
.guide .ext-sec .h2 { max-width: 24ch; }
.guide .ext-sec .eyebrow { margin-bottom: 14px; }

/* "Not open yet". A note rather than the console's `.message`, which is sized
   for a form and looks like an error dropped onto a marketing page. */
.guide-note {
  margin-top: 28px; padding: 16px 20px; max-width: 62ch;
  border: 1px solid var(--line2); border-left: 3px solid var(--cold);
  border-radius: var(--radius-lg); background: var(--surface-2);
}
.guide-note b { display: block; font-size: .98rem; margin-bottom: 4px; }
.guide-note span { display: block; font-size: .9rem; line-height: 1.9; color: var(--mid); }

/* "On this page". Wraps into as many rows as it needs, because Thai renders
   these labels longer than English and a nav that scrolls sideways on a phone
   is a nav nobody uses. */
.guide-toc {
  display: flex; flex-wrap: wrap; gap: 10px 18px; align-items: baseline;
  margin-top: 34px; padding: 16px 20px;
  border: 1px solid var(--line2); border-radius: var(--radius-lg);
  background: var(--surface-2); font-size: .9rem;
}
.guide-toc b { font-size: .7rem; font-weight: 800; letter-spacing: .18em; text-transform: uppercase; color: var(--low); }
.guide-toc a { color: var(--hi); text-decoration: none; border-bottom: 1px solid var(--line2); }
.guide-toc a:hover { border-bottom-color: currentColor; }

/* Feature cards. Unnumbered, unlike `.ext-steps` on the landing page, and that
   is the whole difference between them: those are three things done in order,
   these are things that are all true at once. */
.guide-feats {
  list-style: none; margin: 34px 0 0; padding: 0;
  display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 270px), 1fr));
}
.guide-feats li {
  padding: 20px 22px 22px;
  border: 1px solid var(--line2); border-radius: var(--radius-lg);
  background: var(--surface);
}
.guide-feats b { display: block; font-size: 1rem; font-weight: 800; color: var(--hi); margin-bottom: 6px; }
.guide-feats span { display: block; font-size: .92rem; line-height: 1.9; color: var(--mid); }

.guide-need { margin-top: 40px; }
.guide-need ul { margin: 14px 0 0; padding-left: 1.2rem; max-width: 68ch; }
.guide-need li { font-size: .95rem; line-height: 1.95; color: var(--mid); }

/* Steps beside the thing that belongs to them — the video, the download, the
   caveat. One column under 960px, where two would leave both too narrow to
   read; the aside then reads as a footnote to the steps, which is what it is. */
.guide-split {
  margin-top: 34px; display: grid; align-items: start;
  grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
  gap: clamp(28px, 4cqi, 70px);
}
/* `minmax(0, …)` for the reason spelled out beside `.craft-list li`: a bare
   `1fr` is floored at its contents' min-content width, and this column holds a
   <video>, whose min-content width is the pixel width of the recording. A
   302px screen capture in a 320px column therefore made the column 350px
   wide. */
@media (max-width: 960px) { .guide-split { grid-template-columns: minmax(0, 1fr); } }
.guide-split .craft-list { margin-top: 0; }
.guide-aside {
  padding: 22px 24px;
  border: 1px solid var(--line2); border-radius: var(--radius-lg);
  background: var(--surface-2);
}
.guide-aside .btn { margin-bottom: 16px; }
.guide-aside .small { max-width: 46ch; }
.guide-aside a { color: var(--hi); }

/* The sub-points and caveats inside a numbered step. `.fine` is quieter than
   the step's own text on purpose: it is the sentence you read only after the
   step has gone wrong. */
.guide-sub { margin: 12px 0 0; padding-left: 1.1rem; max-width: 72ch; }
/* Undoing `.craft-list li`, which is a two-column grid with a 38px gutter for
   the step number — inherited by these sub-items, because they are inside a
   `.craft-list` too. The first `<b>` landed in the 38px column and wrapped one
   word per line. A `>` on the outer rule would have been the other fix; this
   one is scoped to the page that has the problem. */
.guide-sub li {
  display: list-item; padding: 0; border: 0; gap: 0;
  font-size: .92rem; line-height: 1.95; color: var(--mid);
}
.guide-sub b { display: inline; font-size: inherit; margin: 0; }
/* The measure, held on the text rather than on the section — a step whose
   sentence runs the full 1,180px column is unreadable however wide the page
   wants to be. */
.guide-use > li > div > span { max-width: 76ch; }
.guide-use .fine { margin-top: 12px; font-size: .86rem; color: var(--low); }
.guide-zip { margin-top: 40px; max-width: 72ch; }
.guide-zip .eyebrow { margin-bottom: 10px; }

/* Troubleshooting. A description list rather than headings, because each entry
   is a symptom somebody is scanning for — the answer is only read once its
   symptom matches. Two columns where there is room, because six symptoms in one
   column is a page of scrolling to find the one that matches. */
.guide-faq {
  margin: 34px 0 0;
  display: grid; gap: 22px 40px;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 330px), 1fr));
}
.guide-faq dt { font-size: 1rem; font-weight: 800; color: var(--hi); line-height: 1.7; }
.guide-faq dd {
  margin: 8px 0 0; padding-left: 14px;
  border-left: 2px solid var(--line2);
  font-size: .92rem; line-height: 1.95; color: var(--mid);
}

/* The optional help video. Ours, played in place — see
   `templates/extension/_video.html` for why nothing is embedded from anywhere.

   Sized to the column it sits in rather than given a fixed height: a screen
   recording of a browser panel is portrait on one machine and landscape on
   another, and `aspect-ratio` on the element would letterbox whichever one the
   operator did not shoot. */
.guide-video { margin: 0 0 18px; }
.guide-video-el {
  /* The other half of the same fix: a replaced element contributes its own
     intrinsic width as its minimum unless it is told it may be smaller, and
     `width: 100%` does not tell it that. */
  min-width: 0;
  display: block; width: 100%; height: auto; max-height: 62vh;
  background: var(--ink2, #000);
  border: 1px solid var(--line2); border-radius: var(--radius-lg);
}
.guide-video figcaption {
  margin-top: 8px; font-size: .82rem; line-height: 1.7; color: var(--low);
}
/* Beside a section rather than inside the aside — "Using it" plays its video
   above the steps, where it is the first thing on the section. */
.guide .ext-sec > .wrap > .guide-video { margin: 30px 0 0; max-width: 620px; }

/* ---------- staff: the site settings screen ----------

   Two columns of switches, and each field wrapped in its own cell before it
   gets here — a checkbox renders as `.check` plus a sibling `.help`, and left
   loose in a grid the explanation lands in the next column, next to a switch it
   is not about. `align-items: start` keeps a short switch from stretching to
   the height of a paragraph beside it. */
.cfg-grid {
  display: grid; gap: 1rem 1.6rem; align-items: start;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
}
.cfg-grid > div { min-width: 0; }

/* ---------- staff: the closed betas ----------

   The one list on that screen that is not a table: `*Beta` groups nothing asks
   about, which is a footnote rather than a record set. A bare `<ul>` carries
   the browser's bullets and its 40px indent, neither of which any other list in
   this product uses. */
.beta-orphans { list-style: none; margin: 0; padding: 0; display: grid; gap: .3rem; }
.beta-orphans li { color: var(--text-muted); font-size: .88rem; }

/* Every text input in this product is `width: 100%`, which is right in a form
   where each field owns its line and wrong in a row where a button sits beside
   it — at full width it takes the whole line and the button wraps under it. The
   field keeps a floor wide enough for an email address and gives back the rest. */
/* The form is itself a flex item in the row it shares with "End the beta", so
   it has to be told to take the space before it has any to hand on. Without
   this it shrinks to its contents, the field cannot reach its own floor, and
   the button wraps under it anyway. */
.beta-add { flex: 1 1 22rem; }
.beta-add > input[type="email"] { width: auto; flex: 1 1 14rem; max-width: 26rem; }


/* ═══════════════════════════════════════════════════════════════════════════
   THE PHONE

   One section, at the foot of the file, and that position is load-bearing:
   almost everything here overrides a rule written for a desktop console, and
   CSS between two selectors of equal weight is decided by source order. Putting
   it last means each rule can stay as specific as the one it answers, without a
   chain of parent classes bolted on to win an argument.

   Three widths are used and they mean different things:

     ≤ 980px   the console's two-column page layouts fold to one. Already the
               case for several screens; the rules here are the ones that were
               missed.
     ≤ 860px   the sidebar stops being furniture and becomes a drawer. This is
               where the shell changes shape, so it is where the mobile bar
               appears and the topbar stops being sticky.
     ≤ 640px   a phone held in one hand. Tables give up being tables, buttons
               go full width, and anything that was two things side by side is
               one thing above another.

   `dvh` and not `vh` for anything full-height: mobile Safari's `vh` is measured
   against the viewport with the URL bar *hidden*, so a `100vh` drawer is taller
   than the screen and its last entry — Sign out — sits under the browser
   chrome, unreachable, until the page is scrolled, which a fixed element does
   not do.
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── The drawer ─────────────────────────────────────────────────────────────
   Above the breakpoint none of this exists: the toggle, the bar and the scrim
   are all `display: none`, and the sidebar keeps the layout it has had all
   along. Stated here as the default so the desktop console is never a special
   case that has to be restored. */
.drawer-toggle, .mobilebar, .drawer-scrim { display: none; }

/* Three bars, one element and two pseudo-elements, shared by two different
   buttons at two different breakpoints: the console drawer's `.drawer-button`
   (≤860px, below) and the public header's `.hdr-burger` (≤1350px). Unconditional
   on purpose — nested inside either menu's own media query, it only drew for
   that one menu, and the other's button rendered as an empty box with nothing
   inside it (Top hit this on the header 23 ส.ค., where the button shows down to
   1350px but the bars only existed down to 860px). The shape has no reason to
   be viewport-gated at all; each button's own display rule already decides
   when it is visible. */
.drawer-bars, .drawer-bars::before, .drawer-bars::after {
  content: ""; display: block; width: 19px; height: 2px; border-radius: 2px;
  background: currentColor;
}
.drawer-bars { position: relative; }
.drawer-bars::before { position: absolute; top: -6px; }
.drawer-bars::after { position: absolute; top: 6px; }

@media (max-width: 860px) {
  /* `:has(> .drawer-toggle)`, and it is not decoration.

     **The staff console is drawn on this same shell.** `templates/staff/base.html`
     opens with its own `.shell` holding its own `.sidebar`, `.topbar` and
     `.content` — the classes are shared, the template is not. It has no drawer
     in it, so every rule below that changes the sidebar's shape has to ask
     whether there is a drawer to change it into. Unscoped, the staff sidebar
     was pinned off-canvas and hidden on a phone with nothing anywhere to open
     it: the whole staff navigation, gone, on a screen narrower than 860px.

     What staff gets instead is the strip it has always had — restored verbatim
     at the foot of this block, under the mirror of this selector. Nothing about
     that console changes here. Wiring the drawer into it is four lines in its
     own template on the day somebody wants it.

     One column, and its rows named: `.shell` is `min-height: 100vh`, and a grid
     with auto rows shares the leftover height out between them — so on a short
     page (the orders list with nothing bought yet, an empty library) the bar and
     the page each took half the screen, and the mark sat in the middle of a
     340px-tall header. Above the breakpoint the grid has one row and the
     question never came up. */
  .shell:has(> .drawer-toggle) { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }

  /* Clipped, never `display: none`. A hidden input takes no focus, and this one
     is the whole keyboard route into the menu when the script has not loaded. */
  .drawer-toggle {
    display: block; position: absolute; width: 1px; height: 1px;
    clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap;
  }

  .mobilebar {
    display: flex; align-items: center; gap: .75rem;
    position: sticky; top: 0; z-index: 45;
    padding: .35rem .6rem; min-height: 3.25rem;
    background: color-mix(in srgb, var(--bg) 92%, transparent);
    backdrop-filter: blur(8px);
    border-bottom: 1px solid var(--border);
  }
  /* The mark only, and centred on the bar's own baseline: `_brand.html` draws
     it at 66px with negative margins that pull its layout box back to ~30px,
     and the glow is allowed to overhang because the pixels around it are
     transparent. */
  .mobilebar-brand { display: flex; align-items: center; }
  .mobilebar-brand .brand-lockup { display: block; }

  /* 44px square, which is the smallest target a thumb hits reliably. The three
     bars are one element and two pseudo-elements rather than an SVG or a glyph:
     it is a shape, not a letter, and at this size a font's rendering of ☰ is a
     different weight on every platform. */
  .drawer-button {
    flex: none; display: grid; place-items: center;
    width: 44px; height: 44px; margin: 0; border-radius: var(--radius);
    cursor: pointer; color: var(--text); -webkit-tap-highlight-color: transparent;
  }
  .drawer-button:active { background: var(--surface-2); }
  /* The focus ring belongs on the thing that looks like the control, not on the
     clipped input that actually holds the focus. */
  .shell:has(.drawer-toggle:focus-visible) .drawer-button {
    outline: 2px solid var(--accent); outline-offset: 2px;
  }

  /* Pushed to the far edge, so the bar reads left to right as: the way in, the
     way home, what is left to spend. */
  .mobilebar-credits {
    flex: none; margin-inline-start: auto; display: flex; align-items: center; gap: .3rem;
    padding: .4rem .6rem; min-height: 36px; border-radius: 999px;
    background: var(--surface-2); border: 1px solid var(--border);
    color: var(--credit); text-decoration: none;
    font-family: var(--mono); font-weight: 700; font-size: .85rem;
    font-variant-numeric: tabular-nums;
  }
  .mobilebar-credits .glyph { width: 15px; height: 15px; }

  /* Off-canvas, and off by a transform rather than by `left`: a transform is
     composited, so the slide does not relayout the document behind it on a
     phone that is already busy decoding page images. `visibility` rides along
     so a closed drawer is out of the tab order and out of the accessibility
     tree — an off-screen menu that can still be tabbed into is a keyboard trap
     with no visible cursor. */
  .shell:has(> .drawer-toggle) > .sidebar {
    position: fixed; inset-block: 0; inset-inline-start: 0; z-index: 60;
    width: min(19rem, 84vw); height: 100dvh; max-height: 100dvh;
    flex-direction: column; align-items: stretch; gap: 1.1rem;
    overflow-y: auto; overscroll-behavior: contain;
    padding: 1rem .8rem calc(1rem + env(safe-area-inset-bottom));
    border-right: 1px solid var(--border);
    transform: translateX(-100%); visibility: hidden;
    transition: transform .22s ease, visibility .22s;
  }
  .drawer-toggle:checked ~ .sidebar { transform: none; visibility: visible; }

  .drawer-scrim {
    display: block; position: fixed; inset: 0; z-index: 55;
    background: rgb(0 0 0 / .5);
    opacity: 0; pointer-events: none; transition: opacity .22s ease;
  }
  .drawer-toggle:checked ~ .drawer-scrim { opacity: 1; pointer-events: auto; }

  /* The page underneath must not scroll while the drawer is over it — on a
     touch screen the drag that closes a menu and the drag that scrolls the page
     are the same gesture, and without this the article slides about behind a
     menu somebody is trying to dismiss. `:has()` on the root so it costs no
     script; `overscroll-behavior` above stops the drawer's own scroll from
     chaining out to the document once it reaches its end. */
  html:has(.drawer-toggle:checked) { overflow: hidden; }

  @media (prefers-reduced-motion: reduce) {
    .sidebar, .drawer-scrim { transition: none; }
  }

  /* Inside the drawer there is room for the name. `collapse` is still asked for
     by the include because the landing header shares that flag and still needs
     the compact mark at this width — see the note beside those two rules. */
  .shell:has(> .drawer-toggle) .brand-collapses .brand-full { display: flex; }
  .shell:has(> .drawer-toggle) .brand-collapses .brand-min { display: none; }

  /* The drawer is as tall as the screen, so the account chip is back at the
     bottom of a column and its menu opens upward again, exactly as on the
     desktop console. Nothing to override. */
  .shell:has(> .drawer-toggle) .nav a { min-height: 42px; }
  .shell:has(> .drawer-toggle) .user-chip { min-height: 46px; }

  /* The page's own bar stops being sticky: two stacked sticky headers cost
     ~7rem of a 844px screen before a word of the page is drawn, and this is the
     one of the two that can be scrolled away — the drawer button and the credit
     figure are what have to stay in reach. */
  .shell:has(> .drawer-toggle) .topbar {
    position: static; backdrop-filter: none; background: transparent;
    flex-wrap: wrap; align-items: flex-start; gap: .75rem;
    padding: .9rem 1rem; min-height: 0;
  }
  /* `flex-basis` is measured along the container's main axis, so this number
     only means "12rem of width" while the topbar is still a row. Below 640 it
     becomes a column and the same declaration reads as 12rem of *height* — 192
     blank pixels between the page title and its buttons. The column case resets
     it; see the rule under the next breakpoint. */
  .shell:has(> .drawer-toggle) .topbar > div:first-child { min-width: 0; flex: 1 1 12rem; }
  .shell:has(> .drawer-toggle) .topbar .page-title { font-size: 1.15rem; }
  .shell:has(> .drawer-toggle) .topbar .row { flex-wrap: wrap; }

  .content { padding: 1rem; }

  /* Anything pinned under the topbar was pinned to a bar that no longer holds
     still. A summary card that follows the reader down a phone screen is a card
     covering the form it summarises. */
  .jobnew-sum { position: static; }

  /* The editor claims the viewport on a desktop — `height: 100vh` on the shell,
     and the canvas scrolling inside itself. On a phone there is no viewport to
     divide: the rail, the canvas and the inspector are stacked by the editor's
     own ≤960px rules and the whole thing is read by scrolling the document, so
     the height cap has to come off or everything below the canvas is unreachable. */
  .shell:has(.content.bleed) { height: auto; }
  .content.bleed { min-height: 0; }

  /* ── The staff console keeps its strip ────────────────────────────────────
     Verbatim, from the block this section replaced. It is not a good layout —
     it is the layout that console has had all along, and changing it is work
     the operator did not ask for on a screen they do not use. The customer's
     shell is the one that got the drawer; this is what stops that being a
     change to a console it was never aimed at.

     `:not(:has(> .drawer-toggle))` rather than a `.staff` class: what decides
     is whether this shell has a drawer to open, which is a fact about the
     markup rather than about who is looking at it. Add the toggle to
     `staff/base.html` and this block stops applying to it on the same commit. */
  .shell:not(:has(> .drawer-toggle)) { grid-template-columns: 1fr; }
  .shell:not(:has(> .drawer-toggle)) > .sidebar {
    position: sticky; top: 0; z-index: 40; height: auto;
    flex-direction: row; align-items: center; overflow-x: auto; gap: .8rem;
    border-right: 0; border-bottom: 1px solid var(--line);
  }
  .shell:not(:has(> .drawer-toggle)) .nav { flex-direction: row; }
  .shell:not(:has(> .drawer-toggle)) .nav a { white-space: nowrap; }
  .shell:not(:has(> .drawer-toggle)) .nav a.nav-action { margin-bottom: 0; white-space: nowrap; }
  .shell:not(:has(> .drawer-toggle)) .sidebar-foot { margin: 0; flex-direction: row; align-items: center; }
  /* The sidebar is a top strip here, so the menu drops down instead of up and
     stops stretching to a chip that is now only as wide as its contents. */
  .shell:not(:has(> .drawer-toggle)) .user-menu-list { top: calc(100% + .4rem); bottom: auto; left: auto; right: 0; min-width: 11rem; }
  .shell:not(:has(> .drawer-toggle)) .brand-sub { display: none; }
  .shell:not(:has(> .drawer-toggle)) .user-chip .meta { display: none; }

  /* The zoom bar is a pill centred over the canvas: five controls in a row that
     refuses to wrap, ~470px of them. Centred on a 390px canvas that puts the
     minus button 40px off one side and the compare toggle off the other, both
     of them unreachable — a floating element has nothing to scroll.

     It stops floating and becomes the strip under the page, where it can wrap
     onto a second line and every control is on screen. Still one row of
     controls in the same order; it has simply stopped being centred on
     something narrower than it is. */
  .editor-zoom {
    position: static; transform: none;
    flex-wrap: wrap; justify-content: center; row-gap: .35rem;
    margin: .5rem .6rem 0; border-radius: var(--radius-lg);
    white-space: normal;
  }
  /* A separator between two controls that are now on different lines is a mark
     floating in the middle of a row. */
  .editor-sep { display: none; }
}

/* ── One column ─────────────────────────────────────────────────────────────
   Grids that were written with a second column that no longer fits. Each of
   these had a breakpoint of its own or none at all; they are gathered here so
   the answer to "what does this screen do on a tablet" is in one place. */
@media (max-width: 980px) {
  /* `minmax(0, 1fr)` and not `1fr`, for the third time in this file and the same
     reason each time — a track's automatic minimum is its contents' min-content
     width, so a single-column fold is only as narrow as the widest thing in it.
     The dashboard's recent-jobs card holds a table: its min-content is the sum
     of the columns' longest unbreakable words, 314px, and at 320px the card
     therefore hung 8px off the side of the screen. The table already scrolls
     inside its own wrapper; it just needed to be allowed to. */
  .buy-cols, .dash-cols, .jobnew-cols, .job-grid, .ordv2 { grid-template-columns: minmax(0, 1fr); }
  .job-side { position: static; }
}

/* ── The public header, signed in ───────────────────────────────────────────
   The bar on the landing page and the four other pages a visitor can read
   without an account. It was measured signed *out*, and signed in it carries
   one thing more: the credit chip, between the language switch and the button.
   That took it 11px past a 390px viewport and 26px past a 360px one — and a
   header that overflows scrolls the whole page sideways with it, so the hero,
   the price grid and the footer all shifted too.

   The chip stays: a signed-in visitor who lands here is more likely to be
   checking the balance than anything else on the row. What goes is the parts of
   it that repeat something already on screen — the word "Credits" under a
   figure already wearing the credit gem, and a "+" that means the same as the
   link the whole chip already is — and a few pixels of padding either side of
   the language switch. That is 56px on a row that needed 26.

   It has to be said here rather than beside the other 560px rules: `.hc-plus`
   is given `display: grid` further down the file, and between two rules of
   equal weight the later one wins. The last word on the subject belongs to the
   narrower screen.

   `flex-wrap` is the backstop, not the plan. Nothing on this row can be
   guaranteed to fit at every width in every language — a translation is longer
   than the string it replaces often enough — so if it ever does not, the
   controls drop to a second line instead of taking the page sideways. */
@media (max-width: 560px) {
  .hdr { flex-wrap: wrap; }
  /* `.hdr .sp` is `flex: 1`, which claims every spare pixel of the row — and a
     flex item that has taken the whole row is a flex item that pushes the next
     one onto the following line the moment wrapping is allowed. It is a spacer,
     so the margin does the same job without being an item that can be wrapped
     against. */
  .hdr .sp { display: none; }
  .ctrls { flex-wrap: wrap; justify-content: flex-end; margin-inline-start: auto; }
  .hdr-credits .hc-num small, .hdr-credits .hc-plus { display: none; }
  .ctrls .hdr-credits { gap: 6px; padding-inline: 9px; }
  .ctrls .lang-option { padding-inline: 10px; }
}

/* ── A phone in one hand ────────────────────────────────────────────────── */
@media (max-width: 640px) {
  /* 24px of padding either side is 15% of a 320px screen, and the card holds a
     video and a full-width button that both want every pixel of the column. */
  .guide-aside { padding: 18px 14px; }
  /* `.btn` is `white-space: nowrap`, which is right for a button sitting in a
     row of them — a two-line label there makes the whole row taller. A
     full-width button is the opposite case: it already has the row to itself,
     and refusing to wrap only means running off the side of the page. The
     download button's Thai label is 297px of unbreakable text, which is what it
     was doing at 320px. */
  .btn.full, .btn-get { white-space: normal; overflow-wrap: anywhere; }

  /* A button beside a heading is one thing; three buttons beside a heading on a
     390px screen is a column of stubs each too narrow to read. Below this width
     the actions take the full row and stack, which also makes every one of them
     a full-width target. */
  .shell:has(> .drawer-toggle) .topbar { flex-direction: column; align-items: stretch; }
  .shell:has(> .drawer-toggle) .topbar > div:first-child { flex: 0 0 auto; }
  .shell:has(> .drawer-toggle) .topbar .row { width: 100%; }
  .shell:has(> .drawer-toggle) .topbar .row > .btn,
  .shell:has(> .drawer-toggle) .topbar .row > form,
  .shell:has(> .drawer-toggle) .topbar .row > form > .btn { flex: 1 1 auto; }

  /* iOS zooms the whole page when a field with a font smaller than 16px takes
     focus, and does not zoom back out. Every text control gets 16px — the
     visual size barely changes and the page stops jumping. */
  input, select, textarea { font-size: 16px; }

  /* Thumb-sized. 40px rather than the 44 the drawer button uses: these sit in
     lists and forms where the neighbours are text, and 44 everywhere makes a
     settings page a ladder. */
  .btn { min-height: 40px; }
  .btn.small { min-height: 36px; }
  .row { flex-wrap: wrap; }

  .card { padding: 1rem; }
  .content { padding: .85rem; }

  /* A `.message` used inline on a page rather than as a toast — the notice at
     the top of the Privacy and Terms pages when their controller details are
     unset, and the non-field errors above a form. Those hold a bold lead and a
     paragraph, and the flex row hands each of them a share of the width: at
     390px the lead's share is about 70px, so a five-word Thai heading sets one
     or two characters to the line and the box is four times as tall as its own
     text. The lead goes above the paragraph instead.

     The toasts are untouched, and are the reason for the `:has()`:
     `partials/_messages.html` puts a `<span>` in there, never a `<strong>`, so
     neither rule reaches them — which matters, because a wrapping toast would
     drop its ✕ onto a line of its own below the text it closes. */
  /* Half a phone screen of padding around a target that cannot be used the way
     it names: there is no dragging a folder onto anything here, so what the box
     is really offering is the button in the middle of it, and the summary card
     under it is what the reader needs next. */
  .dropzone { padding: 2.2rem 1rem; }

  .message:has(> strong) { flex-wrap: wrap; }
  .message > strong { flex: 1 0 100%; }

  /* ── Tables that stop being tables ────────────────────────────────────────
     A six-column table on a 390px screen has 65px a column, and the two ways
     out of that are both bad: let it shrink and every cell wraps to four lines
     of two words, or scroll it sideways and the reader has to drag the screen
     to find out whether an order succeeded. Neither is what a table is *for* —
     these are lists of records that happen to share a shape, and on a phone the
     useful shape is one card per record with its fields stacked.

     Opt-in, by `class="fold-rows"` on the table. Not `stack`, which this file
     already uses for a column of cards, and a table wearing it would have been
     laid out as a flex column at every width. `_page_table.html` deliberately
     does not take it: that one is genuinely tabular — a page number, a size,
     dimensions, a checkbox — read down its columns rather than across its rows,
     and it already offers a grid of thumbnails as the other way to look at it.

     The label on each row comes from `data-label` on the cell, written into the
     template beside the `<th>` it repeats. Not read out of `<thead>` by script:
     the labels ARE the row on a phone, and a list of unlabelled values is not
     something to leave depending on a file that may not load. */
  table.fold-rows, table.fold-rows tbody, table.fold-rows tr, table.fold-rows td { display: block; }
  /* The header row still exists for a screen reader — the cells' own labels are
     what a sighted reader gets, and dropping the <thead> outright would take the
     column names out of the accessibility tree as well. */
  table.fold-rows thead {
    position: absolute; width: 1px; height: 1px;
    clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap;
  }
  table.fold-rows tr {
    padding: .7rem .85rem; margin-bottom: .55rem;
    border: 1px solid var(--border); border-radius: var(--radius);
    background: var(--surface);
  }
  table.fold-rows tr:last-child { margin-bottom: 0; }
  /* Label on the left, value on the right, and the value is whatever the cell
     holds — which may be one string or three buttons. The cell is the flex
     container, so every child is an item: `justify-content: space-between`
     spread a chapter's ZIP, CBZ and PDF buttons across the full width with the
     label stranded at one end. `margin-inline-end: auto` on the label does the
     separating instead, and everything after it stays grouped at the right in
     the order it was written. */
  table.fold-rows td {
    display: flex; flex-wrap: wrap; gap: .35rem .5rem; align-items: baseline;
    justify-content: flex-end;
    width: auto; padding: .28rem 0; border: 0; white-space: normal;
  }
  table.fold-rows td::before {
    content: attr(data-label);
    flex: none; margin-inline-end: auto; padding-inline-end: .4rem;
    color: var(--text-faint); font-size: .76rem; font-weight: 550;
  }
  /* An unlabelled cell is the actions column, or a row spanning the table to say
     the list is empty. Both want the whole width and no label beside them — and
     the actions want a rule above them, because a row of buttons hard against
     the last field reads as another field. `.text-right` on the cell still
     decides which end they sit at, which is why nothing here sets `text-align`. */
  table.fold-rows td:not([data-label]) { display: block; }
  table.fold-rows td:not([data-label])::before { content: none; }
  table.fold-rows td[data-label] + td:not([data-label]) {
    margin-top: .5rem; padding-top: .6rem; border-top: 1px solid var(--border);
  }
  table.fold-rows td .btn { min-height: 34px; }
  /* The value side, which is the half that has to be able to wrap. */
  table.fold-rows td > * { min-width: 0; }
  /* The wrapper was a scroller with a border, and the rows now carry their own. */
  .table-wrap:has(table.fold-rows) { overflow-x: visible; border: 0; border-radius: 0; }
  /* A stacked row is a card already, so the card the table sits in becomes a
     second border a padding's width outside the first — two nested boxes around
     every row of a list. The wrapper keeps its heading and its margin and gives
     up the rest.

     Scoped to a card whose table is its *only* content: several screens put a
     stacked table under a paragraph or beside a summary, and those cards are
     holding more than the list. */
  .card:has(> .table-wrap > table.fold-rows) {
    padding: 0; background: transparent; border: 0; border-radius: 0;
  }
  .card:has(> .table-wrap > table.fold-rows) > .card-head { padding: 0 .15rem; }

  /* The jobs list keeps its own row language — the name is the row's title
     rather than one field of six, and the progress bar spans the card. */
  .jobs-table.fold-rows td.jl-name { display: block; padding-bottom: .5rem; }
  .jobs-table.fold-rows td.jl-name::before { content: none; }
  .jobs-table.fold-rows td.jl-name a { font-size: 1rem; }
  .jobs-table.fold-rows tr.is-draft td { opacity: 1; }
  .jobs-table.fold-rows tr.is-draft { opacity: .55; }
  .jobs-table.fold-rows .jrun { justify-content: flex-end; }
}

/* ---------- the consent bar ----------

   Fixed, and declared as such in `core/test_messages_can_be_dismissed.py`: it
   is pinned over the page because a question nobody scrolls to is a question
   answered by nobody, and it has a way out — two of them, and either one makes
   it never come back.

   Bottom-left, so it does not sit on top of `.messages` in the opposite corner:
   a refusal to start a job and a consent question fighting for the same 340px
   is the state where the reader loses the one that mattered. Below it in
   `z-index` too, for the same reason. */
.consent-bar {
  position: fixed; z-index: 55;
  left: 1rem; bottom: 1rem;
  width: min(30rem, calc(100vw - 2rem));
  display: flex; flex-direction: column; gap: .7rem;
  padding: .9rem 1rem;
  background: var(--surface-2); color: var(--text);
  border: 1px solid var(--border); border-radius: var(--radius);
  box-shadow: 0 10px 30px rgb(0 0 0 / .28);
  font-size: .85rem; line-height: 1.6;
}
.consent-bar p { margin: 0; color: var(--text-muted); }
.consent-bar a { color: var(--text); }
.consent-actions { display: flex; gap: .5rem; justify-content: flex-end; }

/* On a phone the two corners are one corner. The bar goes full width along the
   bottom and the toasts keep their place above it. */
@media (max-width: 640px) {
  .consent-bar { left: .5rem; right: .5rem; bottom: .5rem; width: auto; }
}

/* The illustration on a "how it works" section. Width-led and with its own
   dimensions in the markup, so the paragraph under it does not jump when the
   picture finally arrives — the page is written to read with no figures at all
   and gains them one at a time. */
.how-figure {
  display: block; width: 100%; height: auto; max-width: 900px;
  margin: 18px 0 22px;
  border: 1px solid var(--line2, var(--border)); border-radius: var(--radius-lg, 12px);
}

/* ---------- the diagrams on "how it works" ----------

   Inline SVG rather than pictures, and the reason is written in the partials:
   every real screenshot of a cut balloon is somebody else's comic. Colours live
   here rather than on the elements so both themes are served by one drawing —
   presentation attributes are allowed under the CSP, `style=""` is not. */
.fig { display: block; width: 100%; height: auto; max-width: 900px; margin: 18px 0 24px; }
.fig-page { fill: var(--surface-2); stroke: var(--border); stroke-width: 1.5; }
.fig-page.fig-band { fill: var(--accent-soft); stroke: var(--accent); }
.fig-page.fig-hi { fill: var(--accent-soft); stroke: var(--accent); stroke-width: 2; }
.fig-page.fig-faint { opacity: .38; stroke-dasharray: 4 4; }
.fig-bubble { fill: var(--surface); stroke: var(--text-muted); stroke-width: 1.5; }
.fig-bubble.fig-whole { stroke: var(--accent); }
.fig-line { stroke: var(--text-muted); stroke-width: 3; stroke-linecap: round; }
.fig-line.fig-out { stroke: var(--accent); }
.fig-cut { stroke: var(--danger); stroke-width: 2; stroke-dasharray: 7 5; }
.fig-arrow { stroke: var(--text-faint); stroke-width: 2; fill: none; stroke-linecap: round; stroke-linejoin: round; }
.fig-label { fill: var(--text-faint); font-size: 15px; font-family: inherit; }
.fig-label.fig-strong { fill: var(--text); font-weight: 600; }
.fig-label.fig-faint-t { opacity: .55; }

/* The entry for the page you are actually on.
   Hover already turned an entry red, which answers "what am I about to click"
   and never answered "where am I" — the bar went uniform the moment the pointer
   left. The current entry keeps the underline and the brighter ink, and carries
   the weight as well so it is still legible while a neighbour is hovered. */
.hdr nav a[aria-current="page"] { color: var(--hi); border-color: var(--verm); font-weight: 700; }
