/* ==========================================================================
   Studio — visual system
   ==========================================================================

   Built on one idea borrowed from the reference design: there is no separate
   light palette and dark palette. There is a neutral gray ramp plus a single
   ALPHA ramp that flips from black-on-light to white-on-dark. Every border,
   divider, hover and subtle fill is an alpha value, never a solid. That is
   what lets surfaces stack at any depth and what makes the dark theme need
   essentially no hand-tuning.

   Selection follows the same restraint: an active row is a 4%-alpha fill plus
   a jump from 53%-opacity text to full contrast. No accent bar, no colour, no
   weight change. In an app where the user stares at one list for an hour,
   that quietness is the feature.
   ========================================================================== */

/* ==========================================================================
   Inter, served from this origin

   These were an `@import` against a third-party font CDN, which the login
   screen pulled like every other page — so the IP, User-Agent and Referer of
   anyone who merely *opened* the studio went to someone else's server, and
   then to a second one for the files themselves, before they had proved they
   had an account. The vendor is not named here on purpose: this stylesheet is
   served verbatim, comments and all, so a hostname written in it is a hostname
   the reader learns. That the product depends on nobody outside this origin is
   the point of the change, and a comment is not the place to give it back.

   All four weights point at one file per subset because Inter v20 is a
   variable font, and the stylesheet these rules replace did the same. The
   weights stay pinned at 400/500/600/700 rather than collapsing to a
   `400 700` range: a range would let `.tour-title`'s `font-weight: 620` render
   at 620 instead of snapping up to 700 the way it does today, and a heading
   quietly losing weight is not a change anyone asked for here.

   `unicode-range` is what keeps four files cheap — a Russian session fetches
   the two Cyrillic subsets and never touches latin-ext. Greek and Vietnamese
   are the two subsets deliberately left behind: no source or target language
   this studio offers uses either script, and a stray Greek character in a
   video title falls through to `system-ui` exactly as Arabic and Chinese
   already do.
   ========================================================================== */

/* cyrillic-ext — Kazakh, Kyrgyz, Uzbek, Tajik, Tatar, Bashkir and Turkmen all
   live here, not in `cyrillic`: Ә, Ө, Ү, Ң and Қ are above U+0460. Dropping
   this subset would leave seven of the nine target languages rendering their
   own letters in a fallback face. */
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/fonts/inter-cyrillic-ext.woff2") format("woff2");
  unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url("/fonts/inter-cyrillic-ext.woff2") format("woff2");
  unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("/fonts/inter-cyrillic-ext.woff2") format("woff2");
  unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url("/fonts/inter-cyrillic-ext.woff2") format("woff2");
  unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}

/* cyrillic — Russian, the primary interface language and the default target. */
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/fonts/inter-cyrillic.woff2") format("woff2");
  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url("/fonts/inter-cyrillic.woff2") format("woff2");
  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("/fonts/inter-cyrillic.woff2") format("woff2");
  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url("/fonts/inter-cyrillic.woff2") format("woff2");
  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}

