@font-face { font-family: "Anuphan"; src: url("fonts/Anuphan-Regular.ttf") format("truetype"); font-weight: 400; }
@font-face { font-family: "Anuphan"; src: url("fonts/Anuphan-Medium.ttf") format("truetype"); font-weight: 500; }
@font-face { font-family: "Anuphan"; src: url("fonts/Anuphan-SemiBold.ttf") format("truetype"); font-weight: 600; }
@font-face { font-family: "Anuphan"; src: url("fonts/Anuphan-Bold.ttf") format("truetype"); font-weight: 700; }
/* Curated Thai+Latin caption faces (OFL). One weight each is enough for the picker
   preview; the burn resolves every weight via fontconfig from the family name. */
@font-face { font-family: "Kanit"; src: url("fonts/Kanit-Regular.ttf") format("truetype"); font-weight: 400; }
@font-face { font-family: "Noto Sans Thai"; src: url("fonts/NotoSansThai-Bold.ttf") format("truetype"); font-weight: 400; }
@font-face { font-family: "IBM Plex Sans Thai"; src: url("fonts/IBMPlexSansThai-Bold.ttf") format("truetype"); font-weight: 400; }
@font-face { font-family: "Prompt"; src: url("fonts/Prompt-Regular.ttf") format("truetype"); font-weight: 400; font-display: swap; }
@font-face { font-family: "Sarabun"; src: url("fonts/Sarabun-Regular.ttf") format("truetype"); font-weight: 400; font-display: swap; }
@font-face { font-family: "Mali"; src: url("fonts/Mali-Regular.ttf") format("truetype"); font-weight: 400; font-display: swap; }
@font-face { font-family: "Chakra Petch"; src: url("fonts/ChakraPetch-Regular.ttf") format("truetype"); font-weight: 400; font-display: swap; }

/* ── capkatsu BRAND KIT v1 — ฟอนต์ของ *ตัวแอป* ─────────────────────────────
   These are UI faces, NOT caption faces. They are deliberately registered
   under names that cannot collide with the caption picker's families above.

   Why "Plex Thai UI" and not "IBM Plex Sans Thai": that family is already
   claimed by the CAPTION picker one line up, where weight 400 is wired to the
   BOLD file. Adding a real Regular at 400 there would silently change the
   caption preview for every clip using that face, and the preview would then
   have to be re-proved against a burned mp4 — a different job from adopting a
   brand font. The mislabel is pre-existing debt; it is NOT fixed here.

   Brand rule (BRAND KIT §05): headings very bold, body much lighter, never mix
   mid weights. Mitr SemiBold = Thai headings, Nunito Black = Latin headings,
   IBM Plex Sans Thai + Inter = body. */
@font-face { font-family: "Mitr"; src: url("fonts/Mitr-Regular.ttf") format("truetype"); font-weight: 400; font-display: swap; }
@font-face { font-family: "Mitr"; src: url("fonts/Mitr-SemiBold.ttf") format("truetype"); font-weight: 600; font-display: swap; }
@font-face { font-family: "Plex Thai UI"; src: url("fonts/IBMPlexSansThai-Regular.ttf") format("truetype"); font-weight: 400; font-display: swap; }
@font-face { font-family: "Plex Thai UI"; src: url("fonts/IBMPlexSansThai-SemiBold.ttf") format("truetype"); font-weight: 600; font-display: swap; }
@font-face { font-family: "Nunito"; src: url("fonts/Nunito-var.ttf") format("truetype-variations"); font-weight: 200 1000; font-display: swap; }
@font-face { font-family: "Inter"; src: url("fonts/Inter-var.ttf") format("truetype-variations"); font-weight: 100 900; font-display: swap; }

:root {
  --hdr-btn-h: 32px;   /* ความสูงปุ่มบนแถบหัว — .icon-btn และปุ่มโหลดบนมือถือใช้ร่วมกัน */
  /* capkatsu BRAND KIT v1. Two accent tokens on purpose: --accent is used as
     `color` 45 times and as `background` 22 times in this file, and Katsu Gold
     is a FILL (2.36:1 on white), so one token cannot serve both. --accent is
     the text-safe deep gold; --brand is the gold itself, only ever a fill and
     always paired with --on-brand. Locked by css_brand_tokens.test.js. */
  /* Cream is the PAGE; white is the WORK SURFACE. The kit's 60% cream is the
     ground you see around things, not the thing you look at — a cream panel on
     a cream page gives the eye nothing to rest against, which is what the owner
     saw ("พื้นที่ขาวน่าจะเยอะกว่านี้"). Panels go pure white so every card,
     sheet and field reads as a surface sitting ON the brand ground. */
  /* was #fff6e8 (cream). Owner 2026-08-20, after living with the cream for a
     day: "clean and minimal … more white, less orange or yellow", bar set to
     KakaoBank/KakaoTalk via Mobbin. Their grammar, read off real screens:
     WHITE ground, near-black ink, grey hairlines for separation, and the brand
     yellow in exactly ONE place per screen (the primary button / the active
     toggle). The mascot stays — in Kakao apps the character is content, not
     chrome tint. Warmth now lives only in the ink and the one gold button. */
  /* Round 2 of the Kakao retint, same sitting: the owner liked the clean and
     added two rulings — "ลายเส้นไม่จำเป็นต้องเป็นสีน้ำตาล" (line art need not be
     brown) and "พื้นเทาตัดกับ card ขาวก็สวยดี" (grey ground against white cards),
     which is literally KakaoBank's home screen: grey ground, white cards,
     near-black ink, grey icons, yellow in one place. Brown now survives only
     where it is BRAND: the mascot, the ink (warm black), the gold button, and
     --accent on links/active states. */
  --bg: #f4f5f6;
  --panel-bg: #ffffff;
  --border: #e4e6e9;   /* hairline does the separating now — see --bg */
  /* was #5a3a21 (brown). Owner: line art and text chrome go NEUTRAL; warmth
     stays in the mascot, the ink and the one gold button. #3a3733 is a warm
     charcoal — 10.8:1 on the grey ground. Bonus fix the numbers caught: the
     old muted #8a6c4f measured 4.41 on the soft fill — BELOW the 4.5 floor,
     a fail that had been shipping quietly. The grey set clears every ground:
     text 10.6-11.8, muted 4.8-5.4, accent 4.7-5.3, ink 16.5-18.5. */
  --text: #3a3733;
  /* INK — headings, card titles and big numbers only; body text stays --text.
     Owner's call 2026-08-20 after looking at three mockups side by side ("ก"
     put ink on everything, "ค" put it only on rules and icons, "ข" won).
     #1A1208, not #000: pure black on a cream ground reads as a hole punched in
     the page rather than as ink on it. Measured on --bg: 18.52:1, and on the
     white panel 18.9:1 — both far past the 4.5 floor, which is the point of a
     heading colour. Body text deliberately stays LIGHTER (--text at 8.9:1) so
     the hierarchy comes from the gap between them. */
  --ink: #1a1208;
  /* A rule that has to be VISIBLE, unlike --border which is a soft edge on a
     surface. Measured: --border (#e7d9c4) on the rail's own ground read as no
     line at all. */
  --rail-rule: #d6d9dc;

  /* TYPE SCALE. Before this the stylesheet carried 28 different rem values
     across 196 font-size declarations, and ONE editor screen rendered 19
     distinct sizes; CapCut's editor, measured the same way on the same day,
     renders 5. The values were arbitrary fractions written one at a time
     (.68 / .72 / .78 / .82rem …) — nothing was compounding, they were simply
     never decided. Every step lands on a WHOLE pixel at the 16px root.
     11px is the FLOOR: 33 elements rendered below it, and Thai needs MORE
     vertical room than Latin, not less, because tone marks and vowels stack
     above and below the line.
     --fs-3xl is 1.75rem and not a round 2rem on purpose: .burn-overlay-pct was
     tuned to it against measured competitors (CapCut ~28px, Edits ~24px) and
     web/tests/css_burn_overlay.test.js pins that band. A refactor must not
     quietly move a number somebody measured. */
  --fs-xs: .6875rem;   /* 11px — labels, chips, the smallest thing allowed */
  --fs-sm: .75rem;     /* 12px — rail labels, dense secondary text */
  --fs-md: .8125rem;   /* 13px — default UI text */
  --fs-base: .875rem;  /* 14px — body copy, card titles */
  --fs-lg: 1rem;       /* 16px — section headings */
  --fs-xl: 1.25rem;    /* 20px — screen titles */
  --fs-2xl: 1.5rem;    /* 24px */
  --fs-3xl: 1.75rem;   /* 28px — the burn percentage, measured against CapCut */
  --fs-4xl: 2rem;      /* 32px — the cost total, and nothing else */
  --text-muted: #6e6a64;
  /* Round 3, owner: "แบบ supabase clean — สีเหลืองเหมือนสีเขียวใน supabase
     เป็นแค่ highlight" + "รวมถึง menu แถวด้านข้าง และด้านบน". Read off the
     Supabase light dashboard (Mobbin + the owner's own screenshot): chrome is
     ENTIRELY quiet greys — sidebar icons, links, selected states, breadcrumbs,
     all neutral — and the brand green exists as one small primary pill
     ("Connect"/"Insert") and nothing else. So --accent stops being a colour:
     every "brand-tinted text/border" use goes neutral, and the yellow family
     survives in exactly two sanctioned highlights:
       1. the เบิร์น button fill (--brand, unchanged)
       2. the current-step ring (--hl)
     One deliberate divergence from Supabase: our sidebar KEEPS its Thai labels.
     Their icon-only rail is the exact thing this app already paid for twice —
     the owner met an unlabeled icon and asked "อันนี้คืออะไร". */
  --accent: #55524e;
  --hl: #e8a33d;         /* the highlight — yellow playing Supabase-green */
  --accent-soft: #f1f2f4;   /* neutral wash for selected fills */
  --brand: #e8a33d;      /* fill only */
  --on-brand: #5a3a21;   /* the label that sits on --brand */
  /* Clip status. Home had no status palette at all, so the one signal a
     returning creator actually scans for ("which clip is half-finished") had
     nowhere to live. Declared as tokens because the CSS audit found 41 raw hex
     values bypassing 7 tokens and 8 different ambers for one meaning. */
  /* capkatsu semantic set. The brand owns AMBER now (Katsu Gold #E8A33D), so
     "รอตรวจ" had to leave that hue or the primary button and the row that needs
     checking read as one thing — measured 9.8° apart before the move, which is
     the "8 ambers for one meaning" defect this file already records.
     Green = succeeded (from the kit's Cabbage Green, darkened until it passes),
     rust red = needs checking, and shape carries the difference from a hard
     error (soft fill + ↩ vs solid border). Locked by css_brand_tokens.test.js. */
  --ok: #487630;    --ok-soft: #f1f7ea;    --ok-border: #d5e7c4;
  /* "รอตรวจ" is brand BROWN, not red. The first pass made it rust #b3261e and
     measurement killed it: 3.2° from the error red #b91c1c, i.e. the user could
     not tell "the AI changed this, check it" from "this failed". Brown is 26.3°
     away, reads as a note rather than a failure, and is a colour the kit already
     owns. Error keeps the red family — see --bad. */
  --warn: #5a3a21;  --warn-soft: #f6ecda;  --warn-border: #e7d9c4;
  --bad: #b91c1c;   --bad-soft: #fbdedb;   --bad-border: #fecaca;
  --on-accent: #ffffff;   /* white on the deep gold, measured */
  --scrim: rgba(24,24,27,.72);
  /* Ring around a chip that sits on top of a video frame. The status badge is
     opaque, so its TEXT is safe over any frame (measured 4.63-4.84:1). What
     failed was finding the chip: its pale fill scores 1.04:1 against a white
     region of a real frame and its border 1.21:1, so on a bright frame the
     whole badge dissolved. This ring reads 3.94:1 there; on a dark frame the
     pale fill carries itself (17.77:1). The pair covers both. */
  --chip-ring: rgba(24,24,27,.55);
  --shadow-card: 0 1px 2px rgba(24,24,27,.06), 0 1px 3px rgba(24,24,27,.04);
  --shadow-lift: 0 4px 12px rgba(24,24,27,.10);
}

* { box-sizing: border-box; }
/* Body: Thai first, then Latin. Anuphan stays as the fallback so a failed font
   fetch degrades to the face this app shipped with, not to system-ui. */
body { margin: 0; font-family: "Plex Thai UI", "Inter", "Anuphan", system-ui, sans-serif;
  background: var(--bg); color: var(--text); }
/* Headings carry the brand voice. Weight 600 exactly — the kit forbids mid weights,
   and Mitr only ships Regular/SemiBold here. */
h1, h2, h3, header h1, .panel-title, .sheet-title {
  font-family: "Mitr", "Plex Thai UI", sans-serif; font-weight: 600; color: var(--ink); }
/* The other half of option "ข": the numbers people scan for. Titles above,
   figures here — everything else keeps --text. */
.cost-total, .pack-price, .num, .facts b,
.ai-title, .start-title, .journey-title, .ai-panel-title,
.home-name, .ai-row-name, .tl-cap .th, .tl-text .t-label { color: var(--ink); }

/* The `hidden` attribute must ALWAYS win. Any rule that sets an explicit
   display (flex/grid/block) silently outranks the UA's `[hidden]{display:none}`,
   so `el.hidden = true` sets the property but leaves the element on screen —
   a bug that has bitten this project repeatedly (.panel-group, .review-chat,
   .rail-item, and most recently .ai-card, where step-4 cards stayed visible on
   step 1 even though .hidden was true). One global rule instead of remembering
   a per-class override every time. The specific [hidden] rules further down are
   now redundant belt-and-braces. */
[hidden] { display: none !important; }

/* Login gate overlay. Full-viewport so nothing behind it is reachable while
   logged out — reuses .panel's card look (bg/border/radius) rather than
   inventing a second visual language for one screen. z-index above
   everything else in the app, including the job bar. */
.auth-screen { position: fixed; inset: 0; z-index: 1000; display: flex;
  align-items: center; justify-content: center; background: var(--bg); }
.auth-card { width: 100%; max-width: 360px; display: flex; flex-direction: column;
  gap: .9rem; align-items: center; text-align: center; background: var(--panel-bg);
  border: 1px solid var(--border); border-radius: 8px; padding: 2rem 1.5rem; }
.auth-card h2 { margin: 0; }
.auth-card p { margin: 0; color: var(--text-muted); font-size: var(--fs-base); }
.auth-free { font-size: var(--fs-md) !important; }

/* In-app browser notice. Replaces the Google button (which cannot work here),
   so it must carry the whole job: why nothing will happen, the exact tap that
   fixes it, and a fallback that works even when the menu wording differs. */
.inapp-notice { display: flex; flex-direction: column; gap: .6rem; width: 100%;
  border: 1px solid var(--warn-border); background: var(--warn-soft);
  border-radius: 8px; padding: .9rem .8rem; text-align: left; }
.inapp-why { margin: 0; font-size: var(--fs-base); color: var(--warn); line-height: 1.5; }
.inapp-why b { color: var(--warn); }
.inapp-hint { margin: 0; font-size: var(--fs-base); font-weight: 600; color: var(--text); line-height: 1.5; }
/* Emphasis follows what actually fixes the problem on THIS device: the Chrome
   intent is one tap on Android, so it is the filled button there; on iOS there
   is no equivalent and copying the link is the real action. Same two controls,
   swapped weight — never two filled buttons competing. */
.inapp-chrome, .inapp-copy { display: block; width: 100%; text-align: center;
  font: inherit; font-size: var(--fs-base); font-weight: 600; padding: .6rem .8rem;
  border-radius: 8px; cursor: pointer; text-decoration: none; }
/* Secondary since DEV-72: the email code above is the way in from here; the
   escape to a real browser is for people who would rather use Google. */
.inapp-chrome { background: var(--bg); color: var(--accent); border: 1px solid var(--accent); }
.inapp-chrome:hover { background: var(--accent-soft); }
.inapp-copy { background: var(--bg); color: var(--accent); border: 1px solid var(--accent); }
.inapp-copy:hover { background: var(--accent-soft); }
/* No Chrome intent (iOS): copy becomes the primary action. */
/* Email one-time code (DEV-72) */
.email-login { display: flex; flex-direction: column; gap: .6rem; width: 100%; }
.otp-step { display: flex; flex-direction: column; gap: .6rem; width: 100%; }
.otp-step[hidden] { display: none; }
.otp-step .btn-export { justify-content: center; padding: .7rem .95rem; }
.otp-step .btn-export:disabled { opacity: .5; }
.otp-label { font-weight: 600; color: var(--text); font-size: var(--fs-base); }
/* 16px minimum: iOS zooms the whole page into any smaller focused input. */
.otp-input { width: 100%; box-sizing: border-box; padding: .7rem .8rem; font: inherit;
  font-size: 16px; border: 1px solid var(--border); border-radius: 10px;
  background: var(--bg); color: var(--text); }
.otp-input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.otp-code { letter-spacing: .4em; text-align: center; font-variant-numeric: tabular-nums; }
.otp-code:placeholder-shown { letter-spacing: normal; }
.otp-sent { color: var(--text) !important; }
.otp-sent b { word-break: break-all; }
.otp-help { font-size: var(--fs-sm, .85rem) !important; }
.otp-actions { display: flex; gap: .5rem; justify-content: center; align-items: center; }
.otp-link { background: none; border: 0; padding: .3rem; font: inherit; color: var(--accent);
  text-decoration: underline; cursor: pointer; }
.otp-link:disabled { color: var(--text-muted); text-decoration: none; cursor: default; }
.email-login:not([hidden]) + .inapp-notice { border-top: 1px solid var(--border); padding-top: .8rem; }
.auth-consent { font-size: var(--fs-md) !important; color: var(--text-muted); line-height: 1.7; }
/* The two legally-load-bearing links get a real tap target (UI critic: they
   rendered 15px tall vs the 24px WCAG floor) without growing the layout —
   padding out, negative margin back in. */
.auth-consent a { color: var(--accent); text-decoration: underline;
  display: inline-block; padding: 6px 2px; margin: -6px -2px; }
.auth-error { color: #b91c1c; font-size: var(--fs-md) !important; line-height: 1.5;
  background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px; padding: .5rem .65rem; }

/* Dev-only login harness — deliberately loud/unlike the real .auth-card so it
   can never be mistaken for production login (see CLAUDE.md: this is a full
   auth bypass, local dev only). Amber/black hazard-stripe accent, not the
   app's normal palette. */
.dev-auth-screen { background: rgba(20, 14, 0, .92); }
.dev-auth-card { border: 2px dashed #f5a623; background: var(--panel-bg); }
.dev-auth-badge { align-self: stretch; background: #f5a623; color: #1a1200;
  font-weight: 700; font-size: var(--fs-md); letter-spacing: .02em; padding: .35rem .6rem;
  border-radius: 6px; text-transform: uppercase; }
.dev-auth-input { width: 100%; box-sizing: border-box; padding: .5rem .7rem;
  border: 1px solid var(--border); border-radius: 8px; background: var(--bg);
  color: inherit; font-size: var(--fs-lg); }

/* App shell: tool rail (far left) + workspace. Rail is desktop-only. */
.app { display: flex; min-height: 100vh; }
.workspace { flex: 1; min-width: 0; }

.rail { display: none; }  /* hidden on mobile — header already says "Reels Studio" */
/* The expand/collapse toggle is DESKTOP-ONLY, and this rule is what makes that
   true. Its whole ruleset lived inside the min-width:768 block, so below that
   it rendered as an unstyled <button> — icon plus the words ขยายแถบเมนู — and
   became a giant fifth item on the phone tab bar, where the rail is a bottom
   bar and there is nothing to expand. Seen on the screen at 375px, not in any
   test: no unit test renders this element, and the CSS tests read source text.
   Hidden by default, shown only inside the desktop block below. */
.rail-toggle { display: none; }
@media (min-width: 768px) {
  .rail { display: flex; flex-direction: column; align-items: center; gap: .5rem;
    /* 48px, icon-only — the owner's fourth ruling ends the label saga: "copy
       Supabase 100% ยกเว้น icon & logo", and Supabase's rail is a 48px strip of
       bare 20px icons with tooltips. The 84px labelled version above this in
       git history was itself measured work (Thai labels need 59px unwrapped);
       it lost to a direct order, not to a critic. Every rail item already
       carries a title tooltip, which is what Supabase leans on too. The
       POPOVER menu keeps its labels — Supabase's own popovers are text. */
    width: var(--rail-w, 48px); flex-shrink: 0; padding: .6rem .25rem; border-right: 1px solid var(--border); background: var(--panel-bg); }

  /* ── expandable rail (owner 2026-08-20: "แถบ sidebar ควรยืดออกมาได้มั้ย" →
     "แบบแรก", i.e. a persistent toggle, not hover-to-expand) ───────────────
     48 -> 256px. Both numbers are published: WinUI's CompactPaneLength default
     is 48 DIP and shadcn/ui + Carbon both use 3rem collapsed / 16rem expanded;
     256 is the narrowest expanded width of any system surveyed (WinUI 320,
     Material's drawer 360). Not derived from our own text — the widest Thai
     label, "คำบรรยาย", measures 59px.
     ONE attribute on <html> drives all of it, set by a blocking inline script in
     the head so the rail is never painted at the wrong width and then jumps. */
  :root[data-nav="expanded"] { --rail-w: 256px; }
  /* The 840px floor is enforced in JS (railState) and by the before-paint
     script, NOT by a second CSS media query here. There WAS one, and it created
     exactly the two-authorities bug this repo keeps re-learning: at an 800px
     window the media query pulled the rail back to 48px while data-nav still
     read "expanded", so the toggle kept announcing aria-expanded="true" beside
     a collapsed rail — measured on screen, invisible to every unit test.
     One authority: data-nav decides, and only railState() sets it. */

  .rail-toggle { display: flex; align-items: center; gap: .55rem; width: 100%;   /* re-shown here: desktop only, see the base rule */
    min-height: 40px; padding: .55rem .2rem; margin-bottom: .2rem;
    justify-content: center;
    font-family: inherit; font-size: var(--fs-md); color: var(--text-muted);
    background: none; border: none; border-radius: 8px; cursor: pointer; }
  .rail-toggle:hover { background: var(--accent-soft); color: var(--text); }
  .rail-toggle svg { width: 20px; height: 20px; flex: none; }
  .rail-toggle > span { display: none; white-space: nowrap; }

  .rail > .rail-group > .rail-item > span { display: none; }
  .rail .rail-soon > span:not(.rail-soon-mark) { display: none; }
  .rail-item span { white-space: nowrap; }
  .rail-brand { width: 34px; height: 34px; display: grid; place-items: center; color: var(--accent); margin-bottom: .4rem; }
  .rail-brand svg { width: 24px; height: 24px; }
  /* Mascot alone is allowed here: the kit lists icon/sticker/avatar as its own
     places. The wordmark job is done by the lockup in the header. */
  .rail-brand img { width: 30px; height: auto; display: block; }
  /* Groups, hairline-separated. No group LABELS: at 76px wide they would be
     more sub-11px text, which is the thing this pass exists to remove. */
  .rail-group { display: flex; flex-direction: column; align-items: center; gap: .5rem; width: 100%; }
  /* The rule was 1px of --border (cream on cream) and measured invisible at
     size — what separated the groups was the gap, not the line. Two steps
     darker and a little more air on each side. */
  .rail-group + .rail-group { margin-top: .7rem; padding-top: .7rem; border-top: 1px solid var(--rail-rule); }
  /* An all-hidden group (the clip tools on Home) must not leave a stray rule or
     a gap. :empty cannot see this — the buttons are present but [hidden].
     `.mobile-only` has to be excluded too: on desktop those are hidden by
     `display:none` from the rule below, which no selector can query — so a
     group holding ONLY mobile buttons looked non-empty to :has() and kept its
     separator. Measured on Home: a 26px band of rule + gap with nothing in it. */
  .rail-group:not(:has(> :not([hidden]):not(.mobile-only))) { display: none; }
  /* Account sits at the bottom of the rail, the shell convention Figma, Notion
     and Linear share. Was .rail-soon holding this slot. */
  .rail-group[data-group="account"] { margin-top: auto; }
  /* The account popover. Anchored to its group, opening to the RIGHT of the
     rail; inside it the rail-items relayout as rows (icon beside label), the
     shape every popover menu the bar was measured against uses. */
  .rail-group[data-group="account"] { position: relative; }
  .rail-menu { position: absolute; left: calc(100% + 10px); bottom: 0; z-index: 70;
    min-width: 168px; padding: .35rem; background: #fff;
    border: 1px solid var(--rail-rule); border-radius: 8px;
    box-shadow: 0 8px 24px rgba(90,58,32,.18); display: flex; flex-direction: column; gap: .1rem; }
  .rail-menu[hidden] { display: none; }
  .rail-menu .rail-item { flex-direction: row; justify-content: flex-start; gap: .55rem;
    width: 100%; padding: .5rem .6rem; font-size: var(--fs-md); }
  .rail-menu .rail-item svg { width: 18px; height: 18px; }
  .rail-item { display: flex; flex-direction: column; align-items: center; justify-content: center;
    gap: 0; width: 100%; min-height: 40px;
    /* was .68rem = 10.88px, then 12px to match CapCut's rail. A critic reading
       the screen said 12px is still short for THAI specifically — matching a
       Latin UI size 1:1 ignores that วรรณยุกต์ and สระบน stack above the line
       and สระล่าง below it, so the same nominal size buys less legible height.
       13px (--fs-md). Latin rails can sit at 12; Thai ones should not. */
    font-family: inherit; font-size: var(--fs-md); color: var(--text-muted); background: none; border: none;
    border-radius: 8px; padding: .55rem .2rem; cursor: pointer; }
  .rail-item svg { width: 20px; height: 20px; }   /* Supabase's icon size */

  /* Expanded: the SAME markup relaid out as rows, icon beside label — the shape
     the account popover already uses, so there is one row idiom in the rail
     rather than two. Guarded by the 840px floor so a narrow window that is
     forced back to icons does not also get the row layout. */
  @media (min-width: 840px) {
    :root[data-nav="expanded"] .rail { align-items: stretch; padding: .6rem .5rem; }
    :root[data-nav="expanded"] .rail-item,
    :root[data-nav="expanded"] .rail-toggle {
      flex-direction: row; justify-content: flex-start; gap: .6rem; padding: .5rem .6rem; }
    :root[data-nav="expanded"] .rail > .rail-group > .rail-item > span,
    :root[data-nav="expanded"] .rail-toggle > span,
    :root[data-nav="expanded"] .rail .rail-soon > span:not(.rail-soon-mark) { display: inline; }
    :root[data-nav="expanded"] .rail-group { align-items: stretch; }
    :root[data-nav="expanded"] .rail-brand { justify-self: start; place-items: center start; }
    /* The dot rides the right edge of a full-width row instead of a 48px box. */
    :root[data-nav="expanded"] .rail-dot { right: 10px; top: 50%; margin-top: -4px; }
  }

  .rail-item:hover { background: var(--accent-soft); color: var(--text); }
  .rail-item.active { color: var(--ink); background: var(--accent-soft); }
  .rail-item.mobile-only { display: none; }   /* the panel is always-visible chrome here */
  /* The magnifier comes BACK on desktop: find/replace being permanent chrome
     is what made this panel 86% chrome. Same toggle the phone already uses —
     no new machinery, and the tool stays one tap away. */
  #btn-find-toggle { display: inline-flex; }
  .transcript-panel .review-tools { display: none; }
  .transcript-panel.find-open .review-tools { display: flex; }
  /* No longer margin-top:auto — it sits with Reels in the product group now,
     because "another tool is coming" is a statement about the PRODUCT, not a
     place you can go. .7rem = 11.2px, up from .6rem = 9.6px. */
  .rail-soon { display: flex; flex-direction: column; align-items: center; gap: .2rem;
    font-size: var(--fs-xs); color: var(--text-muted); text-align: center; }
  .rail-soon-mark { width: 26px; height: 26px; display: grid; place-items: center; border: 1.5px dashed var(--border);
    border-radius: 8px; color: var(--text-muted); font-size: var(--fs-lg); }
}

header { display: flex; flex-wrap: wrap; gap: .6rem; align-items: center; justify-content: space-between;
  /* explicit white: the bar sat transparent and silently followed the page
     ground — fine while the ground WAS white, wrong the moment it went grey.
     Supabase's top bar is a white strip over the grey page, cut by a hairline. */
  background: var(--panel-bg);
  padding: .4rem .9rem; border-bottom: 1px solid var(--border); }   /* slim, Supabase-height */
/* On a real screen the header must SHRINK before it wraps: wrapping puts the
   primary action (เบิร์น) on a second row and doubles the header's height from
   60px to 120px, stealing that from the preview. `flex-wrap: wrap` above stays
   for the phone layout, where two rows are the right answer.
   Measured at 798px: with wrap on, adding undo/redo tipped the header to two
   rows; a control run hiding just those two buttons put it back to one, so the
   wrap was theirs and this is the fix for it, not a pre-existing condition. */
@media (min-width: 768px) { header { flex-wrap: nowrap; } }
.header-left { display: flex; align-items: baseline; gap: .5rem; min-width: 0; }
/* The quota pill is the widest discretionary thing in the header (158px
   measured) and it is INFORMATION, not an action — so when the header runs out
   of room it truncates rather than pushing the controls onto a second row.
   Adding undo/redo cost 64px + gaps and tipped an 800px-wide window into a
   two-row header; a control run (hiding just those two buttons at the same
   width) proved the wrap was theirs. The same number is on the ค่าใช้จ่าย
   screen in full, so an ellipsis here loses nothing. */
#quota-pill { flex: 0 1 auto; min-width: 0; overflow: hidden;
  text-overflow: ellipsis; white-space: nowrap; }
