* { box-sizing: border-box; }
/* .screen's own `display: flex` ties in specificity with the browser's
   default `[hidden] { display: none }` rule, and author CSS wins that tie
   over the UA stylesheet — so without this, setting `.hidden = true` in
   JS silently does nothing and every screen renders stacked at once.
   Found by actually loading the page, not assumed. */
[hidden] { display: none !important; }
body {
  margin: 0;
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  background: #0f1115;
  color: #e6e6e6;
  min-height: 100vh;
}
/* brief #65: the real root cause of Toey's "พรีวิวเลื่อนได้" (the preview
   scrolls) + "มีช่องว่างเยอะ" (there's a lot of empty space) reports —
   ONE bug, two symptoms. On mobile, `100vh` resolves to the URL-bar-
   HIDDEN (taller) screen height, while `--real-vh` (index.html's own
   inline script, set from the real, current `window.innerHeight`) is
   SMALLER whenever the URL bar is actually showing. `#previewScreen`
   correctly sizes itself to the smaller `--real-vh` — but `body`'s own
   `min-height: 100vh` above is unconditional and always wins a tie
   against a smaller `height`, forcing `body` itself taller than
   #previewScreen's own real content. The extra space becomes real,
   scrollable overflow (able to scroll the topbar off-screen) — exactly
   matching both of Toey's screenshots. `body`'s own `min-height:100vh`
   is correct and wanted for the login/app screens (a full screen even
   with short content) — this override is scoped to ONLY the preview
   screen (toggled by showScreen() in app.js), not a blanket change.
   Genuinely invisible in any headless test environment: `100vh` and
   `window.innerHeight` are always exactly equal there — see this
   project's own README for the real verification approach used instead
   of relying on scroll-detection, which doesn't discriminate here. */
body.body-fixed-screen { min-height: var(--real-vh, 100vh); }
.screen {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 24px;
}
.card {
  background: #1a1d24;
  border-radius: 16px;
  padding: 32px 24px;
  max-width: 420px;
  width: 100%;
  text-align: center;
  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
}
/* brief #64 item 1: scoped to the upload card ONLY (index.html adds this
   class there specifically) — the login screen's own card and the "คลิป
   ของฉัน" clip-list card both keep the base .card padding untouched.
   Real measured result (before → after, DOM clientHeight): see
   ticker_app/README.md's own brief #64 section for the exact numbers. */
.card-compact { padding: 16px 24px; }
.card-compact h2 { margin-bottom: 6px; }
.card-compact .hint { margin-top: 6px; }
.card-compact .dropzone { margin-top: 8px; padding: 10px 16px; }
.card-compact .dropzone-label { margin-bottom: 4px; }
.card-compact .btn-google { margin-top: 8px; padding: 8px 14px; }
h1 { margin: 0 0 8px; font-size: 28px; }
h2 { margin: 0 0 8px; font-size: 20px; }
p { color: #a8adb8; line-height: 1.5; }
.btn-google {
  margin-top: 20px;
  width: 100%;
  padding: 14px;
  font-size: 16px;
  border-radius: 10px;
  border: none;
  background: #4285f4;
  color: white;
  cursor: pointer;
}
.btn-google:active { background: #3367d6; }
.btn-link {
  background: none;
  border: none;
  color: #a8adb8;
  cursor: pointer;
  font-size: 14px;
  text-decoration: underline;
}
.error { color: #f28b82; margin-top: 12px; }
.hint { font-size: 13px; margin-top: 12px; }
.topbar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px 20px;
  background: #1a1d24;
  font-size: 14px;
}
#app.screen {
  padding-top: 96px;
  justify-content: flex-start;
  gap: 16px;
}
/* brief #59 piece 3: drag-and-drop upload zone — copied close to
   verbatim from caption_web/static/style.css's own .dropzone rules. */
.dropzone { border: 1px dashed var(--border); border-radius: 12px; padding: 20px 16px; text-align: center; margin-top: 16px; }
.dropzone.dragover { border-color: var(--accent); background: rgba(66, 133, 244, 0.08); }
.dropzone-label { color: var(--muted); font-size: 13px; display: block; margin-bottom: 10px; }
#clipFile {
  display: block;
  width: 100%;
  color: #e6e6e6;
}
/* brief #64 item 2: real, distinct cards — Toey's own explicit ask ("ทำ
   รายการคลิปให้เป็น card ชัดขึ้น") — each clip is now its own bordered,
   filled block with real spacing between clips (gap), replacing the old
   flat divider-line list. */