/* latin-ext — Azerbaijani and Turkmen are written in Latin, and their Ə, Ğ, Ň
   and Ž are all above U+0100. The same subset carries the accented characters
   of any European title the sidebar prints. */
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/fonts/inter-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url("/fonts/inter-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("/fonts/inter-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url("/fonts/inter-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

/* latin — the English interface, and the digits every timestamp is made of. */
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/fonts/inter-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url("/fonts/inter-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("/fonts/inter-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url("/fonts/inter-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

:root {
  /* neutral ramp */
  --gray-50: #fafafa;  --gray-75: #ededed;  --gray-100: #e3e3e3;
  --gray-200: #cfcfcf; --gray-300: #b8b8b8; --gray-400: #a3a3a3;
  --gray-500: #8c8c8c; --gray-700: #5c5c5c; --gray-800: #454545;
  --gray-900: #2e2e2e; --gray-950: #171717;

  /* alpha ramp — the workhorse */
  --a50: #00000005;  --a75: #00000008;  --a100: #0000000b; --a150: #00000013;
  --a200: #0000001a; --a250: #00000022; --a300: #00000029; --a400: #00000059;
  --a450: #00000070; --a500: #00000087; --a600: #000000a4;

  /* semantic */
  --bg: #ffffff;
  --bg-alt: #fcfcfc;
  --surface: #fafafa;
  /* A panel that floats *over* the page rather than sitting in it — the
     selection bar. In light it is the page colour lifted off the surface by a
     shadow; in dark, where a shadow reads as nothing, it has to be a genuinely
     lighter fill or the bar disappears into the list behind it. */
  --bg-raised: #ffffff;
  --fg: #0f0f10;
  --fg-secondary: var(--a500);
  --fg-subtle: var(--a450);
  --border: var(--a150);
  --border-strong: var(--a300);
  --accent: #0f0f10;
  --accent-fg: #ffffff;
  --accent-hover: #454545;
  --accent-active: #5c5c5c;

  --ok: #8ab895;
  --warn: #cfaa77;
  --bad: #c5857f;
  --danger: #dd4949;
  --danger-border: #ef4343;

  /* Text selection is the one place this palette breaks its own no-colour
     rule. A 10%-black wash reads as "slightly dirty text" rather than as a
     selection, and selecting a phrase to retype it is the single most-repeated
     gesture in the editor — it has to be unmistakable. Blue because every
     other colour here already means something (ok / warn / bad / danger). */
  --select-bg: #2563eb;
  --select-fg: #ffffff;

  /* elevation: layered black in light, swapped for a white ring in dark */
  --shadow-natural-xs:
    0 0 0 1px #0000000f,
    0 1px 1px -0.5px #0000000f,
    0 3px 3px -1.5px #0000000f;
  --shadow-popover:
    0 0 0 1px #0000000f, 0 1px 1px -0.5px #0000000f, 0 3px 3px 0 #0000000f,
    0 6px 6px 0 #0000000f, 0 12px 12px 0 #0000000a, 0 24px 24px 0 #0000000a;

  --radius: 10px;
  --radius-sm: 8px;
  --radius-xs: 6px;
  --radius-lg: 12px;
  --radius-xl: 16px;

  --header-h: 50px;
  /* The session panel. Collapsed it keeps a rail, so the toggle is always in
     the same place and the list can never be lost. 300px is what a title, a
     thumbnail and a status line need before the title starts truncating at
     four words. */
  --rail-w: 48px;
  /* The sidebar meter asks for this and it was never declared here — only in
     admin.css, which is a different bundle on a different page. So the footer
     bar's base fill and its text have been resolving to nothing since they
     were written, and only looked right because the `data-level` overrides
     that replace the fill declare their own fallbacks. An alias rather than a
     new colour: `--fg-secondary` is what every other piece of secondary text
     in this file already uses. */
  --muted: var(--fg-secondary);
  --sidebar-w: 300px;
  --font: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
  --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}

@media (prefers-color-scheme: dark) {
  :root {
    --gray-50: #171717;  --gray-75: #212121;  --gray-100: #2e2e2e;
    --gray-200: #454545; --gray-300: #5c5c5c; --gray-400: #757575;
    --gray-500: #8c8c8c; --gray-700: #b8b8b8; --gray-800: #cfcfcf;
    --gray-900: #e3e3e3; --gray-950: #fafafa;

    --a50: #ffffff06;  --a75: #ffffff08;  --a100: #ffffff0b; --a150: #ffffff13;
    --a200: #ffffff1a; --a250: #ffffff22; --a300: #ffffff29; --a400: #ffffff59;
    --a450: #ffffff70; --a500: #ffffff87; --a600: #ffffffa4;

    --bg: #0f0f10;
    --bg-alt: #0f0f10;
    --surface: #171717;
    --bg-raised: #212121;
    --fg: #ffffff;
    --fg-secondary: var(--a600);
    --fg-subtle: var(--a500);
    --accent: #ffffff;
    --accent-fg: #0f0f10;
    --accent-hover: #cfcfcf;
    --accent-active: #b8b8b8;

    /* A shade up from the light value: the same blue goes muddy against a
       near-black page, and white-on-it still clears 4.5:1. */
    --select-bg: #2f6feb;
    --select-fg: #ffffff;

    --shadow-natural-xs:
      0 0 0 1px rgba(255, 255, 255, 0.12),
      0 1px 1px -0.5px rgba(255, 255, 255, 0.12),
      0 3px 3px -1.5px rgba(255, 255, 255, 0.12);
    --shadow-popover:
      0 0 0 1px rgba(255, 255, 255, 0.12),
      0 12px 32px -8px rgba(0, 0, 0, 0.6);
  }
}

*, *::before, *::after {
  box-sizing: border-box;
  border-color: var(--border);
}

/* Any class that sets `display` outranks the UA rule for [hidden], which
   would leave elements the app has hidden — the Download button before an
   export exists, the selection bar with nothing selected — on screen. */
[hidden] { display: none !important; }

html {
  font-size: 16px;
  font-family: var(--font);
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
  font-feature-settings: "kern";
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--fg);
  font-size: 14px;
  overflow: hidden;
}

::placeholder { color: var(--fg-subtle); }

::selection {
  background: var(--select-bg);
  color: var(--select-fg);
}

/* Nothing animates unless it asks to — the reference sets its default
   transition duration to zero and so do we. Motion here is a signal, not
   decoration. */
* { transition-duration: 0s; }

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

.skip-link {
  position: absolute;
  left: -9999px;
  top: 8px;
  z-index: 100;
  padding: 8px 12px;
  border-radius: var(--radius);
  background: var(--fg);
  color: var(--bg);
}
.skip-link:focus { left: 8px; }

/* ------------------------------------------------------------------ */
/* primitives                                                         */
/* ------------------------------------------------------------------ */

.icon {
  flex-shrink: 0;
  fill: none;
  stroke: currentColor;
  /* 2 on a 24-unit grid rendered at 18px lands on the reference's 1.5px. */
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  height: 36px;
  padding: 0 12px;
  border: 0;
  border-radius: var(--radius);
  background: transparent;
  color: var(--fg);
  font-family: inherit;
  font-size: 14px;
  font-weight: 500;
  white-space: nowrap;
  cursor: pointer;
  transition: background-color 75ms, border-color 75ms, color 75ms;
}
.btn:disabled { cursor: not-allowed; }
/* Several buttons in this app are anchors on purpose — "New video" is `#/`,
   the admin panel is a real document, a finished export is a file — so that
   they can be middle-clicked, show a URL on hover, and be saved. None of them
   should wear a link's underline. This used to be scoped to `.app-actions`,
   which is why "New video" in the panel head had one and the header's did
   not. */
a.btn { text-decoration: none; }

.btn-sm { height: 32px; padding: 0 10px; border-radius: var(--radius-sm); font-size: 13px; }
.btn-lg { height: 40px; padding: 0 16px; border-radius: var(--radius-lg); }
.btn-icon { width: 36px; padding: 0; }
.btn-icon.btn-sm { width: 32px; }

.btn-primary { background: var(--accent); color: var(--accent-fg); }
.btn-primary:hover:not(:disabled) { background: var(--accent-hover); }
.btn-primary:active:not(:disabled) { background: var(--accent-active); }
.btn-primary:disabled { background: var(--gray-400); color: var(--gray-100); }

.btn-secondary {
  background: var(--bg);
  border: 1px solid var(--a200);
  color: var(--fg);
}
.btn-secondary:hover:not(:disabled) { background: var(--a50); border-color: var(--a300); }
.btn-secondary:active:not(:disabled) { background: var(--a100); }
.btn-secondary:disabled { color: var(--fg-subtle); }

.btn-ghost { background: transparent; }
.btn-ghost:hover:not(:disabled) { background: var(--a100); }
.btn-ghost:active:not(:disabled) { background: var(--a200); }
.btn-ghost:disabled { color: var(--fg-subtle); }

/* Export is the one irreversible, minutes-long action in the app, so it is
   the one control allowed to look different from everything else. */
.btn-accent {
  background: var(--a100);
  border: 1px solid var(--a200);
  color: var(--fg);
}
.btn-accent:hover:not(:disabled) { background: var(--a150); border-color: var(--a300); }
.btn-accent:disabled { color: var(--fg-subtle); background: transparent; }

/* No blue. A foreground ring separated from the control by a gap in the page
   colour, so it reads on any surface. */
.btn:focus-visible,
.line:focus-visible,
.recent-item:focus-visible,
.mode-card:focus-visible {
  outline: none;
  box-shadow: 0 0 0 2px var(--bg), 0 0 0 3.5px var(--fg);
}

.input, .select, .editor-input {
  height: 36px;
  padding: 4px 12px;
  border: 1px solid var(--a200);
  border-radius: var(--radius);
  background: transparent;
  color: var(--fg);
  font-family: inherit;
  font-size: 14px;
  line-height: 20px;
  transition: border-color 100ms;
}
.input:hover, .select:hover, .editor-input:hover { border-color: var(--a300); }
.input:focus-visible, .select:focus-visible, .editor-input:focus-visible {
  outline: none;
  border-color: var(--fg);
  box-shadow: 0 0 0 0.5px var(--fg);
}
.input-sm, .select-sm { height: 32px; border-radius: var(--radius-sm); font-size: 13px; }
.input-lg { height: 48px; border-radius: var(--radius-lg); font-size: 16px; padding: 0 16px; }
.select { padding-right: 8px; cursor: pointer; }

.tag {
  display: inline-flex;
  align-items: center;
  height: 20px;
  padding: 0 8px;
  border: 1px solid transparent;
  border-radius: 9999px;
  font-size: 12px;
  font-weight: 500;
  white-space: nowrap;
}
.tag-soft { background: var(--a100); color: var(--fg-secondary); }
.tag-edited { background: var(--a100); color: var(--fg-secondary); }
.tag-draft { border-color: var(--a200); color: var(--fg-subtle); }
.tag-stale { background: color-mix(in srgb, var(--warn) 22%, transparent); color: var(--fg); }
.tag-warn { background: color-mix(in srgb, var(--warn) 22%, transparent); color: var(--fg); }
.tag-failed { background: color-mix(in srgb, var(--bad) 25%, transparent); color: var(--fg); }
.tag-voice { border-color: var(--a200); color: var(--fg-secondary); font-variant-numeric: tabular-nums; }

.toggle {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 13px;
  color: var(--fg-secondary);
  white-space: nowrap;
  cursor: pointer;
  user-select: none;
}
.toggle input { accent-color: var(--fg); margin: 0; }

.spacer { flex: 1; }

.scroll-subtle { scrollbar-width: thin; scrollbar-color: var(--gray-300) transparent; }
.scroll-subtle::-webkit-scrollbar { width: 6px; height: 6px; }
.scroll-subtle::-webkit-scrollbar-track { background: transparent; }
.scroll-subtle::-webkit-scrollbar-thumb {
  background-color: var(--gray-300);
  background-clip: padding-box;
  border: 1px solid transparent;
  border-radius: 9999px;
}
.scroll-subtle::-webkit-scrollbar-thumb:hover { background-color: var(--gray-400); }

/* ==========================================================================
   the shell — session rail on the left, the current view on the right
   ==========================================================================

   The panel is docked (it pushes the view over) only above `--dock-width`
   below; narrower than that it floats over the view with a scrim, because a
   300px column taken out of a 1100px window leaves the studio's two panes
   fighting for the space the video needs.

   1280px is the threshold, and it is derived rather than picked: the studio
   wants ~980px to keep the script and the picture side by side, and 980 + 300
   is 1280. Between 1280 and roughly 1370 the *script* pane itself drops under
   its own 560px container query and compare mode stacks its two columns —
   which is that query doing its job, and is why the split shifts toward the
   script while the panel is docked (see `.app-shell.is-open .app-body`).
   ========================================================================== */

.app-shell {
  position: relative;
  height: 100dvh;
  overflow: hidden;
  /* The rail is always in flow. Everything else about the panel is layered
     on top of it, so the view's left edge never jumps by more than nothing
     when the panel opens over it. */
  padding-left: var(--rail-w);
}

.app-view { height: 100%; min-width: 0; }

.sidebar {
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  z-index: 40;
  display: flex;
  width: var(--rail-w);
  background: var(--bg);
  border-right: 1px solid var(--border);
  overflow: hidden;
}
.sidebar.is-open {
  width: var(--sidebar-w);
  /* Floating over the view by default: it needs an edge. The shadow is
     dropped again once it is docked, where it would be drawing a division
     that the border already draws. */
  box-shadow: var(--shadow-popover);
}

.sidebar-rail {
  flex: 0 0 var(--rail-w);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  padding: 7px 0;
}
.sidebar-rail .btn-icon { width: 34px; height: 34px; }
/* With the panel open the search box is right there; a second way in from the
   rail would be two controls for one thing, six pixels apart. */
.sidebar.is-open .sidebar-rail-search { display: none; }

.sidebar-panel {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  border-left: 1px solid var(--border);
}
.sidebar-head {
  display: flex;
  align-items: center;
  height: var(--header-h);
  padding: 0 12px;
  flex: 0 0 auto;
}
.sidebar-heading {
  margin: 0;
  font-size: 13px;
  font-weight: 500;
  color: var(--fg-secondary);
}
/* The "New video" button, where the word "Sessions" used to be. Full width
   because it is the primary action of the whole panel, and a button that fills
   its column cannot be mistaken for a list item in the list below it. */
.sidebar-new {
  width: 100%;
  justify-content: center;
  gap: 6px;
  height: 34px;
}
.sidebar-search { flex: 0 0 auto; padding: 0 12px 8px; }
.sidebar-search-input { width: 100%; }

.sidebar-list {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  padding: 0 8px 12px;
}
.sidebar-sentinel { height: 1px; }

.sidebar-foot {
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 4px;
  padding: 8px 12px;
  border-top: 1px solid var(--border);
}
.sidebar-account { display: flex; align-items: center; gap: 8px; }

/* Group three: the things set once — interface language, Telegram, admin.
   A hairline above it rather than more gap, because the distance that would
   read as a group boundary in a 250px column is distance the list above could
   be using. Full-width children so the group reads as rows of one list and
   not as three controls of three different shapes. */
.sidebar-settings {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 6px;
  margin-top: 4px;
  padding-top: 8px;
  border-top: 1px solid var(--border);
}
.sidebar-settings:empty { display: none; }

/* The footer meter. Sits above the account line because it is the only thing
   in the footer that can stop work from starting.

   It used to be a storage quota — `28 MB of 25 GB` — which measured a VPS
   disk the user does not own. It now shows jobs left today to an ordinary
   user and free disk to an admin; the geometry is the same for both, only the
   sentence under it changes. Named `sidebar-meter*` rather than `meter*`
   because the editor's fit bar already owns `.meter-bar` and `.meter-fill`,
   and two unrelated meters answering the same selector is a rule that gets
   changed for one of them and silently repaints the other. */
.sidebar-meter {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 4px 0 8px;
}
.sidebar-meter-bar {
  height: 4px;
  border-radius: 999px;
  background: var(--border);
  overflow: hidden;
}
.sidebar-meter-fill {
  display: block;
  height: 100%;
  border-radius: inherit;
  background: var(--muted);
  transition: width .3s ease, background-color .3s ease;
}
/* Three states, not a gradient: the bar answers one question — is it time to
   stop and do something — and a colour that creeps up continuously never
   announces the moment the answer changes. Colour is never the only signal;
   the figure underneath always reads the same. */
.sidebar-meter-bar[data-level="high"] .sidebar-meter-fill { background: var(--warn, #d98324); }
.sidebar-meter-bar[data-level="full"] .sidebar-meter-fill { background: var(--danger, #d64545); }
.sidebar-meter-text {
  font-size: 11px;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: reduce) {
  .sidebar-meter-fill { transition: none; }
}
.sidebar-link { justify-content: flex-start; padding-left: 0; }
/* Was `.sidebar-link-code`, holding a one-time `LNK-XXXXXX` in a monospace
   `user-select: all` block. The bot takes the account's own email and password
   now, so there is no code to select — what goes here is the way into the bot
   and whether this account is already bound to one. */
.sidebar-link-info {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 8px 10px;
  border-radius: var(--radius-sm);
  background: var(--a75);
}
.link-open { font-size: 13px; color: var(--accent); }
a.link-open { text-decoration: none; }
a.link-open:hover { text-decoration: underline; }
.link-hint { font-size: 11px; line-height: 1.4; color: var(--fg-subtle); }
.sidebar-user {
  flex: 1;
  min-width: 0;
  font-size: 12px;
  color: var(--fg-subtle);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.sidebar-state { padding: 28px 12px; text-align: center; }
.sidebar-note {
  margin: 0 0 10px;
  font-size: 12px;
  line-height: 1.5;
  color: var(--fg-subtle);
}
.sidebar-hint { margin: 0; font-size: 11px; line-height: 1.5; color: var(--fg-subtle); }
.sidebar-count { margin: 8px 8px 4px; font-size: 11px; color: var(--fg-subtle); }

/* Only ever seen while the panel floats. Docked, there is nothing to dismiss. */
.sidebar-scrim { display: none; }
@media (max-width: 1279px) {
  .app-shell.is-open .sidebar-scrim {
    display: block;
    position: absolute;
    inset: 0;
    z-index: 30;
    background: rgba(0, 0, 0, 0.28);
  }
}

@media (min-width: 1280px) {
  .app-shell.is-open { padding-left: var(--sidebar-w); }
  .sidebar.is-open { box-shadow: none; }
  /* With the panel docked, the split shifts toward the script.
     The script pane has a hard minimum — below 560px its two columns stack
     and compare mode, the reason this screen exists, is gone. The player pane
     has no such cliff: a narrower video is a smaller video. So the pane that
     can degrade gracefully is the one that gives up the width. */
  .app-shell.is-open .app-body {
    grid-template-columns: minmax(360px, 1.1fr) minmax(420px, 1fr);
  }
}

/* ------------------------------------------------------------------ */
/* session rows                                                       */
/* ------------------------------------------------------------------ */

.session-row {
  position: relative;
  display: flex;
  align-items: center;
  border-radius: var(--radius-sm);
  transition: background-color 75ms;
}
.session-row:hover { background: var(--a50); }
.session-row.is-current { background: var(--a100); }
.session-row:focus-within {
  outline: none;
  box-shadow: 0 0 0 2px var(--bg), 0 0 0 3.5px var(--fg);
}

/* A stretched link rather than a button inside an anchor: the whole row is
   one target, the delete control is a separate one, and the markup stays
   valid — nesting interactive elements is where keyboard order goes wrong. */
.session-link {
  display: flex;
  align-items: center;
  gap: 8px;
  flex: 1;
  min-width: 0;
  padding: 8px;
  color: inherit;
  text-decoration: none;
}
.session-link::after { content: ""; position: absolute; inset: 0; border-radius: inherit; }
.session-link:focus-visible { outline: none; }

.session-thumb {
  position: relative;
  flex: 0 0 auto;
  display: grid;
  place-items: center;
  width: 56px;
  height: 32px;
  border-radius: var(--radius-xs);
  background: var(--a100);
  color: var(--fg-subtle);
  overflow: hidden;
}
.session-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* The running time, bottom-right of its own picture. Always white on black
   rather than on a theme colour: it sits on a thumbnail we did not choose and
   cannot predict, so it has to be legible over a bright frame and a dark one
   alike. */
.session-duration {
  position: absolute;
  right: 2px;
  bottom: 2px;
  padding: 0 3px;
  border-radius: 2px;
  background: rgba(0, 0, 0, 0.72);
  color: #fff;
  font-size: 9px;
  line-height: 13px;
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.01em;
  pointer-events: none;
}
/* Swept media: the row is still real and still searchable, so it is dimmed
   rather than struck through. */
.session-thumb.is-swept { opacity: 0.45; }

.session-main { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.session-title {
  font-size: 13px;
  font-weight: 500;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.session-sub {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 11px;
  color: var(--fg-subtle);
  white-space: nowrap;
  overflow: hidden;
}
/* The time is the one part of this line that must never be the part that gets
   cut: it is what the list is sorted by. So it holds its size and the pair
   ellipsises instead. */
.session-sub .status { flex: 0 0 auto; }
.session-pair { flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; }
.session-time {
  flex: 0 0 auto;
  margin-left: auto;
  padding-left: 4px;
  font-variant-numeric: tabular-nums;
}
/* Only rendered past 100MB, so it is never competing for width on a row where
   it would not matter. Sits after the timestamp, which keeps its own
   margin-left:auto and so stays the thing pinned to the right edge. */
.session-size {
  flex: 0 0 auto;
  padding-left: 6px;
  font-variant-numeric: tabular-nums;
  opacity: .75;
}
.session-at { font-variant-numeric: tabular-nums; }
/* The bulk-submission strip. Above the list and outside its scroll container,
   so the one figure worth watching and the one way to stop it stay put while
   the fifty rows they describe scroll past. Collapsed to nothing when empty,
   which is every account that has never used bulk translation. */
.sidebar-batches:empty { display: none; }
.sidebar-batches {
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 8px 12px;
  border-bottom: 1px solid var(--border);
}
.sidebar-batch { display: flex; flex-direction: column; gap: 5px; }
.sidebar-batch-main {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 8px;
}
.sidebar-batch-name {
  font-size: 12px;
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.sidebar-batch-count { font-size: 11px; color: var(--fg-secondary); white-space: nowrap; }
.sidebar-batch-bar {
  height: 4px;
  border-radius: 2px;
  background: var(--bg-sunken, rgba(127, 127, 127, 0.2));
  overflow: hidden;
}
.sidebar-batch-fill {
  display: block;
  height: 100%;
  background: var(--accent, currentColor);
  transition: width 0.3s ease;
}
.sidebar-batch-cancel { align-self: flex-start; }

.session-expired { align-self: flex-start; margin-top: 3px; }
.session-batch { align-self: flex-start; margin-top: 3px; opacity: 0.75; }

/* The two controls that sit on a session row, styled as one thing.
 *
 * They were separate rules and drifted: the download arrow was given only an
 * opacity fade, so it never got the `position`/`z-index` that lifts it above
 * the row's full-bleed link — which meant the pointer landed on the link
 * instead and `.btn-ghost:hover` never fired. The halo was not missing from
 * the stylesheet; the hover was never reaching the element. Anything added to
 * that corner of the row belongs in this selector. */
.session-delete,
.session-download {
  position: relative;
  z-index: 1;
  flex: 0 0 auto;
  width: 28px;
  height: 28px;
  opacity: 0;
  transition: opacity 75ms;
}
.session-delete { margin-right: 4px; }
.session-row:hover .session-delete,
.session-row:focus-within .session-delete,
.session-row:hover .session-download,
.session-row:focus-within .session-download { opacity: 1; }
/* Touch has no hover, so a control that only appears on hover is a control
   that does not exist on a phone. */
@media (hover: none) {
  .session-delete,
  .session-download { opacity: 1; }
}

.search-result .session-link { align-items: flex-start; }
.search-excerpt {
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
  font-size: 12px;
  line-height: 1.45;
  color: var(--fg-secondary);
}
/* The hit itself. The one place in this app that renders markup the server
   composed — and it renders exactly one tag, built as a real element from a
   parsed string rather than assigned through innerHTML. */
.search-excerpt b {
  font-weight: 600;
  color: var(--fg);
  background: color-mix(in srgb, var(--warn) 30%, transparent);
  border-radius: 3px;
  padding: 0 1px;
}

/* ------------------------------------------------------------------ */
/* sign in                                                            */
/* ------------------------------------------------------------------ */

.login-screen {
  position: relative;
  display: grid;
  place-items: center;
  min-height: 100dvh;
  padding: 24px;
}
/* The one screen with no panel to hold the language picker, so it keeps the
   corner it used to occupy everywhere. */
.login-lang { position: absolute; top: 12px; left: 16px; width: 150px; }
.login-card { width: 100%; max-width: 360px; }
.login-title { margin: 0 0 8px; font-size: 28px; font-weight: 600; letter-spacing: -0.01em; }
.login-sub {
  margin: 0 0 24px;
  color: var(--fg-secondary);
  font-size: 14px;
  line-height: 1.5;
}
.login-form { display: flex; flex-direction: column; gap: 12px; }
.login-field { display: flex; flex-direction: column; gap: 6px; }
.login-label { font-size: 12px; color: var(--fg-secondary); }
.login-form .input { width: 100%; }
.login-form .btn { width: 100%; margin-top: 4px; }
.login-error {
  margin: 0;
  padding: 10px 12px;
  border-radius: var(--radius);
  background: color-mix(in srgb, var(--bad) 16%, transparent);
  color: var(--fg);
  font-size: 13px;
  line-height: 1.45;
}

/* ------------------------------------------------------------------ */
/* launcher                                                           */
/* ------------------------------------------------------------------ */

.launcher {
  position: relative;
  height: 100%;
  overflow-y: auto;
  padding: 0 20px 64px;
}
.launcher-inner {
  max-width: 720px;
  margin: 0 auto;
  /* Was `min(18vh, 140px)`, which was measured against a shorter form: a URL
     row, two mode cards, and nothing else. The form has since grown a drop
     zone above it and taller mode cards, so that much air pushed the whole
     thing down the page and put the recent list below the fold. Enough to
     clear the language switch in the corner, and no more. */
  padding-top: 56px;
}
.launcher-title {
  margin: 0 0 6px;
  font-size: 32px;
  font-weight: 600;
  letter-spacing: -0.01em;
}
.launcher-sub {
  margin: 0 0 20px;
  max-width: 52ch;
  color: var(--fg-secondary);
  font-size: 15px;
}
.launcher-form { display: flex; flex-direction: column; gap: 16px; }

/**
 * One grid for the first row and for every row the "+" adds.
 *
 * These were two different flex rows: the first held four items (link,
 * language, "+", Start) and the extra ones held three (link, language, "×"),
 * both with `flex: 1 1 240px` on the input. Flex distributes what is left
 * after the fixed items, and the two rows had different fixed items — so the
 * link boxes ended at different widths, the language pickers started at
 * different offsets, and nothing in the form lined up with anything below it.
 * No amount of tuning fixes that, because the two rows were never solving the
 * same layout problem.
 *
 * Named columns solve it instead: a field, a language, an action. Every row
 * declares all three, the first row's action cell is simply empty, and the
 * columns are identical by construction rather than by coincidence.
 */
.launcher-form {
  /* Wide enough for the longest target language in Russian, which runs about a
     third longer than its English label — the reason this cannot be `auto`. */
  --launcher-lang-col: 190px;
  --launcher-act-col: 40px;
}
.field-row,
.batch-row {
  display: grid;
  /* Two columns until there is something to remove. Reserving the action
     column unconditionally left a permanently empty 48px at the end of the
     only row most people ever fill in, so the link row stopped short of the
     drop zone above it and the mode picker below — the form looked ragged in
     its commonest state to keep a column aligned for rows that did not exist.
     The third column appears with the first removable row, when every row that
     needs it is there to use it. */
  grid-template-columns: minmax(0, 1fr) var(--launcher-lang-col);
  gap: 8px;
  align-items: center;
}
.launcher-form.has-rows .field-row,
.launcher-form.has-rows .batch-row {
  grid-template-columns: minmax(0, 1fr) var(--launcher-lang-col) var(--launcher-act-col);
}
/* Nothing to occupy it until then, and an empty grid item still claims a
   track — so it is out of the layout entirely rather than merely invisible. */
.launcher-form:not(.has-rows) .field-row > .row-action { display: none; }
.field-row > *,
.batch-row > * { min-width: 0; }

/* The action column: `×` on a row that can be removed, nothing on the first.
   Fixed width and centred, so the buttons form a column of their own rather
   than sitting wherever the row above happened to end. */
.row-action {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 48px;
}

/**
 * The link box, with the "+" inside it.
 *
 * Inside rather than beside, because "add another one of these" is an act on
 * this field and reads that way when it is in it — and because a fourth item
 * beside the box is what made the first row a different shape from every row
 * it creates. The input's right padding keeps a long URL from running under
 * the button; `text-overflow` is not involved, the text simply scrolls short
 * of it.
 */
.link-field { position: relative; display: flex; align-items: center; }
.link-field .input { width: 100%; }
.link-field .batch-add {
  position: absolute;
  right: 6px;
  top: 50%;
  transform: translateY(-50%);
  width: 34px;
  height: 34px;
  border-radius: var(--radius-sm);
  color: var(--fg-subtle);
  transition: color 100ms, background 100ms;
}
.link-field .input:not(:disabled) { padding-right: 46px; }
.link-field .batch-add:hover:not(:disabled) { color: var(--fg); }
.link-field .batch-add:disabled { opacity: 0.32; }

/* Start, on its own line and after the mode picker, because that is the order
   of the decisions: what to translate, how much to check, then go. It used to
   sit on the first line, which put the button before the choice made with it. */
/* Full width, not right-aligned. Everything above it — the drop zone, the link
   row, the two mode cards — spans the form, so a button hugging the right edge
   left a wide band of nothing beside the one control the whole page exists to
   get pressed. A stack of full-width things ends in a full-width thing. */
.launcher-go { display: flex; }
.launcher-go .btn { flex: 1; justify-content: center; }
/* The bulk entry point, for the accounts that have it. Its own row under
   Start, centred and quiet: this screen is still for one video, and a second
   full-weight control beside Start would make the ordinary case look like a
   choice between two things. */
.launcher-super { align-self: center; margin-top: 8px; opacity: 0.8; }
.launcher-super:hover { opacity: 1; }

/* Narrow: the link and its language stack, and the row's action moves up
   beside the language rather than taking a third line of its own.
   `.field-row` used to be `flex-wrap`, which handled this by accident; a grid
   has to be told, and being told is the point — wrapping put the language
   picker under the link on some widths and beside it on others, with no
   width where the rows below matched it. */
@media (max-width: 620px) {
  .field-row,
  .batch-row {
    grid-template-columns: minmax(0, 1fr) var(--launcher-act-col);
    row-gap: 6px;
  }
  .field-row > .link-field,
  .batch-row > .link-field { grid-column: 1 / -1; }
  .row-action { min-height: 40px; }
  .launcher-go .btn { width: 100%; }
}

/* The target language, beside the URL it applies to rather than behind a
   settings panel.

   This was a *pair* — source → target, with an arrow between them. The source
   half is gone: the language a video is spoken in is identified from its audio
   far more reliably than a person can identify it from the title, and getting
   it wrong is not a preference that reads oddly but a translation from a
   language the video is not in, which fails silently and completely. The
   wrapper stays because the row still needs one element that does not stretch.
   `.lang-arrow` went with the source picker. */
/* Fills its grid column rather than sizing to its contents: the column is the
   thing being aligned, and a picker that shrank to fit "Русский" while the one
   below it fit "Português" would put two edges in two places.

   Scoped to `.lang-pair` deliberately. `width: 100%` on the bare class also
   caught the file rows, where the select is a flex item rather than a grid
   one — so it asked for the whole row's width, shrank by however much the
   filename beside it had taken, and came out a different size on every row.
   The rule is right for a fixed column and wrong for anything that has to
   share a line. */
.lang-pair { display: flex; align-items: center; min-width: 0; }
.lang-pair .lang-select { width: 100%; }
.lang-select { min-width: 0; }
.select-lg {
  height: 48px;
  border-radius: var(--radius-lg);
  font-size: 15px;
  padding: 0 8px 0 12px;
}

/* ------------------------------------------------------------------ */
/* several videos at once                                             */
/* ------------------------------------------------------------------ */

.batch-list { display: flex; flex-direction: column; gap: 8px; }
.batch-list:empty { display: none; }

/* Slides down into place rather than appearing. The point is not decoration:
   the row is added below the control that was clicked, and without the motion
   there is nothing to connect the click to the thing that changed twenty
   pixels lower. `is-in` is added on the next frame so the browser paints the
   "before" state — the universal `transition-duration: 0s` at the top of this
   file means the duration has to be declared here explicitly. */
.batch-row {
  opacity: 0;
  transform: translateY(-6px);
  transition: opacity 140ms, transform 140ms;
}
.batch-row.is-in { opacity: 1; transform: translateY(0); }

.file-list { display: flex; flex-direction: column; gap: 8px; }
.file-list:empty { display: none; }

/**
 * A grid, for the same reason the link rows are one.
 *
 * As a flex row the language picker sat wherever the filename before it
 * happened to end, so two files with names of different lengths put their
 * pickers — and their remove buttons — in two different places. The name is
 * the only part that should vary; everything after it is a column.
 *
 * The picker's column is narrower than the link rows', and that is not an
 * inconsistency: there it is one of two things on the line and can afford the
 * room, whereas here it competes with a thumbnail, a name and a size, and the
 * name is what the reader actually needs to see. A language label needs about
 * nine characters.
 */
.file-row {
  position: relative;
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) 148px var(--launcher-act-col, 40px);
  align-items: center;
  gap: 10px;
  padding: 8px 10px;
  border: 1px solid var(--a200);
  border-radius: var(--radius);
  background: var(--a50);
}
/* Spans every column: it is the row's own progress, not a fifth thing on the
   line. Out of the grid flow entirely, pinned along the bottom edge. */
.file-row > .file-progress { grid-column: 1 / -1; }
.file-row.is-done { border-color: color-mix(in srgb, var(--ok) 55%, var(--a200)); }
.file-row.is-failed { border-color: color-mix(in srgb, var(--bad) 55%, var(--a200)); }

/* Holds the film icon until a real frame arrives, then the frame. Fixed size
   either way, so a preview that never loads does not change the row's height
   and shuffle everything below it. */
.file-thumb {
  display: flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  width: 48px;
  height: 32px;
  border-radius: var(--radius-xs);
  background: var(--a150);
  color: var(--fg-subtle);
  overflow: hidden;
}
.file-thumb.has-preview { background: #000; }
.file-preview { width: 100%; height: 100%; object-fit: cover; display: block; }

.file-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1 1 auto; }
.file-name {
  font-size: 13px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.file-meta { font-size: 12px; color: var(--fg-secondary); }
.file-row.is-failed .file-note { color: var(--bad); }

.file-progress {
  position: absolute;
  left: 10px;
  right: 10px;
  bottom: 3px;
  height: 2px;
  border-radius: 999px;
  background: var(--a150);
  overflow: hidden;
}
.file-progress-fill {
  display: block;
  height: 100%;
  width: 0;
  border-radius: inherit;
  background: var(--accent);
  transition: width 120ms linear;
}

/* The list inside the "start these five?" dialog. */
.confirm-list {
  margin: 12px 0 0;
  padding: 0;
  list-style: none;
  max-height: 40vh;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.confirm-item {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  padding: 6px 10px;
  border-radius: var(--radius-xs);
  background: var(--a75);
  font-size: 13px;
}
.confirm-name {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.confirm-lang { flex: 0 0 auto; color: var(--fg-secondary); font-size: 12px; }
.confirm-note { padding: 4px 2px; font-size: 12px; color: var(--fg-secondary); }

.mode-picker { display: grid; gap: 8px; grid-template-columns: 1fr 1fr; }
.mode-card {
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 16px;
  border: 1px solid var(--a200);
  border-radius: var(--radius-lg);
  background: transparent;
  color: var(--fg);
  font-family: inherit;
  text-align: left;
  cursor: pointer;
  transition: background-color 75ms, border-color 75ms;
}
.mode-card:hover { background: var(--a50); border-color: var(--a300); }
.mode-card.is-selected {
  background: var(--a100);
  border-color: var(--fg);
}
.mode-title {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 14px;
  font-weight: 500;
}
.mode-blurb { font-size: 13px; color: var(--fg-secondary); line-height: 1.45; }

.recent { margin-top: 48px; }
.recent-heading {
  margin: 0 0 8px;
  font-size: 13px;
  font-weight: 500;
  color: var(--fg-secondary);
}
.recent-list { display: flex; flex-direction: column; }
.recent-item {
  position: relative;
  display: flex;
  align-items: center;
  gap: 16px;
  min-height: 52px;
  padding: 8px 10px;
  border-radius: var(--radius-sm);
  color: inherit;
  text-decoration: none;
  transition: background-color 75ms;
}
.recent-item:hover { background: var(--a50); }
.recent-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.recent-title {
  font-weight: 500;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.recent-url {
  font-size: 12px;
  color: var(--fg-subtle);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.recent-meta {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: 12px;
  color: var(--fg-secondary);
  font-variant-numeric: tabular-nums;
}
.recent-delete { opacity: 0; }
.recent-item:hover .recent-delete,
.recent-item:focus-within .recent-delete { opacity: 1; }
@media (hover: none) { .recent-delete { opacity: 1; } }

/* ------------------------------------------------------------------ */
/* project status                                                     */
/* ------------------------------------------------------------------ */

/* A tinted pill, not coloured text.
 *
 * This used to be plain text with a colour on two of the six states, which
 * meant `queued`, `running`, `ready` and `cancelled` were all the same
 * `--fg-subtle` as the timestamp and the language codes beside them — four
 * different answers rendered identically, in the one column whose whole job is
 * to tell them apart. With a batch putting five rows in the list at once,
 * "which of these is running" stopped being answerable at a glance.
 *
 * The wash follows `.tag-stale` / `.tag-failed`, which already solved this for
 * segment state: a `color-mix` background at low alpha with full-contrast
 * text, so the pill reads at a glance without the text losing contrast against
 * it — and so it survives dark mode, where a saturated foreground colour on a
 * dark ground is the first thing to become unreadable. */
.status {
  display: inline-flex;
  align-items: center;
  flex: 0 0 auto;
  height: 18px;
  padding: 0 7px;
  border-radius: 9999px;
  font-size: 11px;
  font-weight: 500;
  text-transform: lowercase;
  white-space: nowrap;
}
.status-queued {
  background: var(--a100);
  color: var(--fg-secondary);
}
/* The only one that is actually happening right now, so it is the only one
   that gets the accent rather than a wash of a semantic colour. */
.status-running {
  background: color-mix(in srgb, var(--warn) 26%, transparent);
  color: var(--fg);
}
.status-ready {
  background: color-mix(in srgb, var(--ok) 26%, transparent);
  color: var(--fg);
}
.status-failed {
  background: color-mix(in srgb, var(--bad) 26%, transparent);
  color: var(--fg);
}
.status-cancelled {
  background: transparent;
  border: 1px solid var(--a200);
  color: var(--fg-subtle);
}
/* Legacy rows only — `JobQueue.recover` retires these on first boot. */
.status-preparing {
  background: color-mix(in srgb, var(--warn) 26%, transparent);
  color: var(--fg);
}

/* A running row earns a moving dot as well as a colour. Colour alone is not a
   signal everyone receives, and this is the one state where "it is happening"
   is the entire message. The blanket reduced-motion rule at the top of this
   file stops it for anyone who asked. */
.status-running::before {
  content: "";
  width: 5px;
  height: 5px;
  margin-right: 5px;
  border-radius: 9999px;
  background: currentColor;
  animation: pulse 1.4s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}

/* ------------------------------------------------------------------ */
/* studio shell                                                       */
/* ------------------------------------------------------------------ */

.studio {
  display: flex;
  flex-direction: column;
  height: 100%;
}

.app-header {
  display: flex;
  align-items: center;
  gap: 12px;
  height: var(--header-h);
  flex: 0 0 auto;
  padding: 0 10px;
  border-bottom: 1px solid var(--border);
  background: color-mix(in oklab, var(--bg) 90%, transparent);
  backdrop-filter: blur(8px);
}
.app-heading { flex: 1; min-width: 0; }
.app-title {
  margin: 0;
  font-size: 14px;
  font-weight: 500;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.app-subtitle {
  margin: 0;
  font-size: 12px;
  color: var(--fg-secondary);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  font-variant-numeric: tabular-nums;
}
.app-actions { display: flex; align-items: center; gap: 8px; }

/* The price of pressing "voice", sitting just left of the button that spends
   it. Deliberately quiet — it is a number to notice, not one to act on — and
   tabular so it does not reflow as the figure changes. */
.voice-cost {
  font-size: 12px;
  font-variant-numeric: tabular-nums;
  color: var(--fg-subtle);
  white-space: nowrap;
  cursor: help;
}

/* Interface language.
   Was a `.lang-switch` segmented pill (EN | RU) floating in the top-left of
   the studio header, the launcher and the login card. A pill is a toggle —
   right for something flipped back and forth, wrong for a setting chosen once
   per account — and it was holding the most valuable corner of the page to do
   it. It is a dropdown in the panel footer now; only the login card, which
   has no panel, still keeps a corner. See `buildLangPicker` in app.js.

   No printed caption. A globe sits at the right edge instead, *replacing* the
   native dropdown arrow rather than joining it — `appearance: none` plus a
   reserved gutter — because two glyphs at one edge read as two controls. The
   icon is `pointer-events: none` so the whole width still opens the menu. */
.lang-picker { position: relative; display: block; }
.ui-lang-select {
  width: 100%;
  appearance: none;
  -webkit-appearance: none;
  padding-right: 32px;
}
.lang-picker-icon {
  position: absolute;
  top: 50%;
  right: 10px;
  transform: translateY(-50%);
  display: flex;
  color: var(--fg-subtle);
  pointer-events: none;
}
.ui-lang-select:hover + .lang-picker-icon { color: var(--fg-secondary); }

.app-body {
  flex: 1;
  min-height: 0;
  display: grid;
  grid-template-columns: minmax(360px, 1fr) minmax(420px, 1.1fr);
  overflow: hidden;
}

/* `min-height: 0` is load-bearing, not tidiness. Grid and flex items default
   to `min-height: auto`, which refuses to shrink below their content — so the
   script pane grew to the full height of every line, `.lines` never overflowed
   its own box, and its `overflow-y: auto` never engaged. The overflow landed
   on the document instead, where `body { overflow: hidden }` clipped it: the
   wheel did nothing, while scrollIntoView still scrolled the page and dragged
   the video off screen with it. */
.pane { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
.pane-script { border-right: 1px solid var(--border); }

.pane-toolbar {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 8px;
  padding: 8px 12px;
  border-bottom: 1px solid var(--border);
}
/* The search box takes what the row can spare, and the cap is now roughly
   double what it was: 180px is about twenty characters, which is not enough
   box to read back a phrase you are hunting for, and the row had slack it was
   refusing to use.

   Still capped and still freely shrinkable, so the two toggles beside it keep
   their place on one line — Russian labels run about a third longer than
   English, and `flex-shrink: 1` against a `flex-basis` well under the cap is
   what lets the box give the width back rather than wrapping the row.
   `flex-wrap` remains the backstop for a pane narrower than either. */
.pane-toolbar .input { flex: 1 1 180px; min-width: 0; max-width: 340px; }

/* The queue notice, and only the queue notice — the one message from the old
   block under the player that cannot be a toast, because it carries the way
   out of the queue and has to outlast three and a half seconds. It is here
   rather than under the picture because a queued project has no picture worth
   looking at and no lines at all, so this is the emptiness that needs the
   explanation. `:empty` keeps it from contributing a gap on every other
   project, which is all of them. */
.script-notice { padding: 8px 12px 0; }
.script-notice:empty { display: none; }

/* ------------------------------------------------------------------ */
/* the selection bar — floats at the bottom of the pane               */
/* ------------------------------------------------------------------ */

/* It used to be a static block between the toolbar and the list, so ticking a
   checkbox pushed every row down: the list moved under the cursor at exactly
   the moment the cursor was being used in it. Absolutely positioned over the
   list, nothing reflows when it appears. */
.selection-bar {
  position: absolute;
  left: 12px;
  right: 12px;
  bottom: 12px;
  /* Above the list and above the pane's own bottom fade, which is z-index 2. */
  z-index: 3;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  background: var(--bg-raised);
  box-shadow: var(--shadow-popover);
  opacity: 0;
  transform: translateY(8px);
  transition: opacity 140ms, transform 140ms;
}
.selection-bar.is-in { opacity: 1; transform: translateY(0); }
.selection-count { font-size: 13px; font-weight: 500; }

/* ------------------------------------------------------------------ */
/* the script                                                         */
/* ------------------------------------------------------------------ */

/* The script scrolls under a soft edge rather than being cut by a hard line —
   a 32px fade in the page's own colour, so the list reads as continuing
   rather than ending. */
/* `position: relative` anchors the floating selection bar; `container-type`
   makes the pane's own width queryable, so compare mode stacks its columns
   when the *pane* is narrow rather than when the window is. A pane can be
   narrow on a wide screen, and a viewport media query cannot tell. */
.pane-script {
  position: relative;
  container-type: inline-size;
  container-name: script;
}
.pane-script::after {
  content: "";
  position: absolute;
  left: 0;
  right: 6px;
  bottom: 0;
  height: 32px;
  background: linear-gradient(to top, var(--bg), transparent);
  pointer-events: none;
  z-index: 2;
}

/* The script's own scrollbar is the exception to `.scroll-subtle`, and it is
   deliberate. This is the longest list in the app — a two-hour video is
   hundreds of rows — so it is the one place people drag the bar rather than
   spin the wheel, and a 6px thumb behind a 1px transparent border left about
   4px of grabbable target. Below roughly 10px a pointer target has to be
   aimed at rather than reached for.

   `scrollbar-width: auto` (not `thin`) for Firefox, and a 12px track with a
   10px thumb for WebKit: the padding-box trick keeps the thumb inset from the
   text without shrinking what the pointer can hit, because the transparent
   border is part of the thumb as far as hit-testing is concerned. */
.lines {
  flex: 1;
  overflow-y: auto;
  padding: 8px 12px 64px;
  scrollbar-width: auto;
  scrollbar-color: var(--gray-300) transparent;
}
.lines::-webkit-scrollbar { width: 12px; }
.lines::-webkit-scrollbar-track { background: transparent; }
.lines::-webkit-scrollbar-thumb {
  background-color: var(--gray-300);
  background-clip: padding-box;
  border: 1px solid transparent;
  border-radius: 9999px;
}
.lines::-webkit-scrollbar-thumb:hover { background-color: var(--gray-400); }

.empty {
  padding: 48px 8px;
  text-align: center;
  color: var(--fg-subtle);
}

.line {
  display: grid;
  grid-template-columns: auto auto minmax(0, 1fr);
  gap: 10px;
  padding: 8px 10px;
  border-radius: var(--radius-sm);
  cursor: text;
  transition: background-color 75ms;
}
/* Compare mode: source left, translation right, row-aligned. `minmax(0, 1fr)`
   rather than a bare `1fr` because a plain `fr` floors at the content's
   min-size, and one long unbreakable token — a URL, an Arabic word — would
   then push its column wider than half and misalign the whole list. */
.lines.is-compare .line {
  grid-template-columns: auto auto minmax(0, 1fr) minmax(0, 1fr);
}
.line:hover { background: var(--a50); }
.line.is-selected { background: var(--a100); }
/* The active line is the one thing on screen that must be findable at a
   glance while the video plays, so it gets the alpha fill AND a marker down
   the leading edge — the only place in the app that earns more than the fill.

   It used to share `--a100` with `.is-selected` and `.is-editing`, which is
   **4% alpha** — a fill you have to look for, on the one row you should never
   have to look for. Worse, three different states rendered identically, so
   the fill carried no information at all and the 2px bar was doing the entire
   job. It is `--a200` now, and the bar is 3px: still restrained enough to sit
   under a hundred rows without becoming a stripe pattern, but findable from
   across the desk while the video plays. */
.line.is-active {
  background: var(--a200);
  box-shadow: inset 3px 0 0 var(--fg);
}
.line.is-editing { background: var(--a100); }

.line-check {
  margin: 4px 0 0;
  accent-color: var(--fg);
  opacity: 0;
  cursor: pointer;
}
.line:hover .line-check,
.line.is-selected .line-check,
.line:focus-within .line-check { opacity: 1; }
@media (hover: none) { .line-check { opacity: 1; } }

/* The timestamp stays pinned at the top of the row; the audition button takes
   the space that is left. `margin: auto 0` on a flex-column child splits the
   free space evenly above and below it, so on a five-line row the button sits
   at the row's optical centre and on a one-line row it simply tucks under the
   time. No min-height, no measuring. */
.line-gutter { display: flex; flex-direction: column; align-items: center; gap: 4px; }
.line-time {
  padding: 0;
  border: 0;
  background: none;
  color: var(--fg-subtle);
  font-family: inherit;
  font-size: 12px;
  font-variant-numeric: tabular-nums;
  cursor: pointer;
}
.line-time:hover { color: var(--fg); }
.line.is-active .line-time { color: var(--fg); }

/* Circular with a hairline border, the same shape the picture badge takes —
   the two are the same gesture at two scales, and reading as one family is
   worth more than either shape on its own. 28px is also a target you can hit
   without aiming. */
.line-audition {
  display: grid;
  place-items: center;
  width: 28px;
  height: 28px;
  margin: auto 0;
  padding: 0;
  border: 1px solid var(--a200);
  border-radius: 9999px;
  background: none;
  color: var(--fg-subtle);
  cursor: pointer;
  transition: background-color 75ms, border-color 75ms, color 75ms;
}
.line-audition .icon {
  width: 16px;
  height: 16px;
  /* The play glyph carries `fill="currentColor"` on its own path, which beats
     this inherited `none`. The thin stroke just softens its edge. */
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
}
.line-audition:hover:not(:disabled) {
  background: var(--a100);
  border-color: var(--a300);
  color: var(--fg);
}
.line-audition:disabled { opacity: 0.3; cursor: default; }
/* The line that is playing right now. The glyph has already become a pause
   bar; this is what makes the button findable from across the pane without
   reading it, which is what you want when the thing you are looking for is
   "how do I stop that". Filled rather than merely tinted — the audition
   button is the only control in the list, so there is nothing for a filled
   state to be confused with. */
.line-audition.is-playing {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-fg);
}
.line-audition.is-playing:hover:not(:disabled) {
  background: var(--accent-hover);
  border-color: var(--accent-hover);
  color: var(--accent-fg);
}

/* Only rendered when the line actually holds a mark, and clicking it clears
   every mark inside — so it is a state indicator that happens to be a
   control, not an "add here" button. Adding is the transport button and B. */
.line-bookmark {
  display: grid;
  place-items: center;
  width: 28px;
  height: 24px;
  padding: 0;
  border: 0;
  background: none;
  color: var(--accent);
  cursor: pointer;
  transition: color 75ms;
}
.line-bookmark .icon { stroke: currentColor; stroke-width: 1.4; }
/* Red on hover only where pressing it *removes* something. On an unmarked row
   the same button adds a bookmark, and warning someone about that would be a
   lie told in the most attention-grabbing colour available. */
.line-bookmark.is-marked:hover { color: var(--danger); }
.line-bookmark:not(.is-marked):hover { color: var(--accent); }
.line-bookmark:focus-visible {
  outline: none;
  border-radius: var(--radius-xs);
  box-shadow: 0 0 0 2px var(--bg), 0 0 0 3.5px var(--fg);
}

/* A bookmarked row: the same restrained alpha fill everything else uses, plus
   a hairline on the RIGHT edge. The active line already owns a bar on the
   left, and two accents on the same edge would read as one state with two
   intensities rather than as two independent facts about the row. */
.line.is-bookmarked { background: var(--a50); box-shadow: inset -2px 0 0 var(--accent); }
.line.is-bookmarked.is-active {
  background: var(--a200);
  box-shadow: inset 3px 0 0 var(--fg), inset -2px 0 0 var(--accent);
}

.line-body { min-width: 0; }

/* The original, as reference beside the translation rather than under it.
   Same size as the translation so the two read as a pair, subdued colour so
   it is clearly not the thing being worked on, and no clamping: the columns
   are top-aligned and the row is as tall as the taller of them.

   What used to be here — one clamped line that expanded to four on hover —
   moved the row out from under the cursor mid-gesture and made comparing more
   than one line at a time impossible. */
.line-source {
  margin: 0;
  min-width: 0;
  color: var(--fg-subtle);
  font-size: 14px;
  line-height: 1.55;
  overflow-wrap: break-word;
}
.line-text {
  margin: 0;
  font-size: 14px;
  line-height: 1.55;
  color: var(--fg-secondary);
}
.line.is-active .line-text { color: var(--fg); }
.line[data-state="ready"] .line-text { color: var(--fg); }

.line-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }

/* Two columns need room to be two columns. Below this the translation column
   drops under about 28 characters of measure, at which point stacking — the
   old arrangement, minus the hover animation — reads better than a pair of
   ribbons.

   The threshold is on the *pane*, which at the app's default split is roughly
   half the window: 560px keeps the columns side by side on a laptop, where
   this feature is actually used, and stacks them on a phone. */
@container script (max-width: 560px) {
  .lines.is-compare .line { grid-template-columns: auto auto minmax(0, 1fr); }
  /* Spelled out rather than left to auto-placement: mixing placed and
     auto-placed items in one grid is exactly where "it works until it
     doesn't" lives. The gutter spans both rows so the audition button still
     centres against the whole row. */
  .lines.is-compare .line-check { grid-row: 1 / 3; grid-column: 1; align-self: start; }
  .lines.is-compare .line-gutter { grid-row: 1 / 3; grid-column: 2; }
  .lines.is-compare .line-source { grid-row: 1; grid-column: 3; margin-bottom: 2px; }
  .lines.is-compare .line-body { grid-row: 2; grid-column: 3; }
}

/* ------------------------------------------------------------------ */
/* inline editor                                                      */
/* ------------------------------------------------------------------ */

/* The panel opens inside the row's translation column, so in compare mode you
   rewrite the Russian with the Arabic still legible beside it. That is the
   whole argument for compare mode, and an editor spanning the full width would
   throw it away at the moment it matters most. */
.editor {
  margin: 4px 0 12px;
  padding: 12px;
  border-radius: var(--radius-lg);
  background: var(--bg);
  box-shadow: var(--shadow-natural-xs);
}
/* In compare mode the source is already on screen, one column across. Printing
   it a second time inside the panel, against itself, is just noise. */
.lines.is-compare .editor-source { display: none; }
.editor-source {
  margin: 0 0 8px;
  font-size: 12px;
  color: var(--fg-subtle);
  line-height: 1.45;
}
.editor-input {
  width: 100%;
  height: auto;
  min-height: 76px;
  padding: 8px 12px;
  font-size: 14px;
  line-height: 1.55;
  resize: vertical;
}
.editor-meter { margin: 10px 0; }
.meter-bar {
  height: 4px;
  border-radius: 9999px;
  background: var(--a200);
  overflow: hidden;
}
.meter-fill { height: 100%; border-radius: 9999px; transition: width 100ms; }
/* Scoped to the bar. The same level word also tags the caption below, which
   wants the colour as text, not as a highlighter. */
.meter-fill.meter-ok { background: var(--ok); }
.meter-fill.meter-warn { background: var(--warn); }
.meter-fill.meter-bad { background: var(--bad); }
/* `.meter-stats` printed `187 chars ≈11s of 15s fits comfortably` under the
   bar, and `.editor-hint` printed the shortcuts beside the buttons. Both are
   gone. The bar says green-or-red without being read, which is what a control
   updating on every keystroke should do, and a keyboard reminder under every
   line is read once and is furniture thereafter. ⌘↵ and Escape still work. */

/* Wraps because button labels change length with the interface language —
   Russian runs about a third longer than English. */
.editor-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.editor-actions .spacer { flex: 1 1 auto; }

/* Revert, as a circle at the far end of the row rather than a fifth word in
   it. It is the one control here that is not part of saving — it throws the
   edit away — so it is set apart from the buttons that commit, and it earns
   its meaning from a hover title rather than from width it would take off
   them. Same 28px circle as the audition button in the script, so the two
   icon-only controls on this screen read as the same kind of thing. */
.editor-revert {
  width: 28px;
  height: 28px;
  padding: 0;
  border: 1px solid var(--a200);
  border-radius: 9999px;
  background: none;
  color: var(--fg-subtle);
}
.editor-revert:hover:not(:disabled) {
  background: var(--a100);
  border-color: var(--a300);
  color: var(--fg);
}
.editor-revert:disabled { opacity: 0.35; }

/* ------------------------------------------------------------------ */
/* player                                                             */
/* ------------------------------------------------------------------ */

/* The picture and its transport never move. You are reading the script against
   the video, so the video going anywhere defeats the point of the split.

   There are exactly three things in this pane now: the picture, the row that
   drives it, and the mixer. `.player-aside` used to hold whatever came after
   them — first the task log, then the count tiles, then the notices — and
   each of those in turn was a block of text under a video that pushed the
   video up the page and told the user something they could not act on from
   there. The last of them is gone and so is the container. */
.pane-player {
  padding: 12px;
  gap: 12px;
  overflow: hidden;
}
.pane-player > * { flex: 0 0 auto; }

.stage {
  position: relative;
  border-radius: var(--radius-lg);
  background: #000;
  overflow: hidden;
  cursor: pointer;
  box-shadow: var(--shadow-natural-xs);
}
.video { display: block; width: 100%; aspect-ratio: 16 / 9; background: #000; }

/* Feedback for the click-the-picture gesture: a circular outline with the
   glyph for what *just happened*, scaling up as it fades. The same shape as
   the audition button in the script, at the scale the picture deserves.

   `pointer-events: none` throughout, so it can never eat the next click —
   which matters because the gesture people make is two clicks in a row.
   `translate` does the centring and `transform` does the scale: separate
   properties, so the animation cannot clobber the position. */
.play-badge {
  position: absolute;
  left: 50%;
  top: 50%;
  translate: -50% -50%;
  z-index: 3;
  display: grid;
  place-items: center;
  width: 72px;
  height: 72px;
  border-radius: 9999px;
  border: 1.5px solid rgba(255, 255, 255, 0.9);
  background: rgba(0, 0, 0, 0.38);
  backdrop-filter: blur(4px);
  color: #fff;
  opacity: 0;
  pointer-events: none;
}
.play-badge .icon { stroke-width: 1.5; }
.play-badge.is-flash { animation: badge-pop 450ms cubic-bezier(0.2, 0, 0.2, 1); }

@keyframes badge-pop {
  from { opacity: 0.9; transform: scale(0.7); }
  to { opacity: 0; transform: scale(1.25); }
}

/* Reduced motion still gets the feedback — losing it entirely would leave the
   click unacknowledged — but as a plain fade with no growth. This overrides
   the blanket `animation-duration: 0.01ms !important` near the top of the
   file, which would otherwise make the badge a single invisible frame. */
@media (prefers-reduced-motion: reduce) {
  .play-badge.is-flash {
    animation: badge-fade 450ms linear !important;
  }
}
@keyframes badge-fade {
  from { opacity: 0.9; }
  to { opacity: 0; }
}

.caption {
  position: absolute;
  left: 50%;
  bottom: 16px;
  transform: translateX(-50%);
  max-width: min(92%, 60ch);
  padding: 6px 12px;
  border-radius: var(--radius-sm);
  background: rgba(0, 0, 0, 0.66);
  backdrop-filter: blur(4px);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
  color: #fff;
  font-size: 16px;
  line-height: 1.4;
  text-align: center;
  text-wrap: balance;
  opacity: 0;
  pointer-events: none;
}
.caption.is-visible { opacity: 1; }

/* Holds the two control rows together so one tour step can light both. It
   reproduces `.pane-player`'s own 12px column gap, so wrapping them changed
   nothing about where they sit. */
.player-controls { display: flex; flex-direction: column; gap: 12px; min-width: 0; }

.transport { display: flex; align-items: center; gap: 8px; }
.time {
  font-size: 12px;
  color: var(--fg-secondary);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.transport .btn-primary { border-radius: 9999px; }

/* A 20px band that responds to a press, with the 4px line painted on the track
   pseudo-element rather than on the input itself. As a 4px input only a 4px
   strip of screen accepted a drag — a line thinner than the cursor — and
   scrubbing meant aiming. The whole band now takes click, drag and keyboard,
   which is what makes scrubbing feel like scrubbing. */
/* The band gets a positioned wrapper so the bookmark marks can be drawn over
   it. A range input cannot hold children, and painting the marks on the track
   pseudo-element would make them a background the user could not click. */
.scrub-wrap { position: relative; flex: 1; min-width: 0; display: flex; align-items: center; }

.scrub {
  flex: 1;
  min-width: 0;
  height: 20px;
  /* The UA gives a range input a 2px margin. Inside a positioned wrapper that
     would put the marks layer 2px wider than the control it is annotating. */
  margin: 0;
  appearance: none;
  background: transparent;
  cursor: pointer;
}

/* Marks over the 20px band. The layer never takes a pointer event, so a drag
   that starts on a mark is still a drag — which is the whole point of the
   taller hit area this sits on top of.

   Inset by half the thumb's width at each end, because that is where the
   thumb's travel actually starts and stops: a browser lays a range thumb out
   from `thumbWidth/2` to `width − thumbWidth/2`, so marks measured against
   the element's full width would drift from the handle by up to 6px near
   either end of a video. */
.scrub-marks {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 6px;
  right: 6px;
  pointer-events: none;
}
.scrub-mark {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 2px;
  margin-left: -1px;
  border: 0;
  padding: 0;
  border-radius: 1px;
  background: var(--accent);
  opacity: 0.8;
}

/* The one exception. A mark that falls outside every line — in the silence
   after the last one — appears on no row, so the timeline is the only place
   it can be removed from. Its target is a 8×6px tab at the TOP of the band,
   clear of the 4px line at the band's centre, so it takes the clicks nobody
   was going to scrub with and leaves the rest of the band alone. */
.scrub-mark.is-orphan {
  pointer-events: auto;
  cursor: pointer;
  top: 0;
  bottom: auto;
  height: 6px;
  width: 8px;
  margin-left: -4px;
  background: transparent;
  opacity: 1;
}
.scrub-mark.is-orphan::before {
  content: "";
  position: absolute;
  left: 3px;
  top: 0;
  height: 20px;
  width: 2px;
  border-radius: 1px;
  background: var(--accent);
  opacity: 0.8;
  pointer-events: none;
}
.scrub-mark.is-orphan:hover::before { background: var(--danger); opacity: 1; }
.scrub-mark.is-orphan:focus-visible {
  outline: none;
  box-shadow: 0 0 0 2px var(--bg), 0 0 0 3.5px var(--fg);
}
.scrub::-webkit-slider-runnable-track {
  height: 4px;
  border-radius: 9999px;
  background: var(--a200);
}
.scrub::-moz-range-track {
  height: 4px;
  border-radius: 9999px;
  background: var(--a200);
}
/* (track height − thumb height) / 2. WebKit lays the thumb out from the top of
   the track and will happily leave the handle riding above the line without
   this; Firefox centres its own. */
.scrub::-webkit-slider-thumb {
  appearance: none;
  width: 12px;
  height: 12px;
  margin-top: -4px;
  border: 0;
  border-radius: 9999px;
  background: var(--fg);
  cursor: ew-resize;
}
.scrub::-moz-range-thumb {
  width: 12px;
  height: 12px;
  border: 0;
  border-radius: 9999px;
  background: var(--fg);
  cursor: ew-resize;
}

/* The mixer row is where the "what am I looking at" switches live: how loud
   each track is, whether subtitles are burned over the picture — and, now,
   the bookmark. Marking a moment is not a transport action. The transport
   moves the playhead and nothing else; the bookmark button writes something
   down about where the playhead already is, which is why it sat oddly at the
   end of a row of play and skip controls and why it belongs beside the
   Subtitles checkbox instead. B is still the shortcut, unchanged. */
.mixer {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 16px;
  padding: 10px 12px;
  border-radius: var(--radius-lg);
  background: var(--a50);
}
/* Sized to the row rather than to the header's buttons: at 36px it would set
   the mixer's height on its own and leave the sliders floating in it. */
.mixer .bookmark-add { width: 28px; height: 28px; }
.slider-row { display: flex; align-items: center; gap: 8px; }
.slider-label { font-size: 12px; color: var(--fg-secondary); white-space: nowrap; }
.slider-value {
  min-width: 34px;
  font-size: 12px;
  color: var(--fg-subtle);
  font-variant-numeric: tabular-nums;
  text-align: right;
}
.slider {
  width: 90px;
  height: 4px;
  appearance: none;
  border-radius: 9999px;
  background: var(--a200);
  cursor: pointer;
}
.slider::-webkit-slider-thumb {
  appearance: none;
  width: 12px;
  height: 12px;
  border: 0;
  border-radius: 9999px;
  background: var(--fg);
  cursor: ew-resize;
  transition: transform 100ms;
}
.slider:hover::-webkit-slider-thumb { transform: scale(1.1); }
.slider::-moz-range-thumb {
  width: 12px;
  height: 12px;
  border: 0;
  border-radius: 9999px;
  background: var(--fg);
  cursor: ew-resize;
  transition: transform 100ms;
}
.slider:hover::-moz-range-thumb { transform: scale(1.1); }

/* ------------------------------------------------------------------ */
/* notices                                                            */
/* ------------------------------------------------------------------ */

/* `.status-panel` lived under the player and held all of these. It is gone,
   and so is `.notice-warn` — the paragraph that restated a `1.40×` tag
   already printed on each of the rows it was describing.

   What survives is the three that are STATES rather than advice — queued, no
   files, failed — and they moved to the top of the script pane rather than
   out of the app: each stays true until something changes, and the queued one
   carries the only button that leaves the queue. See `.script-notice`. */
.notice {
  margin: 0;
  padding: 10px 12px;
  border-radius: var(--radius-lg);
  background: var(--a50);
  color: var(--fg-secondary);
  font-size: 13px;
  line-height: 1.5;
}
.notice-error { background: color-mix(in srgb, var(--bad) 16%, transparent); color: var(--fg); }
/* A queued project has no task, so nothing else on this screen would say why
   it is sitting still. "3 ahead of you" beats a spinner, and the way out of
   the queue belongs in the same sentence as the wait. */
.notice-queue {
  display: flex;
  align-items: center;
  gap: 10px;
  background: color-mix(in srgb, var(--warn) 12%, transparent);
  color: var(--fg);
}
.queue-position { font-weight: 500; font-variant-numeric: tabular-nums; }
.notice-queue .btn { margin: -5px -6px -5px 0; }

/* `.log` lived here — the task-log drawer under the player. Removed: it
   printed the pipeline's whole narration in a panel nobody could act on, and
   pushed the video up the page while a job ran. That output now goes to the
   terminal and to logs/app.txt, where it outlives the tab. */

body.is-busy .app-subtitle::after {
  content: "";
  display: inline-block;
  width: 6px;
  height: 6px;
  margin-left: 6px;
  border-radius: 9999px;
  background: var(--warn);
  animation: pulse 1.4s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse { 50% { opacity: 0.35; } }

/* ------------------------------------------------------------------ */
/* toasts — always dark, with a lit bevel                             */
/* ------------------------------------------------------------------ */

/* What the script pane shows while there is nothing in it yet. Preparation is
   the longest stretch in the product with no visible output — a download, a
   caption fetch and a translation against a blank panel. */
.script-working {
  display: flex;
  flex-direction: column;
  gap: 10px;
  max-width: 320px;
  margin: 48px auto 0;
  padding: 0 16px;
  text-align: center;
}
.script-working-stage { margin: 0; font-size: 13px; color: var(--fg-secondary); }
.script-working-pct {
  font-size: 12px;
  color: var(--fg-subtle);
  font-variant-numeric: tabular-nums;
}
.script-working-pct:empty { display: none; }

/* The same bar, but above a list instead of standing in for one. It stays put
   while the rows scroll under it, because the whole point of removing the
   synthesis modal was that you can now read and scroll the script while it is
   being spoken — a number that scrolls away takes that back. */
.script-working.is-strip {
  position: sticky;
  top: 0;
  z-index: 2;
  flex-direction: row;
  align-items: center;
  gap: 10px;
  max-width: none;
  margin: 0 0 8px;
  padding: 8px 12px;
  text-align: left;
  background: var(--bg-raised);
  border-bottom: 1px solid var(--border);
}
.script-working.is-strip .script-working-stage { flex: 0 0 auto; }
.script-working.is-strip .progress-track { flex: 1 1 auto; }
.script-working.is-strip .script-working-pct { flex: 0 0 auto; }

/* ------------------------------------------------------------------ */
/* dialogs                                                            */
/* ------------------------------------------------------------------ */

/* `<dialog>` opened with showModal() gets the top layer, a real backdrop and
   an inert page behind it from the platform. Nothing here fights z-index. */
.dialog {
  width: min(420px, calc(100vw - 32px));
  padding: 20px;
  border: 1px solid var(--border);
  border-radius: var(--radius-xl);
  background: var(--bg-raised);
  color: var(--fg);
  box-shadow: var(--shadow-popover);
}
.dialog::backdrop {
  /* The "darken the screen" half of locking the studio. */
  background: rgba(0, 0, 0, 0.55);
  backdrop-filter: blur(2px);
}
.dialog-title { margin: 0; font-size: 16px; font-weight: 600; line-height: 1.4; }
.dialog-body { margin: 8px 0 0; color: var(--fg-secondary); font-size: 13px; }
.dialog-actions {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
  margin-top: 20px;
}

/* bulk translation — docs/build/plan-super-mode.md
   A wider shell than `.dialog`'s 420px, because this one asks two questions
   side by side: a list of links that needs room to be read back, and a grid of
   nine languages. Stacked in one column they would push the total and the
   button below the fold on a laptop, and the total is the number the whole
   dialog exists to show. */
.dialog-wide { width: min(760px, calc(100vw - 32px)); }
.super-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 16px;
  margin-top: 16px;
}
.super-col { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.field-label { font-size: 12px; font-weight: 600; color: var(--fg-secondary); }
.field-hint { margin: 0; font-size: 11px; line-height: 1.4; color: var(--fg-secondary); opacity: 0.8; }
.super-col-head { display: flex; align-items: center; justify-content: space-between; }
.super-links { resize: vertical; min-height: 180px; font-family: var(--font-mono, monospace); }
.super-langs {
  display: grid;
  /* Two columns of nine languages, so the grid is as tall as the textarea is
     rather than a single column that would leave the right half mostly empty. */
  grid-template-columns: 1fr 1fr;
  gap: 4px 12px;
}
.super-lang, .super-mode {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 13px;
  cursor: pointer;
}

/* A language that exists but is not open to this account yet — see
   LOCKED_TARGETS in src/web/capabilities.js.

   Dimmed rather than hidden, and the label keeps its full colour contrast on
   the padlock and the word beside it: the row has to read as "coming", not as
   "broken". `not-allowed` on the whole label because the <input> inside it is
   disabled and a disabled control does not fire the pointer events that would
   otherwise show the cursor — the label is the only thing under the mouse. */
.super-lang.is-locked {
  cursor: not-allowed;
  color: var(--fg-subtle);
}
.super-lang.is-locked input { cursor: not-allowed; }
.super-lang-soon {
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--fg-subtle);
}

/* Firefox is the one engine that lets an <option> take a colour, and it is
   also the one whose default disabled grey is faint enough to disappear
   against the panel. The rule is harmless where it is ignored. */
.lang-select option.is-locked { color: var(--fg-subtle); }
.super-modes { display: flex; gap: 16px; }
.super-tally { margin: 16px 0 0; font-size: 14px; font-weight: 600; }
.super-tally.is-over { color: var(--danger, #d33); }
.super-warning { margin: 4px 0 0; font-size: 12px; color: var(--fg-secondary); min-height: 16px; }
.dialog-list { margin: 8px 0 0; padding-left: 20px; font-size: 13px; color: var(--fg-secondary); }

@media (max-width: 640px) {
  .super-grid { grid-template-columns: 1fr; }
}

/* progress overlay */
.dialog-progress { text-align: left; }
.progress-track {
  position: relative;
  height: 6px;
  margin-top: 16px;
  border-radius: 999px;
  background: var(--a150);
  overflow: hidden;
}
.progress-fill {
  display: block;
  height: 100%;
  width: 0;
  border-radius: inherit;
  background: var(--accent);
  transition: width 200ms linear;
}
/* Before ffmpeg has reported a figure. A bar sitting at 0% reads as stuck;
   a moving one reads as working. */
.progress-track.is-indeterminate .progress-fill {
  width: 40% !important;
  transition: none;
  animation: indeterminate 1.1s ease-in-out infinite;
}
@keyframes indeterminate {
  0%   { transform: translateX(-100%); }
  100% { transform: translateX(350%); }
}
.progress-percent {
  display: block;
  margin-top: 8px;
  font-size: 13px;
  font-variant-numeric: tabular-nums;
  color: var(--fg-secondary);
}
.progress-detail { margin-top: 2px; }

/* completion */
.dialog-ready { text-align: center; }
.ready-mark {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 52px;
  height: 52px;
  margin: 0 auto 12px;
  border-radius: 999px;
  background: color-mix(in srgb, var(--ok) 22%, transparent);
  color: var(--ok);
}
.dialog-ready .dialog-actions { justify-content: center; }
.ready-list {
  margin: 12px 0 0;
  padding: 0;
  list-style: none;
  text-align: left;
}
.ready-list li {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 6px 0;
  border-top: 1px solid var(--border);
}
.ready-lang { font-size: 11px; color: var(--fg-subtle); }
.linklike {
  border: 0;
  padding: 0;
  background: none;
  color: var(--fg);
  font: inherit;
  text-align: left;
  cursor: pointer;
  text-decoration: underline;
  text-underline-offset: 2px;
}

/* Confetti sits over everything and must never take a click — the dialog it
   is celebrating is underneath it. */
.confetti {
  position: fixed;
  inset: 0;
  z-index: 9999;
  pointer-events: none;
}

/* The download of a finished export is a navigation into a hidden frame, so
   the browser owns the transfer and the studio is never unloaded. */
.hidden-download {
  position: absolute;
  width: 0;
  height: 0;
  border: 0;
  visibility: hidden;
}

/* Progress on a session row: how far along, without opening it. */
.session-progress {
  position: absolute;
  left: 10px;
  right: 10px;
  bottom: 4px;
  height: 2px;
  border-radius: 999px;
  background: var(--a150);
  overflow: hidden;
}
.session-progress-fill {
  display: block;
  height: 100%;
  width: 0;
  border-radius: inherit;
  background: var(--accent);
  transition: width 200ms linear;
}
.session-row { position: relative; }

/* ------------------------------------------------------------------ */
/* the drop zone                                                      */
/* ------------------------------------------------------------------ */

/* A real <button>, so it is focusable, keyboard-operable and announced as a
   control without any ARIA. Dashed because that is the one border style the
   whole web already reads as "put something here". */
.drop-zone {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  width: 100%;
  /* Not tall. A big empty rectangle above the link box would say the file is
     the main event, and for most jobs it is not. */
  min-height: 68px;
  padding: 12px 16px;
  border: 1px dashed var(--border-strong, var(--border));
  border-radius: var(--radius-lg, 10px);
  background: var(--a50);
  color: var(--fg-secondary);
  font: inherit;
  font-size: 13px;
  text-align: center;
  cursor: pointer;
  transition: background 100ms, border-color 100ms, color 100ms;
}
.drop-zone:hover { background: var(--a100); border-color: var(--a300); color: var(--fg); }
.drop-zone:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
/* While a file is over the zone. Solid rather than dashed: the target has been
   acquired, and the border stops being an invitation. */
.drop-zone.is-dragging {
  border-style: solid;
  border-color: var(--accent);
  background: var(--accent-soft, var(--a100));
  color: var(--fg);
}
.drop-zone.is-loaded {
  border-style: solid;
  border-color: var(--a300);
  color: var(--fg);
}
.drop-label { min-width: 0; overflow-wrap: anywhere; }
.drop-progress {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 3px;
  border-radius: 0 0 var(--radius-lg, 10px) var(--radius-lg, 10px);
  background: var(--a150);
  overflow: hidden;
}
.drop-progress-fill {
  display: block;
  height: 100%;
  width: 0;
  background: var(--accent);
  transition: width 120ms linear;
}

/* Hidden from sight but not from the accessibility tree or from `.click()`.
   `display:none` on a file input makes it unclickable in some browsers. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* ------------------------------------------------------------------ */
/* a line being spoken right now                                      */
/* ------------------------------------------------------------------ */

/* Muted green, not the accent: this is not a control and not a warning, it is
   the sound of work happening. It sits along the bottom edge of the row so it
   cannot move the text it belongs to. */
.line-progress {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 2px;
  background: color-mix(in srgb, #8ab895 22%, transparent);
  overflow: hidden;
}
.line-progress-fill {
  display: block;
  height: 100%;
  width: 0;
  background: color-mix(in srgb, #8ab895 78%, transparent);
  /* Fast to a third, then slower and slower, and it never reaches the end —
     nothing here knows how long a segment takes to synthesise, so the curve
     says "working" without ever claiming "done". The clip landing rebuilds the
     row without a bar, which is the only honest completion available. */
  animation: line-inflight 24s cubic-bezier(0.12, 0.78, 0.2, 1) forwards;
}
@keyframes line-inflight {
  from { width: 0; }
  to { width: 94%; }
}
.line.is-inflight { position: relative; }
@media (prefers-reduced-motion: reduce) {
  .line-progress-fill { animation: none; width: 40%; }
}

/* The bookmark icon is on every row now, so an unmarked one has to recede —
   otherwise forty outlines shout down the two rows that are actually marked. */
.line-bookmark { opacity: 0.28; transition: opacity 75ms, color 75ms; }
.line:hover .line-bookmark,
.line:focus-within .line-bookmark,
.line-bookmark:focus-visible,
.line-bookmark.is-marked { opacity: 1; }
.line-bookmark.is-marked { color: var(--accent); }
.line-bookmark.is-marked .icon { fill: currentColor; }

/* The admin door, at the bottom of the panel. */
.sidebar-admin { width: 100%; justify-content: flex-start; gap: 6px; }

/* ------------------------------------------------------------------ */
/* fullscreen                                                         */
/* ------------------------------------------------------------------ */

/* The wrapper is what goes fullscreen, not the <video>. Putting the element
   itself fullscreen hands the browser's own chrome the job, and that chrome
   cannot draw our subtitle overlay, our play badge, or a scrub bar. */
.stage-wrap { position: relative; }
.stage-wrap:fullscreen {
  display: flex;
  align-items: center;
  justify-content: center;
  /* The browser paints a fullscreen element's backdrop white by default,
     which is a full-screen flash on a dark app every time this opens. */
  background: #000;
}
.stage-wrap:fullscreen .stage {
  width: 100%;
  height: 100%;
  border-radius: 0;
  border: 0;
}
.stage-wrap:fullscreen video {
  width: 100%;
  height: 100%;
  /* `contain`, never `cover`: cropping the picture to fill a screen is a
     choice about someone else's framing, and this is a review tool. */
  object-fit: contain;
}

/* The floating transport. Hidden entirely outside fullscreen — it is a second
   view of the same player, and two identical bars stacked in the pane would be
   a puzzle rather than a convenience. */
.fs-bar { display: none; }
.stage-wrap:fullscreen .fs-bar {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 2;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 16px 20px calc(16px + env(safe-area-inset-bottom, 0px));
  /* A gradient, not a solid bar: a hard edge across the bottom of a
     fullscreen picture cuts the frame in a way a fade does not. */
  background: linear-gradient(to top, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0));
  color: #fff;
  opacity: 1;
  transition: opacity 220ms ease;
}
.fs-bar .scrub { flex: 1 1 auto; min-width: 0; }
.fs-bar .time { color: #fff; font-variant-numeric: tabular-nums; }
.fs-bar .btn-ghost { color: #fff; }

/* Out of the way while the pointer is still, back the moment it moves. A
   permanent bar across the bottom is a permanent hole in the thing you went
   fullscreen to look at. Pointer events go with it, so an invisible bar can
   never swallow a click meant for the picture. */
.stage-wrap:fullscreen.is-idle .fs-bar { opacity: 0; pointer-events: none; }
.stage-wrap:fullscreen.is-idle { cursor: none; }
@media (prefers-reduced-motion: reduce) {
  .stage-wrap:fullscreen .fs-bar { transition: none; }
}
/* The download arrow is styled beside `.session-delete`, near the top of this
   file — the two are one control cluster and drifted apart when they were two
   rules. */

.toasts {
  position: fixed;
  right: 16px;
  bottom: 16px;
  z-index: 60;
  display: flex;
  flex-direction: column;
  gap: 8px;
  max-width: 380px;
}
.toast {
  padding: 10px 14px;
  border-radius: var(--radius);
  background: hsl(0 0% 13% / 0.9);
  backdrop-filter: blur(8px);
  color: #fafafa;
  font-size: 13px;
  line-height: 1.5;
  box-shadow:
    inset 0 0.5px 0 rgba(255, 255, 255, 0.349),
    inset 0 0 0 0.5px rgba(255, 255, 255, 0.075),
    0 2px 6px -2px #00000022,
    0 18px 40px -10px #00000059;
  animation: toast-in 150ms cubic-bezier(0, 0, 0.2, 1);
  transition: opacity 200ms, transform 200ms;
}
.toast-error { color: #f0c4c0; }
.toast-success { color: #c3ddc9; }
.toast.is-leaving { opacity: 0; transform: translateY(4px); }
@keyframes toast-in {
  from { opacity: 0; transform: translateY(8px) scale(0.97); }
}

/* ------------------------------------------------------------------ */
/* narrow screens                                                     */
/* ------------------------------------------------------------------ */

@media (max-width: 900px) {
  body { overflow: auto; }
  /* The page becomes the scroller, so the shell can no longer be the box that
     the panel is positioned inside — an absolute rail against a shell as tall
     as the whole document would scroll away with it. Fixed keeps it against
     the window, which is what a rail is for. */
  .app-shell { height: auto; min-height: 100dvh; overflow: visible; }
  .sidebar { position: fixed; }
  .app-shell.is-open .sidebar-scrim { position: fixed; }
  .launcher { height: auto; }
  .studio { height: auto; min-height: 100dvh; }
  .app-body { grid-template-columns: 1fr; overflow: visible; }
  .pane { min-height: auto; }
  .pane-script { border-right: 0; border-bottom: 1px solid var(--border); }
  /* The video leads on a narrow screen: you scroll the script under it. So
     here the page is the scroller and both panes go back to their natural
     height — the desktop rules above would trap the script in a box inside a
     page that already scrolls. */
  .pane-player {
    order: -1;
    position: sticky;
    top: var(--header-h);
    z-index: 5;
    background: var(--bg);
    overflow: visible;
  }
  .lines { max-height: none; overflow: visible; }
  .mode-picker { grid-template-columns: 1fr; }
  /* Here the page is the scroller and the pane grows to its full content
     height, so "the bottom of the pane" is somewhere far below the fold.
     Pinned to the viewport instead — which on a stacked, full-width layout is
     where a bar like this belongs anyway. Below the toasts, above everything
     else. */
  .selection-bar { position: fixed; z-index: 20; }
}

/* Shown only with JavaScript off, where the studio itself never renders. It
   was an inline `style` attribute until the page gained a Content-Security-
   Policy, which refuses to parse those — the reader with no JavaScript would
   have got an unstyled sentence, which is the one reader who cannot be helped
   by anything else on the page. */
.noscript-note {
  padding: 2rem;
  font: 16px system-ui, -apple-system, "Segoe UI", sans-serif;
}
