
/* ════ AI Film & MV Studio — cinematic dark design system (Claude Design handoff) ════ */
:root {
  /* canonical handoff tokens */
  --bg: #0a0a0c;
  --bg2: #0e0e11;
  --surface: #151518;
  --surface2: #1c1c20;
  --surface3: #27272d;
  --line: rgba(255, 255, 255, 0.07);
  --line2: rgba(255, 255, 255, 0.13);
  --text: #f3f1ec;
  --muted: #a3a097;
  --faint: #8f8d85;
  --accent: #e3a55f;
  --accent-soft: rgba(227, 165, 95, 0.13);
  --on-accent: #1a1206;
  --ok: #67c094;
  --bad: #e0705f;
  /* Error/danger alias resolves to the THEMED --bad. A var() nested inside a custom property
     resolves at the point of use, so light theme's darker --bad (#b23528, AA-tuned) wins there.
     This replaces the scattered hardcoded var(--danger, #e0564f) style fallbacks that were
     never theme-aware and dropped under 4.5:1 on the light surface. */
  --danger: var(--bad);
  --danger-soft: var(--bad-soft);
  /* fonts */
  --font-display: "Newsreader", Georgia, "Times New Roman", serif;
  --font-ui: "Plus Jakarta Sans", system-ui, "Segoe UI", sans-serif;
  --font-mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", monospace;
  /* aliases so pre-migration page CSS keeps resolving (old name → handoff value) */
  --bg-deep: #060709;
  --panel: var(--surface);
  --panel-elevated: var(--surface2);
  --surface-2: var(--surface2);
  --surface-3: var(--surface3);
  --input-bg: var(--bg2);
  --border: var(--line);
  --border-strong: var(--line2);
  --text-faint: var(--faint);
  --text-muted: var(--muted);
  --accent-hover: #ecbd84;
  --accent-press: #d4914a;
  --accent-glow: rgba(227, 165, 95, 0.2);
  --accent-ring: rgba(227, 165, 95, 0.42);
  --accent-shadow: rgba(227, 165, 95, 0.3);
  --ok-soft: rgba(103, 192, 148, 0.14);
  --warn: #e3a55f;
  --warn-soft: rgba(227, 165, 95, 0.14);
  --bad-soft: rgba(224, 112, 95, 0.14);
  --scrim: rgba(4, 5, 8, 0.72);
  --bg-grad-1: #131318;
  --bg-grad-2: rgba(227, 165, 95, 0.05);
  --radius-lg: 16px;
  --radius-md: 12px;
  --r-sm: 9px;
  --r-pill: 999px;
  --space-lg: 28px;
  --space-md: 20px;
  --s1: 4px; --s2: 8px; --s3: 12px; --s4: 16px; --s5: 20px; --s6: 24px; --s8: 32px; --s12: 48px;
  --dur: 0.18s; --dur-fast: 0.12s; --ease: cubic-bezier(0.2, 0.6, 0.2, 1);
  --sidebar-w: 248px; --maxw: 1480px;
  --z-nav: 40; --z-scrim: 55; --z-drawer: 60; --z-modal: 100; --z-popover: 110; --z-toast: 120;
  --grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
  --grain-vignette: rgba(0, 0, 0, 0.46);
  --sidebar-bg: rgba(13, 13, 16, 0.96);
  --topbar-bg: rgba(9, 9, 11, 0.97);
  color-scheme: dark;
}
/* Light theme — designer decision #3 token mapping (keep auto/dark/light; dark is default) */
:root[data-theme="light"] {
  --sidebar-bg: rgba(255, 255, 255, 0.95); --topbar-bg: rgba(246, 245, 242, 0.96);
  --bg: #f6f5f2; --bg2: #efece6; --surface: #ffffff; --surface2: #f3f1ec; --surface3: #e7e3db;
  --line: rgba(20, 18, 14, 0.10); --line2: rgba(20, 18, 14, 0.17);
  --text: #1b1915; --muted: #6c685f; --faint: #6f6b64;
  --accent: #91520f; --accent-soft: rgba(145, 82, 15, 0.12); --on-accent: #ffffff;
  --ok: #147349; --bad: #b23528;
  --bg-deep: #e7e3db; --accent-hover: #7f470d; --accent-press: #6e3d0b;
  --accent-glow: rgba(145, 82, 15, 0.18); --accent-ring: rgba(145, 82, 15, 0.40); --accent-shadow: rgba(145, 82, 15, 0.22);
  --ok-soft: rgba(20, 115, 73, 0.13); --warn: #91520f; --warn-soft: rgba(145, 82, 15, 0.13);
  --bad-soft: rgba(178, 53, 40, 0.12); --scrim: rgba(40, 36, 30, 0.50);
  --bg-grad-1: #ffffff; --bg-grad-2: rgba(145, 82, 15, 0.04); --grain-vignette: rgba(0, 0, 0, 0.12);
  color-scheme: light;
}
@media (prefers-color-scheme: light) {
  :root:not([data-theme]) {
    --sidebar-bg: rgba(255, 255, 255, 0.95); --topbar-bg: rgba(246, 245, 242, 0.96);
    --bg: #f6f5f2; --bg2: #efece6; --surface: #ffffff; --surface2: #f3f1ec; --surface3: #e7e3db;
    --line: rgba(20, 18, 14, 0.10); --line2: rgba(20, 18, 14, 0.17);
    --text: #1b1915; --muted: #6c685f; --faint: #6f6b64;
    --accent: #91520f; --accent-soft: rgba(145, 82, 15, 0.12); --on-accent: #ffffff;
    --ok: #147349; --bad: #b23528;
    --bg-deep: #e7e3db; --accent-hover: #7f470d; --accent-press: #6e3d0b;
    --accent-glow: rgba(145, 82, 15, 0.18); --accent-ring: rgba(145, 82, 15, 0.40);
    --ok-soft: rgba(20, 115, 73, 0.13); --warn: #91520f; --warn-soft: rgba(145, 82, 15, 0.13);
    --bad-soft: rgba(178, 53, 40, 0.12); --scrim: rgba(40, 36, 30, 0.50);
    --bg-grad-1: #ffffff; --bg-grad-2: rgba(145, 82, 15, 0.04); --grain-vignette: rgba(0, 0, 0, 0.12);
    color-scheme: light;
  }
}
* { box-sizing: border-box; }
body {
  margin: 0;
  font-family: var(--font-ui);
  background: radial-gradient(130% 90% at 50% -10%, var(--bg-grad-1) 0%, var(--bg) 46%);
  background-attachment: fixed;
  color: var(--text);
  line-height: 1.5;
  min-height: 100vh;
  min-height: 100dvh;
  -webkit-font-smoothing: antialiased;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
@keyframes scshim { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; } }
::-webkit-scrollbar { width: 11px; height: 11px; }
::-webkit-scrollbar-thumb { background: var(--line2); border-radius: 10px; border: 3px solid transparent; background-clip: padding-box; }
::-webkit-scrollbar-thumb:hover { background: var(--faint); background-clip: padding-box; }
/* handoff display/eyebrow primitives */
.dc-display { font-family: var(--font-display); font-weight: 500; letter-spacing: -0.01em; color: var(--text); margin: 0; }
.dc-eyebrow { font-family: var(--font-mono); font-weight: 600; font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--accent); margin: 0; }
.dc-mono { font-family: var(--font-mono); }
.dc-grain { position: absolute; inset: 0; pointer-events: none; background: radial-gradient(135% 108% at 50% -8%, transparent 50%, var(--grain-vignette) 100%); }
.brand {
  font-size: 17px;
  font-weight: 800;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  background: linear-gradient(135deg, var(--text) 0%, var(--accent) 100%);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  background-clip: text;
}
main { max-width: 1440px; margin: 0 auto; padding: var(--space-lg) 32px 48px; }
.eyebrow {
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--accent);
  margin: 0 0 6px;
}
.panel {
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  padding: var(--space-md);
  margin-bottom: var(--space-md);
}
.panel--flat { background: transparent; border: none; padding: 0; }
.panel--compact { padding: 16px; }
.panel h1 { font-size: 28px; font-weight: 800; margin: 0 0 8px; letter-spacing: -0.02em; }
.panel h2 { font-size: 18px; font-weight: 700; margin: 0 0 12px; }
.panel h3 { font-size: 15px; font-weight: 700; margin: 0 0 8px; }
/* Heading LEVEL is outline depth. Heading SIZE is one of the two classes below.
   The three rules above size headings by TAG (28 / 18 / 15px), and the audit of 2026-08-09
   measured what that cost. COUNTED, not composed (an earlier draft of this comment said "14
   routes plus six pages" and the test header said "21 route-instances"; neither was a count):
   walking 58 route-instances in operator mode against a stub orchestrator gives 30 broken —
   22 h1 -> h3 skips and 8 pages with no <h1> at all. Nearly every one was the same move: a panel
   wanted a 15px sub-heading, ".panel h3" is the 15px rule, so it shipped an <h3> directly under
   the page <h1>. The tag was picked for pixels, not for depth.
   So: choose the level the outline needs, then add .heading-section (what .panel h2 used to
   look like) or .heading-sub (what .panel h3 used to look like) for the look. Each is listed
   twice on purpose — the .panel-scoped copy scores (0,2,0) and so out-specifies
   ".panel h1|h2|h3" at (0,1,1); a single bare class at (0,1,0) would silently lose inside a
   panel, which is the exact trap that made <h3> look like the only way to get 15px.
   test/headingOutline.test.js walks the routes and fails on any skip, so do not "fix" a size
   by dropping a level again. */
.heading-section, .panel .heading-section { font-size: 18px; font-weight: 700; margin: 0 0 12px; letter-spacing: normal; }
.heading-sub, .panel .heading-sub { font-size: 15px; font-weight: 700; margin: 0 0 8px; letter-spacing: normal; }
.grid { display: grid; gap: 16px; }
.grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.muted { color: var(--muted); }
.badge {
  display: inline-block;
  padding: 4px 10px;
  border-radius: 999px;
  font-size: 12px;
  font-weight: 700;
  border: 1px solid var(--border);
  background: var(--surface-2);
}
.badge-ok { color: var(--ok); border-color: var(--ok); }
.badge-warn { color: var(--warn); border-color: #6a4f1d; }
.badge-bad { color: var(--bad); border-color: var(--bad); }
label { display: block; margin: 10px 0 6px; font-weight: 600; }
/* Cover every text-entry control BY CONSTRUCTION (designer #10 [9]): the old enumeration keyed on
   input[type=...], and eight product inputs ship with no type attribute at all — they got no
   border, background, radius, colour or floor (clearest on /create/asset, whose primary name field
   rendered UA-default above a correctly styled textarea in the same form). :not() covers a missing
   type and future tel/date/time without anyone remembering this rule exists.
   16px is the iOS no-auto-zoom floor — form controls NEVER go below it on touch devices. */
input:not([type=checkbox]):not([type=radio]):not([type=hidden]):not([type=submit]):not([type=button]):not([type=range]),
textarea, select {
  width: 100%;
  padding: 10px 12px;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--input-bg);
  color: var(--text);
  font-family: var(--font-ui);
  font-size: 16px;
  min-height: 44px;
}
/* THE SAME CONTROL LIST AS THE SIZING RULE ABOVE — on purpose, not by coincidence.
   It used to name only input[type=text], so number/search/email/url/password got NO focus treatment
   from here at all. That was invisible until you noticed the other half of the pattern: several
   component rules (.topbar-search input, .rail-field input, .cmp-select, …) each declared a blanket
   "outline: none", which at (0,1,1) outranks the app-wide ":focus-visible" ring at (0,1,0). Two
   halves that were each individually defensible added up to controls with no keyboard-focus
   indicator at all — the global search box in the top bar of every page, and every number field in
   a form rail (duration, speed).
   A rule may REPLACE the ring (that is what the box-shadow below is). It may not delete it. The
   blanket declarations are gone; this list is what now provides the replacement, so it has to cover
   every type the sizing rule covers or the gap reopens for the types it forgets. */
input:not([type=checkbox]):not([type=radio]):not([type=hidden]):not([type=submit]):not([type=button]):not([type=range]):focus,
textarea:focus, select:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-ring);
}
textarea { min-height: 110px; resize: vertical; }
button, .button, .btn-primary, .btn--primary {
  display: inline-block;
  padding: 10px 16px;
  border-radius: 8px;
  border: none;
  background: var(--accent);
  color: var(--on-accent);
  font-weight: 700;
  cursor: pointer;
  transition: opacity 0.15s ease, transform 0.1s ease;
}
button:hover:not(:disabled), .button:hover, .btn-primary:hover, .btn--primary:hover { opacity: 0.92; }
/* Token-based disabled so it stays legible in the light theme (opacity-only faded into the bg). */
button:disabled, .button:disabled, button.is-loading, .button.is-loading {
  background: var(--border-strong);
  color: var(--muted);
  opacity: 1;
  cursor: not-allowed;
  box-shadow: none;
}
button.is-loading, .button.is-loading { cursor: wait; }
/* Spinner on a loading button — gives clearer feedback than the disabled colour + label swap alone. */
button.is-loading::before, .button.is-loading::before {
  content: "";
  display: inline-block;
  width: 0.85em;
  height: 0.85em;
  margin-right: 0.5em;
  vertical-align: -0.12em;
  border: 2px solid currentColor;
  border-right-color: transparent;
  border-radius: 50%;
  animation: btn-spin 0.6s linear infinite;
}
@keyframes btn-spin { to { transform: rotate(360deg); } }
/* Reduced-motion: keep the loader meaningful (a frozen spinner is useless) but make it gentle. */
@media (prefers-reduced-motion: reduce) {
  button.is-loading::before, .button.is-loading::before { animation-duration: 1.6s; }
}
/* Required-field marker (rendered by labels as <span class="req">*</span>). */
.req { color: var(--bad, #e85d5d); margin-left: 2px; }
button.secondary, .button.secondary {
  background: transparent;
  border: 1px solid var(--border);
  color: var(--text);
}
/* Destructive secondary action (project delete). Tinted with the themed --bad so both themes keep
   their AA-tuned red; hover fills with the soft variant, never solid red (confirm() is the gate). */
button.secondary.btn-danger, .button.secondary.btn-danger { color: var(--bad); border-color: var(--bad); }
button.secondary.btn-danger:hover, .button.secondary.btn-danger:hover { background: var(--bad-soft); }
.tabs { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
.tab {
  padding: 8px 14px;
  border-radius: 999px;
  border: 1px solid var(--border);
  color: var(--muted);
  font-weight: 600;
}
.tab.active { background: var(--accent-soft); color: var(--text); border-color: var(--accent); }
.gallery-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 16px;
}
.gallery-card {
  border: 1px solid var(--border);
  border-radius: 12px;
  overflow: hidden;
  background: var(--surface-2);
}
/* LETTERBOX, NEVER CROP. Every thumbnail frame is a square and every thumbnail image/poster is
   contained inside it, so a 9:16 portrait or a 16:9 landscape shows its WHOLE frame with black
   bars on the two spare sides. object-fit cover used to fill the tile by cutting the subject out
   of frame — on a portrait video that meant the operator saw a slice of torso and could not tell
   one output from another. The backdrop is the deepest surface so the bars read as intentional
   matting. Enforced by test/thumbnailLetterbox.test.js.
   NOTE: no backticks in this file's CSS — it is one big JS template literal and a stray backtick
   silently ends it, which is how this very comment first broke the whole web tier. */
.gallery-card img {
  width: 100%;
  aspect-ratio: 1;
  object-fit: contain;
  display: block;
  background: var(--bg-deep);
}
.gallery-meta { padding: 12px; font-size: 13px; }
table { width: 100%; border-collapse: collapse; }
th, td {
  border-bottom: 1px solid var(--border);
  padding: 10px 8px;
  text-align: left;
  vertical-align: top;
}
.alert {
  border: 1px solid var(--bad);
  background: var(--bad-soft);
  color: var(--text);
  padding: 12px 14px;
  border-radius: 10px;
  margin-bottom: 16px;
  /* Error text can carry long unbreakable strings (URLs, raw payloads, ids) — wrap them instead of
     forcing horizontal page overflow on narrow screens. */
  overflow-wrap: anywhere;
  word-break: break-word;
}
.success {
  border: 1px solid var(--ok);
  background: var(--ok-soft);
  color: var(--text);
  padding: 12px 14px;
  border-radius: 10px;
  margin-bottom: 16px;
}
details.advanced { margin-top: 12px; }
/* Unset accent-color = the BROWSER answers with its own blue, dead center of every selected
   radio (designer #8: an off-palette dot inside an amber-bordered card). One global rule, the
   product's amber everywhere; per-widget re-declarations were deduped into this. */
input[type="radio"], input[type="checkbox"] { accent-color: var(--accent); }
/* An EMPTY preview box is 160px of dashed nothing (designer #8 measured two of them = 320px of
   a stranger's first phone screen). A box someone WROTE a message into (cr-split-empty) is not
   :empty and stays; a box JS fills on pick/hydrate reappears by itself. NOTE: :empty tolerates
   no whitespace — every render site writes the empty box inline with no gap, pinned by test. */
.upload-preview:empty, .result-preview:empty { display: none; }
.upload-preview, .result-preview {
  margin-top: 8px;
  border: 1px dashed var(--border);
  border-radius: 10px;
  min-height: 160px;
  background: var(--bg-deep);
  overflow: hidden;
}
.upload-preview img, .result-preview img, .compare-grid img {
  width: 100%;
  display: block;
  border-radius: 8px;
  object-fit: contain;
  background: var(--bg-deep);
}
.preview-empty {
  padding: 28px;
  text-align: center;
  color: var(--muted);
  font-size: 13px;
}
.preview-empty.bad { color: var(--bad); }
/* Upload progress + upload ERRORS ("Tệp quá lớn…", "Đang thu nhỏ ảnh…", "Đã chọn <tên>"), written
   by public/studio.js. The only element that carries this class is the status div in
   inputPicker.js, which also carries rail-hint — and .rail-hint sits ~1400 lines LATER at equal
   specificity (0,0,1,0), so the font-size that used to live here never once applied: the line
   rendered at .rail-hint's size, not the 13px this rule claimed. Type now comes from .rail-hint
   (12.5px / --muted) and all this rule owns is reserving the line so the form does not jump when
   a message appears. Do not "restore" a font-size here — it would lose to .rail-hint again and
   re-plant the same lie. */
.upload-status { min-height: 18px; }
.result-panel { position: sticky; top: 88px; }
.muted-panel { color: var(--muted); }
.compare-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 10px; }
.artifact-missing {
  display: grid;
  place-items: center;
  min-height: 180px;
  background: var(--surface);
  color: var(--muted);
  border: 1px dashed var(--border);
  border-radius: 8px;
  font-size: 13px;
  gap: 12px;
  padding: 24px;
}
.artifact-missing--cinematic { min-height: 280px; background: linear-gradient(160deg, #0a0d14, #12161f); }
/* Settled absence (nothing is coming, by design or because the run is over): quiet card on the
   page's own surface — no near-black slab, no pulse ring, no promise of work in flight. */
.artifact-missing--settled { min-height: 96px; }
.button-generate {
  width: 100%;
  padding: 14px 20px;
  font-size: 15px;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}
/* NOT READY — an input has not finished resolving (a Link is still downloading its clip). Grey and flat,
   deliberately unlike the accent-coloured live button AND unlike the Working state: those two mean "your
   click landed", this one means "do not click yet, the data is not here". Submitting during a link fetch
   makes the server download the same clip again and charge a second time, so it has to read as
   unavailable at a glance rather than as merely busy. Not opacity-dimmed: a washed-out accent still
   reads as the live button. */
button.is-not-ready, button.is-not-ready:hover {
  background: var(--surface-3, var(--surface-2)) !important;
  border-color: var(--border) !important;
  color: var(--muted) !important;
  cursor: not-allowed;
  box-shadow: none !important;
  opacity: 1;
}
.copy-btn {
  display: inline-flex; align-items: center; justify-content: center;
  width: 24px; height: 24px; padding: 0; margin-left: 4px;
  border: 1px solid var(--border); border-radius: var(--r-sm, 6px);
  background: var(--surface-2, transparent); color: var(--muted);
  font-size: 13px; line-height: 1; cursor: pointer; vertical-align: middle;
  transition: color .15s ease, border-color .15s ease, background .15s ease;
}
.copy-btn:hover { color: var(--text); border-color: var(--accent); background: var(--surface-3, var(--surface-2)); }
.copy-btn.copied { color: var(--ok); border-color: var(--ok); }
/* Transcript panel — readable text for STT / subtitles / text artifacts (client-loaded). */
.transcript-view {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--r-md, 10px);
  padding: 16px 18px;
  max-height: 340px;
  overflow-y: auto;
  position: relative;
  font-size: 14px;
  line-height: 1.65;
  color: var(--text);
}
.transcript-view .ts-loading { color: var(--muted); font-size: 13px; }
.transcript-view .ts-error { color: var(--bad, var(--muted)); font-size: 13px; }
.transcript-view .ts-text { white-space: pre-wrap; }
.transcript-view .ts-pre {
  white-space: pre-wrap;
  word-break: break-word;
  margin: 0;
  font-family: inherit;
}
.transcript-view .ts-segments { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 10px; }
.transcript-view .ts-segments summary {
  cursor: pointer;
  color: var(--muted);
  font-size: 12px;
  letter-spacing: 0.03em;
  text-transform: uppercase;
  user-select: none;
}
.transcript-view .ts-seg { margin-top: 6px; font-size: 13px; }
.transcript-view .ts-time { color: var(--accent); font-variant-numeric: tabular-nums; margin-right: 6px; }
.ts-download {
  display: inline-block;
  margin-top: 10px;
  font-size: 13px;
  color: var(--accent);
}
.prompt-snippet {
  margin-top: 6px;
  font-size: 12px;
  color: var(--muted);
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.gallery-actions { margin-top: 8px; font-size: 13px; }
.library-grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); margin-top: 16px; }
.library-card { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; background: var(--panel); }
.library-preview img, .library-preview video { width: 100%; aspect-ratio: 1; max-height: 220px; object-fit: contain; display: block; background: var(--bg-deep); }
.library-meta { padding: 12px; font-size: 13px; }
/* Was ".library-meta h3". Despite the generic class name this styles exactly ONE card — the
   project card on /music-video-projects (musicVideoProjectPages.js) — which is a peer of that
   page's h1. Size unchanged; the tag rename is the whole edit. */
.library-meta h2 { margin: 0 0 6px; font-size: 15px; }
.inline-filter { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; align-items: center; }
.inline-filter input, .inline-filter select { max-width: 200px; }
.mini-form { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; align-items: center; }
.mini-form input { max-width: 140px; padding: 6px 8px; font-size: 12px; }
.gallery-save { margin-top: 8px; }
.gallery-save > summary {
  cursor: pointer;
  list-style: none;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.02em;
  color: var(--muted);
  padding: 5px 10px;
  border: 1px solid var(--border);
  border-radius: 999px;
  user-select: none;
  transition: color 0.15s, border-color 0.15s;
}
.gallery-save > summary::-webkit-details-marker { display: none; }
.gallery-save > summary::before { content: "+"; font-weight: 800; }
.gallery-save[open] > summary { color: var(--text); border-color: var(--accent); margin-bottom: 8px; }
.gallery-save[open] > summary::before { content: "−"; }
.gallery-save > summary:hover { color: var(--text); }
.mono { font-family: ui-monospace, monospace; font-size: 11px; word-break: break-all; }
.pipeline-steps { line-height: 1.8; }
.pipeline-steps li { margin-bottom: 8px; }
.picker-block { margin: 12px 0; padding: 12px; border: 1px dashed var(--border); border-radius: 8px; }
/* [designer, 2026-09-09] There is deliberately NO bare element-selector video rule. The old
   test-harness-era global (width:100%, border, max-height:360px) was an invisible law every new
   video surface silently inherited — it capped the hero player inside its own aspect wrapper and
   cost an acceptance round to find. Every video sits in a container that owns its sizing
   (.hero-player, .lightbox-stage, .ht-cell, .library-preview, .gallery-card, …); a new surface
   styles its own container instead of overriding a hidden default. runHeroPlayer.test.js pins
   the absence. */
.gallery-card video { max-height: 200px; }
.upload-preview video { max-height: 180px; }
.progress-bar {
  height: 10px;
  border-radius: 999px;
  background: var(--surface-2);
  border: 1px solid var(--border);
  overflow: hidden;
  margin: 12px 0;
}
.progress-fill {
  height: 100%;
  background: linear-gradient(90deg, var(--accent-soft), var(--accent));
  transition: width 0.4s ease;
}
.progress-bar--cinematic { height: 6px; margin: 14px 0 0; }
.progress-fill--animated {
  background: linear-gradient(90deg, var(--accent-soft), var(--accent), var(--accent-hover), var(--accent));
  background-size: 200% 100%;
  animation: progress-shimmer 2.2s linear infinite;
}
@keyframes progress-shimmer {
  0% { background-position: 100% 0; }
  100% { background-position: -100% 0; }
}
.create-workspace {
  display: grid;
  grid-template-columns: minmax(0, 1.2fr) minmax(340px, 0.8fr);
  gap: 24px;
  align-items: start;
}
.create-preview-col { position: sticky; top: 96px; }
.create-controls-panel {
  background: var(--panel-elevated);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: 22px;
}
.create-subnav, .section-subnav {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
  margin-bottom: 16px;
}
.create-subnav-link, .section-subnav-link {
  padding: 8px 16px;
  border-radius: 999px;
  border: 1px solid var(--border);
  color: var(--muted);
  font-weight: 700;
  font-size: 13px;
  text-decoration: none;
}
.create-subnav-link.active, .section-subnav-link.active {
  background: var(--accent-soft);
  color: var(--text);
  border-color: var(--accent);
}
.hero-preview {
  min-height: 440px;
  border-radius: var(--radius-lg);
  border: 1px solid var(--border);
  /* Theme-adaptive: was a hardcoded dark gradient that left the empty "READY" state jarringly dark
     on the light theme. Tokens keep it dark in dark mode and soft-neutral in light mode; actual
     rendered media sits on top and fills the stage regardless. */
  background: linear-gradient(160deg, var(--surface2) 0%, var(--bg2) 45%, var(--surface3) 100%);
  overflow: hidden;
  position: relative;
  display: grid;
  place-items: center;
}
/* [#76] A skeleton that KNOWS the job's geometry takes its shape: --skeleton-aspect is a W/H
   ratio from the record (source_resolution / picked aspect). aspect-ratio + width:100% +
   max-height uses CSS transferred-size rules to fit both bounds while holding the ratio, so a
   16:9 job shows a landscape card instead of the fixed ~360x440 portrait that read as
   "processing at 9:16" (operator, job_364de244). Cards with no geometry keep the neutral shape. */
.hero-preview--shaped {
  aspect-ratio: var(--skeleton-aspect);
  width: 100%;
  min-height: 0;
  max-height: 480px;
  justify-self: center;
}
.hero-preview-glow {
  position: absolute;
  inset: 20% 15%;
  background: radial-gradient(circle, var(--accent-glow) 0%, transparent 70%);
  pointer-events: none;
}
.hero-preview-inner { position: relative; z-index: 1; text-align: center; padding: 32px; max-width: 360px; }
.hero-preview-icon { font-size: 42px; margin-bottom: 12px; opacity: 0.7; }
/* Was ".hero-preview h3". The element is a <p> now: renderPreviewPlaceholder wraps it in a
   div[role="img"], so assistive tech never announced it as a heading anyway — it was a styled
   caption wearing a heading tag, and being the FIRST thing in the create-workspace column it put
   an h3 ahead of every page title on /edit/upscale/*, /edit/photo-restore, /create/action-video,
   /create/video-to-video and /produce/music-video.
   EVERY declaration the old <h3> got for free has to be restated here, PER PROPERTY — winning the
   specificity contest against ".hero-preview p" only wins the properties actually declared:
     font-weight — the <h3> was bold from the UA stylesheet; a <p> is not.
     color       — the <h3> matched no colour rule and inherited body's var(--text). A <p> matches
                   ".hero-preview p" below and would be repainted var(--muted): dark 15.05:1 ->
                   6.50:1 on the --surface2 stop, light 14.89:1 -> 4.71:1 on --bg2, leaving a
                   20px/700 title the exact same colour as the 14px hint under it. Shipped that way
                   once; do not drop this line. */
.hero-preview .hero-preview-title { margin: 0 0 8px; font-size: 20px; font-weight: 700; color: var(--text); }
.hero-preview p { margin: 0; color: var(--muted); font-size: 14px; }
.hero-preview--rendering .render-skeleton-shimmer {
  position: absolute;
  inset: 0;
  background: linear-gradient(105deg, transparent 40%, rgba(255,255,255,0.04) 50%, transparent 60%);
  background-size: 200% 100%;
  animation: skeleton-sweep 2.5s ease-in-out infinite;
}
@keyframes skeleton-sweep {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}
.render-pulse-ring {
  width: 64px;
  height: 64px;
  margin: 0 auto 16px;
  border-radius: 50%;
  border: 2px solid var(--accent);
  animation: pulse-ring 2s ease infinite;
}
@keyframes pulse-ring {
  0%, 100% { transform: scale(1); opacity: 0.6; }
  50% { transform: scale(1.08); opacity: 1; }
}
.render-live-message { font-size: 15px; font-weight: 600; }
.starter-cards { display: grid; gap: 10px; margin-top: 16px; }
/* A starter card is a <button> — it fills the prompt box, it does not navigate (uxComponents.js
   renderStarterCards records why). The four extra declarations are the button reset: a button does
   not inherit the page font, centres its text and sizes to content, so without them the cards
   render as small centred system-font boxes instead of full-width example rows. */
.starter-card {
  display: block;
  width: 100%;
  text-align: left;
  font: inherit;
  cursor: pointer;
  padding: 12px 14px;
  border-radius: 10px;
  border: 1px solid var(--border);
  background: var(--panel);
  text-decoration: none;
  color: var(--text);
  transition: border-color 0.15s, background 0.15s;
}
.starter-card:hover { border-color: var(--accent); background: var(--panel-elevated); text-decoration: none; }
.starter-card-label { display: block; font-size: 10px; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent); margin-bottom: 4px; }
.starter-card-prompt { font-size: 13px; color: var(--muted); }
.recent-thumb {
  width: 72px;
  height: 72px;
  border-radius: 8px;
  overflow: hidden;
  border: 1px solid var(--border);
}
.recent-thumb img, .recent-thumb video { width: 100%; height: 100%; object-fit: contain; background: var(--bg-deep); }
.render-experience { margin-bottom: 24px; }
.render-experience-header {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 16px;
  margin-bottom: 20px;
  flex-wrap: wrap;
}
.render-title { font-size: 32px; font-weight: 800; margin: 0; letter-spacing: -0.03em; }
.render-hero { margin-bottom: 20px; }
.render-preview-stage {
  min-height: 480px;
  border-radius: var(--radius-lg);
  border: 1px solid var(--border);
  background: var(--bg2);
  overflow: hidden;
  display: grid;
  place-items: center;
}
.render-preview-media { width: 100%; }
.render-preview-media img, .render-preview-media video, .render-preview-media audio {
  width: 100%;
  max-height: 560px;
  object-fit: contain;
  display: block;
  background: #000;
}
.render-preview-media audio {
  max-height: none;
  padding: 24px 16px;
  background: transparent;
}
/* [design order 1] Hero PLAYER stage: the wrapper's aspect-ratio (requested aspect, corrected to
   the file's true geometry at loadedmetadata) is the geometry — the generic stage's min-height
   would re-insert the letterbox bands this exists to remove. */