.clip-list { list-style: none; margin: 12px 0 0; padding: 0; text-align: left; display: flex; flex-direction: column; gap: 8px; }
.clip-list li {
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 10px 12px;
  border-radius: 10px;
  background: var(--input-bg-alt);
  border: 1px solid var(--border);
  font-size: 14px;
}
.clip-row { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
/* brief #70 item 2: a real, measured overflow — the status text plus up
   to 3 buttons (e.g. ดู + ดาวน์โหลด + the new ลบ) no longer reliably fit
   on one line on a narrow phone (confirmed: 11px past the card's own
   edge at 360px wide before this). flex-wrap lets the buttons drop to a
   second line instead of overflowing; every OTHER .clip-row (name+meta,
   the expiry line) never had more than 2 short children and is
   unaffected — scoped to the action row specifically. */
.clip-actions-row { flex-wrap: wrap; row-gap: 8px; }
/* brief #70 item 3: .clip-title wraps the (now editable) project name +
   the real filename underneath — min-width:0 is the real fix (same
   flexbox-shrink mechanism as brief #69's own .preview-main bug) that
   lets this whole block actually shrink/ellipsis inside .clip-row's
   flex row instead of forcing the row wider than the card. */
.clip-title { display: flex; flex-direction: column; overflow: hidden; min-width: 0; gap: 2px; }
/* min-width:0 on BOTH children too — confirmed by a real measured
   overflow, not assumed safe from .clip-title's own min-width:0 alone:
   .clip-name/.clip-filename-sub are themselves flex items of
   .clip-title (a column container), so each one's own default
   min-width:auto floors .clip-title's required width at whichever
   child's real (unbroken, white-space:nowrap) text is widest —
   .clip-title being shrinkable as ITS OWN parent's flex item does
   nothing to stop that from the inside. Exact same recursive flexbox
   mechanism as brief #69's .preview-main bug. */
.clip-list .clip-name {
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
  font-weight: 600; cursor: pointer;
}
.clip-list .clip-name:hover, .clip-list .clip-name:focus-visible { text-decoration: underline dotted; }
.clip-filename-sub {
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
  font-size: 11px; color: #7c8291;
}
.clip-name-edit-input {
  font: inherit; font-weight: 600; width: 100%; min-width: 0;
  background: var(--input-bg); color: var(--text); border: 1px solid var(--accent);
  border-radius: 6px; padding: 2px 6px;
}
.clip-list .clip-meta { color: #7c8291; flex-shrink: 0; font-size: 13px; }
.clip-status { color: #7c8291; font-size: 13px; }
.clip-status-error { color: #f28b82; }
/* brief #70 item 1: same muted tone as .clip-meta/.clip-status while the
   clip is still fine — only turns warning-colored once actually expired,
   same red as .clip-status-error (a real, present-tense problem, not a
   future one anymore). */
.clip-expiry { color: #7c8291; font-size: 12px; }
.clip-expiry-expired { color: #f28b82; }
/* brief #64 item 3: Toey's own explicit complaint — "ปุ่ม 'ดู' และ 'ส่งออก'
   ดูเบาและเล็กไป" (the "ดู"/"ส่งออก" buttons look too light and small).
   Real background + border + bolder text for both (was a bare 1px border
   with no fill at all, on top of the old .btn-link's own no-background
   underlined-text look). "ดู" (view) keeps this neutral secondary
   treatment per Toey's own explicit "ทำเป็น secondary button" ask; the
   real work actions (process/download, whichever the button currently
   represents) get the accent fill instead, since exporting is the more
   consequential action on the row. */
.clip-action {
  padding: 8px 16px;
  font-size: 14px;
  font-weight: 600;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--input-bg);
  color: var(--text);
  flex-shrink: 0;
  cursor: pointer;
}
.clip-action:disabled { opacity: 0.5; cursor: default; }
.clip-action[data-action="download"], .clip-action[data-action="process"] {
  background: var(--accent); color: var(--accent-text); border-color: var(--accent);
}
/* brief #70 item 2: a plain, muted-red outline (same red as
   .clip-status-error) — enough to read as "destructive" next to the
   other actions without a whole new button style; window.confirm() is
   the real safety net, this is just a visual cue. */
.clip-action-delete { color: #f28b82; border-color: #f28b82; }

/* ---- Preview screen (brief #58, UI port piece 1) ----
   CSS variable names below match caption_web/static/style.css's own
   :root tokens on purpose — the player/overlay CSS rules further down
   are copied from there close to verbatim (see B1 in the UI port gap
   analysis: this logic is mirrored twice on purpose and must render
   identically, so matching variable names means copying the actual
   rules, not re-deriving equivalent ones by hand). */
:root {
  --input-bg: #14161b;
  --input-bg-alt: #232837;
  --border: rgba(230, 230, 230, 0.12);
  --text: #e6e6e6;
  --muted: #7c8291;
  --accent: #4285f4;
  --accent-text: #ffffff;
  --font-mono: "IBM Plex Mono", monospace;
}

/* --real-vh (set in index.html's inline <head> script) instead of 100vh
   — see that script's own comment for the real-phone address-bar bug
   this avoids, same fix as caption_web's brief #20. */
.preview-screen { height: var(--real-vh, 100vh); min-height: 0; padding: 0; justify-content: flex-start; align-items: stretch; overflow: hidden; }
.preview-screen .topbar { position: static; }
/* brief #70 item 3: min-width:0 is needed at BOTH levels — on
   .preview-title itself (lets it shrink inside .topbar's row instead of
   pushing the undo/redo buttons off) AND on its own two span children
   below (each is itself a flex item of .preview-title, a COLUMN
   container, so its own default min-width:auto would otherwise floor
   .preview-title's required width at whichever child's real unbroken
   text is widest, regardless of .preview-title's own min-width:0).
   Confirmed by a REAL measured overflow in the clip list's identical
   .clip-title/.clip-name pattern with only the outer min-width:0 set —
   an earlier version of this comment claimed the outer one alone was
   enough, which this project's own tests then disproved. Same
   recursive flexbox mechanism as brief #69's .preview-main bug. */
.preview-title { display: flex; flex-direction: column; overflow: hidden; min-width: 0; gap: 1px; }
.preview-project-name {
  font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; cursor: pointer;
}
.preview-project-name:hover, .preview-project-name:focus-visible { text-decoration: underline dotted; }
#previewFilename {
  font-size: 13px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
}

/* brief #60: .preview-main/.preview-side (defined further down) wrap the
   preview+player and the tabs+panels respectively — this is what makes a
   real desktop side-by-side layout possible later via one media query
   (mirrors caption_web's own .preview-col/.control-panel split) without
   touching anything about how mobile itself is laid out here, which is
   the base/default case, unchanged and already tested against Toey's
   real phone. flex-grow with a real min-height floor (not just
   flex-basis) so a very short/wide video never shrinks the frame below a
   usable size on a real phone — Toey's own explicit complaint on an
   earlier attempt ("มันเล็กไป", it's too small) is exactly the failure
   mode this floor exists to prevent. */
.preview-frame-wrap {
  flex: 1 1 auto; display: flex; align-items: center; justify-content: center;
  /* brief #61: 46% -> 55% on the "พรีวิว" tab specifically (Toey's own
     approved mockup figure) — safe to raise now that brief #61's other
     fix (.preview-frame itself shrinking to the real clip aspect ratio,
     see updateLiveOverlay() in app.js) means this floor no longer also
     has to cover a wide black-bar box around a narrower video; the .app
     .preview-compact override just below drops this back down while an
     adjustment tab (สไตล์/แก้ไขคำ/ปรับแต่ง) is open, so #previewSide gets
     the room instead — same "result tabs stay big, adjust tabs shrink"
     split already proven in caption_web's own brief #48/#49. */
  padding: 16px; min-height: calc(var(--real-vh, 100vh) * 0.55); overflow: hidden;
}
.preview-frame {
  position: relative; border-radius: 16px; background: #000; border: 1px solid var(--border);
  width: 100%; height: 100%; overflow: hidden; display: flex; align-items: center; justify-content: center;
}
.preview-frame video { width: 100%; height: 100%; object-fit: contain; display: block; }
.preview-empty-label { color: var(--muted); font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.08em; }

/* The real export canvas rect — sized/positioned in JS (updateLiveOverlay())
   to match resolve_canvas_resolution() exactly. #previewVideo and
   #captionOverlay both live inside it at 100%/100%, letterboxed the same
   way the real ffmpeg pad filter behaves. */
.canvas-frame { position: absolute; overflow: hidden; background: #000; border: 1px solid var(--border); box-sizing: border-box; }
.caption-overlay { position: absolute; inset: 0; pointer-events: none; }
.caption-overlay-text { position: absolute; line-height: 1.2; }
.caption-overlay-text .cw { display: inline-block; }
.safe-area-frame { position: absolute; border: 1px dashed rgba(255, 255, 255, 0.55); pointer-events: none; box-sizing: border-box; }
.preview-frame:fullscreen { border-radius: 0; width: 100vw; height: 100vh; }
.preview-frame:-webkit-full-screen { border-radius: 0; width: 100vw; height: 100vh; }

/* Tap-to-reveal fullscreen playback controls — see the HTML comment above
   #fullscreenControls and wireFullscreenControls() in app.js for why this
   duplicates .player-controls' row instead of reusing it directly. */
.fullscreen-controls {
  position: absolute; left: 0; right: 0; bottom: 0;
  padding: 14px 20px 22px;
  background: linear-gradient(to top, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0));
}

/* brief #64: padding/gap tightened slightly (was 12px/16px) to offset the
   real height item 6's own taller timeline adds — Toey's own explicit
   "ห้ามแตะขนาดวิดีโอ" (don't touch the video size) — a real screenshot/
   measurement comparison caught the video frame shrinking ~2.4% before
   this compensation, not assumed safe just because .preview-frame-wrap's
   own min-height floor still technically held. */
.player-controls { flex: 0 0 auto; border-top: 1px solid var(--border); padding: 8px 16px; display: flex; flex-direction: column; gap: 12px; }
.transport-row { display: flex; align-items: center; gap: 16px; }
/* brief #62: a small, centered skip/rewind cluster under the main
   transport row — see index.html's own comment for why this is a
   separate row (not folded into .transport-row) and a separate duplicate
   play/pause button (not the real #playPauseBtn moved here). Negative
   margin-top pulls it closer to .transport-row than the column's own
   16px gap would otherwise leave, since these two rows are visually one
   group of playback controls, not two separate sections. */
.review-controls-row { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: -8px; }
.btn-icon {
  background: var(--input-bg-alt); border: 1px solid var(--border); color: var(--text);
  width: 44px; height: 44px; font-size: 14px; border-radius: 8px; flex: 0 0 auto; cursor: pointer;
}
/* brief #59 piece 3: undo/redo — deliberately smaller than .btn-icon's
   44px touch target, same reasoning as caption_web's own copy (the
   preview topbar is already tight on mobile alongside the back button
   and filename). */
/* brief #68 item D4: Codex flagged 34px as smaller than the standard
   44px touch target, but explicitly marked it [ASSUMED] — not confirmed
   from the code alone. Toey has already approved this button's own
   visible size, so the box itself is untouched here — the real fix
   (a same-sized wrapper + a real sibling overlay a few px larger,
   forwarding its own clicks) is applied by expandTouchTarget() in
   app.js, not in this rule. Two things were tried and DISPROVEN first,
   verified with real Playwright clicks before trusting either: a
   `::before` pseudo-element with a negative inset does NOT extend a
   real <button>'s own hit-testable area at all (confirmed: a click
   landing squarely on the pseudo-element's own rendered pixels does not
   register on the button) — and a real DOM child positioned the same
   way is also clipped to the button's own box, unlike on a plain <div>.
   Only a SIBLING element (outside the button, not a descendant) actually
   works — see expandTouchTarget()'s own comment for the full mechanism
   and why it has zero layout impact on any of this button's several
   different container rows. */
.btn-icon-sm {
  background: var(--input-bg-alt); border: 1px solid var(--border); color: var(--text);
  width: 34px; height: 34px; padding: 0; font-size: 16px; line-height: 1; border-radius: 8px;
  display: flex; align-items: center; justify-content: center; flex: 0 0 auto; cursor: pointer;
}
.btn-icon-sm:disabled { opacity: 0.35; cursor: not-allowed; }
/* brief #68 item D4: the wrapper expandTouchTarget() (app.js) inserts
   around a button — `inline-flex` with no explicit size shrink-wraps
   exactly to the button's own real box (verified via real
   getBoundingClientRect() comparisons, not assumed), so it contributes
   identically to whatever flex row it lives in as the plain button did
   before. `.touch-target-hit` is the real sibling overlay itself: sized
   a few px past the wrapper on every side via inline `top/left/right/
   bottom`, invisible, forwarding its own click straight to the button. */
.touch-target-wrap { position: relative; display: inline-flex; }
/* A genuinely hidden button (via its own `hidden` attribute — e.g.
   #reexportBtnTop before a re-export is actually needed) still shrinks
   its own wrap span to 0x0, but a `display:none` element is REMOVED
   from flex layout entirely — a 0x0 `inline-flex` wrapper is NOT — and
   `gap` in a flex row still reserves space around every real flex item,
   zero-sized or not. Found on a REAL desktop screenshot diff (a few
   pixels of #previewFilename's own position shifted, caught by a real
   before/after pixel comparison, not assumed safe from mobile alone):
   without this rule, wrapping #exportActionBtnTop/#reexportBtnTop (both
   `display:none` on desktop, see the `min-width:769px` query below) in
   a wrapper that ISN'T also `display:none` added two extra `gap`s worth
   of dead space to that row, shifting every sibling after it. Mirrors
   `display:none` onto the wrapper whenever its own real button is
   hidden via the `hidden` attribute — the desktop query below adds the
   equivalent for the CSS-driven case, since `:has()` can't see a
   descendant's own COMPUTED style, only structural/attribute state. */
.touch-target-wrap:has(> [hidden]) { display: none; }
.touch-target-hit { position: absolute; cursor: pointer; }
/* A `position:absolute` sibling always paints above a plain, in-flow
   (static) one — REGARDLESS of DOM order — so appending the overlay
   before or after the button in expandTouchTarget() (app.js) makes no
   difference on its own; without this rule the overlay covers the
   button's own visible area too, not just its expanded margin. Found by
   running the full test suite, not assumed: it broke several EXISTING
   tests' own `page.click("#someBtnId")` calls, since Playwright treats
   a button "obscured" by another element as unsafe to click — exactly
   the real bug pattern that check exists to catch, even though a real
   click there still worked (the overlay forwards it). `position:
   relative` + a higher `z-index` gives the real button its own
   stacking context that wins over the overlay for its own area, while
   the overlay (bigger on every side) still catches clicks in the
   surrounding margin, where the button doesn't paint at all. */
.touch-target-wrap > button { position: relative; z-index: 1; }
/* brief #61: the topbar's own mirror of #exportBar's main button —
   sized to sit comfortably next to the 34px undo/redo icons instead of
   #exportBar's own full-width .btn-google treatment. Real Thai status
   strings (e.g. "กำลังถอดเสียง...") can be longer than this button's
   available width on a 360px screen — ellipsis truncates rather than
   wrapping/overflowing, same tolerance this app already accepts for
   #previewFilename next to it. Visible by default (mobile, this app's
   base case) alongside #exportBar being hidden there; both this and
   #reexportBtnTop are hidden again on desktop, where #exportBar itself
   is the one still shown — see the `min-width: 769px` query further
   down, which is where BOTH of those flips actually happen. */
/* brief #68 item D4: same real technique as .btn-icon-sm's own comment
   above (expandTouchTarget() in app.js) — this button also sits in the
   `gap: 6px` topbar cluster, right next to #reexportBtnTop. The box
   itself (including its own `overflow: hidden` needed for its real
   ellipsis truncation — a plain `::before` inset would have been
   silently clipped by that too, on top of not working on a <button> at
   all) is untouched here. */
.btn-export-top {
  background: var(--accent); border: none; color: var(--accent-text); cursor: pointer;
  height: 34px; padding: 0 12px; font-size: 12px; font-weight: 600; border-radius: 8px;
  max-width: 92px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
}
.btn-export-top:disabled { opacity: 0.6; cursor: not-allowed; }
/* #reexportBtnTop otherwise inherits plain .btn-icon-sm flex display —
   its OWN visibility (once actually "done, fresh") is still entirely
   governed by the `hidden` attribute app.js already toggles on it,
   exactly like #reexportBtn always has been. */
.scrub-track { flex: 1 1 auto; height: 6px; background: var(--input-bg-alt); border-radius: 8px; position: relative; cursor: pointer; touch-action: none; }
.scrub-fill { position: absolute; left: 0; top: 0; bottom: 0; background: var(--accent); border-radius: 8px; }
.time-label { font-family: var(--font-mono); color: var(--muted); font-size: 12px; white-space: nowrap; flex: 0 0 auto; }

/* ---- brief #60: layout containers for a real desktop side-by-side view ----
   Base (mobile, unchanged behavior): .preview-body/.preview-main/
   .preview-side are all flex-column, so .preview-main (video+player) sits
   above .preview-side (tabs+panels) exactly as the flat sibling structure
   already did before this brief — zero behavior change on mobile from
   introducing these wrappers alone. The `min-width: 769px` query further
   down (mirrors caption_web's own `max-width: 768px` mobile cutoff,
   flipped since ticker_app's base is mobile-first, not desktop-first
   like caption_web's) switches .preview-body to a row, at which point
   .preview-main naturally gets the remaining width and full height (no
   percentage floor needed there at all — a row layout is not competing
   for vertical space the way stacked mobile is) while .preview-side
   becomes a fixed-width sidebar, same shape as caption_web's own
   .control-panel. */
/* brief #66: real clearance for the fixed bottom tab bar, moved here
   (the SHARED flex parent of .preview-main and .preview-side) rather
   than living only on .preview-side as brief #61 first placed it. Real
   cause of Toey's own real-phone report ("ไทม์ไลน์โดนแถบแท็บบัง" — the
   timeline is covered by the tab bar): #timelineRow lives inside
   .preview-main (video+player), NOT .preview-side (tabs+panel) — a
   clearance on .preview-side alone only ever protected ITS OWN
   scrollable panel content, never .preview-main's own timeline, which
   has no ceiling of its own and can grow tall enough (a long transcript
   + a full 9:16 clip on a narrow phone) to reach into the fixed tab
   bar's own real screen position, regardless of .preview-side's clearance.
   Moving the reservation up here shrinks the SHARED available budget
   both children lay out within — necessary but NOT sufficient on its
   own: .preview-main's own `flex-grow: 1` (see its own comment below)
   still greedily consumed previewBody's full box regardless of this
   padding, until paired with an actual `max-height: 100%` on
   .preview-main itself, which is what makes it actually respect this
   reservation. Together they fix the same real, incidental ~13px overlap
   already found and recorded as out-of-scope in brief #65's own test
   suite, now confirmed by Toey on a real device and fixed at the root
   instead of patched around. --tabbar-h is set in app.js from the tab
   bar's own real measured height (getBoundingClientRect, on load +
   resize/orientationchange, the same pattern this file's own --real-vh
   already uses) rather than a hardcoded guess — see .preview-tabbar's own
   comment for why a fixed number can't be trusted here (checked, not
   just guessed, whether a phone's safe-area inset is the reason: it
   isn't, today, but the real measurement is still the right call
   regardless of that specific factor). Reset to 0 on desktop/landscape,
   where the tab bar returns to normal in-flow, non-fixed positioning and
   needs no reservation at all — same breakpoints .preview-side's own
   clearance used to reset at. */
.preview-body { flex: 1 1 auto; display: flex; flex-direction: column; min-height: 0; overflow: hidden; padding-bottom: var(--tabbar-h, 56px); }
/* flex-shrink:0 (the middle "0" in "1 0 auto") is the real fix for a bug
   found by testing, not assumed from reading the CSS alone: with
   flex-shrink:1 (the default "flex:1 1 auto" shorthand implies), a tall
   .preview-side (e.g. the word editor with many cues) could shrink
   .preview-main below what ITS OWN children (.preview-frame-wrap's real
   min-height floor + .player-controls' real content height, including an
   active timeline) actually need — since a nested flex container's own
   automatic minimum size doesn't automatically protect its children's
   own floors from the OUTER flex algorithm. The visible symptom: the
   overflow rendered past .preview-main's own box, landing on top of
   .preview-side's real content and physically intercepting clicks meant
   for the timeline's own zoom buttons — caught via real Playwright
   testing + a bounding-box dump, the same "measure the real numbers,
   don't just reason about the CSS" discipline caption_web's own brief
   #45-47 fixes for this identical class of bug already used. flex-shrink:0
   means .preview-body's flex algorithm must always give .preview-main at
   least its own real needed height, squeezing .preview-side instead —
   which is exactly what it's built to do (min-height:0 + its own
   .preview-tabpanel's overflow-y:auto scroll it internally). */
/* brief #66: `max-height: 100%` is the actual fix for Toey's real overlap
   report — `flex-shrink: 0` above (kept, still correct — see its own
   comment) only ever protects .preview-main from being squeezed BELOW
   its own natural content size; it does nothing to stop `flex-grow: 1`
   from consuming previewBody's ENTIRE available box on top of that,
   which is exactly what happened once .preview-body carried a tab-bar
   clearance (its own comment above) but .preview-main kept growing right
   through it — the video frame (`.preview-frame-wrap`, flex-grow:1
   inside here) absorbed that extra space and grew past its own 55%
   floor, pushing #timelineRow down into the tab bar's own real screen
   position. `max-height: 100%` resolves against .preview-body's own
   content-box height (border-box aware, so its padding-bottom already
   correctly subtracts out) — this caps the GREED (flex-grow past what's
   actually needed) while leaving the min-height floor untouched, so a
   genuinely tight case (frame-wrap's own 55% floor + player-controls'
   own real content height alone) still can't be forced smaller than
   necessary; min-height always wins over a smaller max-height in CSS. */
.preview-main { flex: 1 0 auto; display: flex; flex-direction: column; min-height: 0; max-height: 100%; }
/* brief #61 originally reserved the fixed bottom tab bar's own clearance
   here (padding-bottom) so the last row of real panel content wouldn't
   render behind it. brief #66 moved that reservation up to .preview-body
   (see its own comment above) instead — since .preview-body's own
   available layout budget now already excludes the tab bar's real
   footprint, .preview-side's own box naturally ends above it, so an
   additional, separate padding here would just double-reserve the same
   space. No behavior change to what's actually protected: the tab bar's
   real height still fully clears every screen this app has. */
.preview-side { flex: 0 1 auto; display: flex; flex-direction: column; min-height: 0; overflow-y: auto; }

/* ---- brief #61: per-tab preview sizing on mobile ("พรีวิว" vs the 3
   adjustment tabs) ----
   Toey's own approved mockup: the result tab (พรีวิว) keeps the big
   floor above (55%); the three adjustment tabs (สไตล์/แก้ไขคำ/ปรับแต่ง)
   shrink the preview to a smaller floor instead, giving the room back to
   #previewSide — a user editing a word or dragging a style slider is
   watching the CONTROL, not the video, but must still see enough of the
   preview to tell what changed. Directly reuses caption_web's own
   already-Toey-tuned mechanism (brief #48/#49's `.preview-compact`
   class, toggled in wirePreviewTabs() here) rather than inventing a new
   one — same idea, same "flip which side gets flex-grow" trick, applied
   to this file's own .preview-main/.preview-side names. 22% (not
   caption_web's own final 30%) because this app's compact floor only
   needs to survive a plain still-frame check, not — like caption_web's —
   also justify keeping BOTH a preview and a full accordion sidebar
   legible at once; re-measure against a real screenshot the same way
   caption_web's own brief #49 did if Toey reports it reads too small in
   practice. */
#previewBody.preview-compact .preview-frame-wrap { flex-grow: 0; min-height: calc(var(--real-vh, 100vh) * 0.22); }
#previewBody.preview-compact .preview-main { flex-grow: 0; }
#previewBody.preview-compact .preview-side { flex-grow: 1; }

/* ---- brief #59 piece 3, redesigned in brief #60: export bar ----
   A plain row in normal document flow (NOT position:fixed — see
   index.html's own comment on exactly why that changed and what real
   problem it caused). Sits between the topbar and .preview-body, so it's
   always visible with no scrolling regardless of which tab is open or
   how tall that tab's own content is, and — being in normal flow — it
   can never visually overlap a sibling the way the old fixed button did.
   Single button (no separate growing status paragraph) plus one small
   fixed-width icon button that only appears once done — both keep this
   row's own height completely predictable, which is what keeps it safe
   for the 46% floor calculation the exact same way .preview-tabbar
   already was before this row even existed. */
.export-bar {
  /* brief #61: hidden by default now (mobile, this app's base case) —
     Toey measured this row costing ~7% of real screen height for one
     button. Its buttons still exist and still work (same ids); the
     topbar's own mirror pair (#exportActionBtnTop/#reexportBtnTop,
     defined above) takes over the visible UI on mobile instead. Restored
     to exactly its brief #60 appearance on desktop by the
     `min-width: 769px` query further down — nothing else about this
     rule changed. */
  display: none;
  flex: 0 0 auto; align-items: center; gap: 8px;
  padding: 10px 16px; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border);
}
.export-bar .btn-google { margin-top: 0; width: auto; padding: 10px 20px; white-space: nowrap; flex: 0 0 auto; }

/* ---- Tab bar + style panel (brief #59 piece 1; moved to a real bottom
   bar on mobile in brief #61) ----
   .preview-frame-wrap above already holds its own min-height floor via
   flex-grow, so these can only ever eat into space *beyond* that floor,
   never below it. The panel scrolls its own overflow internally rather
   than growing past the viewport, since .preview-screen itself is
   `overflow: hidden`.

   brief #61: `position: fixed` here, on mobile, is the SAME real
   mechanism caption_web's own `.bottomnav` already uses (Toey's own
   explicit CapCut reference — "แท็บข้างล่างมันเลื่อนซ้ายขวาได้") — not a
   repeat of brief #60's export-button mistake. The difference that
   matters: this is properly cleared (brief #66: `.preview-body`'s own
   `padding-bottom` reserves exactly this bar's real height so nothing
   ever renders underneath it — moved there from `.preview-side` alone,
   see that rule's own comment for why), whereas the old sticky export
   button had NO clearance reserved anywhere for what it would cover.
   `position:fixed` used *without* that clearance is the actual anti-
   pattern aree flagged, not the property itself. Reset back to normal
   in-flow, non-fixed positioning on desktop (`min-width: 769px` query)
   and in landscape, both of which keep the pre-#61 layout unchanged —
   see those two blocks for the explicit resets.

   brief #66: aree also flagged a phone's own safe-area inset
   (home-indicator/gesture-nav) as a possible reason this bar's real
   height could exceed a hardcoded guess ("แถบมี safe-area ของมือถือด้วย").
   Checked, not assumed: `env(safe-area-inset-*)` only ever resolves to a
   non-zero value when the page's own `<meta name="viewport">` opts in
   with `viewport-fit=cover` — this project's own tag (index.html) doesn't,
   so adding `padding-bottom: env(safe-area-inset-bottom)` here today
   would be genuinely inert (always computes to 0), not a real fix.
   Deliberately NOT adding `viewport-fit=cover` in this brief: it's a
   page-wide opt-in (every screen, not just this one) with no way to
   verify its effect in this environment (headless Chromium has no real
   device inset to simulate either way) — a real, separate, and riskier
   change than this brief's own scope, stated explicitly rather than
   silently bundled in. The demonstrated, sufficient root cause for
   Toey's own real report was found elsewhere (`.preview-main`'s own
   unbounded `flex-grow`, see its own comment) and closes the real,
   measured gap on its own. app.js still measures this bar's own real
   rendered height into `--tabbar-h` (not a hardcoded guess) regardless —
   worth keeping even without the safe-area factor, since it also
   correctly tracks any other real-device difference (font metrics,
   etc.) this file can't predict from here. */
.preview-tabbar {
  flex: 0 0 auto; display: flex; gap: 8px; padding: 0 16px; border-top: 1px solid var(--border);
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 40;
  background: #1a1d24; overflow-x: auto; -webkit-overflow-scrolling: touch;
}
/* brief #66: Toey's own real-phone report — "บอทท่อมบาร์ก็อยู่ซ้ายสุด" (the
   bottom bar is stuck to the left) — 4 tabs fitting comfortably within
   the bar's own width left `flex: 0 0 auto` tabs packed left with dead
   space on the right, not the even, full-width bottom-nav distribution
   both iOS and Android use. `flex: 1 1 0%` (basis 0, not `auto`) grows
   every tab by an EQUAL amount from a shared zero baseline, giving true
   equal-width columns regardless of each tab's own label length —
   `flex: 1 0 auto` (grow from each tab's own natural width instead)
   would keep their relative size differences and only look "close to"
   even. `min-width: max-content` keeps the OTHER half of Toey's own ask
   working: when tabs don't fit (more added later, or a long label),
   flex's own default automatic minimum already refuses to shrink below
   content size, but stating it explicitly here removes any
   browser-default ambiguity — combined with `.preview-tabbar`'s existing
   `overflow-x: auto`, that forces genuine horizontal scrolling instead
   of cramming, the same CapCut-style behavior brief #61 already
   established for the overflow case. Desktop/landscape explicitly
   restore `flex: 0 0 auto` in their own query blocks below — this
   full-width distribution is mobile-only, matching Toey's own explicit
   "ฝั่งคอมห้ามเปลี่ยน" instruction. */
.preview-tab {
  background: none; border: none; color: var(--muted); font-size: 13px; font-weight: 600;
  padding: 6px 10px; cursor: pointer; border-bottom: 2px solid transparent;
  flex: 1 1 0%; min-width: max-content; white-space: nowrap; min-height: 44px; display: flex;
  align-items: center; justify-content: center;
}
.preview-tab.selected { color: var(--text); border-bottom-color: var(--accent); }
.preview-tabpanel { flex: 0 1 auto; min-height: 0; overflow-y: auto; padding: 0 16px 16px; }

/* brief #60 fix: was `repeat(3, 1fr)` — on a wide desktop viewport with
   no side-panel constraint yet, `1fr` resolved to a huge column width,
   and 9:16 then forced an enormous card height to match (exactly what
   Toey screenshotted: a box nearly the size of the screen holding one
   small preview). auto-fill/minmax lets the real available width (now
   also genuinely bounded — .preview-side is a fixed ~380px sidebar on
   desktop, not the full viewport) decide how many columns actually fit,
   instead of a hardcoded count assuming a phone-width column. Aspect
   ratio 9/16 is kept deliberately (Toey asked to reconsider it, not
   necessarily drop it) — it's real information (the actual export canvas
   shape), and the fix above already removes the runaway-width problem
   that made it look wrong; the ratio itself was never the bug. */
.style-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(88px, 1fr)); gap: 10px; }
.style-card { display: flex; flex-direction: column; gap: 6px; cursor: pointer; }
/* brief #68 item C1: the "กำหนดเอง" (custom) card's own real frame
   always reflects the CURRENT config (see renderStyleGrid()'s own
   comment) — it's a status readout, not a destination you can click
   INTO like the other 4 named presets. `cursor: default` removes the
   one remaining visual cue (a clickable-looking pointer) that made it
   read as a broken button when clicked and nothing happened. */
.style-card-status { cursor: default; }
/* brief #64 item 9: Toey's own explicit finding — the card was full 9:16
   tall (the real export canvas shape) but the caption sample only ever
   occupies the bottom portion of it, so most of a 9:16 card was dead
   black space. Switched from the literal export ratio to a real 3:4 box
   (a deliberate judgment call, not "9:16 done wrong" — this card is a
   style PICKER showing a caption sample, not a literal render preview;
   the previous 9:16 choice was about carrying real information, but nothing
   in the actual caption sample benefits from that specific height/width
   relationship). ~25% shorter for the same width — measured against the
   requested 20-25% range, see ticker_app/README.md's own brief #64
   section for the real before/after numbers. */
.style-card-preview {
  position: relative; aspect-ratio: 3 / 4; border-radius: 10px; overflow: hidden;
  background: linear-gradient(160deg, #2a2e37, #14161b); border: 2px solid var(--border);
}
.style-card.selected .style-card-preview { border-color: var(--accent); }
.style-card-overlay { position: absolute; inset: 0; }
/* brief #64 item 10: Toey's own explicit ask — the accent border alone
   ("มีแค่ขอบฟ้า") didn't read as clearly "selected" enough. A real ✓ badge
   in the corner is a second, unambiguous signal that doesn't depend on
   noticing a 2px color change against a dark background. */
.style-card-check {
  position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; border-radius: 50%;
  background: var(--accent); color: var(--accent-text); font-size: 13px; font-weight: 700;
  display: flex; align-items: center; justify-content: center; z-index: 1;
}
/* brief #64 item 11: Toey's own explicit ask — the style names read too
   faint (var(--muted), the same dim gray used for secondary metadata
   text everywhere else in this app). A real, brighter dedicated color —
   not just var(--text), which several other UI elements already use for
   their own "selected"/emphasis state, so this label needs its own step
   between --muted and --text to read clearly without looking selected
   when it isn't. */
.style-card-label { font-size: 12px; color: #b9bfcc; text-align: center; }
.style-card.selected .style-card-label { color: var(--text); font-weight: 600; }

/* ---- Customize tab (brief #59 piece 1, continued) ----
   Copied close to verbatim from caption_web/static/style.css's own
   .subsection/.field/.pill/.checkbox-item/.color-field rules (same class
   names on purpose) — these are generic form-control primitives, not
   render-parity-critical logic, so there's no drift risk the way the
   overlay-drawing CSS above has; copying still avoids quietly reinventing
   a second set of the same controls with subtly different sizing/touch
   targets. */
.subsection { border-top: 1px solid var(--border); padding: 12px 0; }
.subsection-head { display: flex; align-items: center; gap: 8px; cursor: pointer; color: var(--text); font-size: 13px; user-select: none; }
.subsection:first-child { border-top: none; }
.subsection-caret { color: var(--muted); font-size: 11px; }
.subsection-body { padding: 14px 0 4px; display: flex; flex-direction: column; gap: 16px; }
.field-label { color: var(--muted); font-size: 12px; margin-bottom: 8px; }
.field { display: flex; flex-direction: column; }
.customize-tabpanel select, .customize-tabpanel input[type=text], .customize-tabpanel textarea {
  background: var(--input-bg); border: 1px solid var(--border); border-radius: 8px;
  padding: 8px 10px; color: var(--text); font-family: var(--font-mono); font-size: 13px; width: 100%;
}
.customize-tabpanel input[type=range] { width: 100%; accent-color: var(--accent); }
.customize-tabpanel input[type=color] { width: 32px; height: 32px; padding: 0; border: none; background: none; border-radius: 6px; cursor: pointer; }
.pill-group { display: flex; gap: 6px; }
.pill {
  flex: 1 1 0; padding: 8px 0; border-radius: 8px; font-size: 12px; cursor: pointer;
  font-weight: 600; background: var(--input-bg); color: var(--text);
  border: 1px solid var(--border); text-align: center;
}
.pill.selected { background: var(--accent); color: var(--accent-text); border: none; }
.checkbox-item { display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 13px; color: var(--text); }
.checkbox-box {
  width: 14px; height: 14px; border-radius: 3px; display: inline-flex; align-items: center; justify-content: center;
  font-size: 10px; border: 1px solid rgba(230, 230, 230, 0.3); color: var(--accent-text); flex: 0 0 auto;
}
.checkbox-box.checked { background: var(--accent); border: none; }
.color-field { display: flex; align-items: center; gap: 8px; background: var(--input-bg); border: 1px solid var(--border); border-radius: 8px; padding: 6px 10px; }
.color-hex { font-family: var(--font-mono); font-size: 13px; }
.keyword-row { display: flex; gap: 6px; }
.keyword-row input { flex: 1 1 auto; min-width: 0; }
.keyword-list { display: flex; flex-direction: column; gap: 8px; }
.keyword-chip { display: flex; align-items: center; justify-content: space-between; font-size: 13px; color: var(--text); background: var(--input-bg-alt); border-radius: 6px; padding: 6px 10px; }
.keyword-chip-swatch { width: 12px; height: 12px; border-radius: 3px; display: inline-block; }
.keyword-chip-remove { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 14px; padding: 0 4px; }

/* ---- Transcript editor (brief #59 piece 2, redesigned in brief #62) ----
   Toey's own explicit redesign — "อ่านง่าย · แก้เร็ว · ไม่รก" (readable,
   fast to fix, not cluttered). His real complaint about the old design:
   a 3-word cue rendered 18 buttons/inputs (2 time fields + 1 text field +
   3 buttons, PER WORD), so "เวลาจะตรวจคำผิด สายตาถูกดึงไปที่ปุ่มมากกว่า
   ตัวข้อความ" (checking for wrong words, the eye gets pulled to the
   buttons instead of the text). Fixed by tap-to-reveal: words render as
   plain text by default (.word-plain, no buttons under them at all), and
   only the ONE word/cue currently being edited grows a panel with its
   own controls — the same "shrink until needed" idea already applied
   elsewhere in this file (brief #61's own .preview-compact), now applied
   to information density instead of literal screen space. */
.transcript-editor { display: flex; flex-direction: column; gap: 14px; }
.transcript-hint { color: var(--muted); font-size: 11px; margin-bottom: 2px; }
.transcript-save-error { color: #ff6b6b; font-size: 11px; margin-bottom: 4px; }
.cue-block { border: 1px solid var(--border); border-radius: 8px; padding: 8px; display: flex; flex-direction: column; gap: 6px; }
.cue-header { display: flex; align-items: center; gap: 8px; }
.cue-time { font-family: var(--font-mono); color: var(--muted); font-size: 11px; white-space: nowrap; flex: 1 1 auto; }
.cue-menu-btn {
  background: none; border: none; color: var(--muted); cursor: pointer; font-size: 16px;
  padding: 4px 8px; border-radius: 4px; flex: 0 0 auto; line-height: 1;
}
.cue-menu-btn:hover { background: var(--input-bg-alt); color: var(--text); }
/* Real cue-level actions (brief #62's own item 3) — a real dropdown-style
   panel, not a native <select>/<menu>, so it can hold multi-line time
   fields for "แก้เวลา Cue" and a real word-picker for "แยก Cue" (see
   .cue-split-picker) inline without a second popup layer. */
.cue-menu, .cue-time-edit-panel, .cue-split-picker {
  display: flex; flex-direction: column; gap: 6px; padding: 8px;
  background: var(--input-bg); border: 1px solid var(--border); border-radius: 6px;
}
.cue-menu-item {
  text-align: left; background: none; border: none; color: var(--text); cursor: pointer;
  font-size: 13px; padding: 6px 4px; border-radius: 4px;
}
.cue-menu-item:hover { background: var(--input-bg-alt); }
.cue-menu-item.danger { color: #ff6b6b; }
.cue-split-picker-hint { color: var(--muted); font-size: 11px; }
.cue-split-picker-words { display: flex; flex-wrap: wrap; gap: 6px; }
.cue-split-picker-word {
  font-size: 13px; padding: 4px 10px; border-radius: 6px; border: 1px solid var(--border);
  background: var(--input-bg-alt); color: var(--text); cursor: pointer;
}
.cue-split-picker-word:hover { border-color: var(--accent); }
/* The default, "nothing selected" state — plain text, no input chrome,
   no buttons underneath. This is the whole point of the redesign: at
   rest, a cue reads like a caption, not a form. */
.cue-words-row { display: flex; flex-wrap: wrap; gap: 6px 8px; font-size: 15px; line-height: 1.5; }
.word-plain { cursor: pointer; padding: 2px 3px; border-radius: 4px; }
.word-plain:hover { background: var(--input-bg-alt); }
/* brief #62 item (ข): the word currently under the video's own playhead —
   driven by the exact same findActiveCue/findActiveWordIndex the live
   caption overlay itself uses (see updateActiveWordHighlightInEditor()
   in app.js), so this can never highlight a different word than the
   subtitle burned into the preview is showing right now. */
.word-plain.playing { background: var(--accent); color: var(--accent-text); }
.word-inline-input {
  font-size: 15px; padding: 2px 6px; border-radius: 4px; border: 1px solid var(--accent);
  background: var(--input-bg); color: var(--text); width: 6em; min-width: 3em;
}
/* The "แก้ไขคำ" panel — only ONE of these exists at a time (single-focus
   editing, brief #62's own explicit "not a giant form" requirement) and
   it renders directly under whichever cue's word is currently open. */
.word-edit-panel {
  display: flex; flex-direction: column; gap: 8px; padding: 10px;
  background: var(--input-bg); border: 1px solid var(--accent); border-radius: 8px;
}
.word-edit-heading { font-size: 12px; color: var(--muted); font-weight: 600; }
.word-edit-text-input {
  font-size: 14px; padding: 6px 8px; border-radius: 6px; border: 1px solid var(--border);
  background: var(--input-bg-alt); color: var(--text); width: 100%;
}
.word-edit-time-row { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--muted); }
.word-edit-time-input {
  font-family: var(--font-mono); font-size: 12px; width: 84px; padding: 4px 6px; border-radius: 4px;
  border: 1px solid var(--border); background: var(--input-bg-alt); color: var(--text);
}
.word-edit-save-btn {
  align-self: flex-start; background: var(--accent); color: var(--accent-text); border: none;
  padding: 6px 16px; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer;
}
/* brief #62's own explicit "ปุ่มที่ใช้น้อยอยู่หลัง 'ตัวเลือกเพิ่มเติม'" —
   split/merge/split-cue/delete are all real, all still present (Toey's
   own "ห้ามลบความสามารถใดทิ้ง"), just one tap further away than the
   always-visible primary edit (text + time + save). */
.word-edit-more-toggle { color: var(--muted); font-size: 12px; cursor: pointer; user-select: none; }
.word-edit-more-options { display: flex; flex-direction: column; gap: 4px; padding-top: 4px; border-top: 1px solid var(--border); }
.word-edit-more-btn {
  text-align: left; font-size: 12px; padding: 6px 6px; border-radius: 4px; border: none;
  background: none; color: var(--text); cursor: pointer;
}
.word-edit-more-btn:hover { background: var(--input-bg-alt); }
.word-edit-more-btn.danger { color: #ff6b6b; }
.word-split-box { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; margin-top: 2px; }

/* ---- Timeline (brief #59 piece 2) ----
   Copied close to verbatim from caption_web/static/style.css's own
   .timeline-* rules (same class names) — cue-block/zoom/selection
   presentation, not render-parity-critical logic. .timeline-ruler is
   genuinely new (see index.html's own comment on why). */
.timeline-row { flex: 0 0 auto; display: flex; flex-direction: column; gap: 2px; margin-top: 10px; }
.timeline-toolbar { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 6px; height: 20px; }
.timeline-selected-info {
  display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1 1 auto; overflow: hidden;
  font-family: var(--font-mono); font-size: 10px;
}
.timeline-selected-time { flex: 0 0 auto; color: var(--accent); }
.timeline-selected-text { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
.timeline-edit-btn {
  flex: 0 0 auto; background: transparent; border: 1px solid var(--border); color: var(--text);
  width: 20px; height: 20px; font-size: 11px; line-height: 1; padding: 0; border-radius: 3px; cursor: pointer;
  display: flex; align-items: center; justify-content: center;
}
.timeline-zoom-controls { flex: 0 0 auto; display: flex; gap: 6px; }
/* brief #64 item 7: Toey's own explicit ask — "ให้ touch area ใหญ่ขึ้น แม้
   ตัวปุ่มจะดูเล็กเท่าเดิม" (bigger touch area, even though the button
   itself should still look the same small size) — "อย่าทำให้หน้าตาใหญ่ขึ้น"
   (don't make it look bigger). The REAL clickable element (this button)
   gets real padding, making its own bounding box genuinely bigger and
   directly measurable via getBoundingClientRect() — no pseudo-element
   geometry trickery needed for that part. The VISIBLE pill (background +
   border) is drawn by ::before instead, sized/positioned to recreate the
   old small ~20x18px look, inset within the new bigger invisible padding.
   ::before's negative z-index (inside this button's own new stacking
   context from `position:relative`) paints it BEHIND the button's own
   in-flow text content automatically — no extra wrapper span needed for
   the label to stay visible on top. */
.timeline-zoom-btn {
  position: relative;
  background: none; border: none; color: var(--muted);
  font-size: 10px; line-height: 1; padding: 9px 8px; min-width: 30px; min-height: 34px;
  display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
}
.timeline-zoom-btn::before {
  content: ""; position: absolute; left: 3px; right: 3px; top: 8px; bottom: 8px;
  background: var(--input-bg-alt); border: 1px solid var(--border); border-radius: 3px; z-index: -1;
}
.timeline-zoom-btn.active { color: var(--accent); }
.timeline-zoom-btn.active::before { border-color: var(--accent); }
/* brief #64 item 6: Toey's own explicit ask — the timeline strip and its
   own labels read too short/small to comfortably tap or read on a real
   phone. Heights and font sizes bumped; the fixed-height rows around it
   (.timeline-toolbar, .transport-row) are untouched, so this only ever
   eats into space beyond the preview's own protected 46%/55% floor (see
   .preview-main's own flex-shrink:0, brief #60) — re-verified with a
   real screenshot after this change, not assumed safe. */
.timeline-scroll { flex: 0 0 auto; height: 58px; overflow-x: auto; overflow-y: hidden; -webkit-overflow-scrolling: touch; }
.timeline-ruler { position: relative; height: 16px; min-width: 100%; }
.timeline-ruler-mark {
  position: absolute; top: 0; bottom: 0; border-left: 1px solid var(--border);
  font-family: var(--font-mono); font-size: 10px; color: var(--muted); padding-left: 3px; white-space: nowrap;
}
.timeline-track { position: relative; height: 40px; min-width: 100%; }
.timeline-cue {
  position: absolute; top: 3px; bottom: 3px; z-index: 1;
  background: var(--input-bg-alt); border: 1px solid var(--border); border-radius: 3px;
  overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
  font-size: 12px; line-height: 1; padding: 3px 4px; color: var(--muted); cursor: pointer;
}
.timeline-cue:hover { border-color: var(--accent); color: var(--text); }
.timeline-cue.no-label { background: var(--border); }
.timeline-cue.selected { border-color: var(--accent); border-width: 2px; color: var(--text); }
.timeline-playhead { position: absolute; top: 0; bottom: 0; width: 2px; z-index: 2; background: var(--accent); pointer-events: none; }

/* .export-bar itself is defined earlier, next to .preview-body/.preview-main/
   .preview-side — brief #60 replaced the old `position:fixed` sticky
   button (this comment block used to document it) after it turned out to
   float on top of the 3rd style card on mobile, unreadable. See
   index.html's own comment on the new element for the full story. */
#reexportBtn { margin-left: -2px; }

/* ---- brief #60: responsive breakpoints ----
   Per aree's explicit instruction, these reuse caption_web's own
   already-Toey-tuned breakpoint VALUES (768/max-height 600+landscape) —
   not reinvented. The direction of the query is flipped from
   caption_web's own file: caption_web is desktop-first (its base,
   unqueried styles ARE the side-by-side layout, with `max-width: 768px`
   overriding down to a mobile column) — ticker_app's base is mobile
   (matching how it was actually built and tested against Toey's real
   phone throughout brief #59), so the desktop row layout is the
   `min-width: 769px` override here instead. Net effect at the boundary
   is identical either way: ≤768px is mobile, ≥769px is desktop, exactly
   matching caption_web's own cutoff.

   No separate 639px rule (caption_web's own use for it — switching
   between its accordion+stepnav and its bottomnav+full-page-tabs — has
   no equivalent here: ticker_app uses the same tab bar at every width,
   there's no second mobile-only nav system to toggle). Tested at 360px
   (this brief's own acceptance width) with no problem needing a rule at
   that intermediate breakpoint specifically. */
@media (min-width: 769px) {
  /* brief #66: the fixed-bottom-tabbar clearance now lives on
     .preview-body (moved up from .preview-side, see its own comment on
     the base rule) — reset to 0 here for the same reason .preview-side's
     own clearance used to be: desktop's tab bar is back in normal
     document flow (position:static, below), not fixed, so it needs no
     reservation at all. */
  .preview-body { flex-direction: row; padding-bottom: 0; }
  /* No percentage min-height floor needed here — with .preview-side now
     beside it (sharing width, not height), .preview-main naturally fills
     the full available height on its own, which is already "much
     bigger" than any mobile floor without needing one. flex-grow:1
     explicit (brief #61) so the mobile-only .preview-compact class —
     toggled by the same tab-click JS on every viewport, not just mobile
     — can never actually change anything here: desktop always gets the
     video filling its full remaining width regardless of which tab is
     open. */
  .preview-frame-wrap { min-height: 0; flex-grow: 1; }
  /* brief #69 (real bug, Toey's own real 162s clip — never caught before
     since every synthetic test clip used so far was a few seconds long):
     the base rule's `flex: 1 0 auto` (flex-shrink: 0) exists for the
     MOBILE case, where .preview-main is a flex COLUMN item and the 0
     shrink protects its own min-height floor (see the base rule's own
     comment) — reused here by inheritance where .preview-main is instead
     a flex ROW item, so the SAME shrink:0 now protects its WIDTH
     instead, for no reason anyone intended. #timelineTrack's width is
     set in JS to durationSeconds*50px (app.js's TIMELINE_PX_PER_SECOND)
     — for anything longer than ~30s that's already wider than
     .preview-main's own normal ~1540px width, and #timelineScroll's own
     `overflow-x: auto` (style.css, meant to let a long timeline scroll
     WITHIN its own box) never gets the chance to do its job: with
     flex-shrink:0 AND the default `min-width: auto`, a flex item's
     computed width floors at its own content's real size regardless —
     confirmed empirically (a real Playwright repro with a real 162s
     video, not reasoned from the CSS alone: neither flex-shrink:1 alone
     nor min-width:0 alone fixed it, only BOTH together did) that
     .preview-main just kept growing to the timeline's real (8100px for
     Toey's clip) width instead, shoving #previewSide (the tab bar +
     style/word/customize panel) far off past the real 1920px viewport,
     where .preview-body's own `overflow: hidden` clips it invisible —
     matching Toey's own real screenshot exactly (no tabs, no side
     panel, video filling the entire width) despite no thrown exception
     anywhere and every element still genuinely present in the DOM.
     min-width:0 alone doesn't help because flex-shrink:0 already forbids
     any shrinking in the first place, and flex-shrink:1 alone doesn't
     help because the automatic (`auto`) minimum width still floors it
     right back up to the same content size — both together are needed.
     Scoped to this desktop-only block: the mobile base rule's own
     flex-shrink:0 (a different axis there) is untouched. */
  .preview-main { flex-grow: 1; flex-shrink: 1; min-width: 0; }
  /* brief #61: #previewBody.preview-compact's OWN rules (an ID + 2
     classes) are MORE specific than a plain `.preview-frame-wrap`
     selector, media query or not — a real bug caught by an actual
     before/after screenshot diff, not assumed from reading the CSS: the
     two plain-class resets just above looked like they should cancel
     compact mode here, but the compact rule kept winning anyway and
     desktop's own preview visibly shrank when a non-"พรีวิว" tab was
     open. These match the compact rule's own selector shape exactly so
     they're equally specific, and — appearing later in the file — win by
     source order, actually cancelling it out this time. */
  #previewBody.preview-compact .preview-frame-wrap { flex-grow: 1; min-height: 0; }
  #previewBody.preview-compact .preview-main { flex-grow: 1; }
  #previewBody.preview-compact .preview-side { flex-grow: 0; }
  /* flex:0 0 380px (fixed width, no grow/shrink) mirrors caption_web's
     own .control-panel exactly — same 380px Toey has already lived with
     there. overflow-y:auto so a tall customize tab scrolls internally
     rather than pushing the sidebar's own height around. */
  .preview-side {
    flex: 0 0 380px; max-width: 380px; min-width: 280px;
    border-left: 1px solid var(--border); border-top: none;
  }
  /* brief #61: .preview-tabbar returns to plain in-flow positioning here
     (position:static — not fixed) — desktop's own sidebar column already
     has plenty of room above the fold for it, unlike the cramped mobile
     case this brief's fixed-bottom-bar fix targets. padding/.preview-tab
     sizing explicitly restored to their exact pre-#61 values too (the
     mobile base rule now uses a bigger 44px touch target for the fixed
     bottom bar, which — caught the same way as the compact-mode bug
     above, via a real screenshot diff, not assumed safe — silently
     shifted desktop's own tab row by a few px otherwise). This, combined
     with #exportBar/.btn-export-top/#reexportBtnTop below, is what keeps
     desktop's own screenshot byte-for-byte the same as before this brief
     — nothing about this query's PRE-#61 rules changed, only these
     additions were appended. */
  .preview-tabbar {
    border-top: none; position: static; background: none; overflow-x: visible;
    padding: 0 16px 12px; padding-top: 12px;
  }
  /* brief #66: explicitly restore pre-#66 sizing — the new mobile-only
     full-width tab distribution (`flex: 1 1 0%` on the base rule) has no
     reason to exist here, desktop's own tight, left-packed small tabs
     must render byte-for-byte the same as before this brief. */
  .preview-tab {
    padding: 6px 4px; min-height: 0; display: block; align-items: normal;
    flex: 0 0 auto; min-width: 0; justify-content: normal;
  }
  .export-bar { display: flex; }
  .btn-export-top, #reexportBtnTop { display: none; }
  /* brief #68 item D4: the CSS-driven counterpart to the base rule's own
     `.touch-target-wrap:has(> [hidden])` — these two buttons are hidden
     here via a plain display:none rule, not the `hidden` attribute, so
     that :has() selector alone doesn't catch this case. Without this,
     desktop kept two extra `gap`s worth of dead flex space where these
     buttons used to cleanly disappear pre-#68 — a real, measured
     regression caught by a real screenshot diff, not assumed safe. */
  .touch-target-wrap:has(> .btn-export-top), .touch-target-wrap:has(> #reexportBtnTop) { display: none; }
}

/* Mirrors caption_web's own landscape-phone treatment exactly (same
   260px sidebar, same 160px floor, same max-height:600 + orientation
   trigger) — a landscape phone is technically ≥769px wide plenty of the
   time (e.g. 812×375), so this is deliberately AFTER the desktop query
   above: equal-specificity rules resolve by source order, and this one
   needs to win for that shape specifically (a narrower, Toey-tuned
   sidebar fits a real phone turned sideways better than desktop's own
   380px choice, which assumes much more available width than a phone
   landscape actually has). */
@media (max-height: 600px) and (orientation: landscape) {
  /* brief #66: same reasoning as the desktop query above — the
     fixed-bottom-tabbar clearance now lives on .preview-body, reset to 0
     here since landscape also puts the tab bar back in normal, static
     in-flow positioning (below). */
  .preview-body { flex-direction: row; padding-bottom: 0; }
  .preview-frame-wrap { min-height: 160px; flex-grow: 1; }
  .preview-main { flex-grow: 1; }
  .preview-side { flex: 0 0 260px; max-width: 260px; min-width: 0; }
  /* brief #61: same overlap risk as caption_web's own brief #48 fix for
     the identical situation — .preview-compact (meant for the STACKED
     mobile-portrait case) must not carry into landscape, which already
     puts preview and controls side by side with no vertical space
     competition regardless of which tab is open. Unlike the desktop
     query above, .preview-compact's own rules aren't scoped to a
     max-width query on this mobile-first file (they're part of the base
     case), so this landscape query — which can match at the same time as
     the base rules for a narrow phone turned sideways — is what actually
     cancels their effect here. Matches the compact rule's own ID+class
     selector shape (same real specificity-mismatch bug caught via a
     desktop screenshot diff, fixed the same way in both places — see the
     desktop query's own comment above for the full story). */
  #previewBody.preview-compact .preview-frame-wrap { flex-grow: 1; min-height: 160px; }
  #previewBody.preview-compact .preview-main { flex-grow: 1; }
  #previewBody.preview-compact .preview-side { flex-grow: 0; }
  /* Same reasoning for the tab bar itself: back to normal in-flow
     positioning, not fixed — landscape's fixed 260px sidebar already
     keeps it on-screen without needing to pin it to the viewport edge.
     Padding/touch-target explicitly restored to landscape's own
     pre-#61 values too, same reasoning as the desktop query. */
  .preview-tabbar { position: static; background: none; overflow-x: visible; padding: 0 16px 12px; padding-top: 12px; }
  /* brief #66: same explicit restore as the desktop query above — the
     mobile-only full-width tab distribution has no reason to exist in
     landscape either. */
  .preview-tab {
    padding: 6px 4px; min-height: 0; display: block; align-items: normal;
    flex: 0 0 auto; min-width: 0; justify-content: normal;
  }
}