header h1 { font-size: var(--fs-lg); font-weight: 600; margin: 0; }
#mode-badge { font-size: var(--fs-sm); color: var(--text-muted); }
/* Ambient quota status (Task 12). Colours per data-warn: low = amber, out =
   red — everything else (unset, "ok") uses the same muted tone as mode-badge.
   Visibility is the global [hidden]{display:none!important} rule above. */
/* .quota-pill is shared by the header pill (#quota-pill) and Home's
   #home-quota (Task 5 amendment) — refreshQuota() writes the same text and
   data-warn to both, so both must look identical. */
/* DEV-76 ขั้น 2 — แถบ "ย้ายบ้าน" บนโดเมนเก่า: บาง สีสงบ (accent-soft) ไม่ใช่สีเตือน
   ปิดได้ · [hidden] ตัวกลางของ studio.css ครอบไว้แล้ว ไม่ต้องตั้ง display เอง */
.domain-notice { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: .35rem .9rem;
  padding: .45rem .9rem; font-size: var(--fs-sm); color: var(--text); background: var(--accent-soft);
  border-bottom: 1px solid var(--border);
  /* critic 9 ต.ค.: .auth-screen เป็น fixed z-index 1000 ทับแถบตอนยังไม่ล็อกอิน —
     คนที่ยังไม่ล็อกอินบนโดเมนเก่าคือคนที่ต้องเห็นมากที่สุด จึงยกแถบไว้เหนือมัน */
  position: relative; z-index: 1001; }
/* แถบอยู่ในโฟลว์ แต่ .app/.workspace สูง 100vh (และ 100dvh บนมือถือ) พอดี —
   studio.js วัดความสูงแถบใส่ --dn-h แล้วหักออกตรงนี้ ไม่งั้นแถบเมนูล่างหลุดจอ */
body.has-domain-notice .app { min-height: 0; height: calc(100vh - var(--dn-h, 0px)); }
body.has-domain-notice .workspace { height: calc(100vh - var(--dn-h, 0px)); }
@supports (height: 100dvh) {
  body.has-domain-notice .workspace { height: calc(100dvh - var(--dn-h, 0px)); }
}
.domain-notice span { flex: 1 1 14rem; min-width: 0; }
.domain-notice a { color: var(--accent); font-weight: 600; text-decoration: underline; text-underline-offset: 2px; }
.domain-notice button { margin-left: auto; border: 0; background: none; color: var(--text-muted);
  font-size: 1.15rem; line-height: 1; padding: 0 .25rem; cursor: pointer; }
.quota-pill { font-size: var(--fs-sm); color: var(--text-muted); padding: .15rem .5rem;
  border: 1px solid var(--border); border-radius: 999px; white-space: nowrap; }