.render-preview-stage--player,
.review-workspace .render-preview-stage--player,
.audit-output-col .render-preview-stage--player,
.audit-experience .render-preview-stage--player { min-height: 0; display: block; }
/* HEIGHT-capped (designer #13 ⓶): the box derives its height from its WIDTH, so a 9:16 ad on a
   wide monitor measured 1358×2414 in a 1305px viewport — 1.8 screens to see one frame, and the
   wider the screen the worse. The cap keeps one full frame on screen; contain letterboxes the
   sides, which is the letterbox law anyway. Landscape video sits under the cap untouched. */
.hero-player { width: 100%; aspect-ratio: 16 / 9; background: #000; max-height: min(78vh, 760px); }
.hero-player video { width: 100%; height: 100%; max-height: min(78vh, 760px); object-fit: contain; display: block; background: #000; }
/* Scene strip under the player — scrolls horizontally inside ITSELF, never the page. */
.scene-strip { display: flex; gap: 8px; overflow-x: auto; padding: 10px 2px 2px; position: relative; }
.scene-strip-tile {
  position: relative; flex: 0 0 auto; width: 128px; aspect-ratio: 16 / 9;
  border: 2px solid var(--border); border-radius: 8px; overflow: hidden;
  background: #000; padding: 0; cursor: pointer;
}
.scene-strip-tile img { width: 100%; height: 100%; object-fit: contain; display: block; }
.scene-strip-tile.is-current { border-color: var(--accent); }
.scene-strip-tile[disabled] { cursor: default; opacity: 0.6; }
.scene-strip-dur {
  position: absolute; right: 4px; bottom: 4px; font-size: 11px; line-height: 1;
  padding: 2px 5px; border-radius: 4px; background: rgba(0, 0, 0, 0.65); color: #fff;
}
@media (max-width: 700px) {
  /* The player reaches the content edges — the frame IS the video on a phone. */
  .render-preview-stage--player { border-left: 0; border-right: 0; border-radius: 0; }
  .scene-strip-tile { width: 104px; }
}
/* Audio result: a compact player card, NOT the tall video-shaped black box. The generic stage
   forces min-height 320–560px (fine for image/video); an audio job only needs a slim centered
   player. Override every min-height context (base + .audit-* + responsive) with a small one and a
   subtle surface so the waveform-less <audio> control reads as a proper result, not a void. */
.render-preview-stage--audio,
.audit-output-col .render-preview-stage--audio,
.audit-experience .render-preview-stage--audio,
.review-workspace .render-preview-stage--audio {
  min-height: 0;
  padding: 30px 22px;
  background: linear-gradient(160deg, var(--surface2), var(--surface));
}
.render-preview-stage--audio .render-preview-media {
  max-width: 560px;
  margin: 0 auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
}
.render-preview-stage--audio .render-preview-media audio { width: 100%; padding: 0; }
/* The ONE audio player card (server: helpers.js renderAudioPlayerCard; client: MediaViewer
   .audioCard). Job-page hero, poller-mounted hero and the lightbox stage all render this same
   markup — icon block + label + full-width native player — so audio playback has a single chrome.
   The icon is a real element, not a ::before on the stage: the lightbox has no stage--audio
   wrapper, and a pseudo-element cannot travel with the card. */
.audio-player-card { display: flex; flex-direction: column; align-items: center; gap: 14px; width: 100%; max-width: 560px; margin: 0 auto; padding: 26px 22px; border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--panel-elevated); }
.audio-player-icon { width: 58px; height: 58px; border-radius: 50%; flex: none; display: grid; place-items: center; background: var(--accent-soft); border: 1px solid var(--accent-ring); color: var(--accent); }
.audio-player-icon svg { width: 26px; height: 26px; }
.audio-player-title { margin: 0; font: 500 17px/1.3 var(--font-display); color: var(--text); text-align: center; max-width: 100%; overflow-wrap: anywhere; }
.audio-player-card audio { width: 100%; padding: 0; }
.cinematic-status-bar {
  margin-top: 16px;
  padding: 16px 18px;
  border-radius: var(--radius-md);
  border: 1px solid var(--border);
  background: var(--panel-elevated);
}
.cinematic-status-bar--live { border-color: var(--accent); box-shadow: 0 0 24px var(--accent-glow); }
.cinematic-status-message { margin: 0 0 8px; font-size: 16px; font-weight: 700; }
.cinematic-status-meta { display: flex; flex-wrap: wrap; gap: 8px; }
.status-chip {
  display: inline-block;
  padding: 4px 10px;
  border-radius: 999px;
  font-size: 11px;
  font-weight: 700;
  border: 1px solid var(--border);
  background: var(--input-bg);
  color: var(--muted);
}
.status-chip--backend { color: var(--warn); }
.stage-timeline {
  display: flex;
  align-items: center;
  gap: 0;
  margin: 20px 0;
  padding: 14px 16px;
  border-radius: var(--radius-md);
  border: 1px solid var(--border);
  background: var(--panel);
  overflow-x: auto;
  /* A scroll container must establish a containing block (see .table-wrap for the full anatomy).
     Second measured bite of that class, 2026-09-19, /jobs/:id at phone widths (operator report
     "lỗi ui width"): each stage step carries an .sr-only state word (position:absolute) inside
     .stage-label; without this line its containing block fell back to <body>, so the sr-only of a
     step sitting beyond the viewport inside this scrolled strip was laid out at x≈386 — outside
     the strip's clip — and documentElement.scrollWidth read 387 at 320 AND 375px viewports.
     Chromium-measured before/after: 320/375 light+dark all overflowed 387; with this line all
     twelve width×scheme cells came back equal to their viewport. Enforced sheet-wide by
     test/tableWrapContainsItsAbsolutes.test.js — every overflow auto/scroll rule must declare
     position. */
  position: relative;
}
.stage-step { display: flex; flex-direction: column; align-items: center; gap: 6px; min-width: 72px; }
.stage-dot {
  width: 12px;
  height: 12px;
  border-radius: 50%;
  border: 2px solid var(--border);
  background: var(--input-bg);
}
.stage-step--done .stage-dot { background: var(--ok); border-color: var(--ok); }
.stage-step--active .stage-dot {
  background: var(--accent);
  border-color: var(--accent);
  box-shadow: 0 0 12px var(--accent-glow);
  animation: pulse-ring 2s ease infinite;
}
.stage-label { font-size: 11px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
.stage-step--active .stage-label { color: var(--text); }
.stage-hint {
  font-size: 10px;
  color: var(--muted);
  text-align: center;
  max-width: 88px;
  line-height: 1.3;
  opacity: 0.85;
}
.stage-step--active .stage-hint { color: var(--accent); opacity: 1; }
.stage-step--done .stage-hint { opacity: 0.6; }
.stage-connector {
  flex: 1;
  height: 2px;
  min-width: 24px;
  background: var(--border);
  margin-bottom: 28px;
  transition: background 0.45s ease;
}
.stage-connector--done { background: linear-gradient(90deg, var(--ok), var(--accent-soft)); }
/* runStageTimeline (uxComponents) — the production-run steps in the job page's stage-dot
   vocabulary, vertical because a run step carries content (error text, per-step outputs) that the
   horizontal strip above has no room for. */
.run-stage-timeline { list-style: none; margin: 14px 0 0; padding: 0; }
.run-stage { display: flex; gap: 12px; padding: 0 0 14px; }
.run-stage-rail { display: flex; flex-direction: column; align-items: center; width: 14px; flex: none; padding-top: 4px; }
.run-stage-rail::after { content: ''; flex: 1; width: 2px; background: var(--border); margin-top: 4px; }
.run-stage:last-child .run-stage-rail::after { display: none; }
.run-stage--done .stage-dot { background: var(--ok); border-color: var(--ok); }
.run-stage--active .stage-dot {
  background: var(--accent);
  border-color: var(--accent);
  box-shadow: 0 0 12px var(--accent-glow);
  animation: pulse-ring 2s ease infinite;
}
.run-stage--failed .stage-dot { background: var(--danger, #b3453f); border-color: var(--danger, #b3453f); }
.run-stage-body { min-width: 0; flex: 1; }
.run-stage-head { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.run-stage-label { font-size: 13.5px; }
.run-stage-hint { font-size: 12px; }
.render-preview-stage {
  transition: border-color 0.35s ease, box-shadow 0.35s ease;
}
.render-preview-stage--loading { border-color: var(--accent); }
.render-preview-stage--revealed .render-preview-reveal {
  opacity: 1;
  animation: preview-fade-in 0.55s ease forwards;
}
.render-preview-reveal { opacity: 0; }
@keyframes preview-fade-in {
  from { opacity: 0; transform: scale(0.985); }
  to { opacity: 1; transform: scale(1); }
}
.cinematic-status-message--fade {
  animation: message-fade 0.28s ease;
}
@keyframes message-fade {
  from { opacity: 0.55; }
  to { opacity: 1; }
}
.badge--updated { animation: badge-pulse 0.4s ease; }
@keyframes badge-pulse {
  0%, 100% { transform: scale(1); }
  50% { transform: scale(1.04); }
}
.ux-guidance {
  margin: 12px 0;
  padding: 12px 14px;
  border-radius: var(--radius-md);
  font-size: 13px;
  line-height: 1.5;
  border: 1px solid var(--border);
  background: var(--panel-elevated);
  /* Readiness/error text may echo a long service message (URL + raw body) — wrap it so a degraded
     backend never forces horizontal page overflow. */
  overflow-wrap: anywhere;
  word-break: break-word;
}
.ux-guidance--recovery { border-color: var(--warn); color: var(--warn); }
.ux-guidance--waiting { border-color: var(--accent); color: var(--accent); }
.ux-guidance--honesty { border-color: var(--accent-ring); background: var(--accent-soft); }
.ux-guidance--blocked {
  border-color: var(--bad);
  background: rgba(255, 107, 122, 0.08);
  color: var(--text);
}
.alert--fade-in { animation: message-fade 0.35s ease; }
.review-workspace .render-preview-stage { min-height: 520px; }
.review-workspace .table-compact { font-size: 14px; }
/* ── Audit Workbench ─────────────────────────────────────────────────────── */
.audit-workbench {
  display: grid;
  /* minmax(0, 1fr), NOT bare 1fr: a bare 1fr floors at the column's min-content, so a nowrap
     value inside (audit-meta-val is nowrap+ellipsis BY DESIGN) grows the column instead of
     truncating. On a phone that pushed the whole page 46px past the viewport, Android zoomed
     out to fit, and the 100%-wide topbar visibly covered ~72% of the screen (reported
     2026-08-04 with screenshots). Zero-floor tracks let the ellipsis actually engage. */
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: 16px;
  margin-bottom: 20px;
  align-items: start;
}
.audit-input-col,
.audit-output-col {
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.audit-col-header {
  font-size: 10px;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.09em;
  color: var(--muted);
  margin: 0 0 6px;
  padding-bottom: 7px;
  border-bottom: 1px solid var(--border);
}
.audit-col-header--sub {
  font-size: 9px;
  padding-bottom: 4px;
  margin-bottom: 4px;
  border-bottom-style: dashed;
}
.audit-prompt-block {
  margin: 0;
  background: var(--input-bg);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  padding: 11px 13px;
  font-family: "Cascadia Code", "Fira Code", "Consolas", monospace;
  font-size: 12px;
  line-height: 1.65;
  color: var(--text);
  white-space: pre-wrap;
  word-break: break-word;
  max-height: 108px;
  overflow-y: auto;
  position: relative;
}
.audit-meta-grid {
  display: grid;
  /* Same zero-floor rule as .audit-workbench above — the nowrap meta value must truncate, not
     stretch its column past the viewport. */
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 1px;
  background: var(--border);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  overflow: hidden;
}
.audit-meta-cell {
  padding: 8px 11px;
  background: var(--panel-elevated);
  display: flex;
  flex-direction: column;
  gap: 3px;
}
.audit-meta-key {
  font-size: 9px;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.09em;
  color: var(--muted);
}
.audit-meta-val {
  font-size: 12px;
  font-weight: 600;
  color: var(--text);
  font-family: monospace;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.audit-input-media {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
.audit-media-item {
  flex: 1 1 120px;
  border-radius: var(--radius-md);
  overflow: hidden;
  border: 1px solid var(--border);
  background: #080a0f;
}
.audit-media-label {
  font-size: 9px;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.07em;
  color: var(--muted);
  padding: 5px 9px;
  background: var(--panel);
  border-bottom: 1px solid var(--border);
}
.audit-media-item img,
.audit-media-item video {
  width: 100%;
  display: block;
  object-fit: contain;
  background: #000;
  max-height: 176px;
}
.audit-media-item audio { width: 100%; display: block; padding: 10px; box-sizing: border-box; }
/* Multi-subject mapping: a person left untouched renders as a quiet "kept" chip in its slot so
   the input row always shows one tile per person, in order. */
.audit-media-item--kept { display: flex; flex-direction: column; }
.audit-media-kept-chip { flex: 1; display: flex; align-items: center; justify-content: center; padding: 22px 10px; font-size: 12px; font-weight: 600; color: var(--muted); }
/* Neutral fallback when the input kind can't be resolved — a link chip, never a broken player. */
.audit-file-chip { display: flex; align-items: center; justify-content: center; gap: 6px; padding: 22px 10px; font-size: 12px; font-weight: 600; color: var(--muted); text-decoration: none; }
.audit-file-chip:hover { color: var(--text); }
.audit-output-col .render-preview-stage { min-height: 320px; }
.audit-experience .render-preview-stage { min-height: 320px; }
@media (max-width: 1100px) {
  /* Match .create-workspace's collapse (1100px): below it the 248px sidebar is still docked, so a
     2-col job view would be cramped in the 901–1100 tablet band. */
  .audit-workbench { grid-template-columns: minmax(0, 1fr); } /* zero-floor: see the base rule */
}
.queue-cell--updated { animation: message-fade 0.3s ease; }
.debug-drawer {
  margin-top: 20px;
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--input-bg);
}
.debug-drawer summary {
  padding: 12px 16px;
  cursor: pointer;
  font-weight: 700;
  font-size: 13px;
  color: var(--muted);
  list-style: none;
}
.debug-drawer summary::-webkit-details-marker { display: none; }
.debug-drawer-body { padding: 0 16px 16px; }
.hub-hero {
  display: block;
  position: static;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: 22px 26px;
  margin-bottom: 28px;
  backdrop-filter: none;
  z-index: auto;
}
.hub-hero h1 { font-size: clamp(24px, 3.4vw, 34px); font-weight: 800; margin: 0 0 8px; letter-spacing: -0.03em; color: var(--text); }
.hub-subtitle { margin: 0; color: var(--muted); font-size: 16px; max-width: 640px; }
.hub-grid {
  display: grid;
  gap: 16px;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  margin-bottom: 24px;
}
.hub-card {
  display: block;
  padding: 22px;
  border-radius: var(--radius-lg);
  border: 1px solid var(--border);
  background: var(--panel);
  text-decoration: none;
  color: var(--text);
  transition: transform 0.15s, border-color 0.15s, box-shadow 0.15s;
}
.hub-card:hover {
  transform: translateY(-2px);
  border-color: var(--accent);
  box-shadow: 0 8px 32px rgba(0,0,0,0.35);
  text-decoration: none;
}
.hub-card-icon { font-size: 28px; display: block; margin-bottom: 12px; }
/* h2, not h3: a hub card is a sibling section of the page h1, and there is nothing between them
   to be an h2 (audit 2026-08-09 — /audio-studio shipped h1 then seven h3s in a row). Size is
   unchanged; the tag rename is the whole edit. */
.hub-card h2 { margin: 0 0 8px; font-size: 18px; }
.hub-card p { margin: 0; font-size: 13px; color: var(--muted); line-height: 1.5; }
.cinematic-empty { padding: 48px 24px; border-radius: var(--radius-lg); background: var(--panel); }
.cinematic-empty h3 { margin: 0 0 8px; font-size: 20px; }
.empty-samples { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin: 16px 0; }
.empty-sample {
  padding: 6px 12px;
  border-radius: 999px;
  border: 1px dashed var(--border);
  font-size: 12px;
  color: var(--muted);
}
.render-actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 16px 0; }
.render-meta-grid { margin-top: 12px; font-size: 13px; }
.render-prompt { font-size: 14px; margin-top: 12px; line-height: 1.6; }
.runtime-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
.runtime-card {
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 12px;
  background: var(--surface-2);
}
.bad-text { color: var(--bad); font-size: 12px; margin-top: 4px; }
.checkbox { display: flex; gap: 8px; align-items: center; margin: 8px 0; font-weight: 500; }
.form-actions { margin-top: 16px; }
/* [designer 2026-09-09, verified] These classes were PRINTED (createPage output-count row, the
   character-counter hints) with no CSS anywhere in the repo — the "1 · 2 · 4 ảnh" chips fell to
   three full-width blocks and hints rendered at browser-default 16px <p>. Styled with the house
   tokens; :has() carries the checked state (every shipped browser target supports it). */
.radio-chip-row { display: flex; gap: 8px; flex-wrap: wrap; }
.radio-chip {
  display: inline-flex; align-items: center; gap: 7px; padding: 7px 14px; cursor: pointer;
  border: 1px solid var(--border); border-radius: 999px; background: var(--panel);
  font-size: 13.5px; color: var(--text); user-select: none;
}
.radio-chip input { margin: 0; }
.radio-chip:hover { border-color: var(--border-strong); }
.radio-chip:has(input:checked) {
  border-color: var(--accent); background: var(--accent-soft); color: var(--accent);
}
.field-hint, .form-hint { font-size: 12px; color: var(--muted); margin: 4px 0 0; }
.inline { display: inline; }
.empty-state {
  text-align: center;
  padding: 32px 16px;
  border: 1px dashed var(--border);
  border-radius: 10px;
  color: var(--muted);
}
.empty-state .button { margin-top: 12px; }
.health-grid {
  display: grid;
  gap: 12px;
  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
  margin: 12px 0;
}
.health-card {
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 12px;
  background: var(--surface-2);
}
.health-card .label { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
.health-card .value { font-size: 18px; font-weight: 700; }
.perf-bar-wrap {
  position: relative;
  background: var(--bg2);
  border-radius: 6px;
  height: 22px;
  min-width: 80px;
  overflow: hidden;
  border: 1px solid var(--border);
}
.perf-bar {
  height: 100%;
  background: linear-gradient(90deg, var(--accent-soft), var(--accent));
  border-radius: 5px;
}
.perf-bar-label {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 11px;
  font-weight: 700;
}
.production-dashboard-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap; }
.production-stats {
  display: grid;
  gap: 10px;
  grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
  margin: 14px 0;
}
.production-stat {
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 10px;
  background: var(--surface-2);
}
.production-stat .label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
.production-stat .value { font-size: 20px; font-weight: 700; margin-top: 4px; }
.production-stat .value.ok { color: var(--ok); }
.production-stat .value.warn { color: var(--warn); }
.production-latest-thumb { max-width: 320px; margin: 12px 0; border-radius: 8px; overflow: hidden; border: 1px solid var(--border); }
.production-latest-thumb img { width: 100%; display: block; }
.scene-flow-strip {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin: 12px 0;
  align-items: center;
}
.scene-flow-node {
  border: 2px solid var(--border);
  border-radius: 8px;
  padding: 8px 12px;
  min-width: 90px;
  background: var(--surface-2);
}
.scene-flow-num { font-weight: 700; font-size: 16px; }
.scene-flow-role { font-size: 12px; }
.scene-flow-arrow { display: block; font-size: 11px; color: var(--muted); margin-top: 4px; }
.scene-flow-table { margin-top: 12px; font-size: 13px; }
.artifact-lineage {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  align-items: flex-start;
  margin: 8px 0 12px;
}
.lineage-step {
  flex: 1 1 100px;
  max-width: 140px;
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 6px;
  background: var(--bg2);
  font-size: 11px;
}
.lineage-step.done { border-color: var(--ok); }
.lineage-step.pending { opacity: 0.55; }
.lineage-label { font-weight: 700; margin-bottom: 4px; }
.lineage-media img, .lineage-media video { max-height: 72px; border-radius: 4px; }
.lineage-arrow { color: var(--muted); align-self: center; font-size: 14px; }
.timeline-strip { display: flex; flex-wrap: wrap; gap: 4px; margin: 10px 0; align-items: flex-end; }
.timeline-segment {
  height: 28px;
  border-radius: 4px;
  position: relative;
  min-width: 20px;
}
.timeline-segment-label { position: absolute; bottom: -18px; left: 2px; font-size: 10px; color: var(--muted); }
.timeline-emotions { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; margin-top: 24px; }
.timeline-emotion { padding: 4px 8px; border: 1px solid var(--border); border-radius: 6px; }
.gallery-lineage { margin: 4px 0; font-size: 12px; }
.version-history { margin: 8px 0; font-size: 13px; }
.version-history table { font-size: 12px; }
.compare-view video { max-height: 360px; }
.scene-review-card h4 { margin: 10px 0 4px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
/* The runtime-honesty label is an h2.heading-sub now, like both sibling panels in the /jobs/:id
   debug drawer — so it needs no bespoke rule here and the ".runtime-honesty-panel h3" pin that
   used to sit on this line is gone. It existed to restate the UA h4 box for an h3 that was itself
   a level skip (see the comment at renderRuntimeHonestyPanel). */
.chunk-plan-preview { margin: 10px 0; padding: 10px; border: 1px dashed var(--border); border-radius: 8px; font-size: 13px; }
.chunk-plan-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
@media (max-width: 1100px) {
  /* Collapse the create canvas to one column. minmax(0,1fr) (not bare 1fr) + min-width:0 on the
     columns lets the track shrink below the preview's intrinsic content width, otherwise a ~440px
     preview stage forces horizontal overflow on phones. */
  .create-workspace { grid-template-columns: minmax(0, 1fr); }
  .create-preview-col, .create-controls-col { min-width: 0; max-width: 100%; }
  .create-preview-col { position: static; }
  .hero-preview, .render-preview-stage { min-height: 320px; max-width: 100%; }
}
@media (max-width: 900px) {
  .studio-layout, .grid-2, .grid-3, .compare-grid { grid-template-columns: minmax(0, 1fr); }
  .result-panel { position: static; }
  main { padding: 20px 16px 32px; }
  .render-title, .hub-hero h1 { font-size: 26px; }
}
@media (min-width: 1800px) {
  .render-preview-stage { min-height: 560px; }
}
.video-process-range {
  margin: 16px 0;
  padding: 14px 16px;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--panel);
}
/* The title is an h2 now (audit 2026-08-09: "Phạm vi xử lý" was an h3 sitting directly under the
   page h1). ONE component, EIGHT call sites, TEN route-instances that render it: /edit/replace,
   /edit/replace?tab=video, /edit/motion-copy, /edit/upscale/video, /edit/cleanup, /edit/add-audio,
   /edit/transcribe, /edit/voice-over, /audio-studio/stt, /audio-studio/lipsync.
   Scoped to .video-process-range for the specificity, not for looks: as a bare class this rule
   scored (0,1,0) and LOST to ".panel h3" (0,1,1) on the THREE of those ten that nest the panel
   inside a section.panel — /edit/cleanup, /edit/add-audio, and /edit/upscale/video, whose
   section.panel.panel--flat wrapper an earlier pass missed (it claimed two, and listed
   /edit/upscale/video as unchanged). So it rendered 15px on those three and 14px on the other
   seven, and after the tag change it would have lost to ".panel h2" and jumped to 18px.
   At (0,2,0) it wins on all ten and the size is finally the one authored here — which means
   those three routes went 15px/margin-8 to 14px/margin-10; that is the intended unification, and
   the seven are unchanged. font-weight is spelled out so it no longer depends on the UA bold. */
.video-process-range .video-process-range__title { margin: 0 0 10px; font-size: 14px; font-weight: 700; }
.video-process-range__modes {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-bottom: 12px;
}
.radio-card {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 8px 12px;
  border: 1px solid var(--border);
  border-radius: 8px;
  cursor: pointer;
  font-size: 13px;
  background: var(--bg);
}
.radio-card:has(input:checked) {
  border-color: var(--accent);
  background: var(--accent-soft);
}
.video-process-range__meta {
  display: grid;
  gap: 6px;
  font-size: 13px;
}
.video-process-range__meta .meta-row {
  display: flex;
  justify-content: space-between;
  gap: 12px;
}
.help-grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); margin-top: 20px; }
.help-topic-title { font-size: 16px; font-weight: 600; margin: 0 0 6px; }
.help-topic { padding: 16px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--panel); }
.help-impact { display: grid; gap: 8px; margin: 12px 0 0; font-size: 13px; }
.help-impact dt { color: var(--muted); font-weight: 600; }
.help-impact dd { margin: 0 0 8px; }
.help-impact--detail { max-width: 640px; }
/* This IS the page title on all THREE of its call sites (renderVideoStudioPage,
   renderImageUpscalePage, renderPhotoRestorePage), which between them serve FOUR routes:
   /create/action-video, /edit/upscale/{image,video}, /edit/photo-restore. Those pages had NO
   h1 at all before 2026-08-09, so renderOperationHeading emits h1 now. The size is deliberately
   held at the 18px/700 it has always had: ".panel h1" would repaint it 28px/800 with a tighter
   letter-spacing, and this fix is about semantics, not a redesign of these four pages. If a designer
   later wants these to read as page titles, delete the pin here — do not undo the h1. */
.operation-heading { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.operation-heading, .panel .operation-heading { font-size: 18px; font-weight: 700; margin: 0 0 12px; letter-spacing: normal; }
.operation-help { display: inline-flex; gap: 6px; margin-left: 4px; flex-wrap: wrap; }
.help-chip {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 22px;
  padding: 0 8px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: var(--panel-elevated);
  color: var(--accent);
  font-size: 11px;
  font-weight: 600;
  text-decoration: none;
  line-height: 1.2;
  letter-spacing: 0.02em;
}
.help-chip__label { pointer-events: none; }
.help-chip:hover { background: var(--accent-soft); text-decoration: none; }
.settings-form { display: grid; gap: 12px; max-width: 480px; margin-top: 20px; }
.settings-form label { font-weight: 600; font-size: 13px; }
.checkbox-row { display: flex; align-items: center; gap: 8px; font-weight: 600; }
.flash--ok { color: var(--ok); margin-bottom: 12px; }
/* Terms of use — the reading page and the blocking consent card share these. The card is centred and
   narrow like the login card so it reads as a dialog, while staying a real page the server controls.

   THE SCROLL BOX BELONGS TO THE CONSENT CARD, NOT TO THE READING PAGE.
   On the card it is load-bearing and must not be dropped: it keeps the accept button reachable
   without scrolling past nine sections on a phone, and it is focusable so a keyboard user can page
   the text. That is why the box did not simply get deleted here.
   On a READING page (/legal/terms and the published policy pages) the same box was a silent
   truncation. Those pages are captured to PDF — Ctrl+P, Save as PDF — as the disclosure evidence
   the NĐ 248/2026 platform filing asks for, and a 46vh box with its own scrollbar prints exactly
   what is inside the box: roughly one screen, ending mid-sentence, with the rest of the document
   absent from the file and nothing on the page saying so. The reader is the page flow now; only
   .legal-page--consent keeps the box. The print block at the foot of this sheet re-asserts the
   same thing with !important, so a capture comes out whole even on a page that does carry the
   consent class. */
.legal-page { max-width: 780px; margin: 0 auto; padding: 4px 0 40px; }
.legal-page--consent { max-width: 720px; }
.legal-title { font-size: clamp(20px, 3.4vw, 26px); margin: 6px 0 4px; }
.legal-stamp { margin: 8px 0 4px; font-size: 13.5px; }
.legal-langnote { margin: 10px 0; font-size: 13.5px; color: var(--muted); }
.legal-reason { margin: 10px 0; font-size: 13.5px; font-weight: 600; }
.legal-scroll { margin: 14px 0 18px; font-size: 13.5px; line-height: 1.62; }
/* The box itself — consent card only. The mobile rule a few lines down drops its height cap and is
   .legal-page--consent-scoped too, so it still wins there: equal specificity, later in the sheet. */
.legal-page--consent .legal-scroll {
  max-height: 46vh; overflow-y: auto; padding: 14px 16px; position: relative;
  border: 1px solid var(--border); border-radius: 12px; background: var(--surface);
}
.legal-scroll:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.legal-section { margin: 0 0 16px; }
.legal-heading { font-size: 15px; margin: 16px 0 6px; }
.legal-section:first-child .legal-heading { margin-top: 4px; }
.legal-subheading { font-size: 13.5px; margin: 12px 0 4px; color: var(--muted); }
.legal-list { margin: 4px 0 0; padding-left: 20px; }
.legal-list li { margin-bottom: 7px; }
/* THE PUBLISHED POLICY DOCUMENTS (/legal/privacy, /legal/complaints, /legal/service-conditions,
   /legal/service-delivery). Their markup comes from legal/policyMarkdown.js, which emits plain
   tags rather than a class per node — so these rules are element-scoped under one container. They
   sit INSIDE .legal-scroll, which means the reading-page treatment above and the print block at the
   foot of this sheet already apply; only the tags terms.js never uses are declared here. */
.legal-doc h1 { font-size: clamp(19px, 3.2vw, 24px); margin: 4px 0 12px; line-height: 1.3; }
.legal-doc h2 { font-size: 16px; margin: 24px 0 8px; }
.legal-doc h3 { font-size: 14px; margin: 18px 0 6px; }
.legal-doc p { margin: 0 0 10px; }
.legal-doc ul, .legal-doc ol { margin: 0 0 12px; padding-left: 22px; }
.legal-doc li { margin-bottom: 7px; }
/* A nested list or a continuation paragraph inside an item — one level, which is all the documents
   use (the four-ground credit claw-back list in service-delivery.md §1.6). */
.legal-doc li > ul, .legal-doc li > ol, .legal-doc li > p { margin: 7px 0 0; }
.legal-doc li > :last-child { margin-bottom: 0; }
.legal-doc hr { border: 0; border-top: 1px solid var(--line); margin: 22px 0; }
.legal-doc blockquote {
  margin: 0 0 12px; padding: 10px 14px;
  border-left: 3px solid var(--line2); border-radius: 0 8px 8px 0; background: var(--surface2);
}
.legal-doc blockquote > :last-child { margin-bottom: 0; }
.legal-doc code {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.92em;
  padding: 1px 5px; border-radius: 5px; background: var(--surface3);
}
/* Tables: the .table-wrap the renderer emits is this sheet's existing horizontal-scroll box, so a
   ten-column deadline table stays readable on a phone without the page itself scrolling sideways.
   The print sheet releases the box — an overflow container prints only what is inside it, which is
   the same silent truncation the old .legal-scroll caused. */
.legal-doc table { border-collapse: collapse; font-size: 12.5px; min-width: 460px; }
.legal-doc th, .legal-doc td {
  border: 1px solid var(--line); padding: 7px 9px; text-align: left; vertical-align: top;
}
.legal-doc th { background: var(--surface2); font-weight: 600; }
.legal-doc td > p:last-child, .legal-doc th > p:last-child { margin-bottom: 0; }
.legal-accept {
  display: grid; gap: 10px; padding: 16px;
  border: 1px solid var(--border); border-radius: 12px; background: var(--surface);
}
/* THE BUTTON MUST BE ON SCREEN, NOT BELOW IT (owner, 2026-09-11, from a phone: "nút đồng ý phải nằm
   ngay trong màn hình mobile không cần cuộn xuống").
   On a phone the old layout stacked a title, a date, a reason line, a 46vh scroll box and only THEN
   the accept card — past the fold on every handset, and the inner scroll box made it worse by hiding
   its own top line mid-word. Two changes, both only on narrow screens:
     · the document stops being a box with its own scrollbar and becomes the page, so there is one
       scroll instead of two nested ones;
     · the accept card STICKS to the bottom of the viewport, so the button is visible from the first
       paint and stays visible however far the reader scrolls.
   Sticky rather than a fixed magic height: it needs no number for the header, which changes with the
   notification row, the safe-area inset and the operator banner. */
/* MOBILE ONLY (owner, 2026-09-11: "nút đồng ý phải nằm ngay trong màn hình mobile không cần cuộn
   xuống", then "trên pc đang ok rồi. mobile thôi"). Desktop keeps its 46vh box and its ordinary flow —
   it was not broken and is not touched.
   Two changes below 900px:
     · the document stops being a box with its own scrollbar and becomes the page, so there is ONE
       scroller instead of two nested ones (the inner box was hiding its own first line mid-word);
     · the accept card STICKS to the bottom of the viewport, so the button is on screen from the first
       paint and stays there however far the reader scrolls.
   Sticky rather than a shorter box: a height in vh is a guess about how much chrome sits above and
   below it, and the guess changes with the notification row, the safe-area inset and the URL bar. */
@media (max-width: 900px) {
  .legal-page--consent .legal-scroll { max-height: none; overflow-y: visible; }
  .legal-page--consent .legal-accept {
    position: sticky; bottom: 0; z-index: 2;
    margin-bottom: env(safe-area-inset-bottom);
    box-shadow: 0 -10px 24px -12px rgba(0, 0, 0, 0.55);
  }
}
.legal-accept form { display: grid; gap: 10px; justify-items: start; }
/* The wrap sentence, directly above the button it describes. Full body colour on purpose — this is the
   notice that turns the click into consent, so it is not a footnote and must not be dimmed into one. */
.legal-wrap { margin: 0; font-size: 13.5px; line-height: 1.55; justify-self: stretch; }
.legal-decline { margin: 4px 0 0; font-size: 12.5px; }
.legal-cycle { margin: 12px 2px 0; font-size: 12.5px; }
select.upscale-gated option:disabled { color: #6a7384; opacity: 0.55; }
.hardware-limit-badge { font-size: 12px; }
.ux-guidance--ok {
  border-color: var(--ok, #2d6a4f);
  background: rgba(45, 106, 79, 0.08);
}

/* ══════════════════════════ Aurora shell (v2) ════════════════════════════ */
/* Keyboard "skip to content" — visually hidden until focused, then reveals at top-left. */
/* flex, not the default block. This takes the coarse-pointer 44px floor (it measured 200x34 on
   production 2026-08-09 — 14px line + 10px padding twice), and a block box leaves the label pinned
   to the top of the taller box. Same trap .home-trend-act hit; see its comment.
   Honest scope: translateY(-160%) parks this off-screen until :focus, so no thumb ever reaches it.
   The floor here is correctness for a keyboard/switch user landing on it, NOT a tap-target win —
   do not count it as one. */
.skip-link { position: fixed; top: 8px; left: 8px; z-index: var(--z-toast); display: flex; align-items: center; background: var(--accent); color: var(--on-accent); padding: 10px 16px; border-radius: 10px; font: 700 14px/1 var(--font-ui); text-decoration: none; transform: translateY(-160%); transition: transform 0.16s var(--ease); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); }
.skip-link:focus { transform: translateY(0); outline: 2px solid var(--text); outline-offset: 2px; }
/* Consistent, high-contrast keyboard focus ring across the whole app (nav links were faint/ringless). */
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.app-nav-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; background: var(--surface2); }
#app-main-content:focus { outline: none; }
/* Soft-navigation progress bar. A page swapped in place has no browser spinner to borrow, so
   without this a click on a slow page reads as "nothing happened" — the one way an in-place
   navigation can feel WORSE than the full reload it replaced. Painted on the root element by
   product-navigation.js for the duration of the fetch only. */
html[data-nav-pending]::before {
  content: ''; position: fixed; inset: 0 auto auto 0; height: 2px; width: 100%;
  background: linear-gradient(90deg, transparent, var(--accent), transparent);
  transform-origin: 0 50%; animation: nav-pending 900ms ease-in-out infinite;
  z-index: var(--z-toast); pointer-events: none;
}
@keyframes nav-pending { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } }
@media (prefers-reduced-motion: reduce) {
  html[data-nav-pending]::before { animation: none; opacity: 0.6; }
}
.app-shell { min-height: 100vh; min-height: 100dvh; }
.app-grid { display: grid; grid-template-columns: var(--sidebar-w) minmax(0, 1fr); min-height: 100vh; min-height: 100dvh; }
@media (max-width: 900px) { .app-grid { grid-template-columns: minmax(0, 1fr); } }
.app-sidebar {
  background: var(--sidebar-bg); border-right: 1px solid var(--line);
  display: flex; flex-direction: column; gap: 5px; padding: 20px 14px 16px;
  /* align-self:start stops the grid from stretching the cell to the full body height —
     without it a taller page (Create) makes the stretched sidebar scroll away, while a
     viewport-height page (project detail) happens to stay put. Pinning to the top of the
     row lets position:sticky pin the nav consistently across every page. */
  position: sticky; top: 0; align-self: start; height: 100vh; height: 100dvh; overflow-y: auto;
}
@media (max-width: 900px) {
  .app-sidebar {
    position: fixed; top: 0; bottom: 0; left: 0; height: 100%; width: 286px; max-width: 85vw;
    z-index: var(--z-drawer); box-shadow: 0 22px 70px rgba(0, 0, 0, 0.6);
    /* CLOSED = OFF SCREEN *AND* OUT OF REACH. translateX alone only moves the drawer out of sight:
       it stays in the tab order and in the accessibility tree, so a keyboard or screen-reader user
       on a phone walked ~10 invisible stops (brand, every nav link, pricing, settings, the two
       contact links, the account chip, sign-out) before reaching one control they could see. The
       app-shell's focus trap does not help — it sets "inert" on ".app-body", and the sidebar is a
       SIBLING of .app-body inside .app-grid, so nothing has ever removed the closed drawer.
       visibility:hidden is what takes a subtree out of both, and it inherits, so it covers every
       control inside without a hand-kept list that can drift.
       The 0.26s delay is load-bearing: it holds the drawer visible for the whole slide-OUT. The open
       rule below resets the delay to 0 so it is visible for the whole slide-IN. */
    transform: translateX(-102%);
    visibility: hidden;
    transition: transform 0.26s var(--ease), visibility 0s linear 0.26s;
    /* P3-19: keep the account + sign-out row clear of the phone home-indicator (0 on non-notched). */
    padding-bottom: calc(16px + env(safe-area-inset-bottom));
  }
  .app-shell[data-nav="open"] .app-sidebar {
    transform: translateX(0);
    visibility: visible;
    transition: transform 0.26s var(--ease), visibility 0s linear 0s;
  }
}
.app-scrim {
  position: fixed; inset: 0; background: var(--scrim); z-index: var(--z-scrim);
  opacity: 0; visibility: hidden; transition: opacity 0.26s var(--ease); border: 0; display: none;
}
@media (max-width: 900px) { .app-scrim { display: block; } }
.app-shell[data-nav="open"] .app-scrim { opacity: 1; visibility: visible; }
/* Sidebar header: logo on the left, the language picker top-RIGHT, on the SAME row as the logo
   (operator request — moved out of the topbar, and kept there: "vẫn phải nằm ở góc phải trên").
   It lives inside .app-sidebar, so on the ≤900px drawer it collapses in with the menu. The picker's
   PANEL floats out of flow (see .lang-pick-panel) precisely so that opening it cannot reflow this
   row — an in-flow panel grew the row and shoved the logo down. */
.app-sidebar-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding-bottom: 18px; }
.app-brand {
  display: flex; align-items: center; gap: 11px; padding: 6px 8px; min-width: 0;
  background: none; border: none; cursor: pointer; text-align: left; text-decoration: none;
}
.app-brand:hover { text-decoration: none; }
/* The logo tile, matching the brand kit: DARK tile, AMBER mark. It used to be the inverse — an
   amber tile with a dark glyph — which was fine for an abstract sparkle but is upside-down against
   the real Visor logo, where the V is always the lit element. Radius 10/34 ≈ the kit's 232/1024. */
.brand-mark {
  width: 34px; height: 34px; border-radius: 10px; flex: none; display: grid; place-items: center;
  background: linear-gradient(160deg, #17171b, #08080a); color: #e3a55f;
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35), inset 0 0 0 1px rgba(227, 165, 95, 0.16);
}
.brand-name { font: 800 14px/1 var(--font-ui); letter-spacing: 0.01em; color: var(--text); display: block; }
.brand-sub { font: 600 9px/1 var(--font-mono); letter-spacing: 0.22em; color: var(--faint); display: block; margin-top: 2px; }
.app-nav { display: flex; flex-direction: column; gap: 3px; }
.app-nav-link {
  display: flex; align-items: center; gap: 12px; padding: 11px 13px; border-radius: 11px; min-height: 44px;
  color: var(--muted); font: 600 14px/1 var(--font-ui); text-decoration: none;
  border: 1px solid transparent; transition: all 0.15s var(--ease); cursor: pointer;
}
.app-nav-link svg, .app-nav-link .nav-ic { width: 19px; height: 19px; flex: none; }
.app-nav-link:hover { color: var(--text); background: var(--surface2); text-decoration: none; }
.app-nav-link.active { color: var(--text); background: var(--accent-soft); border: 1px solid var(--accent-ring); }
.app-nav-link.active svg { stroke: var(--text); }
.app-contact { display: flex; flex-direction: column; gap: 6px; }
.app-contact-label { font: 600 10.5px/1.2 var(--font-ui); letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding: 0 2px; }
.app-contact-row { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
/* 40px tall, which is BELOW the touch floor, not past it. The comment that stood here claimed the
   opposite — "comfortably past the 44x44 touch floor, the row is two halves of a 248px rail" — and
   that reasoning is wrong in a way worth naming, because it is why this went unfixed: 44x44 is a
   floor on BOTH axes, so being 107px wide buys nothing on the height axis. Measured 107x40 on the
   production drawer 2026-08-09, four px short.
   The 44px is applied under (pointer: coarse) with every other control rather than here — this pair
   is a thumb target only on the drawer, and on desktop 40px is the density the rail was drawn for. */
.app-contact-link { display: flex; align-items: center; justify-content: center; gap: 6px; min-height: 40px;
  padding: 0 8px; border-radius: 10px; background: var(--surface); border: 1px solid var(--line);
  color: var(--muted); font: 600 12px/1 var(--font-ui); text-decoration: none; }
.app-contact-link:hover { color: var(--text); background: var(--surface2); text-decoration: none; }
.app-contact-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.app-contact-link svg { flex: none; }
.app-sidebar-foot { margin-top: auto; padding-top: 14px; border-top: 1px solid var(--line); display: flex; flex-direction: column; gap: 10px; }
.app-account { display: flex; align-items: center; gap: 9px; padding: 8px 9px; border-radius: 11px; background: var(--surface); border: 1px solid var(--line); }
/* The initial is --on-accent on an accent GRADIENT, and it passes: worst case 7.01:1 in dark
   (#1a1206 on the #d4914a stop) and 5.74:1 in light (#ffffff on #95571a).
   It has been filed as "contrast 1.02, the letter is invisible on its own circle". That number is
   real and it means nothing: the background SHORTHAND resets background-color to transparent and
   puts the gradient in background-image, so a probe reading getComputedStyle().backgroundColor gets
   rgba(0,0,0,0), walks up to .app-account and scores the glyph against var(--surface) — a surface
   the letter never touches. The longhands below exist to end that: naming the fill colour costs
   nothing visually (the gradient paints straight over it) and makes the element measure as what
   the eye actually gets. Keep the two the same amber, or the honesty is gone again.
   Do NOT act on the filed number by making the letter white. That is the reflexive move and it
   computes to 1.72:1 on the dark theme's lighter stop and 2.64:1 on its darker one — it would turn
   a passing element into a real failure, worse than the one reported. */
.app-account-avatar { flex: none; width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; font: 700 13px/1 var(--font-ui); color: var(--on-accent);
  background-color: var(--accent-press); background-image: linear-gradient(135deg, var(--accent-hover), var(--accent-press)); }
.app-account-id { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.app-account-name { font: 600 12.5px/1.2 var(--font-ui); color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.app-account-email { font: 500 11px/1.2 var(--font-ui); color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.app-account-out-form { flex: none; margin: 0; display: flex; }
.app-account-out { flex: none; display: grid; place-items: center; width: 30px; height: 30px; border-radius: 8px; color: var(--muted); background: transparent; border: 0; padding: 0; cursor: pointer; }
.app-account-out:hover { color: var(--text); background: var(--surface-2, rgba(255,255,255,.06)); }
.gpu-chip { display: flex; align-items: center; gap: 9px; padding: 10px 11px; border-radius: 11px; background: var(--surface); border: 1px solid var(--line); }
.gpu-chip .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); box-shadow: 0 0 0 3px rgba(127, 127, 127, 0.16); flex: none; transition: background 0.2s var(--ease), box-shadow 0.2s var(--ease); }
.gpu-chip--ready .dot { background: var(--ok); box-shadow: 0 0 0 3px rgba(103, 192, 148, 0.18); }
/* renderer_down: GPU worker parked/off but the studio (browsing, library) works - amber, not red. */
.gpu-chip--warn .dot { background: var(--warn); box-shadow: 0 0 0 3px rgba(227, 165, 95, 0.18); }
/* PENDING is not WARN (designer #11 chấm đợt 4): the two dots shared one amber, so "starting up"
   and "something needs a look" were indistinguishable on the fleet chip. Pending keeps its pulse
   (motion says "in progress") on the neutral tone; warn keeps the amber. */
.gpu-chip--pending .dot { background: var(--muted); box-shadow: 0 0 0 3px rgba(163, 160, 151, 0.16); animation: gpuDotPulse 1.4s ease-in-out infinite; }
@keyframes gpuDotPulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } }
.gpu-chip-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.gpu-chip-title { font: 600 12px/1 var(--font-ui); color: var(--text); }
.gpu-chip-meta { font: 500 11.5px/1.2 var(--font-mono); color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.app-body { min-width: 0; display: flex; flex-direction: column; }
/* The topbar brand is the DRAWER'S STAND-IN, so it appears exactly where the drawer does not.
   Below 900px the sidebar becomes an off-canvas drawer (hidden, visibility:hidden, opened by the
   burger), taking the real brand off screen with it — that is what this replaces. Above 900px the
   sidebar is permanently open and showing its brand, so this one was a SECOND logo sitting a few
   centimetres from the first, on every signed-out page of both doors. It pairs with .app-burger,
   which already switches at the same breakpoint for the same reason. */
.app-brand--top { display: none; align-items: center; text-decoration: none; color: inherit; margin-right: 2px; }
@media (max-width: 900px) { .app-brand--top { display: inline-flex; } }
.app-brand--top .brand-mark { width: 26px; height: 26px; border-radius: 8px; }
.app-brand--top .brand-mark svg { height: 15px; width: 15px; }
.app-brand--top .brand-sub { display: none; }
.app-topbar {
  display: flex; flex-wrap: wrap; align-items: center; gap: 14px;
  padding: 14px max(30px, env(safe-area-inset-right)) 14px max(30px, env(safe-area-inset-left));
  border-bottom: 1px solid var(--line);
  position: sticky; top: 0; z-index: var(--z-nav); background: var(--topbar-bg);
}
@media (max-width: 900px) { .app-topbar { padding: 11px max(16px, env(safe-area-inset-right)) 11px max(16px, env(safe-area-inset-left)); } }
/* Foldable-folded / very narrow portrait (≤480px): the topbar chrome (burger + VI/EN + token + bell
   + quick-create) can exceed the viewport. "flex-wrap: wrap" above is what makes the row REFLOW
   instead of scrolling the page (WCAG 1.4.10); the padding below only tightens it.
   NO "font-size: 0" HERE — it used to sit on .dc-btn-primary to "collapse the CTA to icon-only".
   The only .dc-btn-primary in this bar is the top-up button, and its only <svg> lives INSIDE
   <span class="topup-balance" data-token-chip hidden>. public/token-chip.js hides that span whenever
   billing is off, the balance is not a number, the response is not OK, or the fetch rejects — so the
   "icon-only" state was a 26x44 solid var(--accent) rectangle containing nothing at all: no label,
   no icon, no affordance, on the one control a user who is out of tokens has to find. Even on the
   happy path font-size:0 also zeroed <span data-token-balance>, hiding the number the button exists
   to show. The collapse only ever removed information; wrapping already handled the width. */
@media (max-width: 480px) {
  .app-topbar { gap: 8px; }
  .app-topbar .dc-btn-primary, .app-topbar .dc-btn-outline { padding-left: 12px; padding-right: 12px; margin-left: auto; }
}
.app-burger {
  display: none; align-items: center; justify-content: center; width: 44px; height: 44px; border-radius: 10px;
  border: 1px solid var(--line); background: var(--surface); cursor: pointer; color: var(--text); flex: none;
}
@media (max-width: 900px) { .app-burger { display: inline-flex; } }
.topbar-search { display: flex; align-items: center; gap: 9px; padding: 7px 13px; border-radius: 999px; background: var(--surface); border: 1px solid var(--line); }
.topbar-search input { border: none; background: none; color: var(--text); font: 500 13px/1 var(--font-ui); width: 230px; }
.topbar-search input::placeholder { color: var(--muted); }
@media (max-width: 900px) { .topbar-search { display: none; } }
.app-topbar-spacer { flex: 1; }
/* The language picker (operator 2026-09-19: "chọn ngôn ngữ dùng dropdown đi. không dùng toggle").
   A <details> disclosure in NORMAL FLOW, deliberately: .app-sidebar is overflow-y:auto, so an
   absolutely-positioned panel would be clipped by it and an escape into the top layer would need
   the popover API plus anchor positioning, which this shell cannot yet require. In flow it simply
   lengthens a rail that already scrolls, and it is never clipped anywhere. */
.lang-pick { position: relative; flex: none; }
.lang-pick-summary { display: inline-flex; align-items: center; gap: 6px; list-style: none; cursor: pointer; user-select: none; padding: 6px 9px; border-radius: 999px; background: var(--surface); border: 1px solid var(--line); font: 700 11px/1 var(--font-ui); letter-spacing: 0.04em; color: var(--muted); transition: color 0.15s ease, border-color 0.15s ease; }
.lang-pick-summary::-webkit-details-marker { display: none; }
.lang-pick-summary:hover { color: var(--text); }
.lang-pick[open] > .lang-pick-summary { color: var(--text); border-color: var(--accent); }
.lang-pick-caret { transition: transform 0.15s ease; }
.lang-pick[open] .lang-pick-caret { transform: rotate(180deg); }
/* OUT OF FLOW, anchored to the summary's right edge. In flow the panel lengthened the header row,
   and with align-items:center that pushed the logo down and drew the panel across it — reported as
   "nhìn ui xuống dòng như hiện tại nát lắm". Floating it means opening the picker moves nothing.
   right:0 because the control sits top-right: the panel hangs leftward, into the rail, instead
   of off its edge. Sized to its CONTENT, not to the rail — a two-row list has no business being
   248px wide — with a max so a long language name wraps rather than widening the sidebar.
   position:absolute also makes this scroller its own containing block, which is what
   tableWrapContainsItsAbsolutes requires of anything declaring overflow (it measured an escaped
   absolute dragging scrollWidth past a phone viewport on /wallet and /jobs/:id).
   It CAN be clipped by .app-sidebar's own overflow-y if it ever grows taller than the rail below
   it; at the top of the sidebar with max-height capping it, it does not today, and the rail
   scrolls if a 24-row list ever gets there. */
.lang-pick-panel { position: absolute; top: calc(100% + 6px); right: 0; z-index: var(--z-popover); width: max-content; min-width: 140px; max-width: 200px; display: flex; flex-direction: column; gap: 2px; padding: 4px; border-radius: 12px; background: var(--surface); border: 1px solid var(--line); box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45); max-height: min(52vh, 420px); overflow-y: auto; overscroll-behavior: contain; }
.lang-pick-row { display: flex; align-items: center; gap: 8px; min-height: 44px; padding: 8px 10px; border-radius: 8px; text-decoration: none; color: var(--muted); font: 600 12px/1.2 var(--font-ui); transition: color 0.15s ease, background 0.15s ease; }
.lang-pick-row:hover { color: var(--text); background: var(--surface-2, rgba(255, 255, 255, 0.05)); }
.lang-pick-name { flex: 1 1 auto; min-width: 0; }
.lang-pick-tick { flex: none; }
.lang-pick--settings .lang-pick-summary { font-size: 12px; letter-spacing: 0; }
/* The flag beside the language label (operator 2026-09-19: "ngôn ngữ phải hiện kèm cờ chứ không
   phải mỗi text"). One rule for every home — the summary and every row of the picker, in both
   the sidebar and Settings, all drawn by i18n/localePicker.js. border-radius clips the inline SVG (its UA overflow is hidden), so the
   rounded 4:3 crop needs no clipPath id that would duplicate when both controls share a page; the
   var(--line) ring keeps the Union Jack's white edge from dissolving into a light surface and the
   star flag's red from bleeding into the dark one — same token both themes. */
.lang-flag { display: block; flex: none; border-radius: 3px; box-shadow: 0 0 0 1px var(--line); }
/* var(--on-accent), NOT #fff. This pill is 700 11px — under the 14px-bold large-text exemption, so
   it owes the full 4.5:1. White on the dark theme's amber accent measures 2.14:1, and because the
   product is Vietnamese-first the pill carrying .active is VI, i.e. the failing state is the one
   almost every user is looking at. The audit that found it read the number off the INACTIVE pill;
   that one is var(--muted) on var(--surface) and already passes at 6.97:1, so recolouring it would
   have been a no-op fix on a passing control while the real failure kept shipping.
   Measured: #1a1206 on #e3a55f = 8.66:1 dark. Light is byte-identical to before the fix, because
   --on-accent IS #ffffff there (4.70:1).
   Fix at the CALL SITE, never by retuning --on-accent: seven controls consume that token and six of
   them are already correct. The bug here was opting out of it, not the token's value. */
.lang-pick-row.is-current { color: var(--on-accent); background: var(--accent); }
.job-chip { display: flex; align-items: center; gap: 8px; padding: 7px 12px; border-radius: 999px; background: var(--surface); border: 1px solid var(--line); font: 600 11px/1 var(--font-mono); letter-spacing: 0.04em; color: var(--muted); text-decoration: none; }
.job-chip .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
.job-chip[hidden] { display: none; }
@media (max-width: 560px) { .job-chip { display: none; } }
/* Token balance chip (#token) */
.token-chip { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 999px; background: var(--surface); border: 1px solid var(--line); font: 700 12px/1 var(--font-mono); font-variant-numeric: tabular-nums; letter-spacing: 0.02em; color: var(--text); text-decoration: none; transition: border-color .15s ease, color .15s ease; }
.token-chip svg { color: var(--accent); flex: none; }
.token-chip:hover { border-color: var(--accent); }
.token-chip[hidden] { display: none; }
.token-chip.is-low { color: var(--danger, #e0564f); border-color: var(--danger, #e0564f); }
.token-chip.is-low svg { color: var(--danger, #e0564f); }
@media (max-width: 560px) { .token-chip { padding: 6px 9px; } }
/* Top-up button: the balance rides INSIDE the primary CTA (#token). The number is tabular so it
   does not jitter as the balance changes, and is separated from the label by a hairline rather than
   a bullet so it reads as one control rather than two glued words. */
.topup-btn { gap: 8px; }
.topup-balance { display: inline-flex; align-items: center; gap: 5px; font-variant-numeric: tabular-nums; font-weight: 700; padding-right: 8px; border-right: 1px solid currentColor; opacity: .95; }
.topup-balance[hidden] { display: none; }
/* Bonus (gift) group — the at-risk half of the balance, terms §7. THREE laws hold it in shape:
   ① the TOTAL is always the main number and is never replaced by a paid/bonus pair — it is the
     answer to "can I still render", and a user must never have to add two numbers to get it;
   ② the "purchased" half is NEVER printed: paid = total − bonus, so printing it would put three
     numbers on a control that needs two;
   ③ this whole group is HIDDEN when bonus is 0 or unknown (token-chip.js) — an absent split is
     not a zero one, and a lone "(🎁 0)" would assert a decomposition nobody measured.
   Desktop reads  ⊙ 144.582 (🎁 20) │ Nạp ; ≤560px drops the parentheses and the number and keeps
   the glyph alone,  ⊙ 144.582 🎁 │ Nạp . The glyph is a HINT, never the only carrier — the
   anchor's aria-label spells the whole sentence at BOTH widths (see walletChip).
   (No backtick anywhere in this comment, by rule: STYLES is one big template literal, so a stray
   backtick ENDS it — the CSS-in-template trap this file has been bitten by before.) */
.topup-bonus { display: inline-flex; align-items: center; gap: 3px; }
.topup-bonus[hidden] { display: none; }
.topup-bonus::before { content: "("; }
.topup-bonus::after { content: ")"; }
.topup-bonus svg { flex: none; }
/* Low balance must read as a warning without relying on colour alone — the button keeps its primary
   fill, so the cue is a ring plus the number itself being visible. */
.topup-btn:has(.topup-balance.is-low) { box-shadow: 0 0 0 2px var(--danger, #e0564f); }
@media (max-width: 560px) {
  .topup-balance { padding-right: 6px; gap: 4px; }
  /* NOT font-size:0 and NOT display:none on the group — read the ≤480px note above. The number
     goes, the glyph stays, and the accessible name is unchanged because it never came from here. */
  .topup-bonus-num { display: none; }
  .topup-bonus::before, .topup-bonus::after { content: none; }
}
/* Per-job token cost badge (queue rows + job result) */
.token-cost { display: inline-flex; align-items: center; gap: 4px; font-variant-numeric: tabular-nums; }
.token-cost--refunded { color: var(--muted); text-decoration: line-through; }
/* Wallet page */
.wallet-topup { margin: 0 0 20px; padding: 20px 22px; border: 1px solid var(--line); border-radius: 16px; background: linear-gradient(160deg, var(--surface), var(--bg2)); }
.wallet-topup h3 { margin: 0 0 10px; font-size: 15px; }
/* The QR sizes to its COLUMN, and the column is as generous as the card allows (operator
   2026-09-12: "sao còn diện tích mà không hiện qr to ra" — on PC too). The endpoint serves
   360×360, so 220–300 CSS px stays sharp; the img carries no fixed pixel size of its own. */
.wallet-topup-grid { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 20px; align-items: start; }
@media (max-width: 700px) {
  .wallet-topup-grid { grid-template-columns: minmax(0, 1fr); justify-items: start; }
  /* Stacked: the QR block takes the card width up to 300px — scannable from another device
     without tapping the lightbox. */
  .wallet-topup-grid > div:first-child { width: min(100%, 300px); }
  /* [designer #10 [36]] On a phone the 104px label track left the value 173px; the label moves to
     its own line and the value gets the full card width (same answer .ac-hbar-row already has). */
  .wallet-va-row { grid-template-columns: minmax(0, 1fr) auto; }
  .wallet-va-label { grid-column: 1 / -1; }
}
.wallet-qr { border-radius: 10px; background: #fff; padding: 6px; display: block; width: 100%; height: auto; }
.wallet-qr-cap { color: var(--muted); font-size: 11px; text-align: center; margin-top: 6px; }
.wallet-topup-grid > div:last-child { max-width: 560px; }
.wallet-va-row { display: grid; grid-template-columns: 104px minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--line); font-size: 13.5px; }
.wallet-va-label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; }
.wallet-mono { font-family: var(--font-mono); font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; overflow-x: auto; scrollbar-width: none; position: relative; }
.wallet-code { font-size: 16.5px; letter-spacing: 0.04em; }
/* [designer #10 [36]] Plain-text values (bank name, account holder) are ordinary words — they
   WRAP. nowrap+hidden-scrollbar was written for the digit-grouped account number and silently
   truncated "CONG TY TNHH CONG NGHE VISOR" at ~20 chars on a phone with no ellipsis, no hint and
   no copy button: the human cross-check pair a VN user reads back before a manual transfer. */
.wallet-plain { font-weight: 600; white-space: normal; overflow-wrap: anywhere; }
.wallet-qr[data-media-view] { cursor: zoom-in; }
.wallet-qr--pending { width: 100%; aspect-ratio: 1; display: flex; align-items: center; justify-content: center;
  border: 1px dashed var(--border, #555); border-radius: 8px; font-size: 12px; opacity: 0.7;
  text-align: center; padding: 10px; }
/* Author display rules beat the UA [hidden]{display:none} — the pack-first QR ships hidden and
   the placeholder/details pair swap hidden states on the first pick (app-shell.js), so every one
   of them needs this spelled out (the details container is guarded too, defensively: it has no
   author display today, but the guard is what keeps a future display:grid on it honest). */
.wallet-qr[hidden], .wallet-qr--pending[hidden], .wallet-topup-details[hidden], .wallet-paypal-details[hidden] { display: none; }
/* Pack-first, WHOLE block (operator 2026-09-16: "phần nội địa chỉ show thông tin và qr khi đã
   chọn gói"): the placeholder reuses the pending-QR anatomy but stands in for the full details
   block now, so the square QR ratio goes and the width follows the info column's cap. */
.wallet-details-placeholder { aspect-ratio: auto; padding: 18px 14px; margin-top: 12px; max-width: 560px; }
.wallet-topup-details { margin-top: 12px; }
/* The intl checkout details (select-then-pay): chosen line, total-price sentence, ONE pay
   button — revealed by a card press, same rhythm as the domestic reveal above. */
.wallet-paypal-details { margin-top: 10px; }
.wallet-paypal-details .dc-btn { margin-top: 8px; }
/* Embedded official PayPal Buttons (2026-09-16 "có nhúng thẳng của paypal vào web"): the SDK
   renders its own iframe into this host — width capped so the buttons keep PayPal's proportions
   on a wide panel. The redirect submit beneath them restyles into a quiet text link: with the
   official buttons present it is the fallback, not the primary act, but it must stay a REAL
   control (it is the whole checkout when the SDK is blocked or errors). */
.wallet-paypal-buttons { margin-top: 10px; max-width: 360px; }
.wallet-paypal-fallback { display: inline-block; margin-top: 8px; padding: 0; background: none;
  border: none; color: inherit; opacity: 0.75; font-size: 13px; text-decoration: underline; cursor: pointer; }
.wallet-paypal-fallback:hover { opacity: 1; }
.settings-qr-preview { margin: 10px 0 4px; }
.wallet-tier-lead { font-size: 13px; margin: 12px 0 0; }
.wallet-tier-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; }
.wallet-tier-chip { display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
  border: 1px solid var(--line2, rgba(255,255,255,0.13)); border-radius: 12px; padding: 12px 14px;
  background: var(--bg2, rgba(255,255,255,0.03)); color: inherit; cursor: pointer; min-width: 118px; text-align: left; }
.wallet-tier-chip:hover { border-color: var(--accent, #e3a55f); }
/* The CLOSED wallet's read-only packages (2026-09-17): the same card, stripped of every affordance
   that promises an action — no pointer, no hover accent. A price you cannot act on yet must not
   look like a button that failed to work. */
.wallet-tier-chip--static { cursor: default; }
.wallet-tier-chip--static:hover { border-color: var(--line2, rgba(255,255,255,0.13)); }
/* Active carries a DOUBLE border (inset ring) — hover already borrows border-color, so border
   alone could not separate a hovered chip from the armed one (designer #10 [24]). */
.wallet-tier-chip.active { background: var(--accent-soft, rgba(227,165,95,0.13)); border-color: var(--accent, #e3a55f); color: var(--accent, #e3a55f); box-shadow: inset 0 0 0 1px var(--accent); }
.wtc-amt { font-weight: 700; font-size: 15px; }
.wtc-tok { font-size: 12.5px; opacity: 0.75; }
.wtc-bonus { font-size: 11.5px; color: var(--ok, #4caf7d); border: 1px solid currentColor; border-radius: 999px; padding: 1px 7px; margin-top: 2px; }
/* The paid/bonus split on a pack card — quiet, under the token line (refund doctrine (D)). */
.wtc-split { font-size: 11px; opacity: 0.6; }
.wallet-balance-split { color: var(--muted); font-size: 12px; margin-top: 4px; }
/* International rail — a TAB PANEL beside the domestic QR (operator: tab options, not stacked
   sections). Since 2026-09-16 the panel nests a METHOD bar (PayPal first) and its cards SELECT
   a pack (aria-pressed, the domestic chips' grammar) — the pay itself is one explicit button in
   the revealed details. The --static variant died with the "no method yet" era, so the base
   chip's pointer cursor, hover accent and .active ring are correct again. */
.wallet-topup-tabs { margin: 12px 0 10px; }
.wallet-intl .wallet-tier-lead { margin-top: 4px; }
.wallet-method-tabs { margin: 8px 0 10px; }
.wallet-copy { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px;
  padding: 0; flex: none; color: var(--accent); background: none; border: 1px solid var(--line); border-radius: 9px; cursor: pointer; }
.wallet-copy svg { pointer-events: none; }
.wallet-copy .wc-ic-check { display: none; }
.wallet-copy.copied .wc-ic-check { display: block; }
.wallet-copy.copied .wc-ic-copy { display: none; }
.wallet-copy.copied { color: var(--ok, #4caf7d); }
@media (pointer: coarse) { .wallet-copy { width: 44px; height: 44px; } }
.wallet-live-region { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
/* The queue's wait-compensation PERK card (operator 2026-09-11: a benefit, never a muted line).
   Money-green tint via --ok, same emotional register as the wallet promo. */
.q-perk { display: flex; gap: 12px; align-items: flex-start; margin: 14px 0 0; padding: 13px 16px; border: 1px solid color-mix(in srgb, var(--ok) 35%, var(--line)); border-radius: 14px; background: color-mix(in srgb, var(--ok) 9%, var(--surface)); max-width: 62ch; }
.q-perk-ic { flex: none; color: var(--ok); margin-top: 2px; }
.q-perk-title { font-weight: 700; font-size: 13.5px; margin: 0 0 3px; color: var(--text); }
.q-perk-sub { margin: 0; font-size: 12.5px; color: var(--muted); line-height: 1.5; }
.wallet-topup-hint { margin: 12px 0 0; padding: 10px 12px; border-radius: 9px; background: color-mix(in srgb, var(--accent) 12%, transparent); border-left: 2px solid var(--accent); font-size: 13px; max-width: 560px; }
.wallet-topup-warn { margin: 0 0 10px; padding: 9px 12px; border-radius: 9px; background: color-mix(in srgb, var(--accent) 16%, transparent); font-size: 13px; max-width: 62ch; }
.wallet-topup-history { margin-top: 16px; }
.wallet-topup-history h3 { margin: 0 0 8px; font-size: 14px; }
.wallet-topup-date { color: var(--muted); font-size: 11.5px; margin: 8px 0 3px; }
.wallet-topup-row { font-size: 13.5px; padding: 3px 0; }
/* The closed block's button rule died with the button itself (2026-09-17 — the "Nhắn qua Zalo" CTA
   was a fourth way to pay that no filed policy declares). What sits under the sentence now is the
   read-only package list, spaced by .wallet-tier-lead like every other ladder on this page. */
.wallet-grid { display: grid; grid-template-columns: minmax(260px, 340px) 1fr; gap: 20px; align-items: start; }
@media (max-width: 820px) { .wallet-grid { grid-template-columns: minmax(0, 1fr); } }
.wallet-balance-card { border: 1px solid var(--line); border-radius: 16px; padding: 24px; background: linear-gradient(160deg, var(--surface), var(--bg2)); }
.wallet-balance-num { font: 800 44px/1 var(--font-ui); font-variant-numeric: tabular-nums; letter-spacing: -0.02em; margin: 6px 0 2px; }
.wallet-balance-unit { color: var(--muted); font-size: 13px; }
.wallet-stats { display: flex; gap: 18px; margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--line); }
.wallet-stat { font-size: 12px; color: var(--muted); }
.wallet-stat b { display: block; font: 700 16px/1.2 var(--font-mono); font-variant-numeric: tabular-nums; color: var(--text); }
.wallet-redeem { margin-top: 18px; }
.wallet-redeem form { display: flex; gap: 8px; }
.wallet-redeem input { flex: 1; min-width: 0; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--bg2); color: inherit; text-transform: uppercase; font: 600 13px/1 var(--font-mono); letter-spacing: 0.06em; }
.wallet-flash { margin: 0 0 16px; padding: 11px 14px; border-radius: 10px; font-size: 13px; border: 1px solid var(--line); }
.wallet-flash.is-ok { border-color: var(--ok, #3fae6a); color: var(--ok, #3fae6a); background: color-mix(in srgb, var(--ok, #3fae6a) 12%, transparent); }
.wallet-flash.is-bad { border-color: var(--danger, #e0564f); color: var(--danger, #e0564f); background: color-mix(in srgb, var(--danger, #e0564f) 12%, transparent); }
/* Neutral tone — the PayPal cancel notice: the buyer did nothing wrong, so no red, no green. */
.wallet-flash.is-note { color: var(--muted); background: color-mix(in srgb, var(--muted, #999) 8%, transparent); }
.wallet-ledger { width: 100%; border-collapse: collapse; font-size: 13px; }
.wallet-ledger th { text-align: left; color: var(--muted); font-weight: 600; padding: 8px 10px; border-bottom: 1px solid var(--line); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }
.wallet-ledger td { padding: 9px 10px; border-bottom: 1px solid var(--line); font-variant-numeric: tabular-nums; vertical-align: top; }
/* IN and OUT are told apart THREE ways — arrow, sign, colour — because colour alone is not a
   carrier (WCAG 1.4.1) and this table is the one place a user checks what left their wallet.
   .amt-neg used to be plain var(--text): identical to every other cell, so the only difference
   between "+221" and "-221" was one character at the far left of a tabular-nums column.
   NO HEX FALLBACK. var(--ok, #3fae6a) was the old spelling here, and that fallback is exactly the
   non-theme-aware literal the --danger alias was introduced to kill: --ok/--danger are defined on
   :root in BOTH themes, so the fallback can never fire and only invites a second, unthemed value.
   Measured (both themes, the backgrounds this table actually sits on):
     dark  --bad #e0705f on --surface #151518 = 5.77:1, on --bg #0a0a0c = 6.27:1
     light --bad #b23528 on #ffffff = 6.12:1, on --bg2 #efece6 = 5.19:1
     dark  --ok  #67c094 on --surface = 8.28:1 · light --ok #147349 on #ffffff = 5.87:1
   Both weights are 700 so neither direction reads as the quieter one. */
.wallet-ledger .amt-pos { color: var(--ok); font-weight: 700; }
.wallet-ledger .amt-neg { color: var(--danger); font-weight: 700; }
/* The arrow is DECORATION carrying a duplicate of the sign — aria-hidden in the markup, with an
   sr-only word beside it, so a screen reader hears "Cộng 221" and never "up arrow plus 221". */
.wallet-ledger .amt-arrow { margin-right: 2px; font-family: var(--font-ui); }
/* The recorded paid/bonus split of a top-up, on its own line: the total stays the headline number
   and the decomposition reads as a footnote to it. Rows with no recorded split render nothing at
   all here — see ledgerCreditSplit. */
.wallet-ledger .amt-split { display: block; margin-top: 3px; color: var(--muted); font-weight: 500; font-size: 11.5px; white-space: nowrap; }
.wallet-ledger .tx-type { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; background: var(--bg2); border: 1px solid var(--line); }
.wallet-empty { color: var(--muted); padding: 22px; text-align: center; }
/* Pricing page */
.pricing-hero { display: grid; grid-template-columns: minmax(240px, 320px) 1fr; gap: 20px; align-items: stretch; margin-bottom: 26px; }
@media (max-width: 820px) { .pricing-hero { grid-template-columns: minmax(0, 1fr); } }
.pricing-unit-card { border: 1px solid var(--line); border-radius: 16px; padding: 22px; background: linear-gradient(160deg, var(--surface), var(--bg2)); display: flex; flex-direction: column; gap: 10px; }
.pricing-unit-big { font: 800 26px/1.1 var(--font-ui); letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.pricing-unit-card .dc-btn { margin-top: auto; align-self: flex-start; }
.pricing-notes { margin: 0; padding: 4px 0 0 18px; color: var(--muted); font-size: 13.5px; line-height: 1.7; display: flex; flex-direction: column; gap: 4px; }
.pricing-cat { margin: 0 0 26px; }
.pricing-cat h2 { font-size: 15px; font-weight: 700; margin: 0 0 10px; color: var(--text); }
.pricing-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.pricing-table th { text-align: left; color: var(--muted); font-weight: 600; padding: 8px 12px; border-bottom: 1px solid var(--line); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }
.pricing-table th:nth-child(2), .pricing-table .pr-tok { text-align: right; }
.pricing-table td { padding: 11px 12px; border-bottom: 1px solid var(--line); vertical-align: middle; }
.pricing-table tr:hover td { background: color-mix(in srgb, var(--text) 3%, transparent); }
.pr-op { font-weight: 600; }
.pr-tok { font-variant-numeric: tabular-nums; }
.pr-tok .pr-main { font: 700 15px/1 var(--font-mono); white-space: nowrap; }
.pr-tok .pr-unit { font: 500 11px/1 var(--font-ui); color: var(--muted); margin-left: 3px; }
.pr-tok .pr-max { display: block; margin-top: 4px; font: 500 11.5px/1.35 var(--font-ui); color: var(--muted); }
.pricing-empty { color: var(--muted); padding: 28px; text-align: center; border: 1px dashed var(--line); border-radius: 14px; }
/* Top-up packages on the PUBLIC price page (2026-09-17). Same table anatomy as the task tables —
   one page, one design system — plus a sub-heading for the international ladder and a method row
   that reads as a list of NAMES, never as buttons: nothing here is payable without signing in. */
.pricing-topup .pricing-sub { font-size: 13px; font-weight: 700; margin: 22px 0 6px; color: var(--text); }
.pricing-lead { color: var(--muted); font-size: 13.5px; line-height: 1.7; margin: 0 0 10px; }
.pricing-methods { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 16px 0 10px; font-size: 13.5px; }
.pricing-methods-label { color: var(--muted); font-weight: 600; }
.pricing-method { display: inline-block; padding: 4px 11px; border-radius: 999px; border: 1px solid var(--line); background: var(--bg2); }
/* Admin billing (pricing config + vouchers + system ledger) */
.billing-cfg { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 14px; margin: 12px 0; }
.billing-cfg label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 5px; }
.billing-cfg input { width: 100%; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--line); background: var(--bg2); color: inherit; font-variant-numeric: tabular-nums; }
.voucher-form { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; align-items: end; margin: 12px 0; }
.voucher-code { font: 700 13px/1 var(--font-mono); letter-spacing: 0.08em; }
.app-main { flex: 1; padding: 34px max(40px, env(safe-area-inset-right)) 64px max(40px, env(safe-area-inset-left)); max-width: var(--maxw); width: 100%; margin: 0 auto; }
/* Links-only footer (C2, payment-rail campaign): muted, one quiet row, pinned to the page tail by
   .app-body's flex column. The Điều 4 identity block joins it once the operator supplies it. */
.site-footer { margin-top: auto; max-width: var(--maxw); width: 100%; margin-left: auto; margin-right: auto;
  padding: 16px max(40px, env(safe-area-inset-right)) calc(20px + env(safe-area-inset-bottom)) max(40px, env(safe-area-inset-left));
  border-top: 1px solid var(--line); display: flex; flex-wrap: wrap; gap: 8px 18px; align-items: center;
  justify-content: space-between; font-size: 12.5px; color: var(--muted); }
.site-footer a { color: var(--muted); }
.site-footer a:hover { color: var(--text); }
.site-footer-links { display: flex; flex-wrap: wrap; gap: 14px; }
.site-footer-contact { margin: 0; }
/* The NĐ 248 Điều 4 identity line the C2 comment reserved space for — the operator supplied the
   name 2026-09-12 ("HKD VISOR STUDIO vào footer"). Full-width so it reads as the block's first
   row on desktop and centres with the rest on mobile. Value comes from TERMS_CONTACT, never
   re-spelled here. */
.site-footer-identity { width: 100%; margin: 0; letter-spacing: 0.04em; }
/* Mobile: centred, not left-ragged (operator 2026-09-12: "trên mobile footer nên căn middle") —
   on a narrow screen the two flex children stack, and space-between reads as left-aligned rows. */
@media (max-width: 900px) { .site-footer { padding-left: max(16px, env(safe-area-inset-left)); padding-right: max(16px, env(safe-area-inset-right)); justify-content: center; text-align: center; } .site-footer-links { justify-content: center; width: 100%; } .site-footer-contact { width: 100%; } }
@media (max-width: 900px) { .app-main { padding: 22px max(16px, env(safe-area-inset-right)) 56px max(16px, env(safe-area-inset-left)); } }
/* OpenCut editor embedded in the studio shell (iframe fills the main content area). */
.edit-embed { margin: -8px 0 -40px; height: calc(100dvh - 150px); min-height: 520px; display: flex; flex-direction: column; gap: 8px; }
.edit-embed-frame { flex: 1; width: 100%; border: 1px solid var(--line); border-radius: 14px; background: #08080a; }
.edit-embed-fallback { align-self: center; margin: auto; text-align: center; }
.edit-embed-toolbar { flex: 0 0 auto; }
.edit-embed-toolbar .input-picker { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin: 0; padding: 6px 10px; background: var(--bg2); border: 1px solid var(--line); border-radius: 12px; }
.edit-embed-toolbar .input-picker > label { margin: 0; font-weight: 600; color: var(--text-muted, var(--muted)); }
.edit-embed-toolbar .ip-btns { margin: 0; }
.edit-embed-toolbar .ip-sel { margin: 0; }
/* DESKTOP-ONLY EDITOR. The timeline editor needs a wide screen and a mouse to drag on, so on a
   narrow viewport we swap it for a real explanation with a way out, instead of handing the visitor
   an editor they cannot operate. The breakpoint below is repeated ONCE more, in the page script
   that decides whether to load the editor at all (so a phone never downloads it) — the two are
   pinned together by test/editVideoPcNotice.test.js, which fails if they drift apart. */
.pc-only { display: none; }
@media (max-width: 1023px) {
  .pc-only { display: block; margin: auto; max-width: 460px; text-align: center; padding: 26px 20px;
    background: var(--bg2); border: 1px solid var(--line); border-radius: 16px; }
  .pc-only-ic { color: var(--accent); margin-bottom: 12px; }
  /* h1: this notice is the entire page below the breakpoint, so it is the page title, not a
     section of one (audit 2026-08-09 — /edit/video opened at h2 with no h1 anywhere). Same 19px;
     the UA weight for h1 and h2 is identically bold, so nothing moves. */
  .pc-only h1 { margin: 0 0 8px; font-size: 19px; }
  .pc-only p { margin: 0 0 16px; color: var(--muted); font-size: 14px; line-height: 1.6; }
  .pc-only-hint { font-size: 12.5px; margin: 18px 0 8px !important; }
  .pc-only-acts { display: flex; flex-direction: column; gap: 9px; }
  /* 44px floor: these are the only two things to tap on this screen. */
  .pc-only-acts .button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; }
  .edit-embed-toolbar, .edit-embed-frame, .edit-embed-fallback { display: none !important; }
}
@media (max-width: 900px) { .edit-embed { height: calc(100dvh - 120px); margin: -4px 0 -30px; } }
/* max-width + overflow-x (operator, 2026-09-11: "dài thì trượt ngang" — the /library tab row's
   five EN labels overflowed the mobile container and read as a page-width mismatch, "width lệch
   nhau"). The pill stays inside its parent and SCROLLS when its tabs do not fit; white-space
   nowrap on the buttons stops the other face of the same squeeze — VI labels breaking mid-word
   into two lines. Class-level on purpose: every pref-switch row (library tabs, compose output,
   settings locale/theme) gets the same answer, not just the instance that was reported. */
.pref-switch { display: inline-flex; gap: 3px; padding: 3px; border-radius: var(--r-pill); border: 1px solid var(--line); background: var(--bg2); max-width: 100%; overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; position: relative; }
.pref-switch::-webkit-scrollbar { display: none; }
.pref-switch a, .pref-switch button {
  appearance: none; border: none; background: transparent; cursor: pointer; min-width: 36px; min-height: 32px;
  padding: 0 12px; border-radius: var(--r-pill); color: var(--muted); font: 700 12px/1 var(--font-ui);
  text-decoration: none; display: inline-flex; align-items: center; justify-content: center; gap: 5px; transition: all 0.15s;
  white-space: nowrap; flex: none;
}
.pref-switch a.active, .pref-switch button.active { background: var(--accent-soft); color: var(--text); }
.pref-switch a:hover, .pref-switch button:hover { color: var(--text); text-decoration: none; }
/* Scroll AFFORDANCE for overflowing pill rows (operator 2026-09-11: on a 375px phone the /library
   row fit four tabs EXACTLY, so nothing said "Yêu thích" existed — an off-screen tab a user
   cannot suspect is a tab that does not exist). app-shell.js stamps ps-more-l / ps-more-r
   whenever content continues past that edge; these pin a chevron+fade there. STICKY FLEX
   PSEUDO-CHILDREN, so they ride inside the scroll container yet stay glued to its edges; the
   negative margins give back the width they occupy, so toggling them never shifts the tabs. */
/* The arrows are REAL ELEMENTS on a non-scrolling wrapper, injected by app-shell's wirer — not
   pseudo-elements riding position:sticky inside the scroller. That earlier shape had a device
   class (operator's phone) where the sticky pseudo never painted until the first touch forced a
   repaint ("từ menu vào nếu không động chạm vuốt màn hình thì không bao giờ hiện mũi tên");
   an absolutely-positioned sibling paints like any other box, on first paint. */
/* The identity clickwrap line (operationGate.identityAttestation) — one quiet sentence at the
   submit button; the button itself is the consent. Muted so it informs without shouting. */
.form-attest { margin: 8px 0 0; font-size: 12px; line-height: 1.55; color: var(--muted); }
.form-attest a { color: var(--accent); }
.ps-wrap { position: relative; display: inline-flex; max-width: 100%; }
.ps-arrow {
  position: absolute; top: 50%; z-index: 1; width: 8px; height: 8px; margin-top: -5px;
  border-right: 2.5px solid var(--accent); border-bottom: 2.5px solid var(--accent); border-radius: 1px;
  pointer-events: none; opacity: 0; transition: opacity 0.15s;
  /* A bg-coloured halo instead of a fade layer: the arrow stays crisp and the label it floats
     over stays readable, with no second gradient to keep in tune with the pill background. */
  filter: drop-shadow(0 0 3px var(--bg2)) drop-shadow(0 0 3px var(--bg2));
}
.ps-arrow-l { left: 6px; transform: rotate(135deg); }
.ps-arrow-r { right: 6px; transform: rotate(-45deg); }
.ps-wrap.ps-more-l .ps-arrow-l { opacity: 1; animation: ps-nudge-l 1.6s ease-in-out infinite; }
.ps-wrap.ps-more-r .ps-arrow-r { opacity: 1; animation: ps-nudge-r 1.6s ease-in-out infinite; }
@keyframes ps-nudge-r { 0%, 100% { translate: 0 0; opacity: 1; } 50% { translate: 3px 0; opacity: 0.7; } }
@keyframes ps-nudge-l { 0%, 100% { translate: 0 0; opacity: 1; } 50% { translate: -3px 0; opacity: 0.7; } }
/* Radio-backed pills (the MV lyrics-length toggle): same skin, but the state lives in a real
   radio control so a plain form POST carries the choice — no JS in the selection itself. */
.pref-switch label { display: inline-flex; align-items: center; justify-content: center; gap: 5px; min-width: 36px; min-height: 32px; padding: 0 12px; border-radius: var(--r-pill); color: var(--muted); font: 700 12px/1 var(--font-ui); cursor: pointer; white-space: nowrap; flex: none; transition: all 0.15s; }
.pref-switch label:has(input:checked) { background: var(--accent-soft); color: var(--text); }
.pref-switch label:hover { color: var(--text); }
.pref-switch label input[type='radio'] { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.pref-switch .ic { font-size: 16px; }
/* ════ handoff component primitives (shared across screens) ════ */
.dc-btn { display: inline-flex; align-items: center; justify-content: center; gap: 9px; padding: 12px 20px; border-radius: 12px; border: 1px solid transparent; cursor: pointer; font: 700 14px/1 var(--font-ui); text-decoration: none; transition: all 0.14s var(--ease); }
/* Promo CTA (operator, 2026-09-11, on the wallet's referral road: "nút này màu xanh lá kèm hiệu
   ứng lấp lánh"). Green in BOTH themes on purpose — a promo reads as a promo, not as the theme's
   accent — with its own text colour baked in (no --on-ok pair exists; the gradient's stops are
   chosen dark enough for AA white text at 14px/700). The glint is a light sweep on ::after; the
   global prefers-reduced-motion kill-switch (top of this sheet) freezes it for free. */
.dc-btn-promo { position: relative; overflow: hidden; background: linear-gradient(135deg, #166534, #15803d 55%, #1a9d54); color: #fff; box-shadow: 0 8px 22px rgba(26, 157, 84, 0.32); }
.dc-btn-promo:hover { filter: brightness(1.08); transform: translateY(-1px); text-decoration: none; color: #fff; }
.dc-btn-promo::after { content: ''; position: absolute; top: 0; bottom: 0; width: 42%; left: -70%; background: linear-gradient(105deg, transparent, rgba(255, 255, 255, 0.5), transparent); transform: skewX(-18deg); animation: dc-glint 2.8s ease-in-out infinite; pointer-events: none; }
@keyframes dc-glint { 0% { left: -70%; } 55% { left: 140%; } 100% { left: 140%; } }
/* GLOSS PASS (operator 2026-09-11: "css styles hơi tĩnh… các button chưa bóng bẩy" — the call was
   delegated and this is the restrained answer). Three moves, all at the class root so every button
   inherits: a thin top HIGHLIGHT on filled buttons (background-image over the token color — the
   color itself, and therefore every AA pair, is untouched), a PRESS state (buttons that only hover
   feel like links), and a real hover for ghost buttons (border-only was imperceptible on dark
   surfaces). The green promo stays exactly as shipped — one deliberate accent, its glint already
   carries it. */
.dc-btn-primary { background: var(--accent); background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0) 48%); color: var(--on-accent); box-shadow: 0 8px 26px var(--accent-shadow), inset 0 1px 0 rgba(255, 255, 255, 0.22); }
.dc-btn-primary:hover { filter: brightness(1.07); transform: translateY(-1px); text-decoration: none; box-shadow: 0 11px 30px var(--accent-shadow), inset 0 1px 0 rgba(255, 255, 255, 0.22); }
.dc-btn-primary:active, .dc-btn-promo:active { transform: translateY(0) scale(0.98); filter: brightness(0.97); box-shadow: 0 4px 14px var(--accent-shadow); }
.dc-btn-sm { padding: 9px 15px; font-size: 13px; border-radius: 10px; min-height: 44px; }
.dc-btn-ghost { background: var(--surface2); border: 1px solid var(--line); color: var(--text); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); }
.dc-btn-outline { background: transparent; border: 1px solid var(--accent); color: var(--accent); }
.dc-btn-outline:hover { background: var(--accent-soft); text-decoration: none; transform: translateY(-1px); }
.dc-btn-ghost:hover { border-color: var(--line2); background: var(--surface-3, var(--surface2)); transform: translateY(-1px); text-decoration: none; }
.dc-btn-ghost:active, .dc-btn-outline:active { transform: translateY(0) scale(0.98); }
.dc-btn-link { display: inline-flex; align-items: center; min-height: 44px; background: none; border: none; cursor: pointer; font: 600 13px/1 var(--font-ui); color: var(--muted); padding: 4px 2px; transition: color 0.15s; }
.dc-btn-link:hover { color: var(--text); text-decoration: none; }
/* show-more.js progressive disclosure: the bar sits under a capped list; .sm-collapsed hides the
   overflow (!important so it wins over grid/flex/table-row display and any filter that left the row
   shown). */
.show-more-bar { display: flex; justify-content: center; gap: 10px; margin: 14px 0 2px; }
.sm-collapsed { display: none !important; }
.dc-card { background: var(--surface); border: 1px solid var(--line); border-radius: 16px; }
/* Visually-hidden utility (designer #10 [12]): three password-form labels assumed this existed and
   rendered VISIBLY, duplicating their own placeholders. Standard clip pattern. */
.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; }
/* The /settings account card's form + chips referenced these names with no rules behind them. */
.set-inline-form { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.set-inline-form--stack { flex-direction: column; align-items: stretch; }
.set-methods { display: flex; gap: 8px; flex-wrap: wrap; }
.set-chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 11px; border-radius: 999px; background: var(--surface2); border: 1px solid var(--line); font: 600 12.5px/1.4 var(--font-ui); color: var(--text); }
.dc-status { font: 600 11px/1 var(--font-mono); letter-spacing: 0.06em; padding: 5px 9px; border-radius: 999px; text-transform: uppercase; display: inline-flex; align-items: center; }
/* The tints FOLLOW the tokens (designer #10 [42]): the old hardcoded rgba() constants were the
   DARK palette's hues at low alpha, so the light theme composited dark-tuned tints over white and
   two pills (bad 4.08:1, wait 4.34:1) sat under AA while the P2-4 patch had fixed only ok+run via
   per-pill overrides. The light TOKENS are now AA on their own tints ([21]/[23]), so the pill
   overrides are gone — one home, the palette. wait moved to --surface2 (4.92:1 light). */
.dc-status-ok { color: var(--ok); background: color-mix(in srgb, var(--ok) 14%, transparent); }
.dc-status-run { color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, transparent); }
.dc-status-wait { color: var(--muted); background: var(--surface2); }
.dc-status-idle { color: var(--faint); background: var(--surface2); }
.dc-status-bad { color: var(--bad); background: color-mix(in srgb, var(--bad) 14%, transparent); }
.dc-tile { position: relative; overflow: hidden; }
.dc-tile-grad { position: absolute; inset: 0; }
.dc-tile-scrim { position: absolute; inset: 0; background: linear-gradient(to top, rgba(10, 10, 12, 0.8), transparent 45%); }
/* settings rows + system stats */
.set-card { padding: 6px 20px; }
.set-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 0; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
/* [designer #14 ⓷] center is right for the one-line rows; a row holding a STACK of fields (the
   password group, 235px tall) floated its label into the middle of the stack — the first input
   sat 83px ABOVE the words saying what the group is for. Top-align just those rows. */
.set-row:has(.set-inline-form--stack) { align-items: flex-start; }
.set-row--last { border-bottom: none; }
.set-row-title { font: 700 14px/1 var(--font-ui); color: var(--text); }
.set-row-sub { font: 500 12px/1.4 var(--font-ui); color: var(--muted); margin-top: 5px; }
.set-stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-top: 14px; }
@media (max-width: 560px) { .set-stat-grid { grid-template-columns: minmax(0, 1fr); } }
.set-stat { padding: 14px; border-radius: 12px; background: var(--bg2); border: 1px solid var(--line); }
.set-stat-label { font: 500 11px/1 var(--font-mono); color: var(--faint); margin-bottom: 7px; letter-spacing: 0.04em; }
.set-stat-value { font: 700 15px/1.2 var(--font-ui); color: var(--text); word-break: break-word; }
.set-stat-value.is-ok { color: var(--ok); }
/* device notifications (#push) — the settings block. The status line is the ONLY place this feature
   can explain itself (a denied permission is invisible otherwise), so it is a real element with its
   own error colour, not a title attribute. */
.push-state { font: 500 12.5px/1.5 var(--font-ui); color: var(--muted); margin: 0 2px 12px; }
.push-state.is-err { color: var(--danger); }
.push-note { font: 500 12px/1.5 var(--font-ui); color: var(--faint); margin: 0 2px 14px; }
/* The switch is the whole control: label + box are one target, so the 44px floor below applies to
   the thing a thumb actually aims at rather than to a 13px native checkbox. */
.push-toggle { display: inline-flex; align-items: center; gap: 10px; min-height: 44px; padding: 0 2px; cursor: pointer; font: 500 13.5px/1.3 var(--font-ui); color: var(--text); }
.push-toggle input { width: 20px; height: 20px; flex: 0 0 20px; cursor: pointer; }
.push-toggle input:disabled { cursor: progress; opacity: 0.6; }
.push-enable { min-height: 44px; }
/* install-as-app (#install) — the invitation, on two surfaces: a permanent card on /settings and a
   bottom sheet in the shell. NO BACKTICKS ANYWHERE IN THIS BLOCK — the whole stylesheet is one JS
   template literal and a stray backtick terminates it, taking every page's CSS with it. */
.install-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; padding: 12px 20px; border: 1px solid transparent; border-radius: 12px; background: var(--accent); color: var(--on-accent); font: 700 13.5px/1 var(--font-ui); cursor: pointer; transition: filter 0.14s var(--ease); }
.install-btn:hover { filter: brightness(1.07); }
.install-mount { display: inline-flex; }
/* The iOS half is an INSTRUCTION, not a control — Safari exposes no install API — so it is a
   sentence with the Share symbol beside it, because on an iPhone that button carries no label of
   its own to name in words. */
.install-ios { display: flex; align-items: flex-start; gap: 11px; margin: 4px 2px 14px; }
.install-glyph { flex: 0 0 auto; color: var(--accent); margin-top: 2px; }
.install-ios-body { display: flex; flex-direction: column; gap: 4px; }
.install-ios-title { font: 700 13.5px/1.3 var(--font-ui); color: var(--text); }
.install-ios-steps { font: 500 12.5px/1.55 var(--font-ui); color: var(--muted); }
.install-state { font: 500 12.5px/1.5 var(--font-ui); color: var(--muted); margin: 8px 2px 12px; }
/* THE SHEET. Bottom sheet on a phone (thumb-reachable, clear of the notch and of the browser's own
   URL bar), bottom-right dock on a desktop. NEVER A MODAL: no scrim, no focus trap, and it never
   takes focus — it is an invitation, not a gate, and the product stays fully usable behind it. Sits
   at --z-drawer so a real dialog (lightbox, picker, service notice) always covers it. */
.install-sheet { position: fixed; z-index: var(--z-drawer); left: 12px; right: 12px; bottom: 0; padding-bottom: calc(12px + env(safe-area-inset-bottom)); opacity: 0; transform: translateY(16px); transition: opacity 0.2s var(--ease), transform 0.2s var(--ease); }
/* pointer-events: the sheet is faded out for 220ms after a dismissal but still LAID OUT, and an
   invisible panel across the bottom of a phone swallows every tap that lands on it — the worst
   place to lose taps, since that is where thumbs rest. It only takes clicks while it is open. */
.install-sheet { pointer-events: none; }
.install-sheet.is-open { opacity: 1; transform: none; pointer-events: auto; }
.install-sheet-inner { display: flex; align-items: flex-start; gap: 13px; padding: 15px 14px; border: 1px solid var(--line2); border-radius: 18px; background: var(--surface2); box-shadow: 0 18px 54px rgba(0, 0, 0, 0.5); }
.install-sheet-icon { flex: 0 0 auto; width: 46px; height: 46px; border-radius: 12px; }
.install-sheet-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.install-sheet-title { margin: 0; font: 700 14.5px/1.3 var(--font-ui); color: var(--text); }
.install-sheet-text { margin: 0 0 6px; font: 500 12.5px/1.5 var(--font-ui); color: var(--muted); }
.install-sheet .install-ios { margin: 2px 0 4px; }
/* The sheet's own heading already says "add to your home screen", so the instruction block's
   heading would be the same sentence twice, one line apart — measured at 375px, it was. The card
   on /settings keeps it: there the block has no heading above it. */
.install-sheet .install-ios-title { display: none; }
.install-sheet .install-state { margin: 8px 0 0; }
.install-sheet-close { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 44px; height: 44px; min-height: 44px; margin: -5px -4px 0 0; border: none; border-radius: 12px; background: none; color: var(--faint); cursor: pointer; }
.install-sheet-close:hover { color: var(--text); background: var(--surface3); }
@media (min-width: 768px) { .install-sheet { left: auto; right: 20px; width: 384px; bottom: 10px; } }
/* The toast lane and the sheet both live bottom-right, and the toast wins on z-index — so the
   completion toast that TRIGGERS the sheet would sit on top of it for the rest of its six seconds.
   While the sheet is open the toasts move to the top instead. A later-sibling selector because
   notifications.js appends its lane to <body> after this element; both are direct children. */
.install-sheet.is-open ~ .notif-toasts { bottom: auto; top: calc(12px + env(safe-area-inset-top)); }
/* Author display rules beat the UA [hidden]{display:none} — every flex element toggled through the
   attribute needs its own guard (same law as .ip-linkrow / .notif-panel above). .push-enable is in
   this list because the notification card HIDES its button on an uninstalled iPhone, where the
   permission it asks for cannot be granted. */
.install-sheet[hidden], .install-card[hidden], .install-row[hidden], .install-ios[hidden], .install-mount[hidden], .push-enable[hidden] { display: none; }
/* Reduced motion gets the sheet without the slide. The global rule near the end of this sheet
   already collapses transition durations; this states the resting position outright so nothing
   depends on a 0.001ms transition having run. */
@media (prefers-reduced-motion: reduce) { .install-sheet { transition: none; transform: none; } }
/* queue */
.q-top { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin-bottom: 22px; flex-wrap: wrap; }
.q-filters { display: flex; gap: 7px; flex-wrap: wrap; }
.q-filter { padding: 8px 15px; border-radius: 999px; font: 600 12.5px/1 var(--font-ui); border: 1px solid var(--line); background: var(--surface); color: var(--muted); text-decoration: none; transition: all 0.15s var(--ease); }
.q-filter:hover { color: var(--text); text-decoration: none; }
.q-filter.active { color: var(--text); background: var(--accent-soft); border-color: var(--accent-ring); }
.q-list { display: flex; flex-direction: column; gap: 11px; }
.q-row { display: flex; align-items: center; gap: 16px; padding: 13px 15px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); transition: border-color 0.15s var(--ease); }
.q-row:hover { border-color: var(--line2); }
/* Flow row (a whole composite pipeline collapsed to one entry) — a left accent + a small tag mark it
   as a multi-step production distinct from an atomic job row. */
.q-flow { border-left: 3px solid color-mix(in srgb, var(--accent) 55%, var(--line)); }
.q-flow-tag { font: 600 10px/1 var(--font-ui); letter-spacing: 0.05em; text-transform: uppercase; color: var(--muted); border: 1px solid var(--line2); border-radius: 6px; padding: 2px 6px; }
/* A run parked at a human review gate. It carries NO progress bar (nothing is progressing), so the
   left accent + the tab's warn tint are the only things distinguishing it at a glance — without
   them a row whose only difference is its chip text reads as just another in-flight production,
   which is the confusion this bucket exists to end. */
.q-flow-attention { border-left-color: var(--warn); }
/* Two classes deep so this survives .q-filter.active (0,2,0) — a one-class rule lost exactly on
   the tab the operator is standing on, i.e. the only time the warn tint had a job to do. */
.q-filter.q-filter-attention { color: var(--text); border-color: color-mix(in srgb, var(--warn) 45%, var(--line)); }
.q-thumb-link { flex: none; display: block; }
.q-thumb { width: 74px; height: 74px; border-radius: 11px; overflow: hidden; border: 1px solid var(--line); object-fit: contain; background: var(--bg-deep); display: block; }
/* Operation icon centered on the fallback tile (no preview) — never an empty black square. */
.q-thumb-icon { position: absolute; inset: 0; z-index: 1; display: grid; place-items: center; color: var(--muted); opacity: 0.7; }
.q-body { flex: 1; min-width: 0; }
.q-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; flex-wrap: wrap; }
.q-op { font: 700 14.5px/1 var(--font-ui); color: var(--text); text-decoration: none; }
.q-op:hover { text-decoration: none; color: var(--accent); }
.q-msg { font: 500 12.5px/1.3 var(--font-ui); color: var(--muted); }
.q-msg.is-bad { color: var(--bad); }
.q-msg-sub { font: 500 11px/1.3 var(--font-mono); color: var(--faint); margin-top: 3px; }
.q-bar { height: 5px; border-radius: 999px; background: var(--surface3); overflow: hidden; max-width: 340px; margin-top: 9px; }
.q-bar-fill { height: 100%; border-radius: 999px; background: var(--accent); transition: width 0.3s ease; }

/* Before/after compare (enhancement jobs) */
.compare-panel .audit-col-header { display:flex; align-items:center; gap:10px; }
.compare-side-by-side { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
/* [designer #19 Ⓐ] The 1:1 thumb ratio is right for GRIDS of mixed media and wrong here: a 16:9
   clip in a square cell wastes ~45% of the cell's height (measured 303px of nothing per cell).
   The wrapper passes the artifact's own ratio; contain stays (letterbox law). */
.compare-side-by-side .media-thumb-poster, .compare-side-by-side .media-thumb--image img { aspect-ratio: var(--cmp-ratio, 1); }
.compare-side-by-side figure { margin:0; }
.compare-side-by-side figcaption { text-align:center; color:var(--muted); font-size:13px; margin-top:6px; }
.compare-slider { position:relative; overflow:hidden; border-radius:12px; border:1px solid var(--line); background:var(--bg-deep); }
.compare-slider img { display:block; width:100%; height:auto; }
/* before/after share the SAME aspect (upscale/restore preserve it) — fill aligns the layers
   pixel-perfect without the banned cover crop (thumbnail letterbox rule). */
.compare-before { position:absolute; inset:0; clip-path:inset(0 50% 0 0); width:100%; height:100%; object-fit:fill; }
.compare-divider { position:absolute; top:0; bottom:0; left:50%; width:2px; background:var(--accent, #e8a33d); pointer-events:none; }
.compare-tag { position:absolute; top:10px; padding:3px 9px; border-radius:999px; font-size:12px; background:rgba(0,0,0,.55); color:#fff; pointer-events:none; }
.compare-tag--before { left:10px; }
.compare-tag--after { right:10px; }
/* The AI-origin chip (NĐ 142 Điều 18.1) — bottom-right so it never collides with the after tag. */
.compare-tag--ai { top:auto; bottom:10px; right:10px; }
/* ONE home for "a slider wears the product accent", the same way radios and checkboxes have one.
   The two sliders share nothing else — one is laid over a comparison, the other sits in a rail. */
.compare-range, .wm-range { accent-color:var(--accent, #e8a33d); }
.compare-range { position:absolute; inset:auto 0 8px 0; width:calc(100% - 24px); margin:0 12px; }
@media (max-width:640px){ .compare-side-by-side { grid-template-columns:1fr; } }

.q-bar-fill.is-done { background: var(--ok); }
.q-meta { display: flex; flex-direction: column; align-items: flex-end; gap: 9px; flex: none; }
.q-time { font: 500 11px/1 var(--font-mono); color: var(--faint); white-space: nowrap; }
.q-actions { display: flex; gap: 7px; flex-wrap: wrap; justify-content: flex-end; }
.q-actions form.inline { display: inline; margin: 0; }
.q-actions form.inline[hidden] { display: none; }
.q-empty { text-align: center; padding: 48px 20px; border: 1px dashed var(--line2); border-radius: 16px; }
.q-empty-title { font: 600 14px/1.4 var(--font-ui); color: var(--text); margin-bottom: 5px; }
.q-empty-sub { font: 400 13px/1.5 var(--font-ui); color: var(--muted); }
@media (max-width: 560px) { .q-row { flex-wrap: wrap; } .q-meta { align-items: flex-start; width: 100%; flex-direction: row; justify-content: space-between; } }
/* library */
.lib-top { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin-bottom: 22px; flex-wrap: wrap; }
.lib-tabs { width: fit-content; }
.lib-tabs button { padding: 8px 18px; }
.lib-cats { display: flex; gap: 8px; flex-wrap: wrap; }
.lib-pill { padding: 7px 14px; border-radius: 999px; font: 600 12px/1 var(--font-ui); border: 1px solid var(--line); background: transparent; color: var(--muted); cursor: pointer; transition: all 0.15s var(--ease); }
.lib-pill:hover { color: var(--text); }
.lib-pill.active { color: var(--text); background: var(--accent-soft); border-color: var(--accent-ring); }
.lib-use { font: 600 11.5px/1 var(--font-ui); color: var(--accent); text-decoration: none; }
.lib-use:hover { text-decoration: none; filter: brightness(1.1); }
/* home */
.home-hero { margin-bottom: 38px; }
.home-greet { font-size: clamp(27px, 5.5vw, 40px); line-height: 1.05; margin: 6px 0 8px; }
.home-greet-name { font-style: italic; color: var(--accent); }
.home-lead { font: 400 16px/1.5 var(--font-ui); color: var(--muted); max-width: 60ch; margin: 0; }
.home-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; margin-bottom: 44px; }
/* The four composite flows read as a SENTENCE — what you bring, what the studio does, what you get —
   so the card is wide rather than tall and the three parts sit on one line where there is room.
   300px is the width at which the middle "work" label still fits without hyphenating in Vietnamese;
   below that the track stacks (see the 620px query) rather than squeezing three columns into a phone. */
.home-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 14px; margin-bottom: 44px; }
/* The card tells the whole story the operator's reference told: what goes IN, what the studio does,
   what comes OUT — with a picture on BOTH ends. An earlier version showed one small square and lost
   half the meaning; "cropped down to almost nothing" was the accurate description of it. */
.home-flow { position: relative; overflow: hidden; display: block; padding: 15px 16px 16px; border: 1px solid var(--line); border-radius: 18px; background: var(--surface); text-decoration: none; transition: transform 0.16s ease, border-color 0.16s ease; }
.home-flow:hover, .home-flow:focus-visible { transform: translateY(-3px); border-color: var(--accent-ring); text-decoration: none; }
.home-flow-wash { position: absolute; inset: 0; pointer-events: none; }
.home-flow-top { position: relative; display: flex; align-items: center; gap: 10px; margin-bottom: 13px; }
.home-flow-title { font: 700 15.5px/1.25 var(--font-ui); color: var(--text); flex: 1; min-width: 0; }
.home-flow-pro { flex: none; font: 600 9px/1 var(--font-mono); letter-spacing: 0.1em; padding: 4px 8px; border-radius: 999px; background: var(--accent-soft); color: var(--accent); border: 1px solid var(--accent-ring); }
.home-flow-track { position: relative; display: flex; align-items: flex-start; gap: 10px; }
.home-flow-side { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
/* Each plate carries its OWN aspect: the source art is square, the result frame is 16:9. Cropping
   happens only when a plate and its picture disagree on shape, so they are matched here rather
   than forced into one shared box. */
.home-flow-plate { width: 100%; aspect-ratio: 1; border-radius: 11px; background-size: cover; background-position: center; border: 1px solid var(--line2); }
.home-flow-plate--out { aspect-ratio: 16 / 9; }
.home-flow-cap { font: 600 9.5px/1 var(--font-mono); letter-spacing: 0.09em; text-transform: uppercase; color: var(--muted); }
.home-flow-val { font: 500 12px/1.35 var(--font-ui); color: var(--text); }
.home-flow-arrow { flex: none; width: 82px; display: flex; flex-direction: column; align-items: center; gap: 5px; padding-top: 26px; }
.home-flow-chip { width: 32px; height: 32px; border-radius: 10px; display: grid; place-items: center; background: color-mix(in srgb, var(--flow-tone) 14%, transparent); border: 1px solid color-mix(in srgb, var(--flow-tone) 34%, transparent); }
.home-flow-work { font: 400 10.5px/1.3 var(--font-ui); color: var(--muted); text-align: center; }
@media (max-width: 560px) {
  .home-flow-track { flex-direction: column; gap: 12px; }
  .home-flow-arrow { width: auto; flex-direction: row; align-items: center; gap: 9px; padding-top: 0; }
  .home-flow-work { text-align: left; }
}
.home-sec-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 16px; }
.home-sec-head h2 { font: 700 19px/1 var(--font-ui); margin: 0; }
.home-proj-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 16px; margin-bottom: 46px; }
.home-proj { border: 1px solid var(--line); border-radius: 16px; overflow: hidden; background: var(--surface); text-decoration: none; display: block; transition: transform 0.16s ease, border-color 0.16s ease; }
.home-proj:hover { transform: translateY(-3px); border-color: var(--line2); text-decoration: none; }
.home-proj-still { position: relative; display: block; aspect-ratio: 16 / 9; overflow: hidden; }
.home-proj-badge { position: absolute; top: 11px; right: 11px; font: 600 9.5px/1 var(--font-mono); letter-spacing: 0.06em; text-transform: uppercase; padding: 4px 8px; border-radius: 999px; backdrop-filter: blur(6px); background: rgba(10, 10, 12, 0.6); }
.home-proj-body { display: block; padding: 15px 16px 16px; }
.home-proj-title { display: block; font: 500 19px/1.15 var(--font-display); color: var(--text); margin-bottom: 5px; }
.home-proj-meta { display: block; font: 500 12.5px/1 var(--font-ui); color: var(--muted); margin-bottom: 12px; }
.home-proj-bar { display: block; height: 5px; border-radius: 999px; background: var(--surface3); overflow: hidden; }
.home-proj-fill { display: block; height: 100%; border-radius: 999px; background: var(--accent); }
.home-recent-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 13px; }
.home-recent { position: relative; aspect-ratio: 1; border-radius: 13px; overflow: hidden; border: 1px solid var(--line); cursor: pointer; padding: 0; background: var(--bg-deep); transition: transform 0.16s ease; display: block; }
.home-recent:hover { transform: translateY(-3px); }
/* Hot-trend cell = the square preview + a "Thay thế" action under it. The preview stays a <button>
   (it opens the lightbox), so the action CANNOT be nested inside it — interactive elements do not
   nest. The cell becomes the grid item and the preview keeps its 1:1 box. */
.home-trend-cell { display: flex; flex-direction: column; gap: 7px; min-width: 0; }
.home-trend-cell .home-recent { width: 100%; }
/* flex, not block: with the coarse-pointer 44px floor a block box leaves the label stuck at the
   top of the button. Centred both ways so the label sits in the middle at either height. */
.home-trend-act { display: flex; align-items: center; justify-content: center; text-align: center;
  padding: 6px 8px; border-radius: 9px;
  border: 1px solid var(--line); background: var(--surface2); color: var(--text);
  font: 600 11.5px/1.25 var(--font-ui); text-decoration: none; min-height: 32px; }
/* :active as well as :hover — a touch device never hovers, so a hover-only affordance gives the
   finger no feedback at all that the tap registered. */
.home-trend-act:hover, .home-trend-act:active { border-color: var(--accent); color: var(--accent); }
.home-recent img { width: 100%; height: 100%; object-fit: contain; display: block; }
.home-recent-meta { position: absolute; left: 10px; right: 10px; bottom: 9px; text-align: left; }
.home-recent-op { font: 600 11.5px/1.2 var(--font-ui); color: #fff; }
/* compose */
.cmp-grid { display: flex; flex-wrap: wrap; gap: 22px; align-items: flex-start; }
.cmp-slots { flex: 1 1 300px; min-width: 0; display: flex; flex-direction: column; gap: 11px; }
.cmp-slots-eyebrow { font: 600 10px/1 var(--font-mono); letter-spacing: 0.14em; color: var(--faint); text-transform: uppercase; margin: 0 0 2px; }
.cmp-slot { border: 1px solid var(--line); border-radius: 14px; background: var(--surface); padding: 12px 13px; }
.cmp-slot.is-req { border-color: var(--line2); }
.cmp-slot-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
.cmp-slot-label { font: 600 9px/1 var(--font-mono); letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted); }
.cmp-req { color: var(--accent); }
.cmp-slot-hint { font: 500 9px/1 var(--font-mono); letter-spacing: 0.04em; color: var(--faint); padding: 3px 7px; border-radius: 999px; background: var(--bg2); border: 1px solid var(--line); }
.cmp-select { width: 100%; padding: 10px 12px; border-radius: 9px; border: 1px solid var(--line); background: var(--bg2); color: var(--text); font: 500 13px/1 var(--font-ui); }
.cmp-main { flex: 2 1 440px; min-width: 0; display: flex; flex-direction: column; gap: 16px; }
.cmp-stage { position: relative; border-radius: 18px; overflow: hidden; border: 1px solid var(--line); background: linear-gradient(160deg, var(--surface2) 0%, var(--bg2) 45%, var(--surface3) 100%); aspect-ratio: 16 / 10; display: grid; place-items: center; }
.cmp-stage-empty { text-align: center; padding: 30px; max-width: 34ch; }
.cmp-stage-eyebrow { font: 600 10px/1 var(--font-mono); letter-spacing: 0.16em; color: var(--faint); margin: 0 0 11px; }
.cmp-stage-msg { font: 500 19px/1.4 var(--font-display); color: var(--muted); margin: 0; }
.cmp-prompt { border: 1px solid var(--line2); border-radius: 16px; background: var(--surface); padding: 15px 16px 13px; box-shadow: 0 14px 40px rgba(0, 0, 0, 0.28); }
.cmp-prompt textarea { width: 100%; min-height: 54px; resize: none; border: none; background: none; color: var(--text); font: 400 15px/1.5 var(--font-ui); padding: 0; }
.cmp-prompt textarea::placeholder { color: var(--muted); }
.cmp-prompt-row { display: flex; align-items: center; gap: 8px; margin-top: 11px; flex-wrap: wrap; }
.cmp-out { display: inline-flex; align-items: center; gap: 5px; padding: 6px 12px; border-radius: 999px; cursor: pointer; font: 600 12.5px/1 var(--font-ui); color: var(--muted); }
/* create — unified mode-switched surface */
.cr-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin-bottom: 22px; flex-wrap: wrap; }
.create-modes { display: flex; gap: 5px; padding: 5px; border-radius: 13px; background: var(--surface); border: 1px solid var(--line); flex-wrap: wrap; }
.create-mode { display: inline-flex; align-items: center; gap: 8px; padding: 9px 16px; border-radius: 9px; cursor: pointer; font: 600 13.5px/1 var(--font-ui); white-space: nowrap; border: 1px solid transparent; color: var(--muted); text-decoration: none; transition: all 0.15s var(--ease); }
.create-mode:hover { color: var(--text); text-decoration: none; }
.create-mode.active { color: var(--text); background: var(--accent-soft); border-color: var(--accent-ring); }
.create-mode.active svg { stroke: var(--accent); }
.cr-grid { display: flex; flex-wrap: wrap; gap: 22px; align-items: flex-start; }
.cr-main { flex: 2 1 440px; min-width: 0; display: flex; flex-direction: column; gap: 16px; }
.cr-rail { flex: 1 1 312px; min-width: 0; display: flex; flex-direction: column; gap: 14px; }
.cr-stage { position: relative; border-radius: 18px; overflow: hidden; border: 1px solid var(--line); background: linear-gradient(160deg, var(--surface2) 0%, var(--bg2) 45%, var(--surface3) 100%); aspect-ratio: 16 / 10; display: grid; place-items: center; }
.cr-stage-empty { text-align: center; padding: 30px; max-width: 36ch; }
.cr-stage-eyebrow { font: 600 10px/1 var(--font-mono); letter-spacing: 0.16em; color: var(--faint); margin: 0 0 11px; }
.cr-stage-msg { font: 500 19px/1.4 var(--font-display); color: var(--muted); margin: 0; }
.cr-prompt { border: 1px solid var(--line2); border-radius: 16px; background: var(--surface); padding: 15px 16px 13px; box-shadow: 0 14px 40px rgba(0, 0, 0, 0.28); }
.cr-prompt textarea { width: 100%; min-height: 58px; resize: none; border: none; background: none; color: var(--text); font: 400 15px/1.5 var(--font-ui); padding: 0; }
.cr-prompt textarea::placeholder { color: var(--muted); }
.cr-prompt-row { display: flex; align-items: center; gap: 8px; margin-top: 11px; flex-wrap: wrap; }
.rail-block { border: 1px solid var(--line); border-radius: 16px; background: var(--surface); padding: 16px; }
/* A rail block that holds only the form's submit: no card chrome, and the button fills the rail.
   Replaces the inline background:none;border:none;padding:0 + width:100% the STT page carried. */
.rail-submit { border: none; background: none; padding: 0; }
.rail-submit .dc-btn { width: 100%; justify-content: center; }
/* No display:block here on purpose — it would be a no-op, and one was added (then reverted) on the
   false premise that a <label> is inline. All three elements that wear this class already render as
   blocks: p and legend from the UA sheet, label from the global "label { display: block }" rule
   ~1550 lines above. The 11px also already wins: .rail-label is 0-1-0, that label rule is 0-0-1. */
.rail-label { font: 600 12px/1 var(--font-ui); color: var(--muted); margin: 0 0 11px; }
/* Accent tint for the character/reference block. background-IMAGE, not the background shorthand:
   the shorthand also resets background-color, which silently dropped .rail-block's var(--surface)
   and let the page gradient show through a block everyone downstream assumes is opaque surface.
   That is what pushed the .rail-hint inside it under 4.5:1 (faint 4.35 dark / 4.18 light; even
   --muted only reached 4.38 on the light page body). Composited over --surface it is 5.54 / 4.76.
   The sibling declaration already used the border-COLOR longhand for the same reason — this class
   is a modifier on .rail-block, so every property it touches must be a longhand. */
.rail-char { background-image: linear-gradient(160deg, var(--accent-soft), transparent); border-color: var(--accent-ring); }
.asp-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
.asp-opt { display: flex; flex-direction: column; align-items: center; gap: 7px; padding: 11px 6px; border-radius: 10px; cursor: pointer; font: 600 11.5px/1 var(--font-ui); border: 1px solid var(--line); background: var(--bg2); color: var(--muted); transition: all 0.14s var(--ease); }
.asp-opt input { position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; }
.asp-opt:has(input:checked) { border-color: var(--accent-ring); background: var(--accent-soft); color: var(--text); }
/* P3-14: the radio input is visually hidden, so keyboard focus had no indicator. Mirror the
   app-wide :focus-visible ring (2px solid var(--accent), offset 2px) onto the option wrapper. */
.asp-opt:has(input:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; }
.asp-box { border: 1.8px solid currentColor; border-radius: 3px; }
.rail-advanced { border: 1px solid var(--line); border-radius: 16px; background: var(--surface); padding: 14px 16px; }
.rail-advanced summary { cursor: pointer; font: 600 12.5px/1 var(--font-ui); color: var(--muted); list-style: none; }
.rail-advanced summary::-webkit-details-marker { display: none; }
.rail-adv-body { margin-top: 13px; display: flex; flex-direction: column; gap: 11px; }
.rail-mini { display: flex; flex-direction: column; gap: 6px; font: 500 11.5px/1 var(--font-ui); color: var(--muted); }
.rail-mini input { width: 100%; padding: 9px 11px; border-radius: 9px; border: 1px solid var(--line); background: var(--bg2); color: var(--text); font: 400 13px/1 var(--font-ui); }
/* create canvas — audio / replace shared cr-* controls */
.cr-gate { margin: 14px 0 0; }
/* rail form fields: a labelled select / number / text input inside a .rail-block */
.rail-field { display: flex; flex-direction: column; gap: 7px; }
.rail-field + .rail-field { margin-top: 13px; }
/* The accessible name of every control in a rail — real content, and it was 11.5px, under the
   same 12px floor as .rail-hint above. Raised with it, and the two now MATCH at 12.5px on purpose:
   pages.js nests a rail-hint span (không bắt buộc) INSIDE the language label, so
   leaving the label behind would have printed the optional-suffix LARGER than the label it
   qualifies. Weight (600 vs 500) carries the hierarchy here, not size. */
.rail-field > label, .rail-field > .rail-field-label { font: 600 12.5px/1.3 var(--font-ui); color: var(--muted); }
.rail-field select, .rail-field input, .rail-field textarea {
  width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line);
  background: var(--bg2); color: var(--text); font: 400 13.5px/1.3 var(--font-ui);
  transition: border-color 0.14s var(--ease);
}
.rail-field select:focus, .rail-field input:focus, .rail-field textarea:focus { border-color: var(--accent-ring); }
.rail-field textarea { resize: vertical; min-height: 66px; }
/* Instruction copy — the "what does this field want" text on every form, across 26 call sites in
   9 files ("Người sẽ mặc trang phục — khuôn mặt, tóc và dáng giữ nguyên…", "clip 20 giây ≈ 27
   phút"). It is content, not decoration, and it was 500 11px/1.4 var(--faint): below the 12px
   floor and NOT gated by pointer type, so a phone got 11px too (UI audit 2026-08-09, finding 2).
   Both values below are load-bearing and were measured — do not walk either one back:
   · 12.5px applies to EVERYONE. Do NOT wrap it in @media (pointer: coarse). The defect was that
     small screens were never special-cased; re-gating it rebuilds exactly that.
   · var(--muted), not var(--faint). On var(--surface) faint already passed (5.48 dark / 5.30
     light) so the surface alone does not justify the token — but this class also renders inside
     .rail-char (/create), where faint measures 4.35 dark / 4.18 light and FAILS 4.5:1 while
     muted measures 5.54 / 4.76. It also clears the --bg2 tripwire: faint 4.49 light, muted 4.71.
   .rail-note, .scope-opt-desc and .ac-hint do this same job and were already >=12px on --muted;
   .rail-hint was the lone outlier, which is how it drifted this far without anyone noticing. */
.rail-hint { font: 500 12.5px/1.5 var(--font-ui); color: var(--muted); margin: 4px 0 0; }
.rail-note { font: 500 12px/1.5 var(--font-ui); color: var(--muted); margin: 11px 0 0; }
.rail-note a { color: var(--accent); }
/* radio group inside a rail block (lipsync audio-mode, etc.) */
.rail-radios { display: flex; flex-direction: column; gap: 9px; }
.rail-radio { display: flex; align-items: center; gap: 9px; font: 600 12.5px/1.2 var(--font-ui); color: var(--text); cursor: pointer; }
.rail-radio input { flex: none; }
/* prompt-bar variant that hosts a labelled text stage (TTS): keeps the cr-prompt chrome */
.cr-prompt label.cr-prompt-label { display: block; font: 600 11px/1 var(--font-ui); letter-spacing: 0.02em; color: var(--muted); margin: 0 0 8px; }
.cr-prompt textarea[rows] { min-height: 84px; }
/* stage variants ------------------------------------------------------------- */
/* audio result: a compact centered player card, not the tall 16:10 black void */
.cr-stage--audio { aspect-ratio: auto; min-height: 220px; background: linear-gradient(160deg, var(--surface2), var(--surface)); border-color: var(--line); }
.cr-stage-audio { display: flex; flex-direction: column; align-items: center; gap: 16px; width: 100%; max-width: 520px; padding: 30px 24px; text-align: center; }
.cr-stage-audio-icon { width: 58px; height: 58px; border-radius: 50%; flex: none; display: grid; place-items: center; background: var(--accent-soft); border: 1px solid var(--accent-ring); color: var(--accent); }
/* Two elements share this look and they are NOT the same thing (audit 2026-08-09):
   .cr-stage-audio h2 is a real section — the cloned-voice list on /audio-studio/voice-clone;
   .cr-stage-audio-title is a <p>, because on the SFX/Music/TTS result card and the planned-op
   stub it only repeats the page title beside the player. Both were <h3> under a bare <h1>.
   The "font:" shorthand already pins weight 500, so the <p> renders identically to the old <h3>. */
.cr-stage-audio h2, .cr-stage-audio .cr-stage-audio-title { margin: 0; font: 500 17px/1.2 var(--font-display); color: var(--text); }
.cr-stage-audio audio { width: 100%; }
/* transcript panel (STT) rendered inside the stage */
.cr-stage--doc { aspect-ratio: auto; min-height: 260px; align-items: stretch; justify-items: stretch; background: var(--surface); }
.cr-stage-doc { width: 100%; height: 100%; padding: 22px 24px; overflow: auto; text-align: left; font: 400 14.5px/1.7 var(--font-ui); color: var(--text); white-space: pre-wrap; position: relative; }
/* before | after split (replace result) */
.cr-stage--split { aspect-ratio: auto; min-height: 300px; background: var(--surface); padding: 0; }
.cr-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); width: 100%; height: 100%; }
.cr-split-cell { position: relative; display: grid; place-items: center; padding: 14px; min-width: 0; overflow: hidden; }
.cr-split-cell + .cr-split-cell { border-left: 1px solid var(--line); }
.cr-split-tag { position: absolute; top: 10px; left: 10px; z-index: 2; font: 600 9.5px/1 var(--font-mono); letter-spacing: 0.12em; text-transform: uppercase; padding: 5px 9px; border-radius: 999px; background: rgba(8,8,10,0.62); color: #fff; }
.cr-split-cell img, .cr-split-cell video { max-width: 100%; max-height: 100%; border-radius: 8px; object-fit: contain; }
.cr-split-empty { font: 500 12.5px/1.4 var(--font-ui); color: var(--faint); text-align: center; padding: 20px; }
@media (max-width: 620px) { .cr-split { grid-template-columns: minmax(0, 1fr); } .cr-split-cell + .cr-split-cell { border-left: 0; border-top: 1px solid var(--line); } }
/* Watermark stage. The frame is an inline box that takes the media's own shape, because the stamp
   is positioned as a FRACTION of the frame and a letterboxed box would put it over a black bar
   instead of over the picture. object-fit stays contain (never cover) — the whole promise here is
   that what is on screen is what gets burned in, and a crop would break that first. */
.wm-stage { position: relative; display: grid; place-items: center; min-height: 320px; padding: 14px; background: var(--surface); border: 1px solid var(--line); border-radius: 14px; overflow: hidden; }
.wm-frame { position: relative; display: inline-block; max-width: 100%; line-height: 0; }
.wm-media { display: block; max-width: 100%; max-height: min(62vh, 560px); border-radius: 8px; object-fit: contain; }
.wm-mark { position: absolute; top: 0; left: 0; pointer-events: none; white-space: nowrap; line-height: 1.15; }
.wm-mark[hidden] { display: none; }
.wm-empty { position: absolute; margin: 0; padding: 20px; font: 500 12.5px/1.45 var(--font-ui); color: var(--faint); text-align: center; }
.wm-empty[hidden] { display: none; }
.wm-modes { display: flex; gap: 16px; flex-wrap: wrap; }
.wm-row { display: flex; gap: 12px; align-items: flex-end; margin-top: 12px; }
.wm-grow { flex: 1; min-width: 0; }
.wm-range { width: 100%; }
.wm-color { width: 48px; height: 34px; padding: 2px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); }
/* An author display rule beats the UA [hidden], so the mode blocks need this spelled out. */
.rail-block[data-wm-when][hidden] { display: none; }
/* Replace rail controls: scope tabs, scope option cards, "or" divider */
.tr-panel[hidden] { display: none; }
/* /edit/cleanup Photo and Video tabs. The inactive tab's controls sit in a DISABLED fieldset, so a
   hidden required picker cannot block a submit made with scripting off; this resets the UA fieldset
   frame. min-width 0 is load-bearing: the UA min-inline-size of min-content lets one wide child push
   a 375px page sideways. */
.oc-fieldset { border: 0; padding: 0; margin: 0; min-width: 0; min-inline-size: 0; }
.oc-tabs { margin: 16px 0 12px; }
/* The Photo tab region tool (public/cleanup-regions.mjs). The photo is letterboxed with contain, never
   cropped, because a drawn box over a cropped photo would cover pixels the user cannot see. The drawing
   layer is placed over the photo's content box by the module, and boxes sit in percent of that layer,
   so they follow every resize. touch-action none keeps a drag on the photo from scrolling the page.
   Boxes and the remove buttons are real buttons, so the shell's button look is reset on both. */
.oc-regions { margin: 14px 0 4px; }
.oc-regions[hidden] { display: none; }
.oc-stage { position: relative; width: 100%; max-height: min(60vh, 540px); aspect-ratio: 4 / 3; margin-top: 8px; background: var(--bg-deep); border: 1px solid var(--line); border-radius: var(--radius-md); -webkit-user-select: none; user-select: none; }
.oc-photo { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; border-radius: inherit; pointer-events: none; }
.oc-layer { position: absolute; left: 0; top: 0; touch-action: none; cursor: crosshair; }
.oc-box { position: absolute; box-sizing: border-box; margin: 0; padding: 0; min-width: 0; min-height: 0; border: 2px solid var(--accent); border-radius: 4px; background: var(--accent-soft); color: inherit; font: inherit; box-shadow: none; transform: none; transition: none; cursor: move; touch-action: none; }
.oc-box:hover { background: var(--accent-soft); border-color: var(--accent-hover); box-shadow: none; transform: none; }
.oc-box:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
.oc-box-num { position: absolute; top: 3px; left: 3px; padding: 1px 6px; border-radius: var(--r-pill); background: var(--accent); color: var(--on-accent); font: 700 11px/1.3 var(--font-ui); pointer-events: none; }
.oc-box-handle { position: absolute; right: 0; bottom: 0; width: 16px; height: 16px; border-radius: 4px 0 2px 0; background: var(--accent); cursor: nwse-resize; }
.oc-region-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; margin-top: 10px; }
.oc-region-list { list-style: none; display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 0; padding: 0; }
.oc-region-list:empty { display: none; }
.oc-region-item { display: inline-flex; align-items: center; gap: 4px; padding: 3px 3px 3px 11px; border: 1px solid var(--line2); border-radius: var(--r-pill); background: var(--surface2); color: var(--text); font: 600 12.5px/1.2 var(--font-ui); }
.oc-region-del { display: inline-grid; place-items: center; margin: 0; padding: 0; min-width: 30px; min-height: 30px; border: 0; border-radius: var(--r-pill); background: transparent; color: var(--muted); font: 600 17px/1 var(--font-ui); box-shadow: none; transform: none; cursor: pointer; }
.oc-region-del:hover, .oc-region-del:focus-visible { color: var(--text); background: var(--surface3); box-shadow: none; transform: none; }
/* While the tool shows the photo it IS the preview, so the picker's own preview box steps aside. */
.oc-fieldset[data-oc-staged] .input-picker .upload-preview { display: none; }
.cr-scope-tabs { display: flex; gap: 7px; margin-bottom: 4px; }
.cr-scope-tab { flex: 1; padding: 11px 12px; border-radius: 11px; border: 1px solid var(--line); background: var(--surface); color: var(--muted); font: 600 13.5px/1 var(--font-ui); cursor: pointer; transition: all 0.14s var(--ease); }
.cr-scope-tab:hover { color: var(--text); border-color: var(--line2); }
.cr-scope-tab[aria-selected="true"] { border-color: var(--accent-ring); background: var(--accent-soft); color: var(--text); }
.rail-or { text-align: center; color: var(--faint); font: 500 11px/1 var(--font-mono); letter-spacing: 0.08em; text-transform: uppercase; margin: 11px 0; }
.scope-block { border: 1px solid var(--line); }
.scope-grid { display: flex; flex-direction: column; gap: 9px; margin-top: 4px; }
.scope-opt { display: flex; gap: 11px; align-items: flex-start; padding: 12px 13px; cursor: pointer; border: 1px solid var(--line); border-radius: 12px; background: var(--bg2); transition: border-color 0.14s var(--ease), background 0.14s var(--ease); }
.scope-opt:has(input:checked) { border-color: var(--accent-ring); background: var(--accent-soft); }
.scope-opt input { margin-top: 2px; flex: none; }
.scope-opt-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.scope-opt-title { font: 600 13.5px/1.2 var(--font-ui); color: var(--text); }
.scope-opt-desc { font: 500 12px/1.45 var(--font-ui); color: var(--muted); }
.scope-opt[hidden] { display: none; }
@media (max-width: 560px) { .create-mode { padding: 8px 11px; font-size: 12.5px; } }
/* projects */
.proj-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 18px; margin-bottom: 30px; }
/* Wrap = positioning context for the delete overlay; the card anchor stays the whole-card link
   (a form inside an <a> is invalid HTML, so the form is a SIBLING). Same overlay shape as
   .asset-del; top offset clears the .proj-status badge (top 12px, ~20px tall). */
.proj-card-wrap { position: relative; }
.proj-del-form { position: absolute; top: 44px; right: 12px; z-index: 3; margin: 0; }
/* [#64] Opacity 0.92, not 0.6-until-hover. The operator could not find this control at all;
   a delete that only appears when the pointer is already on the card is invisible to anyone
   scanning the page for it, and invisible on every touch device by construction. The hover
   rule below still lifts it to full — it now brightens a visible control instead of
   revealing a hidden one. */
.proj-del { width: 27px; height: 27px; border-radius: 50%; border: none; cursor: pointer; background: rgba(10,13,20,0.72); color: #fff; font-size: 13px; line-height: 1; display: inline-flex; align-items: center; justify-content: center; opacity: 0.92; transition: opacity var(--dur-fast), background var(--dur-fast); }
.proj-card-wrap:hover .proj-del, .proj-del:focus-visible { opacity: 1; }
.proj-del:hover { background: var(--bad); opacity: 1; }
.proj-card { border: 1px solid var(--line); border-radius: 18px; overflow: hidden; background: var(--surface); text-decoration: none; display: block; transition: transform 0.16s ease, border-color 0.16s ease; }
.proj-card:hover { transform: translateY(-3px); border-color: var(--line2); text-decoration: none; }
.proj-still { position: relative; display: block; aspect-ratio: 16 / 9; overflow: hidden; }
/* The card's own poster (designer #12 ⓵) — CONTAIN per the letterbox law (cover is banned), the
   gradient shows through the bars. Same class serves the home MV cards. */
.proj-still-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
/* The wash behind a portrait poster in a landscape strip (designer, poster follow-up). contain +
   scale(3) fills the box for every practical ratio WITHOUT the banned cover token: this layer is
   decoration — the artwork above it stays contain, uncropped. Blur turns the over-zoom into a
   colour wash; the tile's overflow:hidden clips the spill. */
.proj-still-bg { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; transform: scale(3); filter: blur(24px) saturate(1.15); opacity: 0.5; }
.q-thumb-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
.proj-kind { position: absolute; top: 12px; left: 12px; font: 600 9.5px/1 var(--font-mono); letter-spacing: 0.12em; padding: 5px 9px; border-radius: 999px; background: rgba(10, 10, 12, 0.6); color: #fff; }
.proj-status { position: absolute; top: 12px; right: 12px; font: 600 9.5px/1 var(--font-mono); letter-spacing: 0.06em; text-transform: uppercase; padding: 5px 9px; border-radius: 999px; backdrop-filter: blur(6px); background: rgba(10, 10, 12, 0.55); }
/* [designer #10 [22]] These two badges keep their dark scrim in BOTH themes, so their text keeps
   the DARK palette's hues in both themes too (same pattern as .proj-kind's hardcoded #fff). The
   themed dc-status-* colors are tuned for light tint pills — on this scrim they measure
   2.8-3.6:1 in light. Scrim backgrounds win by order; only the COLOR needs pinning here. */
.home-proj-badge.dc-status-ok, .proj-status.dc-status-ok { color: #67c094; }
.home-proj-badge.dc-status-run, .proj-status.dc-status-run { color: #e3a55f; }
.home-proj-badge.dc-status-bad, .proj-status.dc-status-bad { color: #e0705f; }
.home-proj-badge.dc-status-wait, .proj-status.dc-status-wait { color: #a3a097; background: rgba(10, 10, 12, 0.6); }
.home-proj-badge.dc-status-idle, .proj-status.dc-status-idle { color: #8f8d85; background: rgba(10, 10, 12, 0.6); }
.proj-body { display: block; padding: 17px 18px 18px; }
.proj-title { display: block; font: 500 22px/1.1 var(--font-display); color: var(--text); margin-bottom: 6px; }
.proj-meta { display: block; font: 500 12.5px/1 var(--font-ui); color: var(--muted); margin-bottom: 15px; }
.proj-bar { display: block; height: 5px; border-radius: 999px; background: var(--surface3); overflow: hidden; margin-bottom: 10px; }
.proj-fill { display: block; height: 100%; border-radius: 999px; background: var(--accent); }
.proj-foot { display: flex; align-items: center; justify-content: space-between; }
.proj-stage { display: inline-flex; align-items: center; gap: 6px; font: 600 11.5px/1 var(--font-ui); color: var(--accent); }
.proj-stage::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: var(--accent); }
.proj-updated { font: 500 11px/1 var(--font-mono); color: var(--faint); }
.proj-types { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
.proj-type { display: flex; align-items: center; gap: 12px; padding: 15px 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); color: var(--text); text-decoration: none; transition: border-color 0.15s ease; }
.proj-type:hover { border-color: var(--line2); text-decoration: none; }
.proj-type svg { color: var(--accent); flex: none; }
.proj-type span { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.proj-type b { font: 700 14px/1.1 var(--font-ui); }
.proj-type i { font: 400 12px/1.4 var(--font-ui); color: var(--muted); font-style: normal; }
/* project detail — stepper + keyframe review gate */
.pd-back { margin-bottom: 14px; display: inline-block; }
.pd-hero { position: relative; border-radius: 20px; overflow: hidden; border: 1px solid var(--line); margin: 0 0 26px; min-height: 200px; display: flex; align-items: flex-end; }
.pd-hero-grad { position: absolute; inset: 0; }
.pd-hero-scrim { position: absolute; inset: 0; background: linear-gradient(to top, rgba(8, 8, 10, 0.9), rgba(8, 8, 10, 0.25) 60%, transparent); }
.pd-hero-body { position: relative; padding: 26px 28px; width: 100%; display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; flex-wrap: wrap; }
.pd-hero-kind { font: 600 9.5px/1 var(--font-mono); letter-spacing: 0.14em; padding: 5px 10px; border-radius: 999px; background: rgba(10, 10, 12, 0.55); color: #fff; }
.pd-hero-title { font: 500 clamp(26px, 5.5vw, 38px)/1.02 var(--font-display); margin: 13px 0 7px; color: #fff; }
.pd-hero-meta { font: 500 13.5px/1 var(--font-ui); color: rgba(255, 255, 255, 0.72); margin: 0; }
.pd-section-title { font: 700 16px/1 var(--font-ui); margin: 0 0 15px; }
.pd-stepper { display: flex; align-items: flex-start; padding: 18px 20px; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); overflow-x: auto; margin-bottom: 14px; position: relative; }
.pd-step { display: flex; flex-direction: column; align-items: center; gap: 8px; min-width: 80px; text-align: center; }
.pd-step-dot { width: 13px; height: 13px; border-radius: 50%; flex: none; border: 2px solid var(--line2); background: transparent; }
.pd-step.is-done .pd-step-dot { border-color: var(--ok); background: var(--ok); }
.pd-step.is-active .pd-step-dot { border-color: var(--accent); background: var(--accent); box-shadow: 0 0 0 4px var(--accent-soft); }
.pd-step-label { font: 700 11.5px/1.2 var(--font-ui); color: var(--text); }
.pd-step.is-pending .pd-step-label { color: var(--muted); }
.pd-step-sub { font: 600 9px/1 var(--font-mono); letter-spacing: 0.04em; color: var(--faint); }
.pd-step.is-active .pd-step-sub { color: var(--accent); }
.pd-conn { flex: 1; height: 2px; min-width: 16px; margin-top: 5px; background: var(--line2); }
.pd-conn.is-done { background: var(--ok); }

/* Page head */
.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: var(--s4); flex-wrap: wrap; margin-bottom: var(--s6); }
.page-head h1 { font-size: clamp(24px, 4vw, 32px); font-weight: 800; margin: 0; letter-spacing: -0.02em; }
.page-head .page-sub { margin: 6px 0 0; color: var(--muted); font-size: 15px; max-width: 64ch; }
.page-head-actions { display: flex; gap: var(--s2); flex-wrap: wrap; }

/* Button system (v2) — layered over legacy button styles */
.btn-icon { display: inline-flex; align-items: center; gap: 7px; }
.btn-icon .ic { font-size: 17px; }
button:focus-visible, .button:focus-visible, a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; box-shadow: 0 0 0 3px var(--accent-ring); }

/* Asset grid + cards */
.asset-grid { display: grid; gap: var(--s4); grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); }
.asset-card { position: relative; border: 1px solid var(--border); border-radius: var(--radius-md); overflow: hidden; background: var(--surface); transition: border-color var(--dur-fast), transform var(--dur-fast); display: flex; flex-direction: column; }
.asset-card:hover { border-color: var(--border-strong); transform: translateY(-2px); }
.asset-del { position: absolute; top: 7px; right: 7px; z-index: 3; width: 27px; height: 27px; border-radius: 50%; border: none; cursor: pointer; background: rgba(10,13,20,0.62); color: #fff; font-size: 13px; line-height: 1; display: inline-flex; align-items: center; justify-content: center; opacity: 0.6; transition: opacity var(--dur-fast), background var(--dur-fast); }
.asset-card:hover .asset-del { opacity: 1; }
.asset-del:hover { background: var(--bad); opacity: 1; }
/* Favourite star. Sits on the LEFT so it never collides with the delete X on the right, and stays
   fully visible when starred — unlike delete, a filled star is state the user needs to read at a
   glance, not an action that should recede until hover. 44px hit area on coarse pointers. */
/* Anchors the corner overlays to the SQUARE thumb, not the whole card — bottom of the card is the
   actions row. */
.asset-media { position: relative; }
/* Bottom-RIGHT of the thumb (operator 2026-08-19): at top-left it sat on the .asset-kind badge and
   was unreadable; top-right belongs to the delete button. */
.asset-fav { position: absolute; bottom: 7px; right: 7px; z-index: 3; width: 27px; height: 27px; border-radius: 50%; border: none; cursor: pointer; background: rgba(10,13,20,0.62); color: #fff; font-size: 15px; line-height: 1; display: inline-flex; align-items: center; justify-content: center; opacity: 0.6; transition: opacity var(--dur-fast), color var(--dur-fast); }
.asset-card:hover .asset-fav, .asset-fav:focus-visible { opacity: 1; }
.asset-fav.is-fav { opacity: 1; color: #ffc94d; }
/* Voice attribute chips (#voice-filter) — read-only facet summary on a voice row. Renders only
   when the voice HAS attributes, so an unfilled voice shows nothing rather than a row of blanks. */
.voice-chips { display: inline-flex; flex-wrap: wrap; gap: 5px; margin: 0 8px; vertical-align: middle; }
.voice-chip { font: 500 11px/1 var(--font-ui); padding: 3px 8px; border-radius: var(--r-pill); background: var(--surface2); color: var(--muted); border: 1px solid var(--line); }
.voice-chip--more { background: transparent; }
.voice-facet-set { border: 1px solid var(--line); border-radius: var(--radius-md); padding: 10px 12px; margin: 0 0 var(--s4); }
.voice-facet-set > legend { font: 600 13px/1 var(--font-ui); padding: 0 6px; }
.voice-facet-boxes { display: flex; flex-wrap: wrap; gap: 6px 14px; }
.voice-facet-box { display: inline-flex; align-items: center; gap: 6px; min-height: 36px; font-size: 13px; cursor: pointer; }
.voice-facet-box input { width: 17px; height: 17px; }
/* Voice facet FILTER row. Wraps rather than scrolls — it sits in a narrow rail on the audio forms
   and full width in the Library, and a horizontally-scrolling control bar hides its own options. */
.voice-filter { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin: 0 0 var(--s4); padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface2); }
.voice-filter-title { font: 600 12px/1 var(--font-ui); text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
.voice-filter-sel { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
.voice-filter-sel select { min-height: 34px; font-size: 13px; padding: 4px 8px; max-width: 160px; }
.voice-filter-chips { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.voice-filter-legend { font-size: 12px; color: var(--muted); margin-right: 2px; }
/* aria-pressed carries the state — styling off [aria-pressed] instead of a class keeps the visual
   and the assistive-tech answer from ever disagreeing. */
.voice-fchip { font: 500 12px/1 var(--font-ui); min-height: 32px; padding: 7px 11px; border-radius: var(--r-pill); border: 1px solid var(--line); background: var(--surface); color: var(--muted); cursor: pointer; transition: background var(--dur-fast), color var(--dur-fast), border-color var(--dur-fast); }
.voice-fchip:hover { border-color: var(--accent); color: var(--text); }
.voice-fchip[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.voice-filter-empty { flex-basis: 100%; margin: 2px 0 0; font-size: 13px; color: var(--muted); }
/* Library filter hides. The category pills + header search toggle .lib-hidden on cards; the voice
   facet filter toggles .voice-filtered-out (public/voice-filter.js). Both are named in the grids'
   data-sm-skip list so a filtered-out card never counts toward the "show more" cap — filter and
   progressive disclosure compose. The !important on the voice class stays defensive: it guarantees
   a facet-excluded voice cannot be un-hidden by any later rule that sets these cards' display. */
.voice-filtered-out { display: none !important; }
.lib-hidden { display: none; }
@media (pointer: coarse) { .asset-fav { width: 44px; height: 44px; font-size: 18px; opacity: 1; }
  .voice-fchip { min-height: 44px; padding: 12px 14px; }
  .voice-filter-sel select { min-height: 44px; } }
.asset-thumb { position: relative; aspect-ratio: 1; cursor: pointer; overflow: hidden; display: block; border: 0; padding: 0; width: 100%;
  /* Deep matte so the letterbox bars around a non-square photo/video read as intentional. */
  background-color: var(--bg-deep); }
/* Checkerboard ONLY where assets actually carry alpha (cutout references). Elsewhere it would
   just be noise in the letterbox bars now that thumbnails are contained rather than cropped. */
.asset-card[data-cat="character"] .asset-thumb,
.asset-card[data-cat="costume"] .asset-thumb,
.asset-card[data-cat="item"] .asset-thumb {
  background-color: var(--surface-2);
  background-image:
    linear-gradient(45deg, var(--border) 25%, transparent 25%),
    linear-gradient(-45deg, var(--border) 25%, transparent 25%),
    linear-gradient(45deg, transparent 75%, var(--border) 75%),
    linear-gradient(-45deg, transparent 75%, var(--border) 75%);
  background-size: 18px 18px; background-position: 0 0, 0 9px, 9px -9px, -9px 0; }
.asset-thumb img, .asset-thumb video { width: 100%; height: 100%; object-fit: contain; display: block; pointer-events: none; }
/* Loading state: shimmer skeleton over the tile until the thumbnail paints, then the image
   fades in (removes .is-loading on img load). Kills the "bare checkerboard looks broken" gap. */
.asset-thumb-loading::after { content: ""; position: absolute; inset: 0; z-index: 1; pointer-events: none;
  background: linear-gradient(110deg, var(--surface) 25%, var(--surface3) 50%, var(--surface) 75%);
  background-size: 220% 100%; animation: scshim 1.5s linear infinite; }
.asset-thumb img.is-loading, .asset-thumb .asset-poster.is-loading { opacity: 0; }
.asset-thumb img, .asset-thumb .asset-poster { transition: opacity 0.35s var(--ease); }
.asset-thumb--audio { background-image: none; background-color: var(--surface-2); }
.asset-thumb--audio { display: grid; place-items: center; color: var(--accent); font-size: 34px; }
.asset-thumb--video { background: var(--surface-3); }
.asset-poster { width: 100%; height: 100%; object-fit: contain; display: block; pointer-events: none; }
.asset-thumb-play { position: absolute; inset: 0; display: grid; place-items: center; pointer-events: none; }
.asset-thumb-play .pbtn { width: 46px; height: 46px; border-radius: 50%; background: rgba(8, 10, 16, 0.5); display: grid; place-items: center; color: #fff; transition: background var(--dur-fast) var(--ease), transform var(--dur-fast) var(--ease); }
.asset-thumb:hover .asset-thumb-play .pbtn { background: rgba(8, 10, 16, 0.72); transform: scale(1.06); }
.asset-kind { position: absolute; top: 8px; left: 8px; z-index: 2; padding: 3px 8px; border-radius: var(--r-pill); font-size: 10px; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase; background: rgba(8,10,16,0.72); color: #fff; }
.asset-meta { padding: 11px 12px; display: flex; flex-direction: column; gap: 4px; }
.asset-name { font-size: 13px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.asset-sub { font-size: 11px; color: var(--text-faint); }
.asset-actions { display: flex; gap: 6px; padding: 0 12px 12px; flex-wrap: wrap; }

/* Coming soon */

/* Operation launchpad (Create) */
.op-section { margin-bottom: 26px; }
.op-section-title { font-size: 12px; font-weight: 800; letter-spacing: 0.07em; text-transform: uppercase; color: var(--text-faint); margin: 0 0 12px; }
/* The HOT label (operator 2026-09-11, refined the same day: "chữ HOT cứ viết bình thường nhưng
   ví dụ kèm icon lửa cháy") — the WORD keeps the quiet section-label voice of its siblings; the
   fire lives in the burning flame icon beside it: ember orange, a soft glow, and a gentle
   flicker the global prefers-reduced-motion kill-switch freezes for free. */
.op-section-title--hot { display: inline-flex; align-items: center; gap: 7px; }
.op-section-title--hot .op-flame { color: #f97316; filter: drop-shadow(0 0 6px rgba(249, 115, 22, 0.55)); animation: op-flame-flicker 1.7s ease-in-out infinite; transform-origin: 50% 85%; }
@keyframes op-flame-flicker { 0%, 100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.12) rotate(-2deg); opacity: 0.82; } }
.op-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(258px, 1fr)); }
.op-card { display: flex; align-items: flex-start; gap: 13px; padding: 14px 15px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); text-decoration: none; color: var(--text); transition: border-color var(--dur-fast) var(--ease), transform var(--dur-fast) var(--ease); }
.op-card:hover { border-color: var(--accent); transform: translateY(-2px); text-decoration: none; }
.op-card-icon { flex: none; width: 38px; height: 38px; border-radius: 11px; display: grid; place-items: center; background: var(--accent-soft); color: var(--accent); }
.op-card-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.op-card-title { font-size: 14px; font-weight: 700; display: flex; align-items: center; gap: 6px; }
.op-card-desc { font-size: 12.5px; color: var(--muted); line-height: 1.45; }
.op-card--soon { opacity: 0.6; cursor: default; }
.op-card--soon:hover { border-color: var(--border); transform: none; }
.soon-tag { font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.03em; color: var(--warn); background: var(--warn-soft); padding: 1px 6px; border-radius: var(--r-pill); }
.tech-details { margin-top: 14px; border-top: 1px solid var(--border); padding-top: 10px; }
.tech-details > summary { cursor: pointer; color: var(--muted); font-size: 13px; font-weight: 600; list-style: none; user-select: none; }
.tech-details > summary::-webkit-details-marker { display: none; }
.tech-details > summary::before { content: '▸ '; color: var(--text-faint); }
.tech-details[open] > summary::before { content: '▾ '; }

/* Lightbox / media viewer */
.lightbox { position: fixed; inset: 0; z-index: var(--z-modal); display: none; align-items: center; justify-content: center; padding: clamp(12px, 4vw, 48px); background: var(--scrim); }
.lightbox.is-open { display: flex; animation: lb-in var(--dur) var(--ease); }
@keyframes lb-in { from { opacity: 0; } to { opacity: 1; } }
.lightbox-stage { position: relative; max-width: min(1100px, 100%); max-height: 100%; display: flex; flex-direction: column; gap: 10px; }
.lightbox-stage img, .lightbox-stage video { max-width: 100%; max-height: 82vh; border-radius: var(--radius-md); display: block; margin: 0 auto; background: #000; }
/* Audio in the lightbox rides the shared .audio-player-card, whose width must be pinned here:
   the stage shrink-wraps its content, so the card's width:100% has nothing to resolve against. */
.lightbox-media-wrap .audio-player-card { width: min(560px, 92vw); }
/* While the full-resolution render downloads, the stage shows the grid thumbnail scaled up. The
   slight blur says "this is not the final image yet" without a spinner, and it swaps to the real
   one the moment it decodes (media-viewer.js). */
.lightbox-stage img.is-preview { filter: blur(6px); transform: scale(1.01); transition: filter var(--dur) var(--ease); }
/* The live token quote beside a submit button. It had NO rule at all, so it appeared as bare
   inline text the moment the estimate arrived and shoved the primary button sideways — at exactly
   the moment the user is reaching for it. Two states, and they are not the same state:
   - [hidden] is a chip with NOTHING to say (no quote, an unpriced operation, a failed fetch). It
     holds no space. It used to keep its box through visibility, which left an empty pill-sized gap
     in front of the button on every form whose quote degraded, and a whole invisible sentence while
     the pending line was being swapped (/edit/cleanup's Photo tab, review of 076d1021).
   - [data-estimate-stale] is a quote on screen that a re-quote is replacing. It keeps its box and
     only goes invisible, so the button does not jump while the new number is fetched; the old
     number is not readable beside new settings. public/cost-estimate.js sets it only on a chip that
     is showing something, and clears it with every render. */
.cost-estimate-chip { display: inline-flex; align-items: center; gap: 6px; min-height: 22px; margin-right: 10px; padding: 3px 9px; border-radius: var(--r-pill); background: var(--surface2); border: 1px solid var(--line); font: 600 12px/1 var(--font-ui); color: var(--muted); opacity: 1; transition: opacity var(--dur-fast) var(--ease); }
.cost-estimate-chip[hidden] { display: none; }
.cost-estimate-chip[data-estimate-stale] { visibility: hidden; opacity: 0; }
.cost-estimate-chip[data-insufficient="1"] { color: var(--bad); border-color: var(--bad); }
/* The strongest sales sentence on the page must not dress as a caption (designer #8): same
   12.5px, but body-text color. It sits between the price chip and the submit button. */
.cost-grant-note { display: block; width: 100%; font: 500 12.5px/1.45 var(--font-ui); color: var(--text); }
.lightbox-media-wrap { animation: lb-pop var(--dur) var(--ease); }
@keyframes lb-pop { from { transform: scale(0.97); opacity: 0; } to { transform: scale(1); opacity: 1; } }
.lightbox-close {
  position: absolute; top: 12px; right: 12px; z-index: 2;
  width: 44px; height: 44px; border-radius: 50%;
  border: 1px solid rgba(255, 255, 255, 0.22);
  background: rgba(18, 20, 26, 0.55);
  -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
  color: #fff; cursor: pointer; display: grid; place-items: center;
  /* UA button padding + inline-svg baseline pushed the X off-centre (user report 2026-07-17) */
  padding: 0; line-height: 0;
  box-shadow: 0 4px 18px rgba(0, 0, 0, 0.4);
  transition: transform var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease);
}
.lightbox-close:hover { background: rgba(44, 48, 60, 0.75); transform: scale(1.09); }
.lightbox-close:active { transform: scale(0.95); }
.lightbox-close svg { width: 19px; height: 19px; display: block; }
.lightbox-cap { color: #fff; font-size: 13px; opacity: 0.85; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; }
/* v2 action bar under the media: caption + Download + Open-job. White-on-scrim chrome
   (constant both themes — it floats over the dark overlay, not over a surface). */
.lightbox-bar { display: flex; align-items: center; gap: 10px; }
.lightbox-btn {
  display: inline-flex; align-items: center; gap: 7px; min-height: 44px; padding: 8px 14px;
  border-radius: var(--radius-md); border: 1px solid rgba(255, 255, 255, 0.22);
  background: rgba(18, 20, 26, 0.55); color: #fff; font: 600 13px/1 var(--font-ui);
  cursor: pointer; text-decoration: none;
  -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
}
.lightbox-btn:hover { background: rgba(44, 48, 60, 0.75); }
.lightbox-nav {
  position: absolute; top: 50%; transform: translateY(-50%); z-index: 2;
  width: 44px; height: 44px; border-radius: 50%;
  border: 1px solid rgba(255, 255, 255, 0.22); background: rgba(18, 20, 26, 0.55);
  color: #fff; cursor: pointer; display: grid; place-items: center;
  -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
}
.lightbox-nav:hover { background: rgba(44, 48, 60, 0.75); }
.lightbox-nav[hidden] { display: none; }
/* Same law as its sibling above: .lightbox-btn is inline-flex, which beats the UA [hidden] rule,
   so the "Open render" chip stayed painted on every item that has no job — pointing at the
   PREVIOUS item's job while being dropped from the focus trap. media-viewer.js also clears its
   href; both halves are needed. */
.lightbox-job[hidden] { display: none; }
.lightbox-prev { left: -8px; }
.lightbox-next { right: -8px; }
@media (max-width: 700px) {
  .lightbox-close { top: 8px; right: 8px; }
  .lightbox-bar { flex-wrap: wrap; }
  .lightbox-prev { left: 4px; }
  .lightbox-next { right: 4px; }
}

/* Lazy media thumbnails — every list/card/grid/detail media is a button that opens the lightbox.
   The real <video>/<audio>/<img> player lives only in the lightbox (created on click). */
.media-thumb { cursor: pointer; display: block; padding: 0; border: 0; background: transparent; width: 100%; position: relative; }
.media-thumb-poster, .media-thumb--image img { width: 100%; height: 100%; aspect-ratio: 1; object-fit: contain; display: block; background: var(--bg-deep); }
.media-thumb-play { position: absolute; inset: 0; margin: auto; width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; color: #fff; background: rgba(0, 0, 0, 0.45); border-radius: 999px; pointer-events: none; }
.media-thumb-play svg { width: 22px; height: 22px; }
.media-thumb--audio { display: flex; align-items: center; gap: 0.5rem; width: 100%; text-align: left; color: var(--text); }
.media-thumb-icon { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 999px; background: var(--surface-2, var(--surface)); color: var(--accent, currentColor); }
.media-thumb-icon svg { width: 20px; height: 20px; }
.media-thumb-cap { font-size: 13px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* [#64] Multi-output deliverable grid + visible 1..N ordinal badge — the ONE styling home for
   renderDeliverableGrid (helpers.js) and the client poller's mirror (public/job-poll.js).
   The badge is a corner overlay pill (top-left, like the play chip's fixed white-on-dark idiom:
   it sits ON the media, so explicit colors, not theme tokens, keep it legible in both themes),
   pointer-events:none so the click still lands on the thumb button underneath. Numbering is
   MARKUP ONLY — never in the media src/URL (pinned: jobPageVariantOrdinals.test.js). */
.deliverable-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.ordinal-cell { position: relative; min-width: 0; }
.ordinal-badge { position: absolute; top: 8px; left: 8px; z-index: 2; min-width: 26px; height: 26px; padding: 0 8px; border-radius: 999px; background: rgba(10, 12, 16, 0.72); color: #fff; border: 1px solid rgba(255, 255, 255, 0.35); font: 700 13px/24px var(--font-ui, inherit); text-align: center; pointer-events: none; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35); }

/* Asset picker — radio-card list with thumbnails (replaces a thumbnail-less dropdown). */
.pick-label { display: block; font-size: 13px; color: var(--muted); margin: 0 0 6px; }
/* Unified input picker (#235): Upload / Library / Link buttons + shared Library modal. */
.input-picker .ip-file { display: none; }
.ip-btns { display: flex; gap: 6px; margin: 4px 0 6px; flex-wrap: wrap; }
.ip-btn { padding: 7px 12px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface-2); color: var(--text); font-size: 0.86rem; cursor: pointer; min-height: 34px; transition: background var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease); }
.ip-btn:hover { background: var(--surface-3, var(--surface-2)); border-color: var(--accent); }
/* Pressed state driven by aria-pressed, so what a sighted operator sees and what a screen reader
   announces cannot drift apart — a separate is-active class would have been a second answer to the
   same question. */
/* The how-to-get-a-link button reads as a hint, not a third way to supply media — it sits beside
   Apply but must not compete with it for attention. Opens the shared media lightbox through the
   same data-media-view contract every other clip on the site uses, so there is one player. */
.ip-btn--help { background: transparent; border-style: dashed; color: var(--muted); }
.ip-btn--help:hover { color: var(--accent); }
.ip-btn[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent-ring); color: var(--accent); }
.ip-linkrow { display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0 6px; }
/* The how-to button takes its OWN line under the URL field. Sitting beside Apply it read as a
   second action on the same decision — the eye pairs things that share a row — when it is only a
   hint about the field above it. Full-basis rather than a separate container so it stays inside
   the element the Link toggle shows and hides; two containers would need two toggles. */
.ip-btn--help { flex-basis: 100%; text-align: left; }
/* Author display rules beat the UA [hidden]{display:none} — every flex/grid element toggled via
   the hidden attribute needs an explicit guard (same pattern as .job-chip/.tr-panel above). */
.ip-linkrow[hidden], .ip-sel[hidden], .ipm-overlay[hidden], .notif-panel[hidden], .notif-badge[hidden] { display: none; }
.ip-url-input { flex: 1; min-height: 34px; padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--input-bg); color: var(--text); }
/* The fetch runs for seconds and spends tokens, so the button must look unavailable while it does —
   without this the disabled control still reads as pressable and invites the second click the
   re-entrancy guard then silently swallows. */
.ip-btn:disabled { opacity: 0.6; cursor: progress; }
/* Status line under the link row: "Fetching…" then either nothing (success — the selection chip is
   the result) or the failure reason. NO author display rule on purpose: it is toggled by the
   [hidden] attribute and a <p> needs no override, unlike the flex rows guarded above.
   (No backticks in this block: the whole stylesheet is a JS template literal, so one would end it.) */
.ip-link-msg { margin: 2px 0 6px; font-size: 0.82rem; color: var(--text-muted); }
.ip-link-msg.is-error { color: var(--danger); }
.ip-sel { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--input-bg); margin: 4px 0 6px; }
.ip-sel-thumb { width: 40px; height: 40px; object-fit: contain; border-radius: 6px; background: var(--bg-deep); }
.ip-sel-thumb--icon { display: flex; align-items: center; justify-content: center; background: var(--surface-2); }
.ip-sel-name { flex: 1; font-size: 0.86rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ip-sel-clear { border: none; background: none; color: var(--text-dim, var(--text)); font-size: 1.1rem; cursor: pointer; width: 26px; height: 26px; padding: 0; line-height: 1; display: inline-flex; align-items: center; justify-content: center; border-radius: 6px; }
.ip-sel-clear:hover { background: var(--surface-2); }
/* [#60] Service notice: the one overlay for a declared maintenance window AND for an empty render
   fleet. NO BACKTICKS ANYWHERE IN THIS BLOCK — this whole stylesheet is one template literal and a
   stray backtick terminates it, taking every page's CSS with it. That has happened here before. */
.svc-notice { position: fixed; inset: 0; z-index: var(--z-modal); display: flex; align-items: center; justify-content: center; padding: 16px; }
.svc-notice[hidden] { display: none; }
.svc-notice-scrim { position: absolute; inset: 0; background: var(--scrim); }
.svc-notice-panel { position: relative; max-width: 460px; width: 100%; background: var(--panel-elevated, var(--surface2)); color: var(--text); border: 1px solid var(--surface3); border-radius: 14px; padding: 22px 22px 18px; box-shadow: 0 22px 70px rgba(0, 0, 0, 0.55); text-align: center; }
.svc-notice-title { margin: 0 0 10px; font: 700 1.05rem/1.3 var(--font-ui); }
.svc-notice-body { margin: 0 0 18px; font-size: 0.92rem; line-height: 1.55; color: var(--text-dim, var(--text)); }
.svc-notice-close { border: none; border-radius: 10px; background: var(--accent); color: var(--on-accent); font: 600 0.9rem/1 var(--font-ui); padding: 11px 20px; cursor: pointer; }
.svc-notice-close:hover { filter: brightness(1.08); }
.ipm-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: var(--z-modal); display: flex; align-items: center; justify-content: center; padding: 16px; animation: ipm-fade 0.14s var(--ease); }
@keyframes ipm-fade { from { opacity: 0; } to { opacity: 1; } }
/* Scroll lock while the picker is open: the page behind used to scroll under the dialog, which
   loses the user's place in a long form for nothing. Set by input-picker.js on open. */
html.ipm-open, html.ipm-open body { overflow: hidden; }
@media (prefers-reduced-motion: reduce) { .ipm-overlay { animation: none; } }
.ipm-panel { width: min(760px, 96vw); max-height: 84vh; display: flex; flex-direction: column; background: var(--surface-1, var(--bg)); border: 1px solid var(--border); border-radius: var(--radius-md); box-shadow: 0 18px 60px rgba(0,0,0,0.4); }
.ipm-head { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--border); }
.ipm-close { border: none; background: none; color: var(--text); font-size: 1.3rem; cursor: pointer; width: 34px; height: 34px; padding: 0; line-height: 1; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; }
.ipm-close:hover { background: var(--surface-2); }
.ipm-tools { display: flex; gap: 8px; align-items: center; padding: 10px 16px; flex-wrap: wrap; }
.ipm-chips { display: flex; gap: 6px; flex-wrap: wrap; }
.ipm-chip { padding: 5px 11px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface-2); color: var(--text); font-size: 0.8rem; cursor: pointer; }
.ipm-chip.is-active { border-color: var(--accent); background: var(--accent-soft, var(--surface-2)); }
.ipm-search { margin-left: auto; min-width: 160px; min-height: 32px; padding: 5px 10px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--input-bg); color: var(--text); }
.ipm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(128px, 1fr)); gap: 10px; padding: 12px 16px 16px; overflow-y: auto; position: relative; }
.ipm-item { display: flex; flex-direction: column; gap: 5px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface-2); padding: 6px; cursor: pointer; text-align: left; }
.ipm-item:hover { border-color: var(--accent); }
.ipm-item img { width: 100%; aspect-ratio: 1; object-fit: contain; border-radius: 6px; background: var(--bg-deep); }
.ipm-item-ph { display: flex; align-items: center; justify-content: center; width: 100%; aspect-ratio: 1; background: var(--input-bg); border-radius: 6px; font-size: 1.6rem; }
.ipm-item-name { font-size: 0.78rem; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ipm-empty { grid-column: 1 / -1; text-align: center; color: var(--text-dim, var(--text)); padding: 24px 0; }
/* [#349] The picker modal's failure state. Sits inside .ipm-empty so it inherits the centring;
   the retry is a real button, not a link, because it re-runs a fetch rather than navigating. */
.ipm-retry { margin-left: 8px; background: none; border: 1px solid var(--line); color: var(--accent);
  border-radius: 8px; padding: 4px 10px; font: inherit; cursor: pointer; }
.ipm-retry:hover { background: var(--surface-2, var(--surface)); }
/* Group header rows inside the picker grid (Saved characters / Your inputs / Generated outputs /
   Assets) — full-width dividers between the grid's item tiles. */
.ipm-group { grid-column: 1 / -1; margin: 6px 0 -4px; padding-top: 6px; border-top: 1px solid var(--border); }
.ipm-group:first-child { margin-top: 0; padding-top: 0; border-top: 0; }
.ipm-group-label { font-size: 0.72rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-dim, var(--text)); }
/* Voice rows inside the picker modal (data-ip-source="voices"): a column LIST, not a thumb grid —
   a voice has no picture, its "thumbnail" is the ▶ listen button beside each row. The pick button
   keeps .ipm-item for the shared border/hover; flex-direction must be re-declared row because
   .ipm-item lays its tile content out as a column. ShowMore hides overflow rows via .sm-collapsed
   (display:none !important), so no [hidden] display guard is needed here. */
.ipm-grid--voices { display: flex; flex-direction: column; gap: 6px; }
.ipm-vrow { display: flex; gap: 8px; align-items: center; }
/* [voice-picker] Name over meta, full row width, explicit text color. The old row put name and
   meta side by side with meta flex-shrink:0 — a long meta crushed the NAME to a few letters —
   and the pick button never declared a color, so in dark theme it inherited the UA's default
   black on a dark surface (operator: "màu chữ không nhìn nổi ở dark theme"). */
.ipm-vrow-pick { flex: 1; display: flex; flex-direction: column; align-items: flex-start; gap: 2px; text-align: left; min-width: 0; background: none; border: none; padding: 6px 4px; cursor: pointer; color: var(--text); }
.ipm-vrow-name { width: 100%; color: var(--text); font-size: 0.92rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ipm-vrow-meta { width: 100%; color: var(--text-dim, var(--muted)); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ipm-vrow-play { min-width: 44px; min-height: 44px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface-2); color: var(--text); cursor: pointer; }
.ipm-vrow-play:hover { border-color: var(--accent); }
.ipm-vrow-play:disabled { opacity: 0.4; cursor: default; border-color: var(--border); }
.ipm-vrow-fav { min-width: 44px; min-height: 44px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface-2); color: var(--muted); font-size: 17px; line-height: 1; cursor: pointer; }
.ipm-vrow-fav:hover { border-color: var(--accent); }
.ipm-vrow-fav.is-fav { color: #ffc94d; }
/* ── Web notifications (#242): topbar bell + dropdown + toasts ─────────────── */
.notif { position: relative; display: inline-flex; }
.notif-bell { position: relative; display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; padding: 0; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface); color: var(--text); cursor: pointer; }
.notif-bell svg { display: block; }
.notif-bell:hover { background: var(--surface-2); }
.notif-badge { position: absolute; top: -5px; right: -5px; min-width: 17px; height: 17px; padding: 0 4px; display: inline-flex; align-items: center; justify-content: center; border-radius: 9px; background: var(--accent); color: var(--on-accent); font-size: 11px; font-weight: 700; line-height: 1; }
.notif-panel { position: absolute; right: 0; top: calc(100% + 8px); width: 340px; max-height: 420px; display: flex; flex-direction: column; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-md); box-shadow: 0 12px 34px rgba(0,0,0,.35); z-index: var(--z-popover); }
/* PHONE: the dropdown ran off the LEFT edge and cut every notification's text in half.
   The geometry, not a guess: the panel is 340px wide and anchors its RIGHT edge to the bell, so it
   needs 340px of room to the bell's left. The bell sits mid-topbar with the token chip beside it,
   so on a 427px viewport its right edge lands near 232px and the panel starts around -108px.
   Clamping the WIDTH cannot fix this: the real limit is the distance from the viewport edge to the
   BELL, and CSS on the panel cannot read that.
   So on phones the panel stops anchoring to the bell and anchors to the TOPBAR. Making .notif
   static hands the containing block to .app-topbar, which is already positioned (sticky) and
   already spans the viewport, so top:100% means "below the topbar" and keeps meaning that when the
   topbar WRAPS to two rows on a folded screen. That wrapping is also why this is not a
   fixed-position panel with a hard-coded top: .app-topbar is flex-wrap:wrap and has no stable
   height to hard-code.
   LIMIT: it assumes .app-topbar stays positioned and un-clipped. Give the topbar overflow:hidden,
   or drop its sticky, and this silently reverts to escaping the viewport with nothing going red. */
@media (max-width: 640px) {
  .notif { position: static; }
  .notif-panel { left: 12px; right: 12px; width: auto; max-height: min(420px, 68vh); }
}
.notif-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 11px 14px; border-bottom: 1px solid var(--border); font-size: 14px; }
.notif-markread { border: none; background: none; color: var(--accent); font-size: 12.5px; cursor: pointer; padding: 4px 0; }
.notif-list { overflow-y: auto; padding: 6px; position: relative; }
.notif-empty { text-align: center; color: var(--muted); padding: 22px 0; font-size: 13px; margin: 0; }
.notif-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 9px; border: none; border-radius: var(--r-sm); background: none; cursor: pointer; text-align: left; color: var(--text); }
.notif-item:hover { background: var(--surface-2); }
.notif-item.is-unread { background: var(--accent-soft); }
.notif-item.is-unread:hover { background: var(--accent-soft); filter: brightness(1.06); }
.notif-thumb { width: 40px; height: 40px; flex: 0 0 40px; border-radius: 8px; object-fit: contain; background: var(--bg-deep); border: 1px solid var(--border); }
.notif-thumb--ph { display: inline-flex; align-items: center; justify-content: center; color: var(--muted); font-size: 16px; }
.notif-txt { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.notif-name { font-size: 13.5px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.notif-meta { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.notif-meta .ok { color: var(--ok, #34c477); }
.notif-meta .err { color: var(--danger, #e5484d); }
/* The phone geometry lives in ONE place, in the .notif-panel block above. A second @media rule
   used to sit here capping the panel to min(340px, calc(100vw - 16px)); it was written to replace
   an even older position:fixed/top:58px that slid under the header once the topbar wrapped. The
   cap was inert: on a 427px phone calc(100vw - 16px) is 411px, so min() always picked the 340px it
   was meant to shrink, and the panel went on starting at about -108px. Two rules also fought over
   the width property at 560px and below, where the later one silently won. Deleted rather than
   tuned — the lesson it
   carried (do NOT go back to a fixed top: the topbar is flex-wrap and has no stable height) is
   recorded where the live rule is. */
.notif-toasts { position: fixed; right: 16px; bottom: calc(16px + env(safe-area-inset-bottom)); display: flex; flex-direction: column; gap: 8px; z-index: var(--z-toast); }
.notif-toast { display: flex; align-items: center; gap: 10px; max-width: 320px; padding: 11px 14px; background: var(--surface); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: var(--r-sm); box-shadow: 0 8px 26px rgba(0,0,0,.3); cursor: pointer; font-size: 13.5px; animation: notifIn .22s ease-out; }
.notif-toast.is-fail { border-left-color: var(--danger, #e5484d); }
@keyframes notifIn { from { transform: translateY(8px); opacity: 0; } to { transform: none; opacity: 1; } }

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; }
}

/* ── Responsive hardening (Aurora2 W3) ─────────────────────────────────────────
   Cross-cutting floors that must win over per-component styling. */
/* Every data table sits in a .table-wrap scroller — tables are the #1 source of
   page-wide horizontal overflow on phones. */
/* position:relative is LOAD-BEARING, not tidiness. An .sr-only span is position:absolute, so
   without a positioned ancestor its containing block is <body> -- and one sitting deep inside a
   table that is WIDER than its scroller gets laid out at the table's x offset, OUTSIDE the
   scroller, dragging the whole page's scrollWidth with it. Measured on /wallet 2026-09-17, the
   day the ledger gained sr-only direction words: at a 375px viewport the page reported
   scrollWidth 412 (320px viewport: also 412, i.e. 92px of bleed) while .table-wrap itself was
   correct at 343px wide with its own 560px scroll. The offending span's offsetParent was BODY;
   with this line it is the wrap, and every width came back clean. The bug is NOT wallet-specific
   -- 24 files render a .table-wrap and 6 render an .sr-only -- which is why the fix lives here
   and not on .wallet-ledger. Anything absolutely positioned inside a scroller needs this.
   2026-09-19: the class bit again two days later -- .stage-timeline on /jobs/:id, the same sr-only
   escape, page scrollWidth 387 at every phone width -- so the invariant is now SHEET-WIDE: every
   rule that declares overflow/overflow-x/overflow-y auto|scroll also declares position.
   test/tableWrapContainsItsAbsolutes.test.js scans the whole sheet for it. */
.table-wrap { overflow-x: auto; max-width: 100%; -webkit-overflow-scrolling: touch; position: relative; }
.table-wrap > table { min-width: 0; }
.table-wrap--wide > table { min-width: 560px; }
/* <pre> panels (error payloads, LLM JSON, debug dumps) must soft-wrap, never widen the page. */
pre, pre.mono { white-space: pre-wrap; overflow-wrap: anywhere; max-width: 100%; }
/* Touch-target floor for the small interactive chrome (WCAG 44px; hover-only reveal removed). */
.copy-btn, .help-icon, .asset-del, .proj-del { min-width: 32px; min-height: 32px; }
@media (pointer: coarse) {
  /* iOS zooms the page when a focused control is under 16px — device-class floor. */
  input, textarea, select { font-size: 16px !important; }
  .copy-btn, .help-icon, .asset-del, .proj-del, .q-filter, .lib-pill, .tab, .pref-switch a, .pref-switch button, .ip-btn, .create-mode, .notif-bell, .ipm-close, .lang-pick-summary, .app-account-out, .ip-sel-clear, .lightbox-close, .ipm-retry { min-height: 44px; min-width: 44px; }
  /* P3-20: wide inputs (and .cur-btn, whose tile-ops row can't afford a 44px WIDTH ×3) get the
     height floor only — a 44px min-width would shrink .ipm-search / overflow the curation tile.
     .home-trend-act is the same shape: full-width under its trend tile, so it needs the height
     floor and nothing else. It shipped at min-height 30px, which is below the floor this whole
     block exists to enforce — new controls have to be added here, the list is not automatic.
     .app-contact-link (107x40) and .skip-link (200x34), measured on production 2026-08-09, are the
     same shape and the same miss — both are already far wider than 44px, so a min-width would do
     nothing but a min-height was owed. Both are SHELL-GLOBAL (every page renders them), which is
     why they are here and not next to their component rules. */
  .ip-url-input, .ipm-search, .cur-btn, .home-trend-act, .app-contact-link, .skip-link, .svc-notice-close { min-height: 44px; }
  /* [designer #10 [20]] The before/after drag handle was the UA-default ~16-20px range box — the
     one affordance the comparison has. Raise the whole box, grow the thumb, lift it a little
     clear of the slider's bottom edge. */
  .compare-range { min-height: 44px; inset: auto 0 12px 0; }
  .compare-range::-webkit-slider-thumb { width: 28px; height: 28px; }
  /* The watermark sliders and the colour well are the same shape of miss: wide already, so the
     height floor is what is owed. New controls have to be added here — this list is not automatic. */
  .wm-range { min-height: 44px; }
  .wm-range::-webkit-slider-thumb { width: 28px; height: 28px; }
  .wm-color { min-height: 44px; }
  /* Primary CTAs (.dc-btn, incl. every Create/Audio submit) + Replace scope tabs: height floor
     only — they are already wide via flex/padding, so a min-width would distort them. */
  .dc-btn, .cr-scope-tab { min-height: 44px; }
  /* The cleanup region tool builds these at runtime, where the touch scanner cannot see them, so
     cleanupRegionTool.test.js pins them here. The remove button is the whole target; the resize
     handle grows so a finger can find the corner of a box. */
  .oc-region-del { min-width: 44px; min-height: 44px; }
  /* Half OUTSIDE the corner: a 28px handle inside a small box (104x29 measured on a 347px photo)
     covered most of it and left nothing to grab for a move. */
  .oc-box-handle { width: 28px; height: 28px; right: -14px; bottom: -14px; border-radius: 6px; }
  /* Native checkbox / radio. The UA box is 13x13 and matched NOTHING in this block, so on every
     form in the product the real target was one text line tall (measured 13x13 on /edit/replace,
     production 2026-08-09; the audit found it again on eleven more routes).
     This is deliberately NOT another entry in the hand-kept class list above, because there was no
     single class to add: the wrapper had been invented five separate times — .checkbox-row,
     .rail-radio, .checkbox-label (which has no CSS rule at all, anywhere), four hand-inlined
     min-height:44px copies, and eleven sites with no class whatsoever. A class list would have
     missed a spelling on the day it was written and a sixth spelling next week. So key off the DOM
     shape instead: a label that DIRECTLY wraps the control IS the tap target (clicking anywhere in
     it toggles), so the label carries the floor and all five spellings are covered at once.
     The child combinator is load-bearing, not tidiness: line 181 makes a bare label the CAPTION
     above every text field, and flooring those would inject 44px of dead space above every input on
     every form in the app. Only a label that contains the control is a target.
     NOT resized: the 13px box itself. .asp-opt hides its radio on purpose (width:0, the tile is the
     control) and .voice-facet-box sets 17px on purpose; a blanket input size would silently
     override both. WCAG 2.5.8 measures the target, and the target is the label. */
  label:has(> input[type=checkbox]), label:has(> input[type=radio]) { min-height: 44px; }
  /* The floor alone leaves the text stuck at the top of a 44px block box — the same trap
     .home-trend-act hit; see its comment. :where() zeroes this rule's specificity ON PURPOSE: it
     must reach the plain block label from line 181, and it must LOSE to any component that already
     chose its own layout (.scope-opt is align-items:flex-start so its radio lines up with a
     two-line body, .cmp-out and .voice-facet-box are inline-flex chips in a wrapping row, .asp-opt
     is a column tile). gap replaces the literal space between input and text, which flex collapses.
     Do not "simplify" the :where() away — without it this one rule silently re-lays-out six
     components that were drawn deliberately. */
  label:where(:has(> input[type=checkbox]), :has(> input[type=radio])) { display: flex; align-items: center; gap: 8px; }
  /* summary is the disclosure control of every details panel — 27 of them across 15 source files,
     under five different class contexts (.rail-advanced, .tech-details, .gallery-save,
     .debug-drawer, and bare). Measured 309x12.5 on /edit/replace: that is "Cài đặt nâng cao", the
     toggle that GATES the three checkboxes floored above, so raising them and not it would have
     left the door to them 12.5px tall. Same reasoning as the labels — one native element name
     covers every spelling and no hand-kept class list could.
     align-content, NOT display:flex: flex would blow away the default ::marker triangle on the
     summaries that do not draw their own (musicVideoPages, assistPages and
     chunkWorkflowPages all rely on it). align-content centres the text inside a taller list-item
     box and leaves display alone — measured 11px from the top of the 44px box, i.e. centred.
     Where align-content on block layout is unsupported the floor still applies and the label sits
     at the top: degraded, never broken. */
  summary { min-height: 44px; align-content: center; }
  /* A SUBMIT button is floored by SHAPE, for the same reason summary and the wrapping label are.
     (No backticks anywhere in this comment: this stylesheet is a template literal, and one backtick
     here ends it — audit 2026-08-02 recorded that trap and this comment re-hit it while being
     written.)
     [measured 2026-08-13, in-app Chrome at 375x812 with pointer:coarse emulated, local stack]
     Walking fifteen renderable routes, the ONLY <button> anywhere below the floor was the UNCLASSED
     submit button — 76x35 "Thử lại" and 74x35 "Tạo lại" on the job detail page, and one more on
     /edit/add-audio. Every classed button already measured >= 44.
     A class rule could not have reached them and neither could the derived scanner: those buttons
     carry NO class at all, and scripts/lib/touchTargetScan.mjs builds its control set from the class
     TOKENS on emitted control tags (its header says so) — so a control with no class is not in the
     population it checks. That is the hand-kept-list failure one level up, and a shape rule is the
     answer to it: a submit button is a submit button whatever anyone calls it, and there is no
     legitimate 35px one — it is the control that spends the user's tokens.
     It does not disturb the deliberate .ipm-chip 38px below, and NOT for the reason a first draft
     of this comment gave (it claimed a specificity loss; button[type=submit] is (0,1,1) and would
     in fact WIN over .ipm-chip's (0,1,0)). The real reason is that the two selectors never match the
     same element: input-picker.js emits the chips as button type="button". min-height only raises,
     so a submit that already clears 44 is untouched either way. */
  button[type="submit"] { min-height: 44px; }
  /* Filter chips stay inline; 38px is a meaningful raise without breaking the wrapped row. */
  .ipm-chip { min-height: 38px; }
  .asset-card .asset-del { opacity: 1; } /* hover-reveal doesn't exist on touch */
  .proj-card-wrap .proj-del { opacity: 1; } /* same: the project delete overlay is always visible */

  /* ── Derived sweep, 2026-08-09 (test/touchTargetCoverage.test.js) ─────────────────────────
     Fourteen controls that model below 44px and that none of the rules above name. They were NOT
     found by re-reading this list, which is the point: scripts/lib/touchTargetScan.mjs derives the
     control set from the markup the web tier actually emits, so the list stops being the thing
     that has to be remembered. Two of them are the audit findings the neighbouring test recorded
     as knowingly unfixed (.section-subnav-link 38px, .help-chip 22px); the other twelve nobody
     had measured. Grouped by the SHAPE of the fix, because the shape is what the next control
     will need to copy. */

  /* Text fields and selects: height floor only. A min-width would fight a full-width field or a
     flex row, and every one of these is already wider than 44px. */
  .topbar-search input, .mini-form input, .rail-mini input, .wallet-redeem input,
  .rail-field input, .rail-field select, .cmp-select { min-height: 44px; }
  /* Boxes that are already flex or button-shaped — the floor alone is enough. */
  .notif-markread, .job-chip, .ac-mode { min-height: 44px; }
  /* INLINE boxes. min-height does nothing to a non-replaced inline box, so without the display
     change the floor would be written and have no effect — the same trap .home-trend-act hit from
     the other side (a block box left its label pinned to the top). align-items keeps the text
     centred in the taller box. .help-chip is already inline-flex; it is listed here so the group
     stays readable as one shape rather than splitting on which one happened to have it. */
  .q-op, .ts-download, .help-chip, .section-subnav-link {
    min-height: 44px; display: inline-flex; align-items: center; }
}
/* Review/pipeline pages kept a 520px empty stage on phones: the .review-workspace override
   (0,2,0) beat the 1100px media rule (0,1,0). Re-assert the collapse with equal specificity. */
@media (max-width: 1100px) {
  .review-workspace .render-preview-stage, .review-workspace .hero-preview { min-height: 320px; }
}
/* OpenCut embed: never taller than the viewport (landscape phones got a 520px floor). */
@media (max-height: 640px) {
  .edit-embed { min-height: 0; height: calc(100dvh - 96px); }
}

/* ══════════════════════════ PRINT ═══════════════════════════════════════════════════════════
   WHY THIS EXISTS. The published legal pages are the ones that get printed: the disclosure
   evidence for the NĐ 248/2026 platform filing is a set of PDFs made from the LIVE pages with
   Ctrl+P → Save as PDF. Until this block the whole repo held exactly one @media print rule and it
   was the admin console's (admin/css.js), so a capture of a product page came out on the dark
   ground, carrying the sidebar, the topbar and the footer nav — and, because .legal-scroll used to
   be a 46vh box with its own scrollbar, stopping after about one screen with the rest of the
   document simply not in the file. The box is consent-only now (see .legal-scroll above); this
   block is the other half of that fix.

   DELIBERATELY NOT SCOPED TO .legal-page, for two reasons.
   · The chrome and the dark ground are wrong on EVERY printed page, not only this one. There is no
     page whose paper copy wants a navigation rail.
   · Scoping would make a correct capture depend on which wrapper class a page happens to use. The
     policy pages this filing needs are separate pages written later; a capture that silently loses
     its print styles because a div was named differently is the same defect a second time, and it
     fails the same way — quietly, in a file nobody re-opens before submitting it.
   The console is untouched: its chrome is .adm-* and it keeps its own print block.

   WHAT IT CANNOT DO. It cannot make the browser print background colours (that is the viewer's
   "Background graphics" checkbox), which is fine here because everything below is black on white;
   and it cannot stop a single section longer than one sheet from being broken across sheets —
   break-inside: avoid is a preference the browser drops when the box does not fit at all. */
@media print {
  /* THE THEME DOES NOT SURVIVE INTO PAPER. The site default is <html data-theme="dark"> and every
     rule in this sheet reads its colour through a token, so re-declaring the tokens turns the page
     black-on-white in one place instead of chasing each rule. The pair ":root, :root[data-theme]"
     covers all three theme states — dark default (no attribute), explicit light, explicit dark —
     and the second selector carries the (0,2,0) needed to beat :root[data-theme="light"] above. */
  :root, :root[data-theme] {
    --bg: #ffffff; --bg2: #ffffff; --bg-deep: #ffffff;
    --surface: #ffffff; --surface2: #ffffff; --surface3: #ffffff;
    --bg-grad-1: #ffffff; --bg-grad-2: #ffffff;
    --sidebar-bg: #ffffff; --topbar-bg: #ffffff;
    --text: #000000; --muted: #333333; --faint: #444444;
    --line: #bbbbbb; --line2: #999999;
    --accent: #000000; --accent-soft: transparent; --accent-glow: transparent;
    --accent-ring: transparent; --accent-shadow: transparent; --scrim: transparent;
    --on-accent: #ffffff;
    color-scheme: light;
  }
  @page { margin: 14mm 12mm; }
  /* body carries a fixed radial gradient; the shorthand is what clears it. orphans/widows inherit
     from here, so every printed paragraph gets them without a selector per page. */
  html, body { background: #ffffff; color: #000000; }
  body { orphans: 3; widows: 3; }
  /* Navigation and anything that floats over the page. !important on purpose: these are overlays
     whose visibility is driven by state selectors and by scripts, and a printout must not depend on
     which state the page was left in. */
  .skip-link, .app-scrim, .app-sidebar, .app-topbar, .site-footer,
  .install-sheet, .notif-toasts, .svc-notice, .ipm-overlay, .lightbox { display: none !important; }
  /* The sidebar is grid COLUMN ONE. Hiding it alone would drop .app-body into that 248px column and
     print the page in a ribbon down the left edge, so the grid has to stop being a grid. */
  .app-grid { display: block; min-height: 0; }
  .app-shell { min-height: 0; }
  .app-main { padding: 0; max-width: none; }
  /* 780px is 8.1in of paper and the text column of an A4 page is about 7.3in, so the reading width
     has to be released or the right-hand edge of every line is cut off. */
  .legal-page { max-width: 100%; padding: 0; }
  /* The whole document, whichever page it is on and whichever classes it carries. */
  .legal-scroll {
    max-height: none !important; overflow: visible !important;
    margin: 0; padding: 0; border: 0; background: transparent;
    font-size: 11pt; line-height: 1.5;
  }
  .legal-title { font-size: 18pt; }
  .legal-stamp, .legal-reason { font-size: 11pt; }
  /* Keep a numbered item whole, and never leave its heading stranded at the foot of a sheet. */
  .legal-section { break-inside: avoid; }
  .legal-heading, .legal-subheading { break-after: avoid; }
  .legal-heading { font-size: 13pt; }
  .legal-list li { break-inside: avoid; }
  /* The accept form is a control, not text: it means nothing on paper and it is not part of the
     document being evidenced. */
  .legal-accept, .legal-decline, .legal-cycle { display: none !important; }

  /* ── the published policy documents ──────────────────────────────────────────────────────────
     These four ARE the filing: the PDFs uploaded to online.gov.vn are Ctrl+P captures of these
     pages. Everything above already applies to them (they render inside .legal-page/.legal-scroll
     on purpose); these rules cover the tags the terms document never uses. */
  .legal-doc { font-size: 10.5pt; line-height: 1.45; }
  .legal-doc h1 { font-size: 16pt; }
  .legal-doc h2 { font-size: 12.5pt; }
  .legal-doc h3 { font-size: 11pt; }
  .legal-doc h1, .legal-doc h2, .legal-doc h3 { break-after: avoid; }
  .legal-doc li, .legal-doc tr { break-inside: avoid; }
  .legal-doc hr { border-top: 1px solid #999999; }
  .legal-doc blockquote {
    border-left: 2px solid #000000; background: transparent; padding: 4pt 0 4pt 8pt;
  }
  .legal-doc code { background: transparent; }
  /* A WIDE TABLE MUST PRINT WHOLE. An overflow-x box prints exactly what is inside the box and
     drops the rest without a mark — the same failure the 46vh scroll box caused on /legal/terms,
     one element down. Releasing the box and the min-width lets the columns compress onto the
     sheet; a deadline table with a column missing is a document that says something else. */
  .legal-doc .table-wrap { overflow: visible !important; max-width: none !important; }
  .legal-doc table { min-width: 0 !important; width: 100%; font-size: 9pt; }
  .legal-doc th, .legal-doc td { border: 1px solid #000000; padding: 3pt 5pt; }
  .legal-doc th { background: transparent; }
  /* A table that does break across sheets repeats its header on each one, so a reader of page 4
     still knows which column is the deadline and which is the acknowledgement. */
  .legal-doc thead { display: table-header-group; }
}