.quota-pill[data-warn="low"] { color: var(--warn); border-color: var(--warn); }
.quota-pill[data-warn="out"] { color: #b91c1c; border-color: #b91c1c; }
/* `overflow: hidden` is load-bearing on EVERY screen, not cosmetic. WebKit —
   the engine Safari runs — adds this <select>'s widest OPTION to the scrollWidth
   of every ancestor, even though the control paints at its own width. A clip is
   named things like "copy_87B07B33-9D5B-4BD2-B587-31662B606E74-3", so measured
   at iPhone 15 Pro size that leaked 183px into `.header-left` and 68px into the
   page, and Safari then let the whole app pan sideways — which is what cut the
   stepper and the clip name off on the LEFT in the owner's screenshots, four
   reports over one day. Chromium does not do this, which is why it took a real
   WebKit build to find. Six candidates were measured; this was the only one
   that reached 0 without changing the rendered width. */
.clip-picker { font-family: inherit; font-size: var(--fs-md); max-width: 200px; padding: .35rem .5rem; overflow: hidden;
  border: 1px solid var(--border); border-radius: 8px; background: #fff; color: var(--text); cursor: pointer; }
.upload-btn { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px;
  border: 1px solid var(--border); border-radius: 8px; cursor: pointer; color: var(--text); }
.upload-btn:hover { border-color: var(--accent); color: var(--accent); }
.upload-btn svg { width: 17px; height: 17px; }
.header-right { display: flex; align-items: center; gap: .7rem; }
#status { font-size: var(--fs-md); color: var(--text-muted); }

.btn-export { display: inline-flex; align-items: center; gap: .4rem; font-family: inherit; font-weight: 600;
  font-size: var(--fs-base); padding: .5rem .95rem; border: none; border-radius: 999px; cursor: pointer;
  background: var(--brand); color: var(--on-brand); }
.btn-export:hover { opacity: .9; }
.btn-export .icon { width: 16px; height: 16px; }
.btn-export:disabled { cursor: default; opacity: 1; }
/* while burning: the button fills left→right with the encode %, and its icon spins */
.btn-export.burning { background:
  linear-gradient(90deg, var(--accent) var(--p,0%), color-mix(in srgb, var(--accent) 35%, #fff) var(--p,0%)); }
.btn-export.burning .icon { animation: burn-spin 1s linear infinite; }
@keyframes burn-spin { to { transform: rotate(360deg); } }

/* Full-width real-time progress bar pinned to the very top of the window. */
/* Waiting state on the preview. z-index sits UNDER .burn-overlay (6) so the
   burn's own card always wins — the motion guidelines allow only one MOVING
   mascot per screen, and setWaitState() enforces that by hiding this one
   whenever the burn overlay is up. */
.wait-state { position: absolute; inset: 0; z-index: 5; display: flex;
  flex-direction: column; align-items: center; justify-content: safe center; gap: .5rem;
  background: rgba(0,0,0,.45); border-radius: inherit; pointer-events: none; }
.wait-state[hidden] { display: none; }
/* NOT a fixed 88px. The .preview box this sits in is a flex sibling of the
   caption/AI panels and collapses hard when they open — MEASURED at 88px wide
   with the caption panel out, where an 88px mascot filled the frame edge to
   edge. Cap the size, then let it scale with whatever room is left. */
#wait-mascot { width: min(88px, 46%); }
.wait-label { color: #fff; font-size: var(--fs-md); line-height: 1.5; text-align: center;
  max-width: 24ch; padding: 0 .5rem; text-shadow: 0 1px 2px rgba(0,0,0,.5); }
.job-bar { position: fixed; top: 0; left: 0; right: 0; height: 3px; z-index: 100;
  background: color-mix(in srgb, var(--accent) 18%, transparent); }
.job-bar[hidden] { display: none; }
/* A shimmer sweeps the fill so a stalled percent (e.g. transcribe waiting on
   the ASR call at 40%) still reads as "working", not frozen. */
.job-bar-fill { height: 100%; width: 0; background-color: var(--accent); transition: width .25s ease;
  background-image: linear-gradient(90deg, transparent, rgba(255,255,255,.55), transparent);
  background-size: 160px 100%; background-repeat: no-repeat;
  animation: job-shimmer 1.2s linear infinite; }
@keyframes job-shimmer {
  from { background-position: -160px 0; }
  to   { background-position: calc(100% + 160px) 0; }
}

/* ===== base = MOBILE: stack panel under the preview ===== */
main { display: flex; flex-direction: column-reverse; gap: 1rem; padding: 1rem .9rem; align-items: center; }

/* Contextual Style panel (ElevenLabs Edit-Text shape) */
.panel { width: 100%; max-width: 420px; display: flex; flex-direction: column; gap: 1rem;
  background: var(--panel-bg); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; }
.panel-group { display: flex; flex-direction: column; gap: 1rem; }
.panel-group[hidden] { display: none; }  /* [hidden] must beat the flex display above */
.panel-title { text-transform: uppercase; letter-spacing: .03em; font-size: var(--fs-sm); color: var(--text-muted); }
.field { display: flex; flex-direction: column; gap: .45rem; }
.field > label { font-size: var(--fs-md); color: var(--text-muted); display: flex; justify-content: space-between; }
.divider { height: 1px; background: var(--border); }
/* One-line explanation under a choice (e.g. คำต่อคำ) — answers "กดตอนไหน" */
.field-hint { margin: .2rem 0 0; font-size: var(--fs-sm); line-height: 1.5; color: var(--text-muted); }
.mini-ico { width: 14px; height: 14px; vertical-align: -2px; }

/* single-line text inputs in the panel (EP title, hex) */
.panel input[type="text"] { width: 100%; font-family: inherit; font-size: var(--fs-base); padding: .5rem .65rem;
  border: 1px solid var(--border); border-radius: 8px; background: #fff; color: var(--text); }
.panel input[type="text"]:focus { outline: none; border-color: var(--accent); }
#eng-hex { text-transform: uppercase; }

/* In-font weight list — each option rendered in its own weight (ElevenLabs font-list pattern) */
.font-list { display: flex; flex-direction: column; gap: .4rem; }
/* Segmented mode toggle: both options visible at once, no dropdown to open.
   Matches CapCut's Styles tab and Descript's caption-style picker — a
   frequently-toggled binary choice shown as tappable options, not hidden
   behind a closed <select>. */
.mode-toggle { display: flex; gap: .4rem; }
.mode-toggle .font-opt { flex: 1; text-align: center; }
/* โหมดที่ "แสดง" ไม่ใช่แค่ "บอก" — สองปุ่มนี้ต่างกันที่คำบรรยายมาถึงจอยังไง
   ซึ่งป้ายสองคำสื่อไม่ได้ แต่ตัวอย่างสื่อได้ทันที */
.mode-toggle-vis .font-opt { display: flex; flex-direction: column; align-items: center;
  gap: .3rem; padding: .5rem .4rem; }
.mode-sample { display: block; background: #1a1208; color: #fff; border-radius: 4px;
  padding: .25rem .4rem; font-size: 11px; line-height: 1.5; white-space: nowrap;
  max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
.mode-name { font-size: var(--fs-sm); color: inherit; }
/* คำซ้อนช่องเดียว เพื่อให้มัน "แทนที่" กัน ไม่ใช่ต่อกันเป็นบรรทัด */
.word-stack { display: inline-grid; }
.word-stack .w-word { grid-area: 1 / 1; }
.word-stack .w-word:not(:first-child) { opacity: 0; }
.font-opt { font-family: "Anuphan", sans-serif; font-size: var(--fs-lg); text-align: left; padding: .55rem .7rem;
  border: 1px solid var(--border); border-radius: 8px; background: #fff; color: var(--text); cursor: pointer; }
.font-opt:hover { border-color: var(--accent); }
.font-opt.active { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); }
.font-select { width: 100%; font-size: var(--fs-lg); padding: .55rem .7rem; border: 1px solid var(--border);
  border-radius: 8px; background: #fff; color: var(--text); cursor: pointer; }
.font-select:hover { border-color: var(--accent); }
.check-row { display: flex; align-items: center; gap: .5rem; cursor: pointer; }
.check-row input { width: auto; margin: 0; }
/* Rail panel-toggles: notification dot (e.g. edited-after-burn on รีวิว AI). */
/* Header icon buttons (undo/redo). Disabled drops the pointer as well as the
   opacity, so a dead control cannot be pressed hopefully. */
.icon-btn { display: inline-grid; place-items: center; width: var(--hdr-btn-h); height: var(--hdr-btn-h);
  border: 1px solid var(--border); border-radius: 8px; background: var(--panel-bg);
  color: var(--text); cursor: pointer; }
.icon-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
.icon-btn:disabled { opacity: .38; cursor: default; }
.icon-btn .mini-ico { width: 17px; height: 17px; }
/* The total sits quieter than the playhead: one is where you are, the other is
   a fact about the clip that does not change while you scrub.
   Sizes are set on BOTH, and that is the fix rather than the decoration: the
   playhead was 11px (the scale floor) while the total inherited the 16px body
   size, so the number that never moves was rendering half again as large as the
   number the user watches while scrubbing — measured, not guessed. Same size
   now, and the playhead carries the ink. */
/* NAME COLLISION, found by measuring: `.t-time` was worn by TWO unrelated
   components — the transport's playhead clock and the per-row time in the
   caption list — and the caption-list rule (further down the file) was winning
   for both. The playhead was rendering at the list's 11px muted instead of its
   own size, which is why the total looked bigger than the number the user
   actually watches. The transcript owns `.t-time` (three JS references); the
   transport had exactly one element and an id, so the transport renamed.
   This repo has shipped this same defect twice before (.e / .im.e, .empty /
   .noimg) — fixed by renaming, not by out-specifying. */
/* (the transport clock's own rules live with the rest of the transport, below —
   two rules for one element is how the playhead ended up muted after the
   rename, which is the same mistake twice in one sitting) */
/* Timeline TOOLS carry a word, not just a glyph. The owner met the two new ones
   on a phone and asked "อันนี้คืออะไร" — which is the answer: a bare icon does
   not say what it does, and a tooltip is no help on a touch screen where there
   is no hover. Every other control in this app already pairs icon with label
   (the rail, the phone bar), so these match that, not the round transport pill.
   Their own class ON PURPOSE: `.t-btn` means "round 30px transport button" and
   is worn by ▶. Adding a label to that class squeezed text into a 30px circle
   and turned it into a tall oval — the same overload-an-existing-class mistake
   this file has paid for before. */
.tl-tool { display: inline-flex; flex-direction: column; align-items: center; gap: 2px;
  min-width: 40px; padding: .25rem .3rem; border: none; border-radius: 8px;
  background: none; font-family: inherit; color: var(--text); cursor: pointer; }
.tl-tool:hover:not(:disabled):not([aria-disabled="true"]) { background: var(--accent-soft); color: var(--accent); }
/* ทั้งสองแบบต้องหน้าตาเหมือนกัน — ปุ่มลบใช้ aria-disabled เพื่อให้ยังหาเจอด้วย
   คีย์บอร์ด (ดู syncTimelineTools) ถ้าสไตล์ตามแค่ :disabled มันจะดูใช้ได้ทั้งที่
   ใช้ไม่ได้ ซึ่งแย่กว่าเดิม */
.tl-tool:disabled,
.tl-tool[aria-disabled="true"] { opacity: .35; cursor: not-allowed; }
.tl-tool .mini-ico { width: 17px; height: 17px; }
.t-btn-label { font-size: var(--fs-xs); line-height: 1; }
.rail-item { position: relative; }
.rail-item[hidden] { display: none !important; }
.rail-dot { position: absolute; top: 4px; right: 8px; width: 8px; height: 8px;
  border-radius: 50%; background: var(--warn); }
.rail-dot[hidden] { display: none; }
.head-actions { display: flex; align-items: center; gap: .5rem; }

/* Workflow stepper: Benevity-style — numbered circles + connector line, labels
   BELOW, in its OWN strip under the header (informational, never gating).
   ✓ filled = done, accent ring = current, grey = upcoming, amber ! = stale burn. */
/* The step strip sinks into the page ground instead of being a white band with
   a rule under it (owner 2026-08-20: "ให้พื้นหลังเป็นเทากลืนไปเลย ไม่มีเส้น
   ดีกว่า"). It was white-on-grey with a hairline — three horizontal bands
   stacked under the header, which is exactly the fussiness the Supabase pass
   is removing. Now: same grey as the page, no border; the circles and labels
   carry the whole thing. */
.step-strip { position: relative; display: flex; align-items: center; justify-content: center;
  padding: .5rem 1.2rem .55rem; background: var(--bg);
  flex-shrink: 0; overflow-x: auto; }
.step-strip #status { position: absolute; right: 1.2rem; font-size: var(--fs-sm); color: var(--text-muted);
  max-width: 30%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.stepper { display: flex; align-items: flex-start; }
.step-col { display: flex; flex-direction: column; align-items: center; gap: 5px; width: 88px;
  background: none; border: none; cursor: pointer; font-family: inherit; padding: 0; }
.step-circle { width: 26px; height: 26px; border-radius: 50%; border: 2px solid var(--border);
  background: var(--panel-bg);
  display: grid; place-items: center; font-size: var(--fs-sm); color: var(--text-muted);
  background: #fff; box-sizing: border-box; }
.step-col:hover .step-circle { border-color: var(--hl); color: var(--ink); }
/* DONE is --ok (success green), not --accent. A blind critic comparing this
   screen with CapCut's said the colour carried no meaning here: the finished
   step, the current step, the primary button, the rail icons and the timeline
   clips were all the same brown, so shape was doing all the work. The token
   already existed and was simply never used on the stepper. --warn stays BROWN
   on purpose (see the token comment: rust measured 3.2° from the error red). */
/* DONE is ink, not green (owner 2026-08-20: "สีเขียวดูขัด ๆ"). The green was
   right on the cream palette and wrong the moment the app went Supabase-clean:
   a saturated hue is a second accent competing with the one highlight. Done =
   solid ink + white tick · current = the --hl ring · pending = grey outline.
   --ok itself stays — the clip-status badge on Home still means "finished" in a
   list where green reads instantly, and that screen was not the complaint. */
.step-col.done .step-circle { background: var(--ink); border-color: var(--ink); color: #fff; }
/* SUGGESTION, not location. `current` is where the pipeline thinks you should
   go next; `viewing` is the step actually on screen. They are different
   questions and the strip answers both at once, so the louder mark must be the
   one that matches the panel — it was the other way round.
   Measured: with the panel reading "ขั้น 3: ตรวจ/แก้", `current` sat on step 2
   as a filled amber ring while `viewing` was a 2px outline on step 3. Two blind
   judges independently read the amber as "you are here" and reported the strip
   as contradicting the panel; the DOM was correct, the visual weight was not.
   Now: current = a quiet amber border, viewing = the filled mark. */
.step-col.current .step-circle { border-color: var(--hl); color: var(--ink); }
.step-col.stale .step-circle { background: var(--warn); border-color: var(--warn); color: #fff; }
/* OPTIONAL steps are drawn as a choice, not a stage. Two critics reading this
   screen a week apart both read step 3 as a required stage of the pipeline,
   because a numbered circle in a connected chain is exactly what that means —
   which told a creator who only ever wants Thai captions that they were
   permanently unfinished. Dashed ring, no number; the connector either side
   goes dashed too, so the chain visibly forks around it. */
.step-col.optional .step-circle { border-style: dashed; }
.step-col.optional.skipped .step-circle { border-style: dashed; color: var(--text-muted); }
.step-col.optional .step-label { color: var(--text-muted); }

.step-label { font-size: var(--fs-sm); color: var(--text-muted); white-space: nowrap; }
.step-col.done .step-label { color: var(--text); }
/* `.current` (the next step not yet done) no longer bolds its label or pulses
   (2026-10-02). On a phone it read as a second "you are here": an amber
   breathing ring + bold on step 2 while step 5 — the one on screen — wore the
   viewing ring + bold. Every system studied models ONE current step (Carbon:
   current = "a user is interacting with the information within that step";
   NN/g: "highlighting the current step"). The quiet amber border stays as the
   "this one still needs doing" cue. */
.step-col.current .step-label { color: var(--text); }
/* Passed with ถัดไป, work not done: a dash in a dashed ring — visited, not
   finished (MUI: a skipped step is "not actually complete"). */
.step-col.skipped .step-circle { border-style: dashed; color: var(--text-muted); }
.step-col.stale .step-label { color: var(--warn); }
.step-line { flex-shrink: 0; height: 2px; width: 42px; background: var(--border); margin-top: 12px; }
.step-line.done { background: var(--accent); }

/* First-run walkthrough: ONE reactive coach-mark bubble anchored to the next
   real control; the control itself is the "Next" button (never blocks clicks). */
/* Fixed WIDTH, not max-width. With only a max-width this box is sized by the
   space left over from wherever `left` currently puts it, so renderCoachmark
   measured 77.75px for a bubble that renders at 250px, clamped against that
   number, and parked it 29px off the right edge of a 375px phone (measured
   against origin/main as a control: same bubble, right=367 there, 404 here).
   A width that does not depend on position makes the measurement — and the
   clamp built on it — mean the same thing every time. */
/* Phone-only replacements for two controls taken out of the bottom tab bar.
   Hidden by default and turned on in the phone block — the existing
   `.rail-item.mobile-only` rule only covers rail items, and these are not. */
.btn-back, .logout-m { display: none; }
.coachmark { position: fixed; z-index: 65; width: min(250px, calc(100vw - 16px));
  background: #fff;
  border: 1.5px solid var(--accent); border-radius: 8px; padding: .6rem .75rem;
  /* was rgba(37,99,235,.22) — the pre-repaint BLUE, left behind as a raw hex
     that no token change could reach. A blue shadow under a cream app. */
  box-shadow: 0 8px 24px rgba(90,58,32,.20); display: flex; align-items: flex-start; gap: .5rem;
  animation: coach-in .25s ease;
  /* คำแนะนำ ไม่ใช่ตัวรับคำสั่ง — ต้องปล่อยการกดผ่านลงไปยังของที่อยู่ใต้มัน
     วัดบนจอ 390px ที่ขั้นดาวน์โหลด: บับเบิล "พร้อมแล้ว!" ทับปุ่ม ⛶ พอดี
     elementFromPoint ตรงกลางปุ่มคืน #coach-title ผู้ใช้จึงกดเข้าโหมดซูมไม่ได้
     และรายงานว่า "feature เลื่อนเวลาในหน้าจอซูมหายไป" ทั้งที่ตัวฟีเจอร์ยังดี
     (ลาก 170px เลื่อนเวลาได้ 6.13 วินาที) หายเพราะประตูถูกปิด ไม่ใช่เพราะพัง

     z-index อย่างเดียวไม่พอที่จะบอกว่า "แค่ลอยอยู่ข้างบน" — ของที่ลอยอยู่
     ข้างบนโดยไม่ประกาศ pointer-events จะกินการกดทั้งหมดใต้ตัวมันเสมอ */
  pointer-events: none; }
/* ...แต่ปุ่มของบับเบิลเองต้องกดได้ ไม่งั้นปิดคำแนะนำไม่ได้เลย ซึ่งแย่กว่าเดิม */
#coach-skip, #coach-close { pointer-events: auto; }
.coachmark[hidden] { display: none; }
@keyframes coach-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
/* The mascot rides the coach bubble. Before this it sat only in the header and
   on the menu — the same face, the same expression, on every screen — which a
   blind critic correctly called decoration. The bubble is the one place the app
   SPEAKS to the user, so it is where a mascot earns its keep. Placement first,
   deliberately: whether one pose in the right place is enough, or a per-state
   pose set is needed, is the next thing to measure, not to assume. */
/* Size lives on the WRAPPER now: motion.css sets `.ck-mascot img{width:100%}`
   so the <img> can no longer carry its own width. */
#coach-mascot { width: 34px; flex: none; margin-top: -2px; }
.coach-face { filter: drop-shadow(0 1px 1px rgba(90,58,32,.18)); }
.coach-text { display: flex; flex-direction: column; gap: .15rem; font-size: var(--fs-md); line-height: 1.45; }
.coach-text b { color: var(--accent); font-size: var(--fs-md); }
#coach-skip { border: none; background: none; cursor: pointer; font-family: inherit;
  font-size: var(--fs-sm); color: var(--text-muted); text-decoration: underline; padding: 0; margin-top: .1rem; }
#coach-close { border: none; background: none; cursor: pointer; color: var(--text-muted);
  font-size: var(--fs-md); padding: 0; line-height: 1; }
.coachmark::before { content: ""; position: absolute; width: 10px; height: 10px; background: #fff;
  border: 1.5px solid var(--accent); transform: rotate(45deg); }
.coachmark[data-place="below"]::before { top: -6.5px; right: 24px; border-bottom: none; border-right: none; }
/* The bubble sits ABOVE its anchor, so the arrow hangs off the bottom edge
   pointing down at it. Added when the phone measurement showed there is rarely
   room beside a control: the renderer now moves the bubble off any anchor it
   would cover, and it needs a tail for that direction too. Centred, because at
   that point the bubble is centred on the anchor rather than edge-aligned. */
.coachmark[data-place="above"]::before { bottom: -6.5px; right: 24px;
  border-top: none; border-left: none; }
/* When the renderer had to move the bubble off a control it was covering, it
   centres the bubble on that control — so the arrow centres with it. Without
   this the tail keeps its edge offset and points at empty space. */
.coachmark[data-centred]::before { right: auto; left: calc(50% - 5px); }
/* No tail when the bubble could not stay next to its anchor. A 10px triangle on
   the edge is a claim of adjacency; measured at 117px of separation it was a
   false one (owner: "guide ชี้ไม่ตรง"). The ring below carries the pointing
   instead. */
.coachmark[data-detached]::before { display: none; }

/* The "highlight the control" the owner asked for. An OUTLINE in the highlight
   token, never a fill: a fill would repaint the button in the brand colour and
   become indistinguishable from a selected state, and WCAG 2.2 SC 1.4.1 forbids
   colour as the only means of saying which control to use — so this never
   appears without the coach bubble's text (hideCoach clears it).
   outline + offset rather than border, so it cannot change the element's box
   and re-trigger the flex squeeze that turned .t-btn into an ellipse. */
/* TWO rings, and the second one is not decoration — it is the only thing that
   makes this legal. Measured 2026-08-20: --hl #e8a33d against --panel-bg white
   is 2.16:1 and against --bg grey is 1.98:1. WCAG 2.2 SC 1.4.11 needs 3:1 for a
   non-text indicator, so a yellow-only ring — the obvious reading of "highlight
   เหลืองไปเลย" — fails outright on this palette, on both of the app's grounds.
   So: an amber band hugging the control carries the brand meaning, and a dark
   --ink hairline outside it carries the contrast (18.52:1 on white). This is
   the same construction Chrome and Firefox use for their own focus rings, and
   for the same reason: two tones survive any background underneath. */
.coach-target {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
  box-shadow: 0 0 0 3px var(--hl);
  border-radius: inherit;
}
/* Animate the amber band's presence, never the dark hairline — the hairline is
   load-bearing for contrast and must not fade below 3:1 at any frame. */
@media (prefers-reduced-motion: no-preference) {
  .coach-target { animation: coach-ring 2s ease-in-out infinite; }
}
@keyframes coach-ring {
  0%, 100% { box-shadow: 0 0 0 3px var(--hl); }
  50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--hl) 45%, transparent); }
}
.coachmark[data-place="right"]::before { left: -6.5px; top: 18px; border-top: none; border-right: none; }
.coachmark[data-place="left"]::before  { right: -6.5px; top: 18px; border-bottom: none; border-left: none; }
@media (prefers-reduced-motion: reduce) { .coachmark { animation: none; } }

/* ── ประตูห้าบานของขั้นปรับแต่ง ────────────────────────────────────────
   แทน <details>ปรับละเอียด ตัวเดิม ซึ่งวัดบน mockup แล้วโผล่แค่ 11px ในแผงสูง
   411px = ทางเข้าที่มองไม่เห็น (กับดักที่ CLAUDE.md จดไว้แล้ว)

   ทรงตั้งไว้เป็น "แถวรายการแนวตั้ง" ไม่ใช่แถวไอคอนแนวนอน เพราะบนเดสก์ท็อป
   แผงนี้กว้าง clamp(170px,30vw,220px) — ห้าไอคอนเรียงขวางจึงเหลือช่องละ 34px
   ป้าย "ภาษาที่สอง" ใส่ไม่ลงตั้งแต่ต้น แถวแนวตั้งยังได้ค่าปัจจุบันชิดขวา
   ซึ่งเป็นทรงเดียวกับที่ Captions.ai ใช้ (โชว์ค่าติดทุกแถว 6/6)
   บนมือถือแผงเป็นชีตเต็มความกว้าง จึงสลับเป็นแถวไอคอนห้าช่องตามแบบ v3
   (ดูใน @media ของชีต)

   sticky: ปักไว้บนสุดของแผงที่เลื่อน = ทางเข้าทุกหมวดโผล่เต็มความสูงเสมอ */
/* margin ลบ + padding เท่ากัน = ปูพื้นขึ้นไปคลุม padding ของ .panel ให้ถึงขอบ
   .panel มี padding: 1rem และ sticky top:0 ยึดกับขอบ padding box ไม่ใช่ขอบชีต
   แถบจึงหยุดต่ำกว่าขอบจริง 16px แล้วเนื้อหาเลื่อนโผล่ในช่องนั้น (เจ้าของเห็นบน
   iPhone: "เลื่อนแล้วเห็นทะลุ") วัดยืนยันแล้ว: elementsFromPoint ที่ขอบชีต+6px
   คืน .preset-tile
   ต้องใช้ทั้งสามอย่างคู่กัน วัดทีละอย่างแล้วไม่พอ:
     top: -1rem     ย้ายจุดยึดขึ้นไปที่ขอบชีตจริง (จุดยึดของ sticky คือขอบ
                    content box ไม่ใช่ขอบ border box — วัดได้ 17px ทุกครั้ง)
     margin: -1rem  ให้พื้นหลังยาวออกไปคลุม padding ทั้งบนและสองข้าง
     padding: 1rem  ดันเนื้อในกลับที่เดิม ตอนยังไม่ติดขอบจะได้ไม่ขยับ */
.look-cats { position: sticky; top: -1rem; z-index: 1; display: flex; flex-direction: column;
  gap: 1px; background: var(--panel-bg);
  margin: -1rem -1rem 0; padding: 1rem 1rem .3rem;
  border-bottom: 1px solid var(--border); }
/* padding แน่นกว่าปุ่มอื่นในแผงตั้งใจ: แถวนี้ปักอยู่กับที่ วัดที่ 1280x800
   แล้วแผงนี้สูงจริงแค่ 311px ทุกพิกเซลที่ประตูกินคือพิกเซลที่คอนโทรลไม่ได้ใช้
   ห้าแถวที่ .42rem = 175px (56% ของแผง) · ที่ .28rem = 145px */
.look-cat { display: flex; align-items: center; gap: .5rem; width: 100%;
  padding: .28rem .5rem; border: none; border-radius: 8px; background: none;
  color: var(--text-muted); cursor: pointer; text-align: left; font: inherit; }
.look-cat:hover { background: var(--accent-soft, #f1f2f4); }
.look-cat.active { background: var(--accent-soft, #f1f2f4); color: var(--text); }
.look-cat-ico { flex: 0 0 auto; display: flex; }
.look-cat-ico svg { width: 18px; height: 18px; }
.look-cat-txt { flex: 1 1 auto; min-width: 0; display: flex; align-items: baseline;
  justify-content: space-between; gap: .4rem; }
.look-cat-name { font-size: var(--fs-sm); font-weight: 600; white-space: nowrap; }
.look-cat.active .look-cat-name { color: var(--text); }
/* ค่าปัจจุบัน — จางกว่าชื่อหมวดเสมอ เพราะมันคือคำตอบ ไม่ใช่หัวข้อ */
.look-cat-val { font-size: var(--fs-xs); color: var(--text-muted); font-weight: 500;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.look-pane { display: flex; flex-direction: column; gap: 1rem; }
/* ทางออกของขั้นนี้ ปักที่ก้นแผง ไม่เลื่อนหายไปกับเนื้อหา
   กรรมการเตือนเองว่าปุ่มที่ต้องเลื่อนผ่านห้าประตูกว่าจะเจอ คือปุ่มที่คนซึ่งไม่
   ปรับอะไรเลยมองไม่เห็น แล้วค้างอยู่ในขั้นนี้
   ใช้ --brand/--on-brand ชุดเดียวกับปุ่มเบิร์น ไม่สร้างทรงปุ่มหลักอันที่สอง
   (CSS audit 2026-07-30 นับได้ 15 ทรงปุ่มที่เกือบเหมือนกัน) ต่างที่เต็มความกว้าง
   เพราะมันคือการกระทำเดียวที่ปิดขั้นนี้ */
/* flex-shrink: 0 ไม่ใช่ของประดับ — .panel เป็น flex column ที่เลื่อนได้ ลูกที่ไม่
   ล็อกไว้จะถูกบีบจนสูง 0 จริง ๆ วัดได้ตอนทดสอบ: ปุ่มนี้สูง 0px รอบแรก เป็นกับดัก
   ตัวเดียวกับที่ทำให้ .divider เป็นเส้นที่ไม่เคยขึ้น (คอมมิตก่อนหน้า) */
/* แถบล่างสองช่อง: ทางออกซ้าย · เดินหน้าขวา
   ทรงเดียวกับที่ Captions.ai ใช้ (Cancel ซ้าย · Apply ขวา อยู่นอกพื้นที่เลื่อน) */
.look-footer { position: sticky; bottom: 0; z-index: 1; flex-shrink: 0;
  display: flex; align-items: stretch; gap: .5rem; margin-top: .2rem;
  background: var(--panel-bg); }
.look-skip { flex: 0 0 auto; padding: .7rem .9rem; border: 1px solid var(--border);
  border-radius: 10px; background: var(--panel-bg); color: var(--text-muted);
  font-family: inherit; font-size: var(--fs-base); font-weight: 600; cursor: pointer; }
.look-skip:hover { color: var(--text); border-color: var(--accent); }
#look-next, .look-next { flex: 1 1 auto; width: auto; padding: .7rem 1rem; border: none; border-radius: 10px;
  font-family: inherit; font-size: var(--fs-base); font-weight: 600; cursor: pointer;
  background: var(--brand); color: var(--on-brand);
  box-shadow: 0 -8px 12px -8px rgba(0,0,0,.18); }
/* ชิ้นละบรรทัดได้ แต่ห้ามแตกกลางชื่อหมวด — เหตุผลอยู่ที่ setLookNextLabel */
#look-next span, .look-next span { white-space: nowrap; }
#look-next:hover, .look-next:hover { opacity: .9; }
/* ขั้น ตัด มีสองการ์ด: ตัดช่วงเงียบ (ฟรี) แล้ว ตัดให้กระชับ (กินชิ้น) */
#autocut-group .ai-card + .ai-card { margin-top: .5rem; }
.cut-play { margin-top: .5rem; width: 100%; min-height: 40px; }
/* "＋ เพิ่มขั้น" ท้ายแถบขั้น — ปุ่มเดียว เปิดเมนูขั้นเสริมที่ยังไม่อยู่ในแถบ
   เส้นประ = ยังไม่ใช่ขั้นของคลิปนี้ (ภาษาเดียวกับวงกลมขั้นที่กดข้าม) */
.step-add { position: relative; align-self: flex-start; margin-left: .6rem; flex: none; }
.step-add > summary { list-style: none; cursor: pointer; white-space: nowrap;
  min-height: 28px; display: inline-flex; align-items: center; padding: 0 .6rem;
  border: 1px dashed var(--border); border-radius: 999px;
  font-size: var(--fs-sm); color: var(--text-muted); background: var(--panel-bg); }
.step-add > summary::-webkit-details-marker { display: none; }
.step-add > summary:hover, .step-add[open] > summary { border-color: var(--hl); color: var(--ink); }
/* z 70: above the coach bubble (65), which sits right under the strip on a
   phone and covered the menu's second item (seen at 375 px). */
.step-add-menu { position: fixed; z-index: 70;   /* fixed: the strip's overflow would clip it; top/right set on open */
  display: flex; flex-direction: column; min-width: 9rem; padding: .25rem;
  background: var(--panel-bg); border: 1px solid var(--border); border-radius: 10px;
  box-shadow: var(--shadow-lift); }
.step-add-menu button { min-height: 44px; padding: 0 .8rem; text-align: left;
  font-family: inherit; font-size: var(--fs-base); color: var(--text);
  background: none; border: none; border-radius: 8px; cursor: pointer; }
.step-add-menu button:hover { background: var(--accent-soft); }
.step-add-menu button::before { content: "＋ "; color: var(--text-muted); }
/* บรรทัดเตือนในขั้นดาวน์โหลด — ปุ่มข้อความ ไม่แย่งน้ำหนักจากปุ่มโหลด */
.burn-check { margin-top: .6rem; width: 100%; min-height: 40px; text-align: left;
  color: var(--warn); border-color: var(--warn-border); background: var(--warn-soft); }
.usage-meter { font-size: var(--fs-xs); color: var(--text-muted); white-space: nowrap; }
/* Burn split-button: the ONE primary CTA top-right; secondary export + cost
   live in its dropdown (research: leaders keep the top-right to a single CTA). */
.burn-group { position: relative; display: flex; align-items: stretch; }
/* ปุ่มเดียวเต็มเม็ด — ครึ่งขวาที่เคยเป็น ▾ ถูกยุบเข้าเมนูตัวเลือกแล้ว
   (เจ้าของ 2026-08-24) ถ้าปล่อยรัศมีผ่าครึ่งไว้ ปุ่มจะดูเหมือนถูกตัดข้าง */
.burn-group .btn-export { border-radius: 999px; }
.burn-menu { position: absolute; top: calc(100% + 6px); right: 0; z-index: 40; min-width: 230px;
  background: #fff; border: 1px solid var(--border); border-radius: 8px; padding: .35rem;
  box-shadow: 0 8px 24px rgba(0,0,0,.14); display: flex; flex-direction: column; gap: .15rem; }
.burn-menu[hidden] { display: none; }
.burn-menu-item { text-align: left; font-family: inherit; font-size: var(--fs-base); padding: .5rem .6rem;
  border: none; border-radius: 6px; background: none; cursor: pointer; color: var(--text); }
.burn-menu-item:hover { background: var(--accent-soft); color: var(--accent); }
/* บอกว่าจะได้ไฟล์แบบไหน ก่อนกด — Descript โชว์ "0:15 · ~28 MB" ตั้งแต่ยัง
   ไม่กด, สำรวจ 2026-08-24 พบว่าทุกเจ้าบอกผลลัพธ์ ไม่มีใครให้กดแบบไม่รู้
   align-items เป็น flex-start ไม่ใช่ center: รายการนี้สูงสองบรรทัดแล้ว
   ไอคอนต้องเกาะบรรทัดบน ไม่ใช่ลอยกลางระหว่างสองบรรทัด */
#btn-cancel-job { align-self: center; width: auto; flex: 0 0 auto;
  padding: .15rem .55rem; font-size: var(--fs-xs); }
.burn-menu-item { display: flex; align-items: flex-start; gap: .5rem; }
.burn-menu-item .mini-ico { flex: 0 0 auto; margin-top: 2px; }
.menu-txt { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.menu-spec { font-size: var(--fs-xs); color: var(--text-muted); }
.burn-menu-item:hover .menu-spec { color: inherit; opacity: .75; }
.burn-menu-usage { padding: .35rem .6rem; border-top: 1px solid var(--border); margin-top: .15rem; }
.usage-toast { position: fixed; bottom: 16px; right: 16px; z-index: 60; background: #111827; color: #fff;
  font-size: var(--fs-md); padding: .5rem .8rem; border-radius: 8px; box-shadow: 0 4px 14px rgba(0,0,0,.25); }
.usage-toast[hidden] { display: none; }
/* Learned-correction offer: sits above the usage toast, carries an action. */
.sweep-toast { position: fixed; bottom: 56px; right: 16px; z-index: 60; background: #fff;
  border: 1px solid var(--accent); color: var(--text); font-size: var(--fs-md);
  padding: .5rem .7rem; border-radius: 8px; box-shadow: 0 4px 14px rgba(0,0,0,.18);
  display: flex; align-items: center; gap: .6rem; }
.sweep-toast[hidden] { display: none; }
.sweep-toast button { font-family: inherit; font-size: var(--fs-md); padding: .3rem .7rem; cursor: pointer;
  border: 1px solid var(--accent); border-radius: 999px; background: var(--accent); color: #fff; }
/* "การเรียนรู้ของ AI" modal — usage-all / SkillOpt status / glossary, one panel */
.glossary-modal { border: 1px solid var(--border); border-radius: 8px; padding: 0;
  width: min(440px, 92vw); max-height: 78vh; box-shadow: 0 12px 40px rgba(0,0,0,.18); }
.glossary-modal::backdrop { background: rgba(0,0,0,.25); }
.ai-panel-body { overflow-y: auto; max-height: calc(78vh - 60px); padding: .7rem; display: flex; flex-direction: column; gap: 1.1rem; }
.ai-panel-section + .ai-panel-section { border-top: 1px solid var(--border); padding-top: .9rem; }
.ai-panel-title { font-size: var(--fs-sm); font-weight: 600; color: var(--text-muted); text-transform: uppercase;
  letter-spacing: .04em; margin-bottom: .4rem; }
.usage-all-summary { font-size: var(--fs-base); font-weight: 600; }
.usage-all-breakdown { margin-top: .3rem; font-size: var(--fs-md); color: var(--text-muted); display: flex; flex-direction: column; gap: .15rem; }
.skill-status { font-size: var(--fs-md); color: var(--text); display: flex; flex-direction: column; gap: .2rem; }
.glossary-rows { display: flex; flex-direction: column; gap: .4rem; }
.g-row { display: flex; align-items: center; gap: .5rem; padding: .4rem .55rem;
  border: 1px solid var(--border); border-radius: 8px; font-size: var(--fs-md); }
.g-row.brittle { background: var(--warn-soft); border-color: var(--warn); }
.g-row .g-pair { flex: 1; }
.g-row .g-del { border: none; background: none; cursor: pointer; color: var(--text-muted); font-size: var(--fs-base); }
.g-row .g-del:hover { color: #b91c1c; }
.g-empty { font-size: var(--fs-md); color: var(--text-muted); padding: .6rem; }
.revise-badge { font-size: var(--fs-xs); padding: .2rem .5rem; border-radius: 999px; background: var(--panel-bg); color: var(--text-muted); white-space: nowrap; }
.revise-badge.stale { background: var(--warn-soft); color: var(--warn); }
.preset-row { display: flex; flex-wrap: wrap; gap: .4rem; }
.preset-btn { font-size: var(--fs-base); padding: .4rem .65rem; border: 1px solid var(--border);
  border-radius: 999px; background: #fff; color: var(--text); cursor: pointer; }
.preset-btn:hover { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); }
/* One-shot attention flash on the preset row after a transcribe, so the user
   discovers the style presets at the moment captions first appear. */
.preset-row.flash-once { animation: preset-flash 1.2s ease; border-radius: 8px; }
@keyframes preset-flash {
  0%, 100% { box-shadow: 0 0 0 0 transparent; }
  30%      { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 45%, transparent); }
}

input[type="range"] { width: 100%; accent-color: var(--accent); }
#eng-size-val { color: var(--text); font-variant-numeric: tabular-nums; }

/* ไทล์ฟอนต์ (ข้อ 1 กรรมการ 2026-08-24) — แต่ละไทล์เรนเดอร์ด้วยฟอนต์ตัวเอง */
.font-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: .45rem; }
.font-tile { display: flex; flex-direction: column; gap: .15rem; align-items: flex-start;
  border: 1px solid var(--border); border-radius: 8px; background: var(--bg);
  padding: .5rem .6rem; cursor: pointer; text-align: left; min-height: 44px; }
.font-tile-sample { font-size: var(--fs-lg); color: var(--text); font-family: inherit; }
.font-tile-name { font-size: var(--fs-xs); color: var(--text-muted); font-family: Anuphan, sans-serif; }
.font-tile:hover { border-color: var(--accent); }
.font-tile.active { border-color: var(--accent); background: var(--accent-soft); }
/* ไทล์ขอบ/กล่อง (ข้อ 4 กรรมการ 2026-08-24) — ตัวอย่างขาวบนพื้นเทาเข้มให้ขอบ/เงาเห็นชัด */
.edge-presets { display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: .45rem; }
.edge-tile { display: flex; flex-direction: column; align-items: center; gap: .25rem;
  border: 1px solid var(--border); border-radius: 8px; cursor: pointer; padding: .5rem .3rem;
  background: #6b7280; min-height: 44px; }
.edge-tile .edge-sample { color: #fff; font-family: Anuphan, sans-serif; font-weight: 600;
  font-size: var(--fs-lg); line-height: 1.2; }
.edge-tile .edge-name { font-size: var(--fs-xs); color: #f3f4f6; }
.edge-tile:hover { border-color: var(--accent); }
.edge-tile.active { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
/* จานสี default (ข้อ 3 กรรมการ 2026-08-24) — วงกลม 28px ขอบชัดบนพื้นทุกสี */
.swatch-row { display: flex; gap: .45rem; flex-wrap: wrap; margin-bottom: .45rem; }
.swatch { width: 28px; height: 28px; border-radius: 50%; border: 2px solid var(--border);
  background: var(--sw); cursor: pointer; padding: 0; }
.swatch:hover { border-color: var(--accent); }
.color-row { display: flex; align-items: center; gap: .5rem; }
.color-row input[type="color"] { width: 2.4rem; height: 2rem; border: 1px solid var(--border); border-radius: 8px; padding: 2px; background: #fff; cursor: pointer; }
.color-row input[type="text"] { flex: 1; font-family: inherit; font-size: var(--fs-base); padding: .45rem .6rem;
  border: 1px solid var(--border); border-radius: 8px; text-transform: uppercase; }

.toggle-row { display: flex; align-items: center; justify-content: space-between; width: 100%;
  font-family: inherit; font-size: var(--fs-base); color: var(--text); background: #fff; cursor: pointer;
  border: 1px solid var(--border); border-radius: 8px; padding: .55rem .7rem; }
.toggle-switch { width: 38px; height: 22px; border-radius: 999px; background: var(--border); position: relative; transition: background .15s; }
.toggle-switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px;
  border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,.2); transition: transform .15s; }
.toggle-row[aria-pressed="true"] .toggle-switch { background: var(--accent); }
.toggle-row[aria-pressed="true"] .toggle-switch::after { transform: translateX(16px); }

.panel-action { display: inline-flex; align-items: center; justify-content: center; gap: .45rem;
  font-family: inherit; font-size: var(--fs-base); font-weight: 500; padding: .6rem 1rem; cursor: pointer;
  border: 1px solid var(--border); border-radius: 8px; background: #fff; color: var(--text); }
.panel-action:hover { border-color: var(--accent); color: var(--accent); }
.panel-action .icon { width: 18px; height: 18px; }

/* Preview + on-canvas caption selection */
.preview { position: relative; width: 100%; max-width: 420px; aspect-ratio: 9 / 16; height: auto;
  background: #000; border-radius: 8px; overflow: hidden; }
.preview video { width: 100%; height: 100%; object-fit: contain; }
#caption-overlay { position: absolute; inset: 0; pointer-events: none; }
/* left/right = 70/1080 (the burn's MarginL/R) so the preview wraps at the same
   relative width as the burn. The old 6.5% + `padding: 6px 4px` was ~8px
   narrower than the burn's 940px usable width, because `* { box-sizing:
   border-box }` makes that padding eat INTO the 87%. Horizontal padding is now
   0 so the wrap width is the real one. */
/* การ์ดงานที่ "ทำเสร็จแล้ว" หรือ "กำลังทำ" — จางลง แต่ยังอยู่และยังกดได้
   Figma เขียนกฎนี้ไว้ตรง ๆ: ปุ่มที่ใช้ไม่ได้ควรอยู่ให้เห็น จางลง พร้อมเคอร์เซอร์
   ห้าม เพื่อให้ผู้ใช้รู้ว่าทำได้ แค่ยังไม่ใช่ตอนนี้ · Canva ห้ามซ่อนตรง ๆ
   opacity ไม่ลงต่ำกว่านี้เพราะข้อความบนการ์ดยังต้องอ่านออก: WCAG ยกเว้น
   contrast ให้ของที่ inactive ซึ่งเป็นช่องที่ทำให้ปุ่มจาง ๆ อ่านไม่ออกเต็มไปหมด
   GOV.UK เรียกมันว่าข้อบกพร่อง ไม่ใช่ใบอนุญาต */
.ai-card.is-muted { opacity: .62; }
.ai-card.is-muted .ai-title { font-weight: 500; }
/* กำลังทำงานอยู่ = กดซ้ำไม่มีความหมาย แต่ยังโฟกัสด้วยคีย์บอร์ดได้ (aria-disabled
   ไม่ใช่ disabled) จึงบอกด้วยเคอร์เซอร์ ไม่ใช่ถอดออกจากลำดับ tab */
.ai-card[aria-disabled="true"] { cursor: not-allowed; }
/* Struck retake rows in the transcript list — the drop is still shown, faded
   and line-through, with the reason below it (task 5, autocut). */
.t-row.t-cut { opacity: .55; }
.t-cut-text { text-decoration: line-through; }
.t-cut-why { font-size: .8em; color: var(--text-muted); }
/* ดูต้นฉบับ: ต้นฉบับไม่มีคำบรรยายที่ตัดแล้ว การซ้อน .cap ทับไว้จะโกหกจอ */
body.show-original .cap { display: none; }
.ai-card[data-state="done"] .ai-ico { color: var(--ok, #2e7d32); }

.cap { position: absolute; left: 6.4815%; right: 6.4815%; text-align: center; pointer-events: auto;
  cursor: ns-resize; color: #fff; text-shadow: 0 2px 4px rgba(0,0,0,.7); padding: 6px 0; font-weight: 500; }
/* pre-line is load-bearing, not cosmetic: without it the cue's stored newlines
   COLLAPSE INTO SPACES and Chrome re-wraps the text from scratch with its ICU
   Thai dictionary. That made the preview look correctly broken no matter what
   was actually stored — so a bad break could never be seen before export, which
   is exactly how one survived. Locked by web/tests/css_caption.test.js. */
/* line-height ต้องประกาศ ไม่ปล่อยเป็น `normal`
   `normal` มาจาก metric ของฟอนต์ (Anuphan / Kanit / Noto Sans Thai / IBM Plex
   Sans Thai มี ascent/descent/lineGap ไม่เท่ากัน) แปลว่าช่องว่างที่เห็นบนจอ
   เปลี่ยนไปตามฟอนต์ที่เลือก ขณะที่ libass ฝั่งเบิร์นไม่ได้คิด half-leading แบบนั้น
   วัดบนจอ 375x812 (s=0.1778) กรอบข้อความห่างกัน 18.9px ทั้งที่จุดยึดตั้งไว้ห่าง
   24 หน่วย = 4.3px ส่วนเกินคือ half-leading ล้วน ๆ
   1 ทำให้กรอบบรรทัดแนบตัวอักษร จอกับ mp4 จึงถูกขับด้วยเลขสองตัวเดียวกันจริง */
.cap .cap-text { pointer-events: none; white-space: pre-line; line-height: 1; }
.cap.en { color: #eee; font-size: .8em; font-weight: 400; }

/* ElevenLabs-style selection box: outline + corner handles + rotation nub */
.cap.active { outline: 1.5px solid var(--accent); border-radius: 4px; background: rgba(37,99,235,.06); }
.cap .grip, .cap .rot-handle { display: none; pointer-events: none; }
.cap.active .grip { display: block; position: absolute; width: 9px; height: 9px; background: #fff;
  border: 1.5px solid var(--accent); border-radius: 50%; }
.cap.active .grip-tl { top: -5px; left: -5px; } .cap.active .grip-tr { top: -5px; right: -5px; }
.cap.active .grip-bl { bottom: -5px; left: -5px; } .cap.active .grip-br { bottom: -5px; right: -5px; }
.cap.active .rot-handle { display: block; position: absolute; top: -20px; left: 50%; transform: translateX(-50%);
  width: 10px; height: 10px; background: #fff; border: 1.5px solid var(--accent); border-radius: 50%; }
.cap.active .rot-handle::before { content: ""; position: absolute; top: 9px; left: 50%; transform: translateX(-50%);
  width: 1.5px; height: 11px; background: var(--accent); }

/* User text overlays (Phase F): click empty canvas -> affordance -> creates one */
#text-overlay { position: absolute; inset: 0; pointer-events: none; }
.text-overlay-el { position: absolute; transform: translate(-50%, -50%); white-space: pre;
  cursor: move; pointer-events: auto; padding: 2px 5px; text-shadow: 0 2px 4px rgba(0,0,0,.7); }
.text-overlay-el.active { outline: 1.5px dashed var(--accent); border-radius: 4px; background: rgba(37,99,235,.08); }
.add-text-btn { position: absolute; transform: translate(-50%, -50%); pointer-events: auto;
  font-size: var(--fs-md); padding: .35rem .7rem; border-radius: 999px; border: 1.5px dashed var(--accent);
  background: rgba(37,99,235,.12); color: var(--accent); cursor: pointer; white-space: nowrap; }
.add-text-btn:hover { background: rgba(37,99,235,.22); }

/* Selected-cue editor */
.muted { color: var(--text-muted); font-weight: 400; font-size: var(--fs-md); }
.panel textarea { width: 100%; font-family: inherit; font-size: var(--fs-base); padding: .5rem .65rem; resize: vertical;
  border: 1px solid var(--border); border-radius: 8px; background: #fff; color: var(--text); }
.panel textarea:focus, .panel input[type="number"]:focus { outline: none; border-color: var(--accent); }
.time-row { display: flex; align-items: center; gap: .5rem; }
.time-row input[type="number"] { width: 5.5rem; font-family: inherit; font-size: var(--fs-base); padding: .4rem .5rem;
  border: 1px solid var(--border); border-radius: 8px; }
.cue-actions { display: flex; gap: .5rem; }
.mini-btn { flex: 1; font-family: inherit; font-size: var(--fs-md); padding: .5rem; cursor: pointer;
  border: 1px solid var(--border); border-radius: 8px; background: #fff; color: var(--text); }
.mini-btn:hover { border-color: var(--accent); color: var(--accent); }
.mini-btn.danger:hover { border-color: #dc2626; color: #dc2626; }
.mini-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }

/* Which step's controls the panel is showing (step-scoped journey) */
.journey-title { font-size: var(--fs-sm); font-weight: 600; letter-spacing: .03em;
  text-transform: uppercase; color: var(--accent); padding-bottom: .5rem;
  border-bottom: 1px solid var(--border); margin-bottom: .25rem; }
.journey-hint { font-size: var(--fs-md); line-height: 1.5; color: var(--text-muted);
  background: var(--panel-bg); border: 1px solid var(--border);
  border-radius: 8px; padding: .6rem .7rem; }

/* Shot-inspector media-swap tabs + picker grid */
#media-tabs { flex-wrap: wrap; margin-bottom: .5rem; }
#media-tabs .mini-btn { flex: 0 0 auto; padding: .35rem .6rem; font-size: var(--fs-md); }
#image-thumb-video { width: 100%; border-radius: 8px; margin-bottom: .5rem; }
.media-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: .4rem; margin-top: .5rem; }
.media-cell { padding: 0; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; cursor: pointer;
  aspect-ratio: 9 / 16; background: #f4f4f5; }
.media-cell:hover { border-color: var(--accent); }
.media-cell:disabled { opacity: .5; cursor: default; }
.media-cell img { width: 100%; height: 100%; object-fit: cover; display: block; }
.media-grid-msg { font-size: var(--fs-md); color: var(--text-muted); grid-column: 1 / -1; }

/* Transport + multi-lane timeline */
.timeline-wrap { border-top: 1px solid var(--border); --lane-h: 58px; }
/* Drag the top edge to grow/shrink the timeline (scales every lane via --lane-h). */
.tl-resize { display: flex; align-items: center; justify-content: center; height: 9px; cursor: ns-resize;
  background: var(--panel-bg); border-bottom: 1px solid var(--border); }
.tl-resize:hover { background: var(--accent-soft); }
.tl-resize-grip { width: 34px; height: 3px; border-radius: 999px; background: #c4c4cc; }
.tl-resize:hover .tl-resize-grip { background: var(--accent); }
.transport { display: flex; align-items: center; gap: .6rem; padding: .45rem .9rem; border-bottom: 1px solid var(--border); }
/* `flex: none` and `aspect-ratio` are what keep it a CIRCLE, and they are not
   belt-and-braces for each other — they fail differently.
   Measured at 375px, owner report "ปุ่ม play ไม่กลม": the transport asked for
   296.2px of content plus 67.2px of gaps = 363.4px inside 346.2px of available
   width, so the row overflowed by 17.2px. Every child had the default
   flex-shrink: 1, and the play button — the widest declared basis — absorbed
   essentially the whole overflow on its own: 44px down to 26.3px, while its
   height stayed 44px because the cross axis does not shrink. `border-radius:
   50%` on a 26.3x44 box is an ELLIPSE, which is exactly what was on screen.
   flex: none stops it giving up width; aspect-ratio makes any FUTURE squeeze
   from some other direction take the height with it, so the shape degrades
   round-and-smaller instead of oval. The zoom slider is the control that
   yields instead — the note above its own rule already says it degrades
   gracefully, and − / + still step it. */
.t-btn { flex: none; aspect-ratio: 1; width: 30px; height: 30px; border-radius: 50%; border: 1px solid var(--border); background: #fff; cursor: pointer; font-size: var(--fs-xs); color: var(--text); }
.t-btn:hover { border-color: var(--accent); color: var(--accent); }
.tr-clock { font-size: var(--fs-md); color: var(--ink); font-variant-numeric: tabular-nums; }
.t-sep { font-size: var(--fs-md); color: var(--text-muted); opacity: .6; margin: 0 .1rem; }
.t-total { font-size: var(--fs-md); color: var(--text-muted); font-variant-numeric: tabular-nums; }
.t-spacer { flex: 1; }
/* min-width: 0 so this group is the one that actually CAN shrink. A flex item
   refuses to go below its content's min-content width by default, so before
   this the zoom group held its ground and the shrink landed on the round
   buttons instead — the ellipse described at .t-btn. */
/* .fine-head (หัวข้อย่อย ขนาด/ตัวอักษร/สี/ขอบ) ถูกลบพร้อมกล่องปรับละเอียด
   2026-08-21 — สี่หัวข้อนั้นกลายเป็นห้าหมวดที่กดเข้าทีละอันแล้ว
   คำเตือนเดิมของมันยังจริงและถูกเคารพ: "สีตัวอักษร" ต้องไม่แยกจาก "ความหนาขอบ"
   ไปคนละแท็บ เพราะเป็นงานเดียวกัน (ทำให้อ่านออกบนช็อตสว่าง) — ทั้งคู่จึงอยู่ใน
   หมวด "ขอบ/กล่อง" ด้วยกัน ไม่ได้ถูกหั่นตามชนิดของคอนโทรล */
/* ปุ่มเล่น/หยุด: ไอคอนสองอันอยู่ในปุ่มเดียว โชว์ทีละอันตามสถานะ
   ใช้คลาสแทนการเขียน textContent ทับ ด้วยเหตุผลใน setPlayIcon() */
#btn-play .ico-pause { display: none; }
#btn-play.is-playing .ico-play { display: none; }
#btn-play.is-playing .ico-pause { display: block; }
.tl-zoom { display: flex; align-items: center; gap: .4rem; min-width: 0; color: var(--text-muted); }
/* จอสัมผัสไม่ต้องมีปุ่มซูม — บีบสองนิ้วบนไทม์ไลน์แทน (เจ้าของ 2026-08-20)
   ปุ่ม + / − กับสไลเดอร์กินความกว้างแถบเครื่องมือบนจอ 375px ซึ่งเป็นที่ที่
   แคบที่สุด และ Captions.ai ก็ไม่มีตัวเลขให้กรอกเลย ใช้บีบนิ้วอย่างเดียว
   เงื่อนไขคือ "ไม่มีเมาส์" ไม่ใช่ "จอแคบ" เพราะจอสัมผัสใหญ่ก็บีบได้ ส่วน
   จอเล็กที่ต่อเมาส์อยู่ยังต้องมีปุ่ม */
@media (pointer: coarse) { .tl-zoom { display: none; } }
.zoom-btn { width: 22px; height: 22px; border: 1px solid var(--border); border-radius: 6px; background: #fff; cursor: pointer; font-size: var(--fs-md); color: var(--text); }
.zoom-btn:hover { border-color: var(--accent); color: var(--accent); }
.tl-zoom input[type="range"] { width: 88px; accent-color: var(--accent); }
/* The phone transport is a fixed budget and the two timeline tools now spend
   80px of it on labels. Measured at 375px: with the slider at 88px the row
   overflowed. The slider is the one control here that degrades gracefully —
   − and + still step it, and the timeline itself pinch-zooms. */
@media (max-width: 767px) {
  .tl-zoom input[type="range"] { width: 52px; }
  /* Below 360px (iPhone SE 1st gen) even the 8px gaps are not enough — measured
     49px of overflow at 320px. The range input goes, NOT the − / + buttons, so
     zoom stays fully reachable; the timeline also pinch-zooms. Before .t-btn
     was pinned this width did not "fit" either, it just hid the problem by
     squashing the play button into an ellipse. */
  @media (max-width: 359px) { .tl-zoom input[type="range"] { display: none; } }
  /* The last 5px. Once .t-btn stopped absorbing the overflow by deforming, the
     row genuinely did not fit at 375px — measured scrollWidth 380 vs clientWidth
     375. Seven gaps at 9.6px were 67.2px of the 375, more than the play button
     itself; 8px gives back 11.2px, which clears it with room. Taking it from the
     gaps rather than from a control means nothing gets smaller to touch. */
  .transport { gap: .5rem; }
}

.tl-grid { display: grid; grid-template-columns: 104px 1fr; }
.tl-gutter { border-right: 1px solid var(--border); background: var(--panel-bg); }
.tl-gutter-ruler { height: 22px; border-bottom: 1px solid var(--border); }
.tl-lane-label { display: flex; align-items: center; gap: .4rem; height: var(--lane-h); padding: 0 .7rem; border-bottom: 1px solid var(--border);
  font-size: var(--fs-sm); color: var(--text-muted); font-weight: 600; }
.tl-lane-label svg { width: 15px; height: 15px; }

.tl-scroll { overflow-x: auto; overflow-y: hidden; scrollbar-width: thin; }
.tl-inner { position: relative; min-width: 100%; }
/* The run-up spacers. 0 wide unless the phone turns them on, so desktop keeps
   click-to-seek against a strip that starts at its own left edge. */
/* The run-up reads as "outside the clip", not as an empty box someone forgot
   to fill — the owner saw the blank strip and asked what was wrong with it.
   A flat tint plus the same hairline the lanes use is enough to say "nothing
   lives here"; anything busier competes with the film for attention. */
.tl-pad { flex: 0 0 var(--tl-pad, 0px); background: var(--bg);
  border-bottom: 1px solid var(--border); }
.tl-scroll { display: flex; }
.tl-inner { flex: 0 0 auto; }
.tl-ruler { position: relative; height: 22px; border-bottom: 1px solid var(--border); background: var(--bg); }
.tl-ruler .tick { position: absolute; top: 0; font-size: var(--fs-xs); color: var(--text-muted); padding-left: 3px; border-left: 1px solid var(--border); height: 7px; line-height: 10px; }
/* The tick at the very end of the clip: its line stays on the boundary, its
   label reads back INTO the timeline. Measured 25px of page-level overflow on
   iOS from this one label, which scrolled the whole app right and clipped the
   stepper on the left. `translateX(-100%)` moves only the box, so the line
   itself does not move off the boundary it marks. */
.tl-ruler .tick.tick-end { transform: translateX(-100%);
  padding-left: 0; padding-right: 3px;
  border-left: none; border-right: 1px solid var(--border); }
.tl-lane { position: relative; height: var(--lane-h); border-bottom: 1px solid var(--border); }
.tl-lane-media { background: repeating-linear-gradient(90deg,var(--border),var(--border) 1px,transparent 1px,transparent 40px); }
.tl-viewonly { position: absolute; top: 8px; right: 8px; z-index: 2; font-size: var(--fs-xs); color: var(--bg); background: rgba(0,0,0,.55); padding: 1px 6px; border-radius: 999px; }

/* THE THREE BLOCK FAMILIES ARE NO LONGER HUE-CODED (owner's call, 2026-08-20).
   They used to be violet / amber / green, which worked on the old blue-grey
   shell and stopped working the moment the app was repainted: the brand palette
   is cream/brown/gold on purpose, and brown and amber are neighbours, so
   "captions" and "text overlays" were the two families most likely to sit side
   by side and the two hardest to tell apart.
   Kind is now carried by a GLYPH plus fill WEIGHT, both of which survive a
   narrow palette and colour-blindness:
     captions  ≡  solid tan   — the main content of the clip, so the heaviest
     text      T  white + rule — an addition on top, so it reads lighter
     images    ▢  thumbnail    — carries its own picture; dashed while planned */
.tl-cap { position: absolute; top: 7px; bottom: 7px; border-radius: 8px;
  background: var(--accent-soft); border: 1.5px solid var(--border);
  box-shadow: 0 1px 2px rgba(0,0,0,.06); padding: 4px 8px; overflow: hidden; cursor: grab;
  display: flex; align-items: center; gap: 6px; }
.tl-cap .c-ico { flex: none; font-size: var(--fs-md); line-height: 1; color: var(--accent); }
.tl-cap.narrow { gap: 0; padding: 4px 5px; }
.tl-cap.narrow .c-ico { display: none; }
/* flex:1 as well as min-width:0. With min-width:0 alone the body has no basis
   to grow from and collapses to zero width — MEASURED: the caption text was
   rendering at 0px and the block showed only its glyph. */
.tl-cap .c-body { flex: 1 1 auto; min-width: 0; }
.tl-cap.sel { background: var(--accent-soft); border-color: var(--accent); box-shadow: 0 2px 6px rgba(90,58,32,.22); }
.tl-cap.flash { background: var(--warn-border); border-color: var(--warn); }
.tl-cap .th { font-size: var(--fs-xs); color: var(--text); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tl-cap .en { font-size: var(--fs-xs); color: var(--accent); opacity: .85; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tl-cap.sel .th { color: var(--text); } .tl-cap.sel .en { color: var(--accent); }

/* Text-overlay blocks (Phase I): amber, to read as a different family from the
   violet caption blocks. "T" glyph + truncated text, same trim/move affordance. */
.tl-text { position: absolute; top: 7px; bottom: 7px; border-radius: 8px; background: #fff; border: 1.5px solid var(--border);
  box-shadow: 0 1px 2px rgba(0,0,0,.06); padding: 4px 8px; overflow: hidden; cursor: grab;
  display: flex; align-items: center; gap: 5px; }
.tl-text.sel { background: var(--warn-border); border-color: var(--accent); box-shadow: 0 2px 6px rgba(90,58,32,.22); }
.tl-text .t-ico { flex: none; font-weight: 800; font-size: var(--fs-md); color: var(--accent); line-height: 1; }
.tl-text .t-label { font-size: var(--fs-xs); color: var(--text); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tl-text.sel .t-ico { color: var(--text); } .tl-text.sel .t-label { color: var(--text); }

/* AI image blocks (Nano Banana storyboard): green family. Ghost (dashed, no
   fill) = planned shot awaiting generation; solid + thumbnail bg once a PNG
   exists on disk. */
.tl-img { position: absolute; top: 7px; bottom: 7px; border-radius: 8px; background: var(--bg) center/cover no-repeat;
  border: 1.5px solid var(--border); box-shadow: 0 1px 2px rgba(0,0,0,.06); padding: 4px 8px; overflow: hidden;
  cursor: grab; display: flex; align-items: flex-end; }
.tl-img.ghost { background-color: transparent; border-style: dashed; }
.tl-img.sel { border-color: var(--accent); box-shadow: 0 2px 6px rgba(90,58,32,.22); }
.tl-img .i-label { font-size: var(--fs-xs); font-weight: 700; color: var(--text); background: rgba(255,255,255,.88);
  padding: 0 4px; border-radius: 4px; white-space: nowrap; }
.tl-img.ghost .i-label { color: var(--accent); }
.tl-img.sel .i-label { color: var(--text); }

/* AI image preview: full-frame fills the canvas; overlay floats at its x/y/w.
   pointer-events off (B7 adds drag-to-reposition once the shot-plan drawer lands). */
#image-overlay { position: absolute; inset: 0; pointer-events: none; }
.image-overlay-el.full { position: absolute; inset: 0; }
.image-overlay-el.full img { width: 100%; height: 100%; object-fit: cover; }
.image-overlay-el.overlay { position: absolute; cursor: move; pointer-events: auto; }
.image-overlay-el.overlay img { width: 100%; display: block; border-radius: 6px; }
.image-overlay-el.active { outline: 2px dashed var(--accent); }

/* Explicit height (not top+bottom): a <canvas> is a replaced element with an
   intrinsic 90x160 ratio, and setting only its width (positionThumbs) makes the
   browser derive height from that ratio (162px), overflowing the 58px lane and
   ignoring top/bottom. An explicit height pins the box; object-fit crops. */
.tl-thumb { position: absolute; top: 7px; height: calc(var(--lane-h) - 14px); object-fit: cover; opacity: .92; border-radius: 3px; }
.tl-playhead { position: absolute; top: 0; bottom: 0; width: 2px; background: #ef4444; z-index: 4; pointer-events: none; }

/* ===== DESKTOP: panel sits to the LEFT of the preview (ElevenLabs editor shell) ===== */
@media (min-width: 768px) {
  /* Supabase-height bar: this desktop override was quietly beating the slimmer
     base padding (measured 13.6px rendered while the base said 6.4). Baseline
     alignment also stretched the row — a 26px logo baseline-aligned with a
     37px select pushes the box past either.
     FULL-WIDTH like Supabase (owner: "header ต้องทับ tab ซ้าย"): the header
     lives inside .workspace, to the RIGHT of the rail — moving it out of the
     DOM would put the editor header on Home too, so it reaches left with a
     negative margin instead, and the rail starts below the line. --topbar-h is
     the one number both sides agree on; height is pinned so the contract
     cannot drift with content. Studio only: Home has no workspace header. */
  :root { --topbar-h: 54px; }
  header { padding: .4rem .9rem; height: var(--topbar-h);
    margin-left: -48px; padding-left: .9rem; position: relative; z-index: 5; }
  .in-studio .rail { padding-top: calc(var(--topbar-h) + .6rem); }
  .header-left { align-items: center; }
  header .mini-btn { padding: .3rem .55rem; }
  .clip-picker { padding: .25rem .5rem; }
  header h1 { font-size: var(--fs-lg); }
  .btn-export { font-size: var(--fs-base); padding: .55rem 1rem; }

  /* Desktop app shell fills the REAL viewport height — not a guessed
     constant. Header and the timeline are fixed-height chrome; main gets
     however much vertical space is actually left on the user's screen,
     the same way the ElevenLabs shell does (its canvas isn't a fixed px
     figure either — it's "whatever's left between the header and the
     transport bar"). Mobile is untouched: this block only applies >=768px. */
  .app { height: 100vh; }
  .workspace { display: flex; flex-direction: column; height: 100vh; }
  header { flex-shrink: 0; }
  .timeline-wrap { flex-shrink: 0; }

  main { flex: 1; min-height: 0; flex-direction: row; align-items: stretch; padding: 1.5rem 1rem; }
  /* Only the config panel scrolls internally when its content is taller than
     the real available height — canvas/video position and size are never
     affected by how long the panel's content is (min-height:0 is what lets
     a stretched flex item actually clip+scroll instead of growing to fit
     its content and dragging the row taller with it). Width is fluid
     (clamp) rather than a rigid 260px — a rigid panel+preview combo forced
     a hard ~750px minimum before anything at all could fit, so any real
     desktop window narrower than that (not unusual — an unmaximized
     browser, a half-screen split) fell through to the mobile layout even
     though it's clearly a desktop context. */
  .panel { width: clamp(200px, 24vw, 260px); flex-shrink: 0; min-height: 0; overflow-y: auto;
    scrollbar-width: thin; scrollbar-color: #d4d4d8 transparent; }
  /* scrollbar-width:thin alone renders an invisible/near-invisible thumb in
     Chromium — on common shorter laptop screens (1366x768 etc.) that made a
     genuinely-scrollable panel look broken/cut off with no visible affordance
     that "แปลเป็น ENG" and other fields exist below. Explicit thumb color
     (both APIs) makes the scrollbar itself the "more content below" signal. */
  .panel::-webkit-scrollbar { width: 6px; }
  .panel::-webkit-scrollbar-thumb { background: #d4d4d8; border-radius: 3px; }
  .panel::-webkit-scrollbar-thumb:hover { background: #a1a1aa; }
  /* Canvas: a flexible workspace stage the preview floats in — fills the width
     the panel doesn't use, instead of leaving dead space (matches the
     ElevenLabs editor's proportions: rail/panel narrow, canvas dominant).
     Height comes from the real viewport (via main's flex:1 above), not a
     hardcoded number, so it genuinely fits the desktop at any window size. */
  .canvas { flex: 1; display: flex; align-items: center; justify-content: center;
    margin-left: 1.5rem; padding: 1.25rem; background: var(--panel-bg); border-radius: 8px; min-height: 0; min-width: 0; }
  /* Preview: EXACTLY 9:16 (aspect-ratio, not a hardcoded box), grows to FILL the
     canvas height (canvas padding gives breathing room) so a large/full-screen
     window shows a big preview instead of a small 512px box floating in dead
     space. `max-width: 100%` still guards against horizontal overflow on a
     narrow canvas; height is bounded by the canvas, so it never pushes layout. */
  .preview { aspect-ratio: 9 / 16; height: 100%; width: auto; max-width: 100%;
    border-radius: 8px; flex-shrink: 1; }

  /* Docked review column: in-flow flex child of <main>, so opening it shrinks
     the canvas (flex:1) instead of covering it. `main .review-chat` outranks the
     base `.review-chat` rule (which appears later in the file). */
  main .review-chat { position: static; max-height: none; min-height: 0; flex-shrink: 0;
    width: clamp(260px, 24vw, 320px); margin-left: 1rem; border-top: none;
    border-left: 1px solid var(--border); border-radius: 8px; box-shadow: none; }
  main .transcript-panel { position: static; max-height: none; min-height: 0; flex-shrink: 0;
    width: clamp(280px, 26vw, 360px); margin-left: 1rem; border-top: none;
    border-left: 1px solid var(--border); border-radius: 8px; box-shadow: none; }

  /* ── The caption list is the HERO while it is open (T1, owner 2026-08-20) ──
     Measured at 1440x900 before this: the list sat in a 360px column while the
     canvas held 668px of mostly empty grey, and 3 of 5 English lines were
     clipped (326px of text in a 267px box). The weighting was backwards
     against the only user-time number this project actually has:
     docs/designs/caption-edit-burden.md, 167 clips from 114 users on prod —
     people touch 60% of cues per clip, and in 25% of clips the transcription
     is character-perfect and the ONLY edit is where lines split.
     So while the list is up it takes the width and the preview docks small.
     `order` puts the list LEFT of the preview: reading order should be the
     thing you are working on, then the thing you are checking it against.
     Scoped with :has() so every other step keeps the big preview it deserves —
     nothing changes unless the transcript panel is actually open. */
  main:has(> .transcript-panel:not([hidden])) .canvas {
    flex: 0 0 clamp(220px, 20vw, 300px); order: 2; margin-left: 1rem; }
  main:has(> .transcript-panel:not([hidden])) .transcript-panel {
    flex: 1 1 auto; width: auto; min-width: 0; order: 1; margin-left: 1.5rem;
    border-left: none; border: 1px solid var(--border); }
  /* NO .preview override here, deliberately. The first version of this block
     forced `width: 100%; height: auto` on the docked preview, reasoning that a
     height-driven box would overflow the narrow dock. Measured: it produced a
     248x373 frame — ratio 0.665 where 9:16 is 0.5625 — because width:100% and
     max-height both bound the box and aspect-ratio lost. A 9:16 preview that is
     not 9:16 is the one thing this frame must never be; it is what the burned
     mp4 is checked against.
     The base rule (aspect-ratio + height:100% + width:auto + max-width:100%)
     already handles a narrow dock correctly: height fills, width follows the
     ratio, max-width catches the case where it cannot. Verified in the dock at
     1440x900 — nothing to add. */
}

/* A narrower desktop window still deserves the rail/panel/canvas shell, not
   a fallback to the mobile stacked layout — only true small/phone widths
   should stack. Rail/timeline-gutter tighten slightly to give the fluid
   panel/canvas above a bit more room to breathe at the low end. */
@media (min-width: 560px) and (max-width: 767px) {
  .rail { display: flex; flex-direction: column; align-items: center; gap: .5rem;
    width: 60px; flex-shrink: 0; padding: .7rem .3rem; border-right: 1px solid var(--border); background: var(--panel-bg); }
  .rail-brand { width: 28px; height: 28px; display: grid; place-items: center; color: var(--accent); margin-bottom: .3rem; }
  .rail-brand svg { width: 20px; height: 20px; }
  .rail-item { display: flex; flex-direction: column; align-items: center; gap: .2rem; width: 100%;
    font-family: inherit; font-size: var(--fs-xs); color: var(--text-muted); background: none; border: none;
    border-radius: 8px; padding: .45rem .15rem; cursor: pointer; }
  .rail-item svg { width: 19px; height: 19px; }
  .rail-item.active { color: var(--ink); background: var(--accent-soft); }
  .rail-item.mobile-only { display: none; }
  #btn-find-toggle { display: none; }
  .rail-soon { margin-top: auto; display: flex; flex-direction: column; align-items: center; gap: .15rem;
    font-size: var(--fs-xs); color: var(--text-muted); text-align: center; }
  .rail-soon-mark { width: 22px; height: 22px; display: grid; place-items: center; border: 1.5px dashed var(--border);
    border-radius: 7px; color: var(--text-muted); font-size: var(--fs-base); }

  .app { height: 100vh; }
  .workspace { display: flex; flex-direction: column; height: 100vh; min-width: 0; }
  header { flex-shrink: 0; padding: .65rem .8rem; }
  header h1 { font-size: var(--fs-lg); }
  .clip-picker { max-width: 130px; }
  .timeline-wrap { flex-shrink: 0; }

  main { flex: 1; min-height: 0; display: flex; flex-direction: row; align-items: stretch; padding: 1rem .6rem; }
  .panel { width: clamp(170px, 30vw, 220px); max-width: none; flex-shrink: 0; min-height: 0; overflow-y: auto;
    scrollbar-width: thin; scrollbar-color: #d4d4d8 transparent; }
  .panel::-webkit-scrollbar { width: 6px; }
  .panel::-webkit-scrollbar-thumb { background: #d4d4d8; border-radius: 3px; }
  .canvas { flex: 1; display: flex; align-items: center; justify-content: center;
    margin-left: 1rem; background: var(--panel-bg); border-radius: 8px; min-height: 0; min-width: 0; }
  .preview { aspect-ratio: 9 / 16; height: min(512px, 100%); width: auto; max-width: 100%;
    border-radius: 8px; flex-shrink: 1; }
}

/* AI review chat drawer */
/* Mobile: a capped bottom sheet (never covers the whole screen). Desktop docks
   it as a flex column inside <main> (override below) so the canvas shrinks
   instead of being covered. */
/* Transcript line-list (fast text editing) — same two-mode dock pattern as the
   review chat: fixed bottom sheet on mobile, in-flow right column on desktop. */
.transcript-panel { position: fixed; left: 0; right: 0; bottom: 0; max-height: 60vh;
  background: #fff; border-top: 1px solid var(--border); display: flex; flex-direction: column; z-index: 20;
  box-shadow: 0 -8px 24px rgba(0,0,0,.08); }
.transcript-panel[hidden] { display: none; }
/* Review tools strip (find/replace + filler flagging) above the cue list */
.review-tools { padding: .6rem .6rem 0; display: flex; flex-direction: column; gap: .4rem; }
.review-tools .time-row { gap: .4rem; }
.review-tools input[type="text"] { flex: 1; min-width: 0; font-family: inherit; font-size: var(--fs-md);
  padding: .4rem .5rem; border: 1px solid var(--border); border-radius: 8px; }
.review-tools .mini-btn { flex: 0 0 auto; }
.review-tools-row { display: flex; align-items: center; justify-content: space-between; gap: .5rem; min-height: 1.6rem; }
.review-tools-note { font-size: var(--fs-sm); color: var(--text-muted); }
/* A cue containing a hesitation sound — flagged, never auto-changed */
.t-row.t-filler .t-th { border-color: var(--warn); background: var(--warn-soft); }

/* Word chips (click to hear that exact moment). Present on every row but only
   revealed for the selected one, so the list stays compact and selecting a
   line needs no re-render. */
/* grid-column is load-bearing, not cosmetic. `.t-row` is `auto 1fr` and
   `.t-time` spans rows 1-2, so auto-placement drops this strip into column 1 —
   the `auto` one — where its own width sizes the column and starves the `1fr`
   caption inputs. Measured: 9 Thai chips gave `298.2px 13.2px`, i.e. 13px-wide
   inputs you cannot type in. Spanning the row keeps it out of that track. */

.transcript-rows { flex: 1; overflow-y: auto; padding: .6rem; display: flex; flex-direction: column; gap: .45rem; }
/* FIXED first column, not `auto` (owner 2026-08-20: "เรียงกันมั่วไปหมด").
   `auto` sizes the timecode cell to its own content, and a row that has the ↻
   revert glyph under its time is wider than one that does not — measured on two
   adjacent rows: text columns 243.8px vs 245.8px, starting 2px apart. Every row
   drifting by whatever it happens to contain is exactly the raggedness. 44px
   fits "10 · 0:00" and the glyph at every row. */
.t-row { display: grid; grid-template-columns: 44px 1fr; gap: .1rem .55rem; padding: .45rem .55rem;
  border: 1px solid var(--border); border-radius: 8px; background: #fff; cursor: pointer; }
.t-row.active { border-color: var(--accent); background: var(--accent-soft); }
.t-time { font-size: var(--fs-xs); color: var(--text-muted); padding-top: .3rem; white-space: nowrap; grid-row: span 2; }
.t-row input { width: 100%; border: none; background: transparent; font-family: inherit;
  font-size: var(--fs-base); padding: .12rem .25rem; border-radius: 4px; }
.t-row input:focus { outline: none; background: #fff; box-shadow: 0 0 0 2px var(--accent); }
.t-row input.t-en { font-size: var(--fs-sm); color: var(--text-muted); }
/* AI-fixed line (clean pass): amber tint + ↩ revert until the human verifies. */
.t-row.t-fixed { border-left: 3px solid var(--warn); }
.t-row.t-fixed input.t-th { background: #fefce8; }
.t-revert { display: block; margin-top: .2rem; border: none; background: none; cursor: pointer;
  color: var(--warn); font-size: var(--fs-md); padding: 0; }
.review-chat { position: fixed; left: 0; right: 0; bottom: 0; max-height: 60vh;
  background: #fff; border-top: 1px solid var(--border); display: flex; flex-direction: column; z-index: 20;
  box-shadow: 0 -8px 24px rgba(0,0,0,.08); }
.review-chat[hidden] { display: none; }  /* [hidden] must beat the flex display above */
/* One row, one height. The actions carried Thai labels that wrapped onto two
   and three lines inside a 333px panel, so the title sat at y=177 while the
   button cluster sat at y=159 and every button was a different height — 109px
   of head for four verbs. Icon-only with tooltips, same call as the rail. */
.review-head { display: flex; align-items: center; gap: .4rem; padding: .55rem .7rem;
  border-bottom: 1px solid var(--border); font-weight: 600; font-size: var(--fs-base); }
.review-head > :first-child { margin-right: auto; min-width: 0; }
.review-head .mini-btn { flex: 0 0 auto; width: 32px; height: 32px; padding: 0;
  display: inline-grid; place-items: center; font-size: 0; }
.review-head .mini-btn .mini-ico { width: 16px; height: 16px; font-size: initial; }
.review-head .mini-btn::first-letter { font-size: var(--fs-md); }
.review-close { border: none; background: none; font-size: var(--fs-lg); cursor: pointer; color: var(--text-muted); }
.review-messages { flex: 1; overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: .8rem; }
.review-msg { display: flex; flex-direction: column; gap: .4rem; }
.review-msg.user { align-items: flex-end; }
.review-bubble { max-width: 100%; padding: .55rem .7rem; border-radius: 8px; font-size: var(--fs-base); line-height: 1.45;
  white-space: pre-wrap; }
.review-msg.assistant .review-bubble { background: var(--panel-bg); border: 1px solid var(--border); }
.review-msg.user .review-bubble { background: var(--accent-soft); border: 1px solid var(--accent); color: var(--accent); }
.suggestion { display: flex; align-items: center; gap: .5rem; padding: .5rem .6rem; border: 1px solid var(--border);
  border-radius: 8px; background: #fff; }
.suggestion-text { flex: 1; font-size: var(--fs-md); line-height: 1.4; }
.suggestion-apply { flex-shrink: 0; font-family: inherit; font-size: var(--fs-md); padding: .35rem .7rem; cursor: pointer;
  border: 1px solid var(--accent); border-radius: 999px; background: var(--accent); color: #fff; }
.suggestion-apply:disabled { background: var(--text-muted); border-color: var(--text-muted); cursor: default; }
.suggestion-apply.apply-all { align-self: stretch; margin-top: .2rem; padding: .5rem; font-weight: 600; }
/* "AI กำลังคิด" — three bouncing dots while a review call is in flight. */
.review-bubble.typing-dots { display: flex; align-items: center; gap: 4px; padding: .7rem .8rem; }
.review-bubble.typing-dots span { width: 6px; height: 6px; border-radius: 50%; background: var(--text-muted);
  animation: typing-bounce 1.1s infinite ease-in-out; }
.review-bubble.typing-dots span:nth-child(2) { animation-delay: .15s; }
.review-bubble.typing-dots span:nth-child(3) { animation-delay: .3s; }
@keyframes typing-bounce { 0%, 60%, 100% { transform: translateY(0); opacity: .5; } 30% { transform: translateY(-4px); opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
  .review-bubble.typing-dots span { animation: none; opacity: .8; }
}
/* Next-step nudge (e.g. "แปลก่อนมั้ย?") — two side-by-side choices, no default. */
.nudge-actions { display: flex; gap: .5rem; margin-top: .5rem; }
.nudge-actions .suggestion-apply { flex: 1; text-align: center; }
.nudge-actions .nudge-skip { background: #fff; color: var(--text); border-color: var(--border); }
.nudge-actions .nudge-skip:hover { border-color: var(--accent); color: var(--accent); }
.review-input { display: flex; gap: .5rem; padding: .7rem; border-top: 1px solid var(--border); }
.review-input input { flex: 1; font-family: inherit; font-size: var(--fs-base); padding: .5rem .6rem;
  border: 1px solid var(--border); border-radius: 8px; }
.review-input button { font-family: inherit; font-size: var(--fs-base); padding: .5rem .9rem; cursor: pointer;
  border: none; border-radius: 8px; background: var(--accent); color: #fff; }

/* AI image style intake: preset chips (style, then density) + optional free text. */
.style-chip-row { display: flex; flex-wrap: wrap; gap: .4rem; margin: .4rem 0; }
.style-chip { font-family: inherit; font-size: var(--fs-md); padding: .3rem .65rem; cursor: pointer;
  border: 1px solid var(--border); border-radius: 999px; background: #fff; color: var(--text); }
.style-chip:hover { border-color: var(--accent); color: var(--accent); }
.style-chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.style-chip:disabled { opacity: .6; cursor: default; }
.style-freetext { display: block; width: 100%; font-family: inherit; font-size: var(--fs-md); padding: .45rem .6rem;
  margin: .3rem 0 .5rem; border: 1px solid var(--border); border-radius: 8px; box-sizing: border-box; }
.style-freetext:focus { outline: none; border-color: var(--accent); }

/* Shot (image) inspector thumbnail + mode-toggle active state */
#image-thumb { width: 100%; border-radius: 8px; margin-bottom: .6rem; display: block; }
#btn-image-mode-full.active, #btn-image-mode-overlay.active { background: var(--accent); border-color: var(--accent); color: #fff; }

/* ElevenLabs-style AI cards + section labels */
.ai-card { display: flex; align-items: center; gap: .65rem; width: 100%; text-align: left; font-family: inherit;
  padding: .6rem; border: 1px solid var(--border); border-radius: 8px; background: #fff; color: var(--text); cursor: pointer; }
.ai-card:hover { border-color: var(--accent); }
.ai-card:disabled { opacity: .55; cursor: default; }
.ai-ico { width: 32px; height: 32px; border-radius: 8px; background: var(--accent-soft); color: var(--accent);
  display: grid; place-items: center; flex-shrink: 0; }
.ai-ico svg { width: 17px; height: 17px; }
.ai-txt { display: flex; flex-direction: column; }
.ai-title { font-size: var(--fs-md); font-weight: 600; }
/* Declining is a quieter act than doing — a link under the card, not a second
   button competing with it. */
/* One step louder than it shipped (--fs-sm -> --fs-md, muted -> accent): a
   blind critic reading the screen said the decline was "เล็กและจมเมื่อเทียบกับ
   การ์ดข้างบนทั้งที่เป็นทางเลือกที่ผู้ใช้ไทยจำนวนมากจะใช้". Quieter than the
   card is right — invisible is not. */
.ai-skip { display: block; width: 100%; margin: -.2rem 0 .2rem; padding: .45rem .2rem;
  background: none; border: none; font-family: inherit; font-size: var(--fs-md);
  color: var(--accent); text-align: left; cursor: pointer; text-decoration: underline;
  text-underline-offset: 2px; }
.ai-skip:hover { color: var(--accent); }
.ai-skip[hidden] { display: none; }
.ai-sub { font-size: var(--fs-sm); color: var(--text-muted); }

/* ---------- start screen ----------
   Covers the app until a clip is chosen. The app used to auto-open clips[0],
   so it always launched holding an old file. Uses `hidden` for its own
   visibility, which the global [hidden]{display:none !important} rule enforces
   — do NOT give this a competing explicit display. */
/* A PAGE inside the shell, not an overlay on top of it. It used to be
   `position: fixed; inset: 0; z-index: 70`, which painted over the rail — so
   the rail's own #btn-logout was unreachable from Home and a proxy button had
   to duplicate the handler just to let the owner sign out of the wrong
   account. Every future rail button would have needed the same proxy. As a
   flex sibling of .workspace the rail simply stays where it is.

   `start center`, not `center`: centring was fine when this card held four
   small tool tiles; holding a clip library it only centres in the case that
   matters most — a brand-new account with no clips — and pushed the card down
   206px on a 375x812 phone with nothing to scroll. */
/* `grid-template-columns: minmax(0, 1fr)` is load-bearing, not tidying.
   This was `place-items: start center` with no explicit track. `justify-items:
   center` sizes an implicit column to its item's MAX-CONTENT, so the track grew
   to whatever .start-card wanted — and .start-card asks for `min(1120px, 100%)`,
   whose 100% resolves against that same track. Circular: the card sized the
   track, the track sized the card, and both settled above the viewport.
   Measured at 375px before the fix: content box 327px, .start-card 382px, and
   99 elements past the right edge — with scrollWidth === clientWidth, so the
   overflow was CLIPPED, not scrollable. The "＋ คลิปใหม่" button, which starts
   the whole app, lost 6px off its right edge.
   minmax(0, 1fr) pins the track to the container's width, so `100%` finally
   means the viewport; justify-items keeps the card centred on wide screens. */
.start-screen { flex: 1; min-width: 0; display: grid;
  grid-template-columns: minmax(0, 1fr); align-items: start; justify-items: center;
  background: var(--bg, #f6f7f9); padding: 1.5rem; overflow: auto; }
/* 1120px, was 760: the card now holds a clip grid rather than four small tool
   tiles, and at 760 a 1280px screen showed three columns where five fit. */
.start-card { width: min(1120px, 100%); background: #fff; border: 1px solid var(--border);
  border-radius: 8px; padding: 1.6rem 1.5rem; box-shadow: 0 8px 30px rgba(0,0,0,.07); }
.start-title { margin: 0; font-size: var(--fs-2xl); }
.start-upload { display: block; text-align: center; font-size: var(--fs-base); font-weight: 600;
  padding: .7rem; border: 1px dashed var(--border); border-radius: 8px; cursor: pointer;
  color: var(--accent); margin-bottom: 1.2rem; }
.start-upload:hover { border-color: var(--accent); background: var(--accent-soft); }
.start-status { font-size: var(--fs-md); color: var(--text-muted); margin-top: .8rem; min-height: 1.1em; }

/* ---- workspace Home ----
   Rebuilt 2026-08-13 against 8 real product home screens + 6 project-library
   pages. What changed and why is in
   docs/superpowers/specs/2026-08-13-home-after-login-design.md — the short
   version: 7/8 leaders render work as a thumbnail grid with a status badge and
   a relative time, 0/8 put spend or AI stats on the home screen, and every one
   that lost a blind comparison lost for the same reason: several equal-weight
   entry points and no single primary action. */
.home-top { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; }
/* Redesign 2026-08-24 — ต้นเหตุกล่องเหลื่อมเดิม: quota pill + ปุ่มใหม่ + logout
   สามชิ้น nowrap ไม่ยอมหด รวม 365px ในการ์ด 277px (วัดจริงที่ 375px)
   โครงใหม่: หัวมีแค่ ชื่อ+avatar · ปุ่มหลักได้แถวของตัวเองบนมือถือ ·
   logout เข้าเมนู avatar · quota เป็นบรรทัดจาง ๆ ใต้ชื่อ ไม่แย่งที่ header */
.home-brand { display: none; }                     /* desktop: rail ถือโลโก้อยู่แล้ว */
.home-title-wrap { min-width: 0; }
.home-quota { display: block; margin-top: .15rem; font-size: var(--fs-sm);
  color: var(--text-muted); }
.home-me { position: relative; margin-left: auto; order: 3; }
.home-me summary { list-style: none; display: inline-flex; align-items: center;
  justify-content: center; width: 44px; height: 44px; border-radius: 50%;
  background: var(--brand); color: var(--on-brand); cursor: pointer; }
.home-me summary::-webkit-details-marker { display: none; }
.home-me summary svg { width: 22px; height: 22px; }
.home-me[open] summary { outline: 2px solid var(--accent); outline-offset: 2px; }
.home-me-menu { position: absolute; right: 0; top: calc(100% + 6px); z-index: 30;
  min-width: 180px; background: var(--bg-card, #fff); border: 1px solid var(--border);
  border-radius: 10px; box-shadow: var(--shadow-card); padding: .35rem; }
/* ในเมนู avatar รายการต้องเห็นทุกจอ — .logout-m ตัวเดี่ยว ๆ เป็นของมือถือ (display:none
   บน desktop) แต่เมนูนี้เปิดได้บน desktop ด้วย เห็นกับตา 2026-09-30: เปิดแล้วว่างเปล่า */
.home-me-menu .logout-m { display: inline-flex; align-items: center; min-height: 44px; padding: 0 .7rem;
  background: none; font-family: inherit; font-size: var(--fs-sm); color: var(--text-muted); cursor: pointer;
  white-space: nowrap; width: 100%; justify-content: flex-start; border: 0; }
.apikey-value { display: block; padding: .6rem .7rem; margin: .5rem 0; border: 1px solid var(--border); border-radius: 8px;
  font-size: var(--fs-sm); word-break: break-all; user-select: all; background: var(--panel-bg); }
.apikey-actions { display: flex; gap: .5rem; margin-top: .5rem; }
/* nowrap เพื่อไม่ให้ปุ่มหลักแตกกลางคำ — มือถือได้เต็มแถวจึงไม่มีอะไรบีบมันอีก */
.home-new-btn { display: inline-flex; align-items: center; justify-content: center;
  gap: .4rem; border: 0; white-space: nowrap; order: 4; flex: 1 1 100%; min-height: 48px;
  background: var(--brand); color: var(--on-brand); border-radius: 8px; cursor: pointer;
  padding: .55rem .95rem; font: inherit; font-weight: 600; font-size: var(--fs-base);
  box-shadow: var(--shadow-card); }
@media (min-width: 768px) {
  .home-new-btn { flex: 0 0 auto; order: 2; margin-left: auto; min-height: 0; }
  .home-me { margin-left: 0; }
}
.home-new-btn:hover { filter: brightness(1.06); }

/* scan handle: search + status chips */
.home-tools-row { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap;
  margin: 1.1rem 0 0; }
.home-search { display: inline-flex; align-items: center; gap: .4rem; flex: 1 1 190px;
  min-width: 0; max-width: 320px; border: 1px solid var(--border); background: var(--bg);
  border-radius: 8px; padding: .4rem .6rem; }
.home-search svg { width: 16px; height: 16px; color: var(--text-muted); flex: none; }
.home-search input { border: 0; outline: 0; background: none; font: inherit;
  font-size: var(--fs-base); color: inherit; width: 100%; min-width: 0; }
.home-search:focus-within { border-color: var(--accent); }

/* clip grid. 130px, not 150: at a 320px viewport the content box is 288px and
   two 150px columns plus the .9rem gap needs 314.4px, so wider minimums silently
   dropped small phones to one card per row and made the page 2665px tall. */
.home-grid { display: grid; gap: .9rem; margin-top: 1.1rem;
  grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); }
@media (min-width: 768px) {
  .home-grid { gap: 1.1rem; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); }
}
.home-clip { display: flex; flex-direction: column; gap: .55rem; text-align: left;
  border: 0; background: none; padding: 0; font: inherit; color: inherit; cursor: pointer; }
.home-thumb { position: relative; aspect-ratio: 9 / 16; border-radius: 8px;
  border: 1px solid var(--border); background: var(--panel-bg); overflow: hidden;
  box-shadow: var(--shadow-card); transition: box-shadow .15s, transform .15s; }
.home-clip:hover .home-thumb { box-shadow: var(--shadow-lift); transform: translateY(-2px); }
.home-thumb video { width: 100%; height: 100%; object-fit: cover; pointer-events: none;
  display: block; }
.home-dur { position: absolute; right: .4rem; bottom: .4rem; background: var(--scrim);
  color: var(--on-accent); font-size: var(--fs-sm); padding: .1rem .35rem; border-radius: 5px; }
.home-badge { position: absolute; left: .4rem; top: .4rem; font-size: var(--fs-sm);
  font-weight: 600; padding: .15rem .45rem; border-radius: 999px; border: 1px solid;
  box-shadow: 0 0 0 1px var(--chip-ring); }
.home-badge.is-warn { color: var(--warn); background: var(--warn-soft); border-color: var(--warn-border); }
.home-badge.is-ok   { color: var(--ok);   background: var(--ok-soft);   border-color: var(--ok-border); }
.home-badge.is-idle { color: var(--text-muted); background: var(--panel-bg); border-color: var(--border); }
.home-name { font-size: var(--fs-base); font-weight: 500; line-height: 1.3;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.home-when { font-size: var(--fs-md); color: var(--text-muted); }
.home-none { color: var(--text-muted); font-size: var(--fs-base); padding: 2rem 0; }

/* no clips yet — replaces the grid, never sits under it */
.home-empty { border: 2px dashed var(--border); border-radius: 8px; background: var(--panel-bg);
  padding: 3rem 1.25rem; text-align: center; margin-top: 1.1rem; }
.home-empty h2 { margin: .6rem 0 .3rem; font-size: var(--fs-xl); }
.home-empty p { margin: 0 0 1.1rem; color: var(--text-muted); font-size: var(--fs-base); }
.home-empty svg { width: 40px; height: 40px; color: var(--text-muted); }
/* Whole-page drop target: Descript is the one leader that leads with a
   dropzone, and it does so on a screen with no project list. With a list to
   show, the page itself takes the drop and the button is the click affordance. */
.start-screen.is-dropping .home-empty,
.start-screen.is-dropping .home-grid { outline: 2px dashed var(--accent); outline-offset: 6px; }

/* Export file name (step 5) */
#out-name { width: 100%; font-family: inherit; font-size: var(--fs-base); padding: .5rem .65rem;
  border: 1px solid var(--border); border-radius: 8px; background: #fff; color: var(--text); }
#out-name:focus { outline: none; border-color: var(--accent); }
.out-name-hint { font-size: var(--fs-sm); color: var(--text-muted); margin-top: .35rem; }

/* Brand kit editor (step 4) */
#brandkit-group textarea { width: 100%; font-family: inherit; font-size: var(--fs-md); line-height: 1.45;
  padding: .5rem .6rem; border: 1px solid var(--border); border-radius: 8px;
  background: #fff; color: var(--text); resize: vertical; }
#brandkit-group textarea:focus { outline: none; border-color: var(--accent); }

/* Motion-look chips reuse .style-chip; the overlay needs a transform origin
   that matches ffmpeg's zoompan, which scales about the frame centre. */
.image-overlay-el { transform-origin: center center; }

/* A cue line longer than the burn's budget. thaiwrap emits it over-width and
   WrapStyle 0 lets libass re-break it with no Thai dictionary, so what burns
   can differ from what the preview shows. Warning only — never alters text. */
.t-row.t-over .t-th { box-shadow: inset 2px 0 0 var(--warn); }
/* var(--warn) with white text measured 3.19:1 — under the 4.5:1 floor for text
   this small. var(--warn) (the house warning colour) gives 7.0:1. */
/* Sits UNDER the timecode inside the 44px column, never beside it.
   It used to be `margin-left: 6px` on an inline badge, which pushed it out of
   the fixed 44px column and straight across the caption text: measured at
   1440x900 the column ended at x=428, the badge's right edge reached x=465, and
   the Thai line starts at x=437 — a 27px overlap on 3 of the first 4 rows, i.e.
   the over-length warning was sitting on the very characters it is warning
   about. Three blind judges reported this independently; an earlier probe of
   mine "refuted" them by measuring the COLUMN (.t-time) instead of the badge
   that escapes it, which is why the wrong element is worse than no probe.
   display:block + margin-left:0 keeps it in the column and lets the row grid
   own the horizontal space, as .t-row's `44px 1fr` always intended. */
/* Merge-on-the-seam. Zero height until hovered or keyboard-focused, so it costs
   no permanent vertical space — the objection that got the old shortcut hint
   block removed in ca4f7c2. The button is absolutely positioned so revealing it
   never reflows the list under the pointer (a row that shifts as you approach it
   is a row you mis-click). :focus-within keeps it reachable by Tab, since a
   control that only exists on hover is unreachable by keyboard and invisible on
   touch — both stated failure modes in the repo's own notes. */
.t-seam { position: relative; height: 0; }
.t-seam-btn {
  position: absolute; left: 50%; top: -12px; transform: translateX(-50%);
  z-index: 2; white-space: nowrap;
  padding: .1rem .55rem; border: 1px solid var(--rail-rule); border-radius: 999px;
  background: var(--panel-bg); color: var(--text-muted);
  font-family: inherit; font-size: var(--fs-xs); cursor: pointer;
  opacity: 0; transition: opacity .12s ease;
}
.t-seam:hover .t-seam-btn,
.t-seam:focus-within .t-seam-btn,
.t-row:hover + .t-seam .t-seam-btn { opacity: 1; }
.t-seam-btn:hover { border-color: var(--hl); color: var(--ink); }
.t-seam-btn:focus-visible { opacity: 1; outline: 2px solid var(--ink); outline-offset: 2px; }
/* Touch has no hover: below 768px the seam control is always visible, quieter.
   The phone also keeps its .transcript-touch-keys buttons — this is an
   additional door on the seam, not a replacement. */
@media (max-width: 767px) {
  .t-seam-btn { opacity: .55; font-size: 10px; padding: 0 .45rem; }
}

.t-over-badge { display: block; width: fit-content; margin: 2px 0 0; padding: 0 4px;
  border-radius: 3px; background: var(--warn); color: #fff; font-size: 10px;
  font-variant-numeric: tabular-nums; }

/* ---------- carousel ---------- */
/* Reuses .glossary-modal (border/shadow/backdrop/max-height) plus the same
   .review-head + .ai-panel-body split, so padding and scrolling come from the
   established pattern rather than being re-invented. Two independent reviews
   caught the first version inheriting `padding: 0` with no body wrapper, which
   put Thai copy flush against the border. */
.carousel-modal { width: min(780px, 92vw); }
.car-body { display: flex; flex-direction: column; }
.carousel-modal textarea { width: 100%; font: inherit; line-height: 1.45;
  padding: .5rem .65rem; border: 1px solid var(--border); border-radius: 8px;
  resize: vertical; background: #fff; color: var(--text); }
.carousel-modal textarea:focus { outline: none; border-color: var(--accent); }
/* Thai above/below vowels and tone marks need room. Every rule here that can
   carry Thai sets a line-height on the house 1.45 step — the first version left
   .car-slide .th (the ONLY Thai-copy rule) with none while its English sibling
   had 1.5, which is backwards. */
.car-hint { color: var(--text-muted); font-size: var(--fs-sm); line-height: 1.45; margin: 0 0 .6rem; }
.car-hint code { background: var(--panel-bg); padding: 2px 5px; border-radius: 4px; }
.car-label { font-size: var(--fs-sm); line-height: 1.45; color: var(--text-muted);
  display: block; margin: .6rem 0 .25rem; }
.car-row { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
.car-row .car-label { margin: 0; }
.car-row input, .car-row select { font: inherit; font-size: var(--fs-md); padding: .35rem .5rem;
  border-radius: 8px; border: 1px solid var(--border); background: #fff; color: var(--text); }
.car-row input { width: 78px; }
.car-note { color: var(--text-muted); font-size: var(--fs-sm); line-height: 1.45; margin: .4rem 0 0; }
.car-sections { margin-top: .35rem; font-size: var(--fs-sm); line-height: 1.45; color: var(--accent); }
.car-sections.bad { color: var(--warn); }
.car-breakdown { font-size: var(--fs-sm); line-height: 1.45; color: var(--text-muted); margin-top: .35rem; }
.car-breakdown.warn { color: var(--warn); }
.car-actions { display: flex; align-items: center; gap: .5rem; margin-top: .8rem; flex-wrap: wrap; }
.car-actions .mini-btn { flex: 0 0 auto; }
.car-status { font-size: var(--fs-sm); line-height: 1.45; color: var(--text-muted); margin-top: .5rem; }
.car-status.bad { color: var(--warn); }
.car-plan-out { margin-top: .8rem; border-top: 1px solid var(--border); padding-top: .7rem; }
/* Dimmed while the form has moved on — the plan is still readable, but it no
   longer describes what the form would produce. */
.car-plan-out.stale { opacity: .45; }
.car-style { font-size: var(--fs-sm); line-height: 1.45; color: var(--text-muted); margin-bottom: .6rem; }
.car-slides { display: grid; gap: .4rem; }
.car-slide { border: 1px solid var(--border); border-radius: 8px; padding: .5rem .65rem; }
.car-slide .role { font-size: var(--fs-xs); line-height: 1.45; color: var(--accent); font-weight: 600; }
.car-slide .th { font-size: var(--fs-md); line-height: 1.45; margin: .15rem 0; }
.car-slide .en { font-size: var(--fs-xs); line-height: 1.45; color: var(--text-muted); }
/* --car-ar comes from the chosen aspect so tiles match the requested shape. */
.car-results { margin-top: .8rem; display: grid; gap: .5rem;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
.car-cell { border: 1px solid var(--border); border-radius: 8px; overflow: hidden;
  background: var(--panel-bg); }
.car-cell img { display: block; width: 100%; aspect-ratio: var(--car-ar, 1 / 1);
  object-fit: cover; }
/* NOT `.cap` — that is the burn-preview caption overlay. */
.car-cap { font-size: var(--fs-xs); line-height: 1.45; padding: .35rem .5rem; color: var(--text-muted); }
/* A slide that FAILED is an error, not a warning: the house error red, not the
   amber used for "heads up". */
.car-cell.failed { border-color: #dc2626; }
.car-cell .err { font-size: var(--fs-xs); line-height: 1.45; color: #dc2626; padding: .5rem; }

/* Unsaved caption work — must be impossible to miss. */
#status.status-bad { color: #b91c1c; font-weight: 600; }

/* ---------- cost page ---------- */
/* Billing-page shape everyone already reads: total → category bars → ranking. */
.cost-modal { width: min(560px, 92vw); }
.cost-head { padding: .4rem 0 .9rem; border-bottom: 1px solid var(--border); margin-bottom: .9rem; }
.cost-total { font-size: var(--fs-4xl); font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.15; }
.cost-sub { font-size: var(--fs-md); color: var(--text-muted); margin-top: .2rem; }
.cost-sec { font-size: var(--fs-sm); letter-spacing: .05em; text-transform: uppercase;
  color: var(--text-muted); font-weight: 600; margin: .9rem 0 .45rem; }
.cost-row { display: grid; grid-template-columns: 9.5rem 1fr auto; gap: .6rem;
  align-items: center; padding: .28rem 0; font-size: var(--fs-md); }
.cost-row.clip { grid-template-columns: 1fr auto; }
.cost-row .k { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cost-row .track { height: 10px; border-radius: 5px;
  background: color-mix(in srgb, var(--accent) 10%, transparent); }
.cost-row .bar { height: 100%; border-radius: 5px; background: var(--accent); min-width: 2px; }
.cost-row .v { font-variant-numeric: tabular-nums; color: var(--text-muted); white-space: nowrap; }
.cost-empty { font-size: var(--fs-md); color: var(--text-muted); padding: .3rem 0; }
.cost-note { font-size: var(--fs-xs); color: var(--text-muted); line-height: 1.5;
  border-top: 1px solid var(--border); margin-top: 1rem; padding-top: .7rem; }

/* ---------- brand kit dialog (Home's door in) ---------- */
.bk-label { display: block; font-size: var(--fs-sm); font-weight: 600; margin: .6rem 0 .2rem; }
#brandkit-modal textarea { width: 100%; font: inherit; font-size: var(--fs-md); padding: .45rem .55rem;
  border: 1px solid var(--border); border-radius: 8px; resize: vertical; }

/* ---------- asset library dialog ---------- */
/* .inp: one generic single-line text input for a dialog that isn't nested in
   .panel (`.panel input[type="text"]` doesn't reach here) — same visual
   treatment as #brandkit-modal's textarea, so this doesn't read as a second
   input style in the app. */
.inp { display: block; width: 100%; font: inherit; font-size: var(--fs-base);
  padding: .45rem .6rem; border: 1px solid var(--border); border-radius: 8px;
  background: #fff; color: inherit; margin-bottom: .4rem; }
.inp:focus { outline: none; border-color: var(--accent); }
#assets-body textarea { width: 100%; font: inherit; font-size: var(--fs-md); padding: .45rem .55rem;
  border: 1px solid var(--border); border-radius: 8px; resize: vertical; margin-bottom: .4rem; }
.asset-thumb { display: block; width: 100%; max-width: 160px; aspect-ratio: 1;
  object-fit: cover; border: 1px solid var(--border); border-radius: 8px; margin-bottom: .4rem; }

/* Gaps: stretches with no caption at all. Two kinds, two actions — "dropped"
   holds words the recogniser produced and lost downstream (recoverable from
   data already on disk), "unheard" holds nothing (re-transcribe or type it).
   Styled from the existing seven tokens only: a CSS audit on this project
   already found 41 raw hex colours bypassing them, and this is not the place
   to make it 42. The two kinds are told apart by a LABEL and a left border
   weight, never by colour alone. */
.gap-strip { padding: .5rem .6rem; display: flex; flex-direction: column; gap: .3rem;
  border-block-end: 1px solid var(--border); background: var(--panel-bg); }
.gap-strip-head { font-size: var(--fs-md); color: var(--text-muted); }
.gap-strip-head b { color: var(--text); }
.gap-item { display: flex; align-items: baseline; gap: .45rem; width: 100%;
  text-align: start; font: inherit; font-size: var(--fs-md); cursor: pointer;
  background: var(--bg); color: var(--text);
  border: 1px solid var(--border); border-inline-start-width: 3px;
  border-radius: 6px; padding: .35rem .5rem; }
/* .gap-item used to BE the button; it is now a row holding the seek surface
   (.gap-go) plus its own action (.gap-add), because a button inside a button is
   invalid markup. The hover moves to the seek surface so the row does not light
   up when the pointer is over the action. */
.gap-item { align-items: center; cursor: default; padding-inline-end: .35rem; }
.gap-go { flex: 1; min-width: 0; display: flex; align-items: baseline; gap: .45rem;
  text-align: start; font: inherit; font-size: var(--fs-md); cursor: pointer;
  background: none; border: none; color: inherit; padding: 0; }
.gap-item:has(.gap-go:hover) { border-color: var(--accent); }
.gap-go:hover { color: var(--accent); }
.gap-add { flex: none; }
.gap-item[data-kind="unheard"] { border-inline-start-color: var(--border); }
.gap-item[data-kind="dropped"] { border-inline-start-color: var(--accent); }
.gap-item .gap-at { font-variant-numeric: tabular-nums; color: var(--text-muted);
  flex: none; font-size: var(--fs-sm); }
.gap-item .gap-tag { flex: none; font-size: var(--fs-sm); font-weight: 700; }
.gap-item[data-kind="dropped"] .gap-tag { color: var(--accent); }
.gap-item[data-kind="unheard"] .gap-tag { color: var(--text-muted); }
.gap-item .gap-text { flex: 1; min-width: 0; overflow: hidden;
  text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted); }

/* ---------- AI activity strip + list (web/ai_log.js) ---------- */
/* One line, always in the same place, so "is the AI doing anything?" is
   answered without hunting. Tokens use the existing type tokens rather than a
   new scale. The first draft of this block invented --line/--card/--ink/
   --danger, none of which this file defines, so every one would have fallen
   back to a browser default: exactly the failure the post-v3 CSS audit found
   from the other direction (41 raw hex colours bypassing 7 tokens). The
   tokens that exist are --bg --panel-bg --border --text --text-muted
   --accent --accent-soft; #dc2626 is the red .mini-btn.danger already uses. */
.ai-activity {
  display: inline-flex; align-items: center; gap: 8px;
  margin-left: 10px; padding: 4px 10px;
  border: 1px solid var(--border); border-radius: 999px;
  background: var(--panel-bg); color: var(--text-muted);
  font: inherit; font-size: 12px; cursor: pointer;
}
.ai-activity:hover { border-color: var(--accent); color: var(--text); }
.ai-activity-dot {
  width: 7px; height: 7px; border-radius: 50%; background: var(--text-muted); flex: none;
}
.ai-activity.busy .ai-activity-dot { background: var(--accent); animation: ai-pulse 1s ease-in-out infinite; }
.ai-activity.failed .ai-activity-dot { background: #dc2626; }
@keyframes ai-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .25; } }
@media (prefers-reduced-motion: reduce) {
  .ai-activity.busy .ai-activity-dot { animation: none; }
}
.ai-activity-rows { display: flex; flex-direction: column; gap: 6px; padding: 1rem; }
#ai-activity-modal .out-name-hint { padding: 0 1rem 1rem; }
.ai-row {
  display: grid; grid-template-columns: minmax(9rem, 1fr) auto; gap: 2px 12px;
  padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px;
}
.ai-row.running { border-color: var(--accent); }
.ai-row.failed { border-color: #dc2626; }
.ai-row-name { font-weight: 600; }
.ai-row-what, .ai-row-meta { font-size: 12px; color: var(--text-muted); }
.ai-row-meta { text-align: right; }
.ai-row-err { grid-column: 1 / -1; font-size: 12px; color: #dc2626; }

/* The two structural caption gestures, stated where the editing happens.
   Same muted-footer treatment the gap strip and the AI panel already use —
   no new colour, no new scale. */
/* MEASURED 2026-08-20 at 1280x800 with the panel open: the panel stood 313px
   tall and the chrome above the captions took 270 of it — head 109 + find/
   replace 81 + this hint block 80 — leaving the captions, the entire point of
   the panel, 42px. The owner pointed at the block: "ส่วนนี้ก็ดูไม่ค่อยจำเป็น".
   The keys still work; the hints stop being permanent furniture. */
.transcript-keys { display: none; }
.transcript-keys kbd {
  font: inherit; font-size: var(--fs-xs); padding: .05rem .3rem;
  border: 1px solid var(--border); border-block-end-width: 2px;
  border-radius: 4px; background: var(--bg); color: var(--text);
}

/* The flow as a checklist, above the per-call rows. Elicit's Status panel:
   ticked steps keep the result they produced, the running one is marked, the
   ones not reached yet are visibly not reached. */
.ai-step {
  display: grid; grid-template-columns: 1.2rem minmax(7rem, 1fr) auto;
  gap: .4rem; align-items: baseline;
  padding: .3rem .1rem; font-size: var(--fs-md);
}
.ai-step.waiting { color: var(--text-muted); }
.ai-step.running .ai-step-name { font-weight: 600; }
.ai-step-mark { text-align: center; color: var(--text-muted); }
.ai-step.done .ai-step-mark { color: var(--accent); }
.ai-step.running .ai-step-mark { color: var(--accent); }
.ai-step.failed .ai-step-mark, .ai-step.failed .ai-step-meta { color: #dc2626; }
.ai-step-meta { text-align: right; font-size: var(--fs-sm); color: var(--text-muted); }

/* ---------- learning curve (web/learn_chart.js) ---------- */
/* Dumbbell, one row per clip, oldest at the top. Two categorical hues,
   validated for this light surface with the dataviz validator:
     #2563eb (หลัง AI) / var(--warn) (ก่อน AI) — CVD ΔE 31.3 protan, 27.3 tritan,
     normal-vision ΔE 34.6, both ≥ 3:1 on the surface. ALL CHECKS PASS.
   The app has no dark mode (no prefers-color-scheme rule anywhere in this
   file), so only the light palette is specified — deliberately, not skipped. */
.learn-hero { font-size: var(--fs-xl); font-weight: 600; margin: .2rem 0 .1rem; }
.learn-hero .learn-hero-sub { display: block; font-size: var(--fs-md); font-weight: 400; color: var(--text-muted); }
.learn-legend { display: flex; flex-wrap: wrap; gap: .1rem .9rem; font-size: var(--fs-sm); color: var(--text-muted); margin-bottom: .4rem; }
.learn-key { display: inline-flex; align-items: center; gap: .3rem; }
.learn-key-note { color: var(--text-muted); }
.learn-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; }
.learn-dot.before { background: #b45309; }
.learn-dot.after  { background: #2563eb; }

.learn-chart { display: flex; flex-direction: column; gap: .1rem; }
.learn-row { display: grid; grid-template-columns: 10rem 1fr 3.4rem; gap: .5rem; align-items: center; }
.learn-row-name { font-size: var(--fs-sm); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.learn-row-name b { color: var(--text); font-weight: 600; }
.learn-plot { position: relative; height: 22px; }
/* Hairline, solid, one shade off the surface — never dashed. */
.learn-grid { position: absolute; inset: 0; }
.learn-grid i { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--border); }
.learn-bar { position: absolute; top: 50%; height: 2px; transform: translateY(-50%);
  background: color-mix(in srgb, #2563eb 30%, transparent); border-radius: 1px; }
.learn-bar.worse { background: color-mix(in srgb, #b45309 45%, transparent); }
.learn-pt { position: absolute; top: 50%; border-radius: 50%; transform: translate(-50%, -50%);
  /* 2px surface ring so overlapping markers stay countable — never a border
     drawn to separate marks. */
  box-shadow: 0 0 0 2px var(--bg); }
.learn-pt.before { background: #b45309; }
.learn-pt.after  { background: #2563eb; }
.learn-row-val { font-size: var(--fs-sm); text-align: right; font-variant-numeric: tabular-nums; color: var(--text); }
.learn-row.thin .learn-row-val::after { content: " ⚠"; color: var(--text-muted); }
.learn-axis { display: grid; grid-template-columns: 10rem 1fr 3.4rem; gap: .5rem; margin-top: .15rem; }
.learn-axis-ticks { position: relative; height: 1rem; font-size: var(--fs-xs); color: var(--text-muted); }
.learn-axis-ticks span { position: absolute; transform: translateX(-50%); font-variant-numeric: tabular-nums; }
.learn-table-wrap { margin-top: .5rem; font-size: var(--fs-sm); }
.learn-table-wrap summary { cursor: pointer; color: var(--text-muted); }
.learn-table table { border-collapse: collapse; width: 100%; margin-top: .3rem; }
.learn-table th, .learn-table td { text-align: right; padding: .2rem .35rem; border-bottom: 1px solid var(--border); font-variant-numeric: tabular-nums; }
.learn-table th:first-child, .learn-table td:first-child { text-align: left; font-variant-numeric: normal; }
/* The row label is two parts with different jobs: the clip identity may elide,
   the sample size never may — n is the secondary encoding the chart depends on
   and a clipped "8 บรร…" is the reader losing it. */
.learn-row-name { display: flex; gap: .3rem; align-items: baseline; }
.learn-row-name b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.learn-row-n { flex: none; font-variant-numeric: tabular-nums; }
/* The interval sits under the point estimate, quieter than it but never
   optional — every source on unit-level rates requires the two to be read
   together. */
.learn-row-val { line-height: 1.15; }
.learn-row-ci { display: block; font-size: var(--fs-xs); color: var(--text-muted); font-weight: 400; }
/* A thin sample is made QUIETER, not hidden — the monitoring literature's rule
   is to suppress the alarm, not the data. */
.learn-row.thin { opacity: .55; }
.learn-warn { color: var(--text); margin-bottom: .3rem; }

/* ตั้ง API key เอง — ค่าคีย์ไม่เคยมาถึงหน้านี้ มีแค่สถานะกับ 4 ตัวท้าย */
.key-row { border-top: 1px solid var(--border); padding: .7rem 0; display: grid; gap: .3rem; }
.key-row:first-child { border-top: 0; }
.key-head { display: flex; align-items: center; gap: .5rem; }
.key-badge { font-size: var(--fs-sm); color: var(--text-muted); border: 1px solid var(--border);
  border-radius: 99px; padding: .05rem .5rem; }
.key-badge.on { color: var(--accent); border-color: var(--accent); }
.key-what { font-size: var(--fs-md); color: var(--text-muted); }
.key-row input { font: inherit; padding: .45rem .6rem; border: 1px solid var(--border);
  border-radius: 8px; background: var(--bg); color: var(--text); }
.key-link { font-size: var(--fs-md); color: var(--accent); text-decoration: none; }
.key-warn { font-size: var(--fs-md); color: var(--text); background: var(--accent-soft);
  border-radius: 8px; padding: .4rem .55rem; }
/* top-right CTA: one pulse when the pipeline story advances (never looped) */
@keyframes cta-pulse {
  0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); }
  100% { box-shadow: 0 0 0 14px transparent; }
}
.cta-pulse { animation: cta-pulse .9s ease-out 2; }
/* a step's job just finished: its chip settles to ✓ with one small pop
   (owner 2026-10-06 — paired with .cta-pulse on ถัดไป, see cueNextStep) */
@keyframes step-done-pop {
  0% { transform: scale(.6); } 60% { transform: scale(1.18); } 100% { transform: scale(1); }
}
.step-col.just-done .step-circle { animation: step-done-pop .32s ease-out 1; }
/* motion is the cue's emphasis, never its only channel: the ✓ and the primary
   ถัดไป button stay put without it */
@media (prefers-reduced-motion: reduce) {
  .cta-pulse, .step-col.just-done .step-circle { animation: none; }
}

/* ── 2026-08-12 left-panel audit fixes (blind vs Descript/VEED/Riverside) ──
   Each step leads with its OWN primary card; the shared caption settings sink
   below a labelled divider. Flex order does the reordering so DOM stays put. */
aside.panel { display: flex; flex-direction: column; }
/* The ✕ has flex order 0, so once the title/action cards took negative orders
   it rendered mid-sheet between them — pin it into the title row instead
   (negative bottom margin lets the title flow up beside it). */
aside.panel > .panel-close { order: -11; margin-bottom: -40px; }
aside.panel > .journey-title { order: -10; padding-right: 44px; }
aside.panel > .journey-hint { order: -9; }
/* `aside.panel[data-step="translate"] > #btn-translate { order: -1 }` was here
   and could NEVER match: แปล stopped being a step on 2026-08-20, so data-step
   is never "translate". Found by the 2026-08-20 panel audit; deleted rather
   than repointed, because the rule it was trying to enforce (translate above
   the shared settings) is moot now that no step carries shared settings.
   #shared-label's order and .shared-label's whole ruleset went with the
   element — see the note in index.html. */
/* Presets FIRST. All five tools surveyed on 2026-08-20 (Submagic, Opus Clip,
   VEED, CapCut, Captions.ai) open their caption-appearance destination with a
   gallery of named looks; per-property controls are the fallback behind it.
   The old order led with recap-group, so the first thing in the tab was the
   word-by-word mode switch and, four controls later, ตัดบรรทัดใหม่ทั้งคลิป —
   a destructive button that discards every hand edit — sitting above the
   thing people actually came here for. */
aside.panel > #style-group { order: 11; }
/* section headers get real hierarchy: darker, bolder, breathing room */
.panel-title { font-weight: 600; color: var(--text); font-size: var(--fs-md); }
/* เส้นคั่นระหว่างกลุ่ม ต้องขึ้นเฉพาะเมื่อมีกลุ่มที่ "มองเห็นจริง" อยู่ข้างบน
   ของเดิมเป็น `+` ซึ่งไม่สนใจว่าเพื่อนบ้านซ่อนอยู่หรือเปล่า — #style-group จึงขีด
   เส้นคั่นกับ #outname-group ที่ display:none อยู่ แล้วลากระยะ margin .4rem +
   padding .8rem มาด้วย รวมเป็นที่ว่างเปล่าราว 47px ใต้หัวข้อขั้น พร้อมเส้นลอย
   หนึ่งเส้นที่ไม่ได้คั่นอะไรเลย (เจ้าของชี้ 2026-08-21)
   `~` + :not([hidden]) อ่านว่า "มีกลุ่มที่ไม่ได้ซ่อนอยู่ก่อนหน้าฉัน" = ฉันไม่ใช่
   กลุ่มแรกบนจอ ซึ่งเป็นเงื่อนไขที่เส้นคั่นต้องการจริง ๆ */
.panel-group:not([hidden]) ~ .panel-group:not([hidden]) {
  margin-top: .4rem; padding-top: .8rem; border-top: 1px solid var(--border); }
/* the step whose controls the left panel is SHOWING (≠ pipeline's next step) */
/* The step the panel is SHOWING — now the loudest mark in the strip, because it
   is the only one that describes where the user actually is. Ring + halo, the
   treatment `.current` used to wear. `.done.viewing` keeps its dark fill and
   gains the halo, so a finished step you are revisiting still reads as done. */
.step-col.viewing .step-circle {
  border-color: var(--hl); border-width: 3px;
  box-shadow: 0 0 0 3px var(--accent-soft); }
/* สีตัวอักษรของวงกลมเป็นของ "สถานะ" ไม่ใช่ของ "กำลังดูอยู่" — .viewing บอกแค่
   ตำแหน่งของผู้ใช้ มันจึงเติมได้แค่วงแหวนกับรัศมี ห้ามแตะสีตัวอักษร
   ก่อนแก้: .done ให้พื้นสีหมึก + ตัวอักษรขาว แล้ว .viewing เขียนทับ color เป็น
   สีหมึก ได้ดำบนดำ อัตราส่วนความต่าง 1.00:1 — ✓ อยู่ใน DOM แต่มองไม่เห็นเลย
   (gyo-review 2026-08-24 วัดตรงกันทั้ง 320/375/768/1440/1920)
   .stale ก็พังแบบเดียวกัน พื้นสีเตือน + ตัวอักษรขาว
   เหลือแค่ขั้นที่ยังไม่เสร็จซึ่งพื้นเป็นขาว ที่ยังต้องได้สีหมึกของมัน */
.step-col.viewing:not(.done):not(.stale) .step-circle { color: var(--ink); }
.step-col.viewing .step-label { color: var(--ink); font-weight: 700; }
/* checkbox labels hug their box — .field>label's space-between pushed them apart */
.field > label.check-row { justify-content: flex-start; }

/* preset tiles: each renders its own look on a video-dark stage */
.preset-tile { display: flex; flex-direction: column; gap: .3rem; padding: 0; border: 1px solid var(--border);
  border-radius: 8px; overflow: hidden; background: #fff; cursor: pointer; width: calc(50% - .25rem); }
.preset-stage { display: grid; place-items: center; height: 44px;
  background: linear-gradient(135deg, #2b2f36 0%, #14161a 70%), #14161a; }
/* DELIBERATELY OUTSIDE THE TYPE SCALE, both of these. Their font-size is set
   in studio.js from the caption style's own numbers (th_size / 4.6, eng_size /
   4.6) because the tile is a MINIATURE OF THE BURNED CAPTION, not a piece of
   UI chrome — forcing it onto the UI scale would make the preview lie about how
   big the text will actually be, and about the Thai/English size ratio the user
   is choosing between. Measured after the scale landed: these are the only two
   rendered sizes on the editor screen that are not scale steps (19px and 10px),
   and that is correct. */
.preset-sample { line-height: 1.35; white-space: nowrap; }
.preset-name { font-size: var(--fs-xs); color: var(--text-muted); padding: 0 .4rem .35rem; text-align: center; }
.preset-tile.active { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.preset-tile.active .preset-name { color: var(--accent); font-weight: 600; }
.preset-row { display: flex; flex-wrap: wrap; gap: .5rem; }

/* ---- ซื้อเครดิต -----------------------------------------------------------
   `.review-head` เป็น flex ที่ปล่อยให้ลูกตัวสุดท้ายกินพื้นที่ที่เหลือ dialog
   อื่นหัวข้อยาวพอจนไม่เห็นผล แต่ "เติมชิ้น katsu" สั้น ปุ่มปิดเลยยืดเต็มแถวจนดู
   เหมือนแถบ ไม่เหมือนปุ่ม — เห็นบนจอ ไม่ใช่จาก diff */
#buy-dialog .review-head > button { flex: 0 0 auto; }

/* QR กับข้อความรอบ ๆ อยู่กึ่งกลาง ของเดิมชิดซ้ายแล้วเหลือที่ว่างครึ่งขวา
   ทั้งที่ทั้งบล็อกมีแค่รูปสี่เหลี่ยมกับสองบรรทัด */
#buy-qr { text-align: center; }
#buy-qr img {
  display: block;
  margin: var(--space-3, 12px) auto;
  border-radius: 8px;
  background: #fff;   /* QR ต้องอยู่บนพื้นขาวเสมอ กล้องอ่านสลับสีไม่ได้ */
}

/* กลุ่มแพ็กเรียงลงมา ไม่ใช่ยืนข้างกัน
   เดิมเป็น `display:flex; flex-wrap:wrap` ตั้งแต่สมัยที่กล่องนี้มีปุ่มเรียบ ๆ 5 ปุ่ม
   พอเปลี่ยนเป็นการ์ดสองกลุ่ม กฎเก่าที่ค้างอยู่ทำให้สอง <section> ไปยืนข้างกัน
   หัวข้อสองอันเหลื่อมกัน 16px และเดสก์ท็อปเหลือที่ว่างขวามือ 108px */
#buy-plans { display: flex; flex-direction: column; gap: 1rem; }

/* ---------------------------------------------- กล่องเติมเครดิต: การ์ดแพ็ก
   ออกแบบจากหลักฐาน ไม่ใช่รสนิยม (วิจัย 2026-08-13, สรุปใน billing_ui.js):

   - เรียงลงมาแทนแถวเดียว: CXL วัดหน้า pricing มือถือ 78% ใช้การ์ดเรียงลง เฉลี่ย
     3.4 ตัวเลือก ส่วน carousel/แถวเลื่อนใช้กัน 8% และ 13% ของตารางที่เลื่อนได้
     ไม่มีสัญญาณบอกว่าเลื่อนได้เลย ของเดิมยัด 5 การ์ดแถวเดียวจนตัดคำกลางคำ
   - เป้ากดขั้นต่ำ 44px: WCAG 2.5.8 บังคับ 24px เป็นขั้นต่ำระดับ AA, Apple HIG
     และ Material อยู่ที่ 44-48 เลือกตัวสูงเพราะนิ้วโป้งคนไทยไม่ได้เล็กกว่าใคร
   - ใช้ token เดิมทั้งหมด ไม่มี hex ดิบ (บทเรียน CSS audit: 41 สีดิบเลี่ยง 7
     token มาแล้วครั้งหนึ่ง) */
.pack-group h4 { margin: 0 0 .45rem; font-size: var(--fs-base); }
.pack-list { display: flex; flex-direction: column; gap: .5rem; }
.pack { width: 100%; display: grid; grid-template-columns: 1fr auto; align-items: center;
  gap: .1rem .6rem; min-height: 44px; padding: .6rem .8rem; cursor: pointer;
  font-family: inherit; text-align: left; background: var(--bg);
  border: 1px solid var(--border); border-radius: 8px; color: var(--text); }
.pack:hover { border-color: var(--accent); }
.pack-n { font-size: var(--fs-lg); font-weight: 600; }
.pack-price { font-size: var(--fs-lg); font-weight: 700; grid-column: 2; grid-row: 1; }
.pack-unit { font-size: var(--fs-sm); color: var(--text-muted); grid-column: 1; }
.pack-save { font-size: var(--fs-sm); color: var(--accent); grid-column: 2;
  justify-self: end; }
/* แพ็กที่เลือกอยู่ต้องอ่านออกโดยไม่ต้องพึ่งสีอย่างเดียว — เส้นขอบหนาขึ้น +
   พื้นหลังอ่อน + จุดเช็ค ทำงานได้ทั้งกับคนตาบอดสีและจอที่สีเพี้ยน */
.pack[aria-checked="true"] { border-color: var(--accent); border-width: 2px;
  background: var(--accent-soft); padding: calc(.6rem - 1px) calc(.8rem - 1px); }
.pack[aria-checked="true"] .pack-n::after { content: " ✓"; color: var(--accent); }

#buy-confirm { display: flex; flex-direction: column; gap: .4rem;
  margin-top: 1rem; }
/* ปุ่มจ่าย และ "ลิงก์ไปหน้าชำระเงิน" ต้องหน้าตาเดียวกัน — จังหวะที่ผูกมัดจริง
   คือลิงก์อันหลัง ไม่ใช่ปุ่มอันแรก มันเคยใส่คลาส .mini-btn ตัวเดียวกับปุ่ม "ปิด"
   (วัดได้ 201x35px) แปลว่าที่จังหวะสำคัญที่สุด "จ่าย" กับ "ยกเลิก" น้ำหนักเท่ากัน
   และเตี้ยกว่ากฎ 44px ที่ไฟล์นี้เขียนไว้เอง 9px */
.buy-go, a.buy-go { display: block; width: 100%; text-align: center;
  text-decoration: none; font-family: inherit; font-size: var(--fs-lg); font-weight: 600;
  min-height: 48px; padding: .7rem 1rem; cursor: pointer; color: #fff;
  background: var(--accent); border: 1px solid var(--accent);
  border-radius: 8px; }
.buy-go:hover { filter: brightness(1.08); }
/* แถบความน่าเชื่อถือต้องอยู่ "ติดปุ่ม" — Baymard วัดว่าความรู้สึกปลอดภัยมาจาก
   ตำแหน่งที่วางไอคอน/ข้อความเทียบกับจุดที่ต้องตัดสินใจ ไม่ใช่จากการมี HTTPS */
.buy-trust { margin: 0; font-size: var(--fs-sm); color: var(--text-muted); }

/* ยอดคงเหลือคือสมอของหน้าจอนี้ ไม่ใช่ข้อความประกอบ — คนตัดสินใจว่าจะเติม
   เท่าไหร่จาก "ตอนนี้เหลือเท่าไหร่" กล่องซื้อเครดิตของ CapCut เปิดด้วยตัวเลข
   ยอดคงเหลือที่ใหญ่ที่สุดบนจอด้วยเหตุผลเดียวกัน เดิมตัวนี้เป็น .9rem สีจาง
   คือเล็กกว่าราคาบนการ์ดทุกใบ (1.05rem หนา) กลับหัวกับที่ตั้งใจไว้ */
.buy-balance { margin: 0 0 1rem; font-size: var(--fs-xl); font-weight: 700;
  color: var(--text); }
.buy-balance .buy-balance-lead { display: block; font-size: var(--fs-md);
  font-weight: 400; color: var(--text-muted); }
/* บรรทัดแยก "ซื้อไว้ / โควตาเดือนนี้" — เลขรวมข้างบนตอบไม่ได้ว่าอันไหนจะหาย */
.buy-balance .buy-balance-detail { display: block; margin-top: .2rem;
  font-size: var(--fs-md); font-weight: 400; color: var(--text-muted); }

/* ใบเสร็จย้อนหลังเป็นของที่ต้องแตะจริง ไม่ใช่ข้อความประดับ — วัดได้ 15px
   ทั้งหัวข้อที่กางและลิงก์แต่ละใบ เตี้ยกว่ากฎ 44px ของไฟล์นี้เกือบสามเท่า */
#buy-dialog details > summary { min-height: 44px; display: flex;
  align-items: center; cursor: pointer; }
#buy-receipt-list { margin: 0; padding-left: 1.1rem; }
#buy-receipt-list li { min-height: 44px; display: flex; align-items: center; }
#buy-receipt-list a { min-height: 44px; display: flex; align-items: center; }

/* ที่ว่างท้ายรายการ เท่าความสูงปุ่มจ่ายที่ลอยทับ — ไม่งั้นการ์ดใบสุดท้าย
   เลื่อนยังไงก็ไม่พ้นปุ่ม (วัดได้ว่าโดนบัง 48 จาก 60px ที่จอ 1280x720) */
#buy-prices { padding-bottom: 3.5rem; }
/* Why the dialog opened on its own — the refused tap's sentence, first thing
   in the box, in the warning tokens the rest of the app uses. */
.buy-why { margin: 0 0 .8rem; padding: .55rem .7rem; border-radius: 8px;
  font-size: var(--fs-md); color: var(--warn); background: var(--warn-soft);
  border: 1px solid var(--warn-border); }
/* ชิ้น katsu (spec 2026-10-01-katsu-pieces) — the unit's icon rides inline with
   whatever text names it: 1.1em tall, stroke = the text colour. */
.ck-piece { width: 1.15em; height: 1.15em; flex: none; vertical-align: -.22em;
  margin-right: .25em; }
.pack-n { display: inline-flex; align-items: center; }
/* The price badge on a paid button — icon + number, pushed to the right edge
   (Higgsfield's Generate button). Not a second button: no border of its own,
   tabular digits, never shrinks, so a long title wraps instead of the price. */
.piece-badge { margin-left: auto; flex: none; display: inline-flex; align-items: center;
  padding: .2rem .5rem; border-radius: 999px; background: var(--warn-soft);
  color: var(--warn); font-size: var(--fs-md); font-weight: 600;
  font-variant-numeric: tabular-nums; white-space: nowrap; }
.piece-badge .ck-piece { margin-right: .2em; }
/* The price list under the packs: what a piece buys, label left, number right. */
.piece-prices { display: grid; grid-template-columns: 1fr auto; gap: .25rem 1rem;
  margin: .3rem 0 .5rem; font-size: var(--fs-md); }
.piece-prices dt { color: var(--text); }
.piece-prices dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; color: var(--text); }
/* Three packs sit side by side once the dialog is wide enough for three
   44-px-tall cards with room to read; below that they stay stacked (CXL: 78%
   of mobile pricing pages stack). Pieces, price, bonus — centred, one column. */
@media (min-width: 520px) {
  #buy-dialog .pack-list { flex-direction: row; }
  #buy-dialog .pack { grid-template-columns: 1fr; justify-items: center; text-align: center;
    padding: .8rem .5rem; }
  #buy-dialog .pack-price, #buy-dialog .pack-save { grid-column: 1; grid-row: auto;
    justify-self: center; }
  #buy-dialog .pack[aria-checked="true"] { padding: calc(.8rem - 1px) calc(.5rem - 1px); }
}
/* ปุ่มจ่ายติดขอบล่างของกล่องที่เลื่อนได้ — Baymard วัดบนมือถือว่าคีย์บอร์ดกิน
   จอครึ่งหนึ่งในแนวตั้ง และวิธีแก้ที่เห็นผลคือปุ่มยืนยันที่ไม่หลุดจอไปกับการ
   เลื่อน ไม่ใช่การย่อรายการให้สั้นลง */
#buy-confirm { position: sticky; bottom: 0; background: var(--bg);
  padding-top: .6rem; }
/* ปุ่มปิดของกล่องนี้เป็นทางออกทางเดียว (คลิกฉากหลังไม่ปิด) จึงต้องได้ 44px
   เท่ากับของอื่นในเส้นทาง ไม่ใช่ 35px แบบ .mini-btn ทั่วไป */
#buy-close { min-height: 44px; min-width: 44px; }
/* ทางกลับไปเลือกแพ็กใหม่ — กดจ่ายผิดแพ็กแล้วเดิมต้องปิดกล่องเปิดใหม่ */
.buy-back { background: none; border: 0; padding: .5rem 0; min-height: 44px;
  font-family: inherit; font-size: var(--fs-base); color: var(--accent);
  text-align: left; cursor: pointer; }

/* กฎเก่า `#buy-plans { flex-wrap: wrap }` เคยอยู่ตรงนี้ — merge พามันกลับมา
   หลังกฎใหม่ จึงชนะ cascade และพาบั๊ก "สองกลุ่มยืนข้างกัน" กลับมาทั้งดุ้น
   ลบทิ้ง ตัวจริงอยู่ข้างบนบรรทัด #buy-plans { flex-direction: column } */

/* ===== MOBILE FIT (gauntlet round 1) ==================================== */

/* Drags own these surfaces. Without touch-action Safari runs its own pan/zoom
   in parallel with every pointer drag — the page scrolls while a cue is being
   trimmed. Harmless on desktop (mouse ignores touch-action). #tl-scroll still
   scrolls horizontally: the empty-space drag handler scrubs, which is the
   mobile-category gesture (fixed playhead, content moves) — native pan would
   fight it. */
#tl-scroll, #tl-resize, .cap, .text-overlay-el, .image-overlay-el.overlay { touch-action: none; }

/* Burn progress ON the preview (CapCut export screen: big % over the video).
   Desktop keeps the filling button instead — hidden entirely >=768px. */
.burn-overlay { position: absolute; inset: 0; z-index: 6; display: flex; flex-direction: column;
  align-items: center;
  /* `safe center` is the whole fix, and the plain keyword is the bug: with
     `justify-content: center`, overflowing flex content escapes past the START
     edge where it can never be scrolled back into view — the CSS Box Alignment
     spec's stated reason for the `safe` keyword. That is exactly what
     decapitated the mascot: measured face top 179 against overlay top 182.
     `safe` falls back to flex-start the moment the content does not fit. */
  justify-content: safe center; gap: .4rem;
  /* MEASURED at 1280x800: this column's content is 332px inside a 265px box.
     Centred and with overflow visible, the excess splits both ways and the
     FIRST child escapes upward, out of the overlay and under the preview frame
     — which is how the mascot came out decapitated. It was already 7px over
     before the mascot existed; adding a face only made a live overflow legible.
     `auto` keeps everything inside the box and reachable. */
  overflow-y: auto; overscroll-behavior: contain;
  background: rgba(0, 0, 0, .55); border-radius: inherit; }
.burn-overlay[hidden] { display: none; }
/* The clip, held on screen for the wait. Sized to leave room for the buttons
   under it rather than to a fixed height — a 9:16 reel at 60vh already fills a
   phone. */
.burn-thumb { height: 46vh; max-width: 72vw; border-radius: 8px;
  object-fit: contain; box-shadow: 0 10px 34px rgba(0,0,0,.45); }
.burn-thumb[hidden] { display: none; }
/* 1.75rem = 28px on a 375px phone. Sized from measured competitor export
   screens, not taste: CapCut ~28px and Meta's Edits ~24px both lay the
   percentage over the video thumbnail as a SECONDARY element, because the
   thumbnail is what the user is actually waiting for. 3.2rem shipped first
   and owned the screen — measured at a 375px viewport: font-size 51.2px,
   line box 66.5px, the string "45%" 111px wide = 47.7% of the overlay's own
   width (55.8% of `.preview`). That is the owner's "ตัวเลข % ใหญ่เกินไป".
   Apple HIG and Material 3 specify no numeric size at all (Material's
   progress component has no text slot; Carbon calls the percentage "helper
   text"), so the band comes from the competitors. Locked by
   web/tests/css_burn_overlay.test.js. */
/* Sized against the ✓ it stands above, not picked by eye: the percent readout
   is 1.75rem/700, so 56px reads as the same "one beat" of the overlay. */
/* 40px, not 56: the overlay's column is already over its box (see .burn-overlay),
   so the face has to earn its height. It REPLACES the ✓ glyph on success rather
   than stacking above it — the mascot is the success signal, and two of them is
   one too many. */
.burn-face { width: 40px; display: block; flex: none; margin: 0 auto; }
.burn-overlay.is-done .burn-overlay-pct { display: none; }
.burn-face[hidden] { display: none; }
.burn-overlay-pct { font-size: var(--fs-3xl); font-weight: 700; color: #fff; font-variant-numeric: tabular-nums; }
.burn-overlay-note { font-size: var(--fs-base); color: #e5e7eb; }
/* Base scope, NOT inside the phone block. These two are the only way a user
   gets their finished work off the server, and desktop now sees them. Locked
   by web/tests/css_burn_overlay.test.js. */
.burn-download, .burn-srt { font: inherit; font-size: var(--fs-base); font-weight: 600;
  color: #1d4ed8; background: #fff; border: none; border-radius: 999px;
  padding: .55rem 1rem; text-decoration: none; cursor: pointer; }
/* `font: inherit` is load-bearing, not tidiness: a <button> does NOT inherit
   font-family from body, and this file has no global button reset — every
   button class declares it individually. Without it the Thai label rendered
   in Arial next to an Anuphan pill (measured by a critic before shipping). */
/* Where the file landed, for the path that cannot reach Photos in one tap.
   Kapwing ships the same sentence; it is the only thing that answers "ผมเองยัง
   หาไม่เจอเลย" when the share sheet is unavailable. Sits under the buttons and
   wraps — it is a sentence, not a label. */
.burn-where { margin: .1rem 0 0; max-width: 30ch; text-align: center;
  font-size: var(--fs-md); line-height: 1.6; color: rgba(255,255,255,.82); }
.burn-where b { color: #fff; font-weight: 600; }
/* The captions are the second choice here — the video is what was just made. */
.burn-srt { background: rgba(255,255,255,.16); color: #fff; }
/* Desktop hides the overlay WHILE burning — the filling button already tells
   that story on a big screen. It must NOT hide the success state: the only
   control that pulls the finished mp4 off the server (#burn-download) lives
   in here, and a blanket hide left every desktop user with no way to get
   their video. That was invisible while output_dir() defaulted to the
   operator's own ~/Downloads (the file just appeared on their disk); on the
   deployed server it lands on the VPS instead. Owner, 2026-08-13: "ตอนนี้
   เวลาใช้ desktop burn เสร็จไฟล์ก็ไม่อยู่ใน download".
   `.is-done` is set by showBurnDownload and cleared by setBurnProgress. */
@media (min-width: 768px) { .burn-overlay:not(.is-done) { display: none; } }
/* The success state must sit ABOVE #btn-expand-preview (z-index 7), which is
   a sibling and covers the overlay's own ✕ exactly: a critic probed 25 points
   over the close button on a phone and reached it at 0 of them, so the
   success overlay could not be dismissed by its own control. */
.burn-overlay.is-done { z-index: 8; }

/* Fullscreen preview — EVERY width, not phone-only (owner 2026-08-20: "ใน
   desktop ผมอยากให้มีขยายหน้าจอเหมือนกับ mobile ด้วย").
   The button and its handler always existed; this rule and the two below were
   written inside the max-width:767px block, so on a desktop the button was
   display:none and the two state rules could not match either. Enabling only
   the button would have shipped a control that does nothing — the exact defect
   this file keeps re-learning.
   Moved to top level rather than copied into a desktop block: two copies of a
   rule are two things that drift, and the 9:16 note below was bought with a
   real-device bug. */
.preview-expand { display: grid; place-items: center; position: absolute;
  top: .5rem; right: .5rem; z-index: 7; min-width: 42px; height: 42px;
  padding: 0 .6rem; border: none; border-radius: 999px;
  background: rgba(0, 0, 0, .45); color: #fff; font-size: var(--fs-lg); cursor: pointer; }
/* ต้นฉบับ shares .preview-expand with ⛶ — both absolute at the same top/right,
   so the wider ต้นฉบับ pill painted OVER the expand button (measured: expand
   x=131 w=42, ต้นฉบับ x=103 w=70, same y). Park it to the LEFT of ⛶: the
   expand button's own 42px plus one gap, in the same tokens the rule above
   uses. Only this button moves, so ⛶ stays where every other screen has it. */
#btn-show-original { right: calc(.5rem + 42px + .4rem); font-size: var(--fs-sm); }
body.pv-full .canvas { position: fixed; inset: 0; z-index: 80; margin: 0;
  padding: 0; background: #000; display: flex; align-items: center;
  justify-content: center; }
/* Strictly 9:16 even fullscreen. `height:100dvh + max-width:100vw` broke the
   aspect box into 390x844 — captions were then dragged against a frame TALLER
   than the real video and snapped elsewhere on exit ("กด confirm รูปเล็กไม่ใช่
   ตำแหน่งเดียวกัน", real iPhone). min() keeps the box the largest true 9:16
   that fits, so a margin means the same spot at every preview size. */
body.pv-full .preview {
  height: min(100vh, calc(100vw * 16 / 9));
  height: min(100dvh, calc(100vw * 16 / 9));
  width: auto; max-width: 100vw; border-radius: 0; }

/* Touch twins of the split/merge shortcuts — desktop keeps only the kbd hints. */
.transcript-touch-keys { display: flex; gap: .5rem; padding: .5rem .6rem 0; }
.transcript-touch-keys .mini-btn { flex: 1; min-height: 44px; }
@media (min-width: 768px) { .transcript-touch-keys { display: none; } }

@media (max-width: 767px) {
  /* The page itself does not pan sideways. CONTAINMENT, not a diagnosis — say
     so plainly: measured from the owner's iPhone with ?diag=1 on three separate
     reports, `scale peak 1.00` every time (the page never zoomed) with 25px of
     overflow and the page scrolled 25px right, which is what cut the stepper
     and the clip name off on the LEFT. Chromium reproduces none of it, in any
     state including mid-edit with the caption sheet open, so whatever lays out
     25px wider is something only Safari does and local measurement has not
     found it.
     Refusing the pan costs nothing here: everything in this app that is
     deliberately wider than the screen — the timeline, the action strips —
     carries its own scroller, so nothing becomes unreachable. It removes a
     symptom the owner has now hit four times while the hunt continues.
     `overflow-x: clip` would be the modern choice, but it is not safe next to
     the position:fixed sheets and overlays this layout depends on; `hidden`
     creates a scroll container that those are already living with. */
  html { overflow-x: hidden; }
  /* body ห้าม hidden: มันจะกลายเป็น scroll container ที่ไม่เคยเลื่อนจริง แล้ว
     position:sticky ทุกตัวข้างในไปยึดกับมันแทน window = ไม่ติดเลย (พิสูจน์สด
     2026-08-24: แถวค้นหา sticky ติดทันทีที่เปลี่ยนเป็น visible) การกันล้นแนวนอน
     ยังอยู่ครบที่ html ตัวเดียว */
  body { overflow-x: visible; }
  /* The rail reborn as a bottom tab bar. Below 560px it used to be
     display:none outright, which severed the ONLY doors to the transcript
     list and Home. Three tabs survive: Home, คำบรรยาย, รีวิว AI — everything
     else is a desktop power feature or is switched off by CLIP_ONLY. (Home
     used to carry tool cards that opened the rest; the clip grid replaced
     them.) Home no longer overlays this bar — it is a sibling page inside the
     shell, so the bar stays on screen there too and Home must reserve room for
     it (below). */
  /* The desktop rail-GROUPS dissolve here: the tab bar lays out the rail's
     ITEMS in a row, and wrapping them in group divs (added 2026-08-20 for the
     desktop separators) stacked the three tabs into a vertical column at the
     bottom of the phone screen — visible in the owner's own screenshot before
     it was caught. display:contents keeps the grouping markup for desktop and
     makes it invisible to this layout. */
  .rail-group { display: contents; }
  .rail-group + .rail-group { margin-top: 0; padding-top: 0; border-top: none; }
  .rail { display: flex; position: fixed; left: 0; right: 0; bottom: 0; z-index: 60;
    flex-direction: row; justify-content: space-around; align-items: stretch;
    background: #fff; border-top: 1px solid var(--border);
    padding: .1rem .5rem calc(.1rem + env(safe-area-inset-bottom)); }
  /* Reserve the bar's own height under Home's content. Measured 48px tall and
     fixed to the bottom; Home's generic 1.5rem padding left only a 29px gap
     below the last card, which clears today only because of where this
     library's content happens to end. Reserve the bar, not a coincidence. */
  .start-screen { padding-bottom: calc(48px + 1.5rem + env(safe-area-inset-bottom)); }
  /* The bar STAYS while a sheet is open. Hiding it (tried, CapCut-style) was
     reverted the same day: the owner's first real-device reaction was "เมนู
     หายไป จะกดไปอีกหน้าทำไม่ได้" — tab-to-tab switching matters more here
     than the ~50px of video it costs, because the tabs are how the three
     sheets swap. */
  .rail-brand, .rail-soon, .rail-item[aria-current],
  /* #btn-logout is NOT in this list. It used to be, and that was survivable
     only because Home carried a proxy button — Home painted over the rail, so
     the rail's own logout was unreachable anyway. Home is a sibling page now
     and the proxy is gone, which left a phone with no way out of the wrong
     account at all: measured display:none at 375px with nothing else bound to
     signOut. One button, reachable at every width, beats a second button that
     has to be kept in sync. */
  /* The bar carries DESTINATIONS only. Three items came out, each for its own
     reason, and all three stayed reachable somewhere better:
       · #btn-home — leaving the clip is not a peer of switching panels, and a
         mis-tap abandoned the work. Now the ‹ at the top left of what you are
         leaving (#btn-back).
       · #btn-logout — one tap ended the session, same size, right next to the
         controls used every minute. Now with the account settings, and it asks.
       · #btn-review-toggle — not in the MVP at all (owner: "ซ่อนไปก่อน ยังไม่
         เปิดใน MVP"), so it was spending a slot on nothing.
     Two left: คำบรรยาย and ปรับแต่ง — which is what the owner said he actually
     reaches for. Fewer, bigger, and none of them can lose his work. */
  #btn-home, #btn-logout, #btn-review-toggle { display: none; }
  /* #btn-account rides this list too: it is only a DOOR to #btn-keys/#btn-cost/
     #btn-logout, and all three are hidden here by the destinations-only rule —
     measured at 445px: the door showed, and opened onto an empty 32px box. A
     door to a room this bar does not carry is worse than no door. */
  #btn-glossary, #btn-carousel, #btn-assets, #btn-keys, #btn-cost, #btn-account { display: none; }
  /* Back sits where iOS puts back: the top-left of the view being left. */
  .btn-back { display: block; order: -1; width: 40px; min-height: 40px; margin-left: -.25rem;
    border: none; background: none; color: var(--accent); font-size: var(--fs-2xl);
    line-height: 1; cursor: pointer; font-family: inherit; }
  /* Sign-out reads as what it is — a way out, not an action to reach for. */
  /* Sign-out now sits in the Home header beside "＋ คลิปใหม่", not at the floor
     of a step panel (owner: keep it out of steps 1-4). So it is sized as a
     header control, not a full-width panel row — and deliberately quieter than
     the primary button next to it: muted text, no fill, so the eye still lands
     on "new clip". 44px min stays; it is the iOS touch floor. */
  .logout-m { display: inline-flex; align-items: center; min-height: 44px;
    padding: 0 .7rem; border: 1px solid var(--border); border-radius: 8px;
    background: none; font-family: inherit; font-size: var(--fs-sm);
    color: var(--text-muted); cursor: pointer; white-space: nowrap; }
  .home-brand { display: block; height: 20px; width: auto; margin-bottom: .35rem;
    flex: 1 1 100%; align-self: flex-start; object-fit: contain; object-position: left; }
  /* sticky ต้องมีพื้นทึบ — กับดักแถบโปร่งเคยชิปมาแล้ว (CLAUDE.md) */
  .home-tools-row { position: sticky; top: 0; z-index: 20;
    background: var(--bg-card, #fff); padding: .5rem 0; margin-top: .6rem; }
  .home-grid { gap: .5rem; grid-template-columns: repeat(auto-fill, minmax(92px, 1fr)); }
  .home-badge { font-size: var(--fs-xs); }
  /* การ์ดมือถือ: padding 1.6/1.5rem + จอ กิน 98px เหลือกริดแค่ 277px = 2 คอลัมน์
     ลดขอบลงให้กริดได้ ~330px → 3 คอลัมน์ (~105px/ใบ) เท่า mockup ที่กรรมการผ่าน */
  /* base .start-screen เป็น overflow:auto (desktop เลื่อนข้างใน) แต่บนมือถือหน้า
     เลื่อนที่ window — ถ้าปล่อย auto ไว้ sticky จะยึดกับกล่องที่ไม่เคยเลื่อน = ไม่ติด
     (เจอจากการเลื่อนจริง ไม่ใช่จากอ่านโค้ด) */
  .start-screen { padding-left: .5rem; padding-right: .5rem; overflow: visible; }
  .start-card { padding-left: .75rem; padding-right: .75rem; }
  .rail-item { display: flex; flex-direction: column; align-items: center; justify-content: center;
    gap: .1rem; flex: 1; min-height: 44px; font-family: inherit; font-size: var(--fs-xs);
    color: var(--text-muted); background: none; border: none; border-radius: 8px;
    padding: .15rem .2rem; cursor: pointer; }
  .rail-item svg { width: 19px; height: 19px; }
  .rail-item.active { color: var(--ink); }
  .rail-dot { top: 2px; right: calc(50% - 14px); }

  /* The bottom sheets dock on top of the tab bar rather than underneath.
     One height for all three sheets AND the preview formula below — change it
     in one place or the frame and the sheet drift apart. */
  /* dvh, not vh: on iPhone Safari vh includes the space BEHIND the bottom URL
     bar (~100px), so a 52vh sheet overflowed the visible screen — reported as
     "เมนูขึ้นมาบังหน้าจอ" on a real device. Chromium emulation could never
     catch this (dvh == vh there). The vh line stays as fallback for engines
     without dvh. */
  /* svh, not dvh: dvh can re-derive while the iOS keyboard animates and the
     preview keeps the mid-animation height — the "ratio เพี้ยน" the owner saw
     after dismissing the keyboard. svh never moves. */
  /* --tabbar-base is the bar's real height and never moves (the preview's
     height is computed from it, so the video does not jump while the bar
     slides); --tabbar-h is where sheets sit, and drops to the safe area
     while the bar hides on scroll-down (2026-10-07). */
  :root { --sheet-h: 44vh; --sheet-h: 44svh;
    --tabbar-base: calc(47px + env(safe-area-inset-bottom));
    --tabbar-h: var(--tabbar-base); }
  body.tabbar-hidden { --tabbar-h: env(safe-area-inset-bottom); }
  .rail { transition: transform .2s ease; }
  body.tabbar-hidden .rail { transform: translateY(100%); }
  /* แผ่นของขั้นปรับแต่งสูงกว่าอีกสองแผ่น และมีตัวแปรของตัวเอง
     ผลกรรมการเอกฉันท์ 3-0 (2026-08-21): คงทรงแผ่นซ้อนทุกประตูเหมือนกันหมด
     แต่ขยายความสูง — ที่ 44svh (355px) แถวประตูกิน 156px + ปุ่มถัดไป 45px
     เหลือที่คอนโทรลจริง 154px คือ 43% ของแผ่น
     ห้ามไปขยับ --sheet-h เพราะแผ่นรายการคำบรรยายกับแผ่นรีวิว AI ใช้ค่าเดียวกัน
     อยู่ กรรมการรัศมีความเสียหายเตือนข้อนี้ไว้ตรง ๆ
     ไม้บรรทัดคือ Captions.ai iOS แท็บ Color ที่ให้คอนโทรลราวสองในสามจอ เหลือ
     วิดีโอไว้ราวหนึ่งในสามบน — **แต่ลอกความสูงนั้นมาตรง ๆ ไม่ได้ วัดแล้ว**
     บนจอ 812px ของแอปนี้ หัวบวกแถบขั้นตอนกิน 151px ก่อนวิดีโอจะเริ่ม วิดีโอเอง
     สูง 236px จบที่ 387 ถ้าแผ่นสูง 66svh (534px) ขอบบนแผ่นจะอยู่ที่ 229 =
     เห็นวิดีโอแค่ 78px และที่ถูกบังคือ "ครึ่งล่างของเฟรม" ซึ่งเป็นที่ที่คำบรรยาย
     อยู่พอดี — ปรับสีแล้วมองไม่เห็นผล ซึ่งทำลายเหตุผลข้อเดียวที่แผ่นซ้อนชนะ
     จอเต็มตั้งแต่แรก
     46svh (374px) คือค่าที่สูงที่สุดที่ขอบบนแผ่น (391) ยังอยู่ต่ำกว่าก้นวิดีโอ
     (387) = เห็นเฟรมครบทั้งเฟรม ตัวเลขนี้มาจากการวัด ไม่ใช่การเลือกให้สวย
     ที่ได้คืนมาจริงรอบนี้คือปุ่มถัดไปออกไปนอกตัวเลื่อน (คืนพื้นที่ 45px) และ
     แถบหมวดปูพื้นถึงขอบ ไม่ใช่การขยายแผ่น
     ห้ามไปขยับ --sheet-h เพราะแผ่นรายการคำบรรยายกับแผ่นรีวิว AI ใช้ค่าเดียวกัน
     อยู่ กรรมการรัศมีความเสียหายเตือนข้อนี้ไว้ตรง ๆ */
  :root { --look-sheet-h: 46vh; --look-sheet-h: 46svh; }
  .transcript-panel, .review-chat { bottom: var(--tabbar-h); }
  /* The cue list owns the sheet: fixed height (not max-) so #transcript-rows'
     flex:1 always gets the leftover — it had shrunk to an 88px peephole
     showing one cue of thirty. */
  .transcript-panel { height: var(--sheet-h); }

  /* One-screen shell (CapCut / Instagram Edits): preview pinned, timeline
     docked, NOTHING scrolls. Before this the mobile editor was a ~2,400px
     scrolling document, so preview and timeline were never on screen together
     and every timeline drag was done blind. dvh over vh where supported: vh
     ignores Safari's collapsing URL bar. */
  .workspace { display: flex; flex-direction: column; height: 100vh; height: 100dvh;
    padding-bottom: calc(56px + env(safe-area-inset-bottom)); }
  header, .step-strip { flex-shrink: 0; }
  main { flex: 1; min-height: 0; overflow: hidden; padding: .5rem .9rem; }
  .canvas { flex: 1; min-height: 0; display: flex; align-items: center;
    justify-content: center; width: 100%; }
  .preview { height: 100%; width: auto; max-width: 100%; }
  .timeline-wrap { flex-shrink: 0; --lane-h: 40px; }

  /* The settings/journey panel becomes the third bottom sheet, opened by the
     ปรับแต่ง tab. Same dock geometry as the other two. Visibility rides the
     `hidden` ATTRIBUTE (syncPanelVisibility in studio.js), not a display rule:
     the global [hidden]{display:none!important} is the one mechanism this
     codebase has proven always wins the cascade. */
  .panel { position: fixed; left: 0; right: 0; bottom: var(--tabbar-h);
    z-index: 20; max-height: var(--sheet-h); overflow-y: auto; max-width: none;
    /* contain: แผ่นที่เลื่อนได้สั้น (โหมดปกติเหลือ ~7px) บน iOS จะส่งนิ้วต่อไป
       rubber-band หน้าหลังทันทีที่ชนสุด = ผู้ใช้รู้สึกว่า "เลื่อนไม่ได้เลย" */
    overscroll-behavior: contain;
    /* ที่เผื่อท้ายแผ่นให้แถบล่าง (ถัดไป) ของ "ทุกขั้น" — เดิมมีเฉพาะขั้นปรับแต่ง
       พอทุกขั้นได้ปุ่ม ถัดไป (#step-footer, 6 ขั้น 29 ก.ย.) แผ่นขั้นอื่นไม่ได้
       เผื่อ: วัดที่ 375x812 ปุ่มสูง 62px ทับปุ่มหลักของขั้น 1/4/5 ไป 72% และทับ
       "ตัดแค่ช่วงเงียบ" ทั้งปุ่ม เลื่อนหนีไม่ได้เพราะเนื้อหาพอดีแผ่น (เจ้าของ
       2 ต.ค.: "ปุ่มถัดไปบังทุกอย่าง") ไม่มีแถบ = วัดได้ 0 = กลับไป 1rem เดิม */
    padding-bottom: max(1rem, calc(var(--bottom-bar-h, 68px) + .5rem));
    border-radius: 8px 12px 0 0; box-shadow: 0 -8px 24px rgba(0,0,0,.08); }
  /* ที่เผื่อให้แถบล่างต้องเป็นค่าที่ "วัดมา" ไม่ใช่ค่าคงที่ — เหตุผลเดียวกับ
     .sweep-toast ข้างล่างเป๊ะ ๆ และมันเคยพลาดตรงนี้: 68px เขียนตายตัวไว้ ขณะที่
     .look-footer สูงจริงตามเนื้อหา (.5rem + ปุ่ม ~40px + .5rem) บวก safe-area
     ที่บวกเข้ามาสองที่บน iPhone มีติ่ง
     จำลองบนจอ 375x812 (tabbar 47->81, padding แถบ +34): แถบสูงจริง 98px แต่
     เผื่อไว้ 68px = คอนโทรลตัวท้ายของประตู "ตัวอักษร" จมใต้แถบ 29px เลื่อนไป
     สุดแล้วก็ไม่โผล่ = อาการ "บาง module scroll ลงมาไม่ได้"
     บาง module เท่านั้นเพราะประตูสั้น (สไตล์ จังหวะ) ปลายทางไม่ตกลงไปในโซนนั้น
     ค่าสำรอง 68px ไว้สำหรับเฟรมแรกก่อน ResizeObserver วัดเสร็จ */
  .panel[data-step="look"] { max-height: var(--look-sheet-h);
    padding-bottom: calc(var(--bottom-bar-h, 68px) + .5rem); }
  /* ปุ่มถัดไปออกจาก "ข้างในตัวเลื่อน" มาเป็นแถบล่างของแผ่นจริง ๆ
     sticky ยังอยู่ในสายไหลของเนื้อหา จึงลอยทับไทล์กลางทางเสมอ (เจ้าของ: "แถบ
     ถัดไปมันบัง") fixed ที่ขอบล่างเดียวกับแผ่นทำให้มันไม่เคยทับอะไร และ
     padding-bottom ด้านบนคือที่ที่จองไว้ให้เนื้อหาเลื่อนพ้น
     ไม้บรรทัด: Captions.ai วางแถบ Cancel/Style/Apply ไว้นอกพื้นที่เลื่อนทั้ง
     แท็บ Templates และ Color
     ซ่อนพร้อมแผ่นเอง เพราะมันอยู่ใน #style-group ที่ journey สั่ง hidden */
  /* โทสต์ทุกตัวยืนบนแถบล่างที่ขั้นนั้นแสดงอยู่ ไม่ใช่ทับมัน — วัดที่ 375x812
     ก่อนแก้: โทสต์ y 704-755 แถบล่าง y 700-764 ทับกันทั้งแถบ ปุ่ม ข้าม/ต่อไป
     ถูกบังหมด (Material ระบุว่า snackbar ต้องอยู่เหนือ bottom navigation เสมอ)
     --bottom-bar-h มาจาก ResizeObserver ที่วัดแถบล่างจริง ไม่ใช่ค่าคงที่:
     ค่าคงที่พังบน iPhone เพราะ safe-area บวกเข้ามาสองที่ ทั้งใน --tabbar-h
     และใน padding-bottom ของแถบล่างเอง แถบซ่อนอยู่ = สูง 0 = โทสต์กลับที่เดิม */
  .sweep-toast { bottom: calc(var(--tabbar-h) + var(--bottom-bar-h, 0px) + .5rem); }
  .look-footer { position: fixed; left: 0; right: 0; bottom: var(--tabbar-h);
    margin: 0; padding: .5rem .8rem;
    padding-bottom: calc(.5rem + env(safe-area-inset-bottom));
    border-top: 1px solid var(--border);
    box-shadow: 0 -8px 16px -8px rgba(0,0,0,.22); }
  #look-next { border-radius: 10px; padding: .7rem 1rem; }
  .panel-close { position: sticky; top: -1rem; align-self: flex-end; z-index: 2;
    margin: -.5rem -.4rem 0 0; width: 40px; height: 40px; border: none; border-radius: 50%;
    background: var(--panel-bg); color: var(--text-muted); font-size: var(--fs-lg); cursor: pointer; }

  /* ── ชีตมือถือ: ประตูห้าบานกลายเป็นแถวไอคอน ตามแบบที่ผ่านกรรมการ ──────
     ที่นี่แผงกว้างเต็มจอ (position:fixed left:0 right:0) ห้าช่องจึงได้ช่องละ
     ~78px บนจอ 390px พอสำหรับไอคอน + ชื่อหมวด + ค่าปัจจุบัน
     ปักบนสุดของชีต ไม่เลื่อนตามเนื้อหา — วัดบน mockup ได้ 52/52px ทุกบาน
     เทียบกับประตูเดิมที่โผล่ 11px
     top ไม่ใช่ 0: .panel-close เป็น sticky อยู่เหนือมันในชีตเดียวกัน */
  /* top/margin/padding ต้องเขียนซ้ำที่นี่ ไม่งั้นค่า top: 0 เดิมของบล็อกนี้จะทับ
     top: -1rem ของกฎฐาน แล้วช่องโหว่ 16px กลับมาเฉพาะบนมือถือ — ซึ่งเป็นจอเดียว
     ที่เจ้าของเห็นบั๊กนี้ */
  .look-cats { flex-direction: row; gap: 2px; top: -1rem;
    margin: -1rem -1rem 0; padding: 1rem 1rem .3rem; }
  .look-cat { flex: 1 1 0; min-width: 0; flex-direction: column; align-items: center;
    justify-content: center; gap: 3px; padding: .4rem .15rem; text-align: center; }
  .look-cat-ico svg { width: 20px; height: 20px; }
  .look-cat-txt { flex-direction: column; align-items: center; justify-content: center;
    gap: 1px; width: 100%; }
  .look-cat-name, .look-cat-val { max-width: 100%; overflow: hidden;
    text-overflow: ellipsis; }
  /* ชื่อประตูขึ้นสองบรรทัดได้ที่นี่ ไม่ตัดหาย: วัดที่ 320px ประตูกว้าง 56px
     เหลือที่ให้ชื่อ 51px แต่ "ขอบ/กล่อง" ต้อง 55px และ "ภาษาที่สอง" ต้อง 57px
     สองบานจากห้าจึงถูกตัดเป็น "ขอบ/ก…" "ภาษาที…" (gyo-review 2026-08-21)
     ชื่อประตูคือตัวบอกว่าประตูบานนี้คืออะไร ตัดทิ้งไม่ได้ · ค่าที่อยู่ข้างใน
     (.look-cat-val) ยังตัดด้วย … ได้ตามเดิม เพราะมันคือคำตอบ ไม่ใช่หัวข้อ
     ไทยไม่มีช่องว่างระหว่างคำ แต่ Chrome ตัดบรรทัดไทยตามพจนานุกรมให้เอง —
     วัดด้วย Range.getClientRects() ที่ 320px ได้ "ภาษา|ที่" / "สอง" และ
     "ขอบ/" / "กล่อง" ทั้งคู่เป็นขอบคำจริง ไม่ใช่กลางคำ ลอง <wbr> คั่นเองแล้ว
     ความสูงเท่าเดิมเป๊ะ (27.59px ทั้งสองแบบ) = มันไม่ได้ทำอะไร จึงถอดออก */
  .look-cat-name { white-space: normal; line-height: 1.15; }

  /* Compact top bar (CapCut keeps only a slim strip: picker + small Export).
     The two-row header with a full-width burn pill read as "ใหญ่เกิน" on the
     real phone — one tight row now. */
  header h1, #mode-badge { display: none; }
  header { padding: .4rem .7rem; gap: .4rem; flex-wrap: nowrap; }
  /* The burn group NEVER leaves the screen: header-left is the only side
     allowed to shrink. Found on prod, invisible locally — the logged-in
     header carries #quota-pill (owner mode never shows it), which pushed the
     burn button past the right edge: "กด burn ไม่ได้ ปุ่มหาไม่เจอ". Quota
     already lives on Home (#home-quota); the ? walkthrough is desktop-taught
     anyway — both yield their pixels to the one button that ships the clip. */
  .header-left { flex: 1 1 auto; min-width: 0; gap: .35rem; align-items: center; }
  .header-right { flex: 0 0 auto; }
  /* The clip picker and the upload button leave too (owner, 2026-08-20:
     "พวก demo upload ผมว่าไม่จำเป็น เกะกะ") — and they were already redundant
     here: on the phone the ‹ back leads Home, and Home is where clips are
     chosen and uploaded (#home-new, the clip grid). The header keeps exactly
     what works on THIS clip: back, undo/redo, and เบิร์น. Mid-clip footage
     re-upload is #video-up-repick inside the workspace, not this button, so
     nothing becomes unreachable. */
  #quota-pill, #btn-help, #clip-picker, .upload-btn { display: none; }
  .btn-export { font-size: var(--fs-md); padding: .32rem .7rem; white-space: nowrap; }
  /* ปุ่มโหลดบนมือถือ: ไอคอนอย่างเดียว (เจ้าของ 2026-08-24 "ปุ่มโหลดใหญ่เกินไป")
     วัดที่ 375px ก่อนแก้: กลุ่มปุ่มกว้าง 101.4px = 27% ของความกว้างจอ
     ตัดคำว่า "โหลด" ออกเหลือ 66.9px = 18% — แบบเดียวกับที่ Instagram/TikTok
     ใช้กับปุ่มหลักบนแถบบน

     ซ่อน "เฉพาะตอน idle" เท่านั้น: "โหลดแล้ว ✓" กับ "ทำใหม่ ↻" บอกสถานะที่
     ไอคอนดาวน์โหลดอันเดียวบอกไม่ได้ — ว่าเบิร์นไปแล้ว หรือไฟล์ที่มีอยู่เก่าไป
     แล้วเพราะเพิ่งแก้คำบรรยาย สองสถานะนั้นจึงยอมกินที่กว้างเท่าเดิม
     ตัวตัดสินคือ data-cta (steps.js:burnCta.state) ไม่ใช่ข้อความในป้าย

     ความสูงเพิ่มเป็น 44px: ของเดิมวัดได้ 31.7px ซึ่งต่ำกว่าเป้านิ้วขั้นต่ำ
     44pt ของ Apple HIG อยู่แล้ว — งานนี้ลดความกว้าง ไม่ใช่ลดพื้นที่กด */
  #btn-burn #burn-label { display: none; }
  #btn-burn { padding: .32rem .6rem; }
  /* done/stale ยังต้องอ่านออก แต่ไม่ต้องกินที่เท่าประโยคเต็ม: วัดที่ 375px
     ถ้าโชว์ป้ายเต็ม "โหลดแล้ว ✓" กว้าง 138.7px = 37% ของจอ ซึ่ง *กว้างกว่า*
     ของเดิมที่เจ้าของบอกว่าใหญ่เกินไปเสียอีก (27%) เหลือแค่เครื่องหมายพอ
     ตัวตัดสินคือ data-cta ตัวเดียวกับที่ซ่อนป้าย — ไม่มีแหล่งที่สองมาเถียง */
  #btn-burn[data-cta="done"]::after { content: "✓"; }
  #btn-burn[data-cta="stale"]::after { content: "↻"; }
  /* สูงเท่าปุ่ม undo/redo ในแถวเดียวกัน (เจ้าของ 2026-08-24 พร้อมสกรีนช็อต
     แถบหัว: "ความสูงปุ่มมันควรจะเท่าปุ่ม undo") ก่อนแก้ undo 32px ปุ่มโหลด 44px
     — ผมตั้ง 44 ไว้เองตอนย่อความกว้าง โดยอ้างเป้านิ้ว 44pt ของ Apple แต่
     ปุ่มในแถวเดียวกันต้องเป็นชุดเดียวกันก่อน (sibling shape language) และ 32px
     ยังผ่าน WCAG 2.5.8 AA ซึ่งขั้นต่ำคือ 24x24 CSS px
     อ่านจากตัวแปรเดียวกับ .icon-btn — เลข 32 ที่เขียนสองที่คือค่าที่รอวันแยกกัน */
  .burn-group .btn-export { min-height: var(--hdr-btn-h); }
  .workspace { padding-bottom: var(--tabbar-base); }

  /* Burn success on the phone: ✓ + pull-the-file link, dismissible.
     The pill styling lives at base scope now (desktop shows this state too).
     A phone-only copy used to sit here with the identical values — deleted,
     because two rules that agree today are a drift waiting to happen. */
  /* Export takes the whole phone (owner 2026-08-14, with CapCut's export
     screens as the reference). It used to be clipped to `.preview` — a ~300px
     box — so the one moment the user actually waits through was the smallest
     thing on the screen, with the tab bar and timeline still competing for
     attention around it.
     `inset: 0` on a FIXED element, so the iOS URL bar cannot cut the bottom off
     the way a `height: 100vh` would. z-index clears the tab bar (60) and both
     bottom sheets; `border-radius: inherit` from the base rule has to go or the
     full-screen surface keeps the preview's rounded corners. */
  /* BOTH selectors, and `.is-done` is not decoration here: `.burn-overlay.is-done`
     (0,2,0) is declared at base scope with `z-index: 8` and OUTRANKS a plain
     `.burn-overlay` (0,1,0) no matter how far down the file this sits — media
     queries add no specificity. Measured with only the plain selector: the
     success screen computed z-index 8 and the tab bar (60) and coach mark (65)
     painted straight through a "full-screen" takeover. Third specificity trap
     of the day; the fix is always to MATCH the selector, never to move the rule. */
  .burn-overlay, .burn-overlay.is-done { position: fixed; inset: 0; z-index: 200;
    gap: .7rem; background: #0b1020; border-radius: 0;
    padding: calc(1rem + env(safe-area-inset-top)) 1rem
             calc(1rem + env(safe-area-inset-bottom));
    /* Measured at 375x667 (the shortest phone still in use) the stack ends at
       596px against 667 — it fits. `auto` is insurance for the cases the
       measurement cannot cover: a long out_name wrapping the note, a larger
       system font, or a future fourth button. Without it there is no fallback
       at all and the last control is simply unreachable, which on a screen with
       no other exit is the difference between cramped and stuck.
       `safe center` because plain `center` on an overflowing flex column pushes
       the first child ABOVE the scroll origin, where no scrollbar can reach it —
       the overflow rule would then hide the ✕ instead of rescuing it. */
    overflow-y: auto; justify-content: safe center; }
  /* Freed from the 300px box, the clip can be the subject the research in
     web/tests/css_burn_overlay.test.js says it should be. The percentage does
     NOT grow with it — measured off the owner's own CapCut screenshot, their
     "29.5%" is ~27pt on a 393pt device, still inside the 1.5-2rem band. */
  .burn-thumb { height: 42vh; }
  .burn-overlay-close { position: absolute; top: calc(.5rem + env(safe-area-inset-top));
    right: .6rem; width: 44px; height: 44px;
    border: none; border-radius: 50%; background: rgba(255,255,255,.18); color: #fff;
    font-size: var(--fs-lg); cursor: pointer; }

  /* Status painted OVER the stepper at 390px (position:absolute right) —
     give it its own centered line instead. justify-content:center on an
     overflowing flex container CLIPS both ends unreachably (the classic
     flexbox bug — first/last step were cut off with no way to scroll to
     them); flex-start + margin:auto centers when it fits and scrolls from
     the left edge when it doesn't. */
  .step-strip { flex-wrap: wrap; justify-content: flex-start; }
  .stepper { margin: 0 auto; }
  /* All four steps must FIT the phone width — the desktop geometry (88px
     columns + 42px connectors = 478px) overflowed a 375-390px screen and put
     ขั้น 3 แปล / 4 เบิร์น behind an invisible horizontal scroll ("ไม่โชว์
     ขั้น 3 แปล", real device). Columns shrink to their labels and the
     connectors absorb the leftover, so every width ≥ ~320px shows all four. */
  .stepper { width: 100%; }
  .step-col { width: auto; flex: 0 1 auto; min-width: 0; }
  .step-line { flex: 1 1 8px; width: auto; min-width: 6px; }
  .step-label { font-size: var(--fs-xs); }
  .step-strip #status { position: static; max-width: 100%; flex-basis: 100%;
    text-align: center; margin-top: .2rem; }

  /* Edit mode (a bottom sheet is open): CapCut's batch-edit shape — page
     scroll locked, chrome out of the way, and the WHOLE 9:16 frame shrunk to
     fit above the sheet so the caption being edited is visible on the video. */
  .transcript-panel, .review-chat { max-height: var(--sheet-h); }
  body.sheet-open { overflow: hidden; }
  /* step-strip STAYS while a sheet is open — hiding it (round 2, to buy the
     frame ~90px) cost the doors to แปล/เบิร์น: "คุณซ่อน step ไว้ มันเลยกด
     ทั้งแปล eng และ burn ไม่ได้" (owner, real device). Visible steps beat a
     bigger frame. */
  body.sheet-open .ai-activity { display: none; }
  /* While a sheet is up the frame parks at the TOP of the stage (not centered
     behind the sheet) and shrinks to the room the sheet leaves. */
  body.sheet-open .canvas { align-items: flex-start; }
  body.sheet-open .preview {
    height: calc(100vh - var(--sheet-h) - var(--tabbar-base) - 172px);
    height: calc(100svh - var(--sheet-h) - var(--tabbar-base) - 172px);
    width: auto; max-width: 100%; }

  /* Timeline: the 104px label gutter was 27% of a 390px screen — icons only.
     The 9px resize strip is un-grabbable by touch; 22px is. */
  .tl-grid { grid-template-columns: 44px 1fr; }
  .tl-lane-label { padding: 0; justify-content: center; }
  .tl-lane-label span { display: none; }
  .tl-resize { height: 22px; }

  /* The top progress bar carries upload/transcribe/burn on a phone — 3px is
     a desktop whisper; 6px reads from arm's length. */
  .job-bar { height: 6px; }

  /* iOS Safari AUTO-ZOOMS the page when a focused input's font is under
     16px — and never zooms back out, leaving the whole app oversized
     ("หน้าขยาย ไม่ fit กับหน้าจอ", real device). 16px on every text entry
     a finger can reach kills the trigger. */
  .t-row input, .review-tools input[type="text"], .panel input[type="text"],
  #review-text, .clip-picker, .dev-auth-input { font-size: 16px; }
  /* ...and the English box needs its OWN line, because the rule above cannot
     reach it. `.t-row input.t-en` (0,2,1) outranks `.t-row input` (0,1,1) and a
     media query adds no specificity, so the .75rem set 800 lines earlier kept
     winning and only the ENGLISH caption triggered the zoom — which is exactly
     how it was reported (the owner was editing the translated captions).
     Match the selector; do not try to out-position it. */
  .t-row input.t-en { font-size: 16px; }
  /* The two rules above are a hand-kept LIST, and a list only covers what
     someone remembered. Measured at 375px after they shipped: 21 of 55
     focusable controls were still under 16px — the caption inspector's
     #cue-th/#cue-en, #out-name, #home-q, the font and pace <select>s, every
     number box in the cue/text/image panels. They set no font-size of their
     own; they INHERIT one, and any direct declaration beats an inherited
     value, so one blanket rule catches the whole class including controls
     nobody has written yet.
     This matters more than it looks: iOS never zooms back out, so a single
     untreated control anywhere leaves the app oversized for the rest of the
     session — the symptom then surfaces at some later, unrelated moment
     ("มันยังเบี้ยวหลังจากตัดเสร็จ" was reported about the SPLIT, which focuses
     a control that was already 16px). Range/checkbox/radio/colour have no
     text field to zoom into and keep their own sizing.
     `!important` is deliberate and is the same call as the global
     `[hidden] { display: none !important }` a few rules up: this is a PLATFORM
     CONSTRAINT, not a design preference. A bare element selector (0,0,1) loses
     to every `.panel textarea` (0,1,1), `.font-select` (0,1,0) and
     `#brandkit-modal textarea` (1,0,1) in the file — measured, that left 12 of
     the 21 still zooming — and out-specificing them one by one rebuilds the
     hand-kept list this rule exists to delete. Nothing may opt a touch control
     back under 16px, so nothing gets to win. */
  input:not([type="range"]):not([type="checkbox"]):not([type="radio"]):not([type="color"]),
  textarea, select { font-size: 16px !important; }

  /* Keyboard-shortcut hints teach gestures a phone doesn't have. */
  .transcript-keys { display: none; }

  /* Transport: the 30px play button is a mouse target; give the finger 44px. */
  .t-btn { width: 44px; height: 44px; font-size: var(--fs-base); }

  /* Fullscreen position mode (owner's ask, entry adjusted so it doesn't fight
     tap-to-play): ⛶ on the frame corner expands the canvas over everything;
     the same button becomes ✓ เสร็จ. Captions drag with the existing pointer
     code — just on a screen big enough to be precise. */
  /* (ย้ายออกไประดับบนสุดแล้ว — เดสก์ท็อปก็ใช้เต็มจอได้) */
  /* Strictly 9:16 even fullscreen. `height:100dvh + max-width:100vw` broke
     the aspect box into 390x844 — captions were then dragged against a frame
     TALLER than the real video, and snapped elsewhere on exit ("กด confirm
     รูปเล็กไม่ใช่ตำแหน่งเดียวกัน", real iPhone). min() keeps the box the
     largest true 9:16 that fits, so a margin means the same spot at every
     preview size. */


  /* Paused ▶ badge on the frame — the video is the play button here (wired in
     studio.js). Pseudo-element takes no events, so the tap lands on <video>. */
  .preview.is-paused::after { content: "▶"; position: absolute; left: 50%; top: 50%;
    transform: translate(-50%, -50%); z-index: 5; width: 64px; height: 64px;
    display: grid; place-items: center; padding-left: 6px; box-sizing: border-box;
    border-radius: 50%; background: rgba(0, 0, 0, .45); color: #fff; font-size: 24px; }

  /* Give the sheet back to the cue list (measured: 88px of 844 left for rows).
     Find/replace folds behind 🔍; split/merge appear only with a selected row
     (they act on the selection anyway — and a split button with no caret was
     a dead-looking control); chrome rows tighten. All attribute/class states
     here are driven from studio.js. */
  .transcript-panel .review-tools { display: none; }
  .transcript-panel.find-open .review-tools { display: flex; }
  /* ✂ ตัด / ⤴ รวม pulled from the phone for now (owner 2026-08-13) — the
     sheet is for reading+retyping; structural edits stay a desktop verb until
     a better phone gesture exists. Code stays wired; delete this line to
     bring them back. */
  .transcript-touch-keys { display: none; }
  /* Sheet title "คำบรรยายทั้งหมด" says what the open sheet already shows —
     its pixels go to the action strip. */
  .transcript-panel .review-head > span:first-child { display: none; }
  #btn-find-toggle { border: none; background: none; padding: .3rem .4rem; }
  /* The ✕ must NEVER scroll away: it sits at the END of the head-actions
     strip, which scrolls sideways — on a real iPhone it was off-screen and
     the sheet read as "กดออกไม่ได้". Anchor it to the header itself (its
     containing block is .review-head, outside the scroller, so the strip
     scrolls under it). */
  .review-head { padding: .55rem 52px .55rem .8rem; position: relative; }
  .review-head .review-close { position: absolute; top: 50%; right: .35rem;
    transform: translateY(-50%); width: 42px; height: 42px; display: grid;
    place-items: center; background: var(--panel-bg); border-radius: 50%; }
  /* Header actions: one line each, scroll sideways when tight — 53px-wide
     two-line-wrapped buttons were below comfortable touch width. */
  .head-actions { overflow-x: auto; }
  .head-actions .mini-btn { font-size: var(--fs-sm); padding: .3rem .5rem; white-space: nowrap; }
  /* Gap banner: one-line chips, not a permanently-mounted 78px card — and the
     find bar REPLACES it (both open at once left ~2.5 cue rows on screen). */
  .gap-strip { padding: .3rem .6rem; max-height: 72px; overflow-y: auto; }
  .gap-strip .gap-strip-head { display: none; }
  .transcript-panel.find-open .gap-strip { display: none; }
}

/* วิดีโอกำลังทยอยขึ้นเบื้องหลัง (audio-first upload)
   แถบเดียว ใต้ header อยู่เหนือทั้งสองคอลัมน์ เพราะมันพูดถึงคลิปทั้งคลิป
   ไม่ใช่แผงใดแผงหนึ่ง — และผู้ใช้ต้องเห็นมันโดยไม่ต้องเลื่อนหา */
.video-up {
  display: flex; align-items: center; gap: 10px;
  padding: 8px 14px; font-size: 13px;
  background: var(--accent-soft, #eff6ff); color: var(--ink, #111827);
  border-bottom: 1px solid var(--line, #e5e7eb);
}
.video-up.is-failed { background: #fef2f2; color: #991b1b; }
.video-up #video-up-label { flex: 1 1 auto; min-width: 0; }

/* Style tiles carry a one-line "when do I use this" under the name. The audit
   that produced this basket found the old five tiles unpickable not because they
   looked bad but because nothing on screen said what any of them was FOR. */
.preset-why { display: block; font-size: var(--fs-xs); line-height: 1.35; color: var(--text-muted);
  padding: 0 .45rem .45rem; text-align: center; }
.preset-tile.active .preset-why { color: var(--accent); }
/* Tiles FILL the panel instead of sitting in a 112px column (owner 2026-08-20:
   "ทำให้มันเต็ม card ไม่ดีกว่าหรอ เหลือพื้นที่ด้านขวาไว้ทำไม"). MEASURED before
   the change: the panel is 226px wide, every tile was pinned to 112px, and all
   six wrapped one-per-row — so each tile left 114px of dead space on its right,
   half the panel, six times over. It cost nothing vertically either: they
   already stacked. auto-fit means the same rule gives one full-width column in
   the 226px panel and two the moment the panel is wider, instead of a fixed
   width that is wrong at every size but one. */
/* 2026-08-20, measured: `minmax(150px, 1fr)` resolved to a SINGLE 226px column
   in the real panel, so the six tiles stacked 98px each and the row measured
   629px — half again the entire 411px panel viewport, for one control. The
   150px floor was the bug: it can never fit two columns in 226px, so the
   auto-fit above only ever produced the one-column case it was written to
   avoid. Two explicit columns give ~109px tiles, which still render a legible
   caption sample, and take the row from 629px to ~320px. Kept as a hard 2
   rather than auto-fit with a smaller floor so the count cannot drift back to
   1 the next time the panel width changes. */
.preset-row { align-items: stretch; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }

/* ── ไทล์ขยับ ────────────────────────────────────────────────────────────
   Owner 2026-08-20: "ทางที่ดีควรเป็นภาพเคลื่อนไหวรึเปล่าแบบ gif". Two of the
   seven tiles ARE motion — karaoke lights words in sequence — and a still frame
   of that is a still frame of a plain line, which is why it is one of the only
   tiles that still needs a written hint.

   Driven by `.is-playing`, added in studio.js on mouseenter/focus, NOT by
   :hover. That is not a style preference: a :hover-only version could not be
   verified on screen (a CSS animation does not restart when the pointer
   re-enters an element it never left) and was reverted once already. A class
   can be toggled by a probe, so this one is checkable.

   Three rules, each with evidence behind it (research 2026-08-20):
   * ONE at a time. Kapwing's gallery was measured: 120 template cards, every
     <video> paused with preload="none", only the hovered card plays. NN/g:
     peripheral vision is tuned to motion, and unrelated movement
     "substantially degrades the user experience".
   * FINITE. WCAG 2.2 SC 2.2.2 requires a pause control for anything moving
     more than five seconds; this ends on its own in 3, so it needs none.
   * The written hints STAY. Kapwing, Captions.ai and Submagic all keep text
     labels alongside motion — Submagic names tiles "Matt" and "Jess", which no
     animation can convey. Motion says what a tile DOES; the label says what it
     is CALLED, and the user needs the name to ask for it again. */
@media (prefers-reduced-motion: no-preference) {
  /* BOTH words sweep, and both RESET. The first version lit only .k-rest and
     left .k-lit on its inline gold, so the first word was permanently gold —
     owner: "มันต้องเหลืองแล้วเทาแล้วเหลือง ไม่ใช่เหลืองค้าง". A word that is
     already lit before the sweep starts is not karaoke, it is a two-tone line.
     Each cycle now reads: whole line dim -> first word lights -> second word
     lights -> both drop back to dim, which is what the burn actually does as
     the speaker moves through the line. */
  .preset-tile.is-playing .k-lit  { animation: k-lit-sweep 1.4s ease-in-out 3; }
  .preset-tile.is-playing .k-rest { animation: k-rest-sweep 1.4s ease-in-out 3; }
  @keyframes k-lit-sweep {
    0%, 12%   { color: var(--dim); }
    26%, 88%  { color: var(--lit); }
    100%      { color: var(--dim); }
  }
  @keyframes k-rest-sweep {
    0%, 45%   { color: var(--dim); }
    58%, 88%  { color: var(--lit); }
    100%      { color: var(--dim); }
  }

  /* punch: each word owns a third of the cycle. No steps() — an earlier version
     used steps(1) and it was never established what that actually rendered;
     plain percentage keyframes are the thing that can be read off a colour
     sample. Total 3s, same budget as the sweep above. */
  .preset-tile.is-playing .p-word { animation: p-show 3s linear 1; }
  .preset-tile.is-playing .p-word:nth-child(1) { animation-name: p-show-1; }
  .preset-tile.is-playing .p-word:nth-child(2) { animation-name: p-show-2; }
  .preset-tile.is-playing .p-word:nth-child(3) { animation-name: p-show-3; }
  /* โหมด "คำต่อคำ" ใช้ keyframes ชุดเดียวกับไทล์ตอกย้ำ — กลไกเดียวในไฟล์นี้
     ไม่ใช่สองชุดที่จะเพี้ยนกันภายหลัง */
  .font-opt.is-playing .w-word:nth-child(1) { animation: p-show-1 3s linear 1; }
  .font-opt.is-playing .w-word:nth-child(2) { animation: p-show-2 3s linear 1; }
  .font-opt.is-playing .w-word:nth-child(3) { animation: p-show-3 3s linear 1; }
  @keyframes p-show-1 { 0%,32% { opacity: 1; } 33%,100% { opacity: 0; } }
  @keyframes p-show-2 { 0%,32% { opacity: 0; } 33%,65% { opacity: 1; } 66%,100% { opacity: 0; } }
  @keyframes p-show-3 { 0%,65% { opacity: 0; } 66%,99% { opacity: 1; } 100% { opacity: 0; } }
}

/* One cell, so the words replace each other instead of reading as a line. */
.punch-stack { display: inline-grid; }
.punch-stack .p-word { grid-area: 1 / 1; }
.punch-stack .p-word:not(:first-child) { opacity: 0; }

/* ไทล์ที่ไม่มีการเคลื่อนไหวก็ต้องตอบรับการชี้ ไม่งั้น "นิ่งเพราะสไตล์นี้นิ่ง"
   กับ "นิ่งเพราะพัง" หน้าตาเหมือนกันเป๊ะ — เจ้าของทักตรงนี้เอง
   ขยับเบามาก 1px ครั้งเดียว ไม่ใช่ลูป จึงไม่แตะเกณฑ์ 5 วินาทีของ WCAG 2.2.2
   และไม่แข่งความสนใจกับสองใบที่การเคลื่อนไหวคือตัวข้อมูลจริง */
@media (prefers-reduced-motion: no-preference) {
  .preset-tile { transition: transform .12s ease, border-color .12s ease; }
  .preset-tile:hover { transform: translateY(-1px); }
}
.preset-tile { width: auto; }
/* Bilingual tile stacks its two tracks the way the burn does — Thai over English. */
.preset-stage { flex-direction: column; gap: 2px; }
.preset-sample-en { line-height: 1.2; white-space: nowrap; text-shadow: 1px 0 #000, -1px 0 #000, 0 1px #000, 0 -1px #000; }

/* The editor header carries the WORDMARK ALONE, not the lockup. It used to
   carry the lockup, and a blind critic comparing this screen against Duolingo's
   real app screens named the reason to change it: the mascot was appearing
   three times in one frame — header lockup, rail, and the coach bubble — twice
   as BRANDING and once as a SPEAKER, and the speaker role loses that argument
   every time. Its words: adding ten new expressions would still read as a logo.
   Duolingo does put its bird in its logo (its course picker shows exactly that),
   so the rule is not "never brand with the mascot" — it is never brand and
   speak with it in the SAME FRAME. Home still shows the full lockup and the
   rail mascot, because Home has no coach bubble to compete with.
   Never compose mascot and wordmark by hand — a hand-built pair put the mascot
   at 26px, under the kit's own 48px floor, and at proportions the brand did not
   draw. Min wordmark size in the kit is 24px; 26px here clears it. */
/* The rail is shared by Home and the editor, so this is the one mascot that
   cannot be settled in the markup — it is hidden only while the editor (and so
   the coach bubble) is on screen. Locked by web/tests/css_hidden.test.js's
   global [hidden] rule for the header; this one is a display swap by state. */
.in-studio .rail-brand { display: none; }
.app-brand { margin: 0; line-height: 0; }
.app-brand img { height: 26px; width: auto; display: block; }
@media (max-width: 767px) { .app-brand img { height: 22px; } }

/* ── แท็บ สี (2026-10-07, เจ้าของ "เหมือน CapCut") ──
   ปุ่มย่อยแบบเม็ดยา + แถบสีแถวเดียวปัดซ้ายขวา (ต้นแบบ _mock_look_capcut.html) */
.color-subs { display: flex; gap: .4rem; overflow-x: auto; scrollbar-width: none; margin-bottom: .6rem; }
.color-subs::-webkit-scrollbar { display: none; }
.color-sub { flex: 0 0 auto; border: 1px solid var(--border); background: var(--panel-bg); color: var(--text-muted);
  border-radius: 999px; padding: .35rem .8rem; font: inherit; font-size: var(--fs-md); cursor: pointer; }
.color-sub.active { background: var(--ink); border-color: var(--ink); color: #fff; }
.color-target .swatch-row { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none;
  padding: 4px 2px 8px; scroll-snap-type: x proximity; }
.color-target .swatch-row::-webkit-scrollbar { display: none; }
.color-target .swatch { flex: 0 0 auto; width: 32px; height: 32px; scroll-snap-align: start; }
.swatch.on { box-shadow: 0 0 0 2px var(--panel-bg), 0 0 0 4px var(--ink); }
.swatch-pick { background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red); position: relative; }
.swatch-pick input { position: absolute; inset: 0; opacity: 0; width: 100%; height: 100%; cursor: pointer; border: 0; padding: 0; }
.swatch-none { background: #fff; position: relative; }
.swatch-none::after { content: ""; position: absolute; left: 50%; top: 3px; bottom: 3px; width: 2px;
  margin-left: -1px; background: var(--bad); transform: rotate(45deg); }

/* กล่อง "คิวที่เลือก" ไม่ขึ้นในขั้นปรับแต่งกับดาวน์โหลด (เจ้าของ 2026-10-07) —
   สองขั้นนี้ไม่ได้แก้ข้อความ ขั้นอื่นยังตามการเลือกเหมือนเดิม */
aside.panel[data-step="look"] #cue-panel,
aside.panel[data-step="burn"] #cue-panel { display: none !important; }
@media (max-width: 767px) {
  aside.panel, .look-footer, .transcript-panel, .review-chat { transition: bottom .2s ease; }
}
@media (max-width: 767px) and (prefers-reduced-motion: reduce) {
  .rail, aside.panel, .look-footer, .transcript-panel, .review-chat { transition: none; }
}
