:root {
  --bg: #f4f4f6;
  --panel: #ffffff;
  --panel-2: #fafafc;
  --text: #1a1a1a;
  --muted: #6b6b76;
  --line: #e3e3ea;
  --accent: #667eea;
  --accent-strong: #5568d3;
  --ok: #1e7a45;
  --ok-bg: #e6f5ec;
  --warn: #8a6100;
  --warn-bg: #fff3cd;
  --err: #a32020;
  --err-bg: #fdeaea;
  /* Separate from --err, which is a *text* colour on --err-bg. A destructive button
     needs a fill dark enough for white text in both themes. */
  --danger-fill: #a32020;
  --danger-fill-strong: #8a1a1a;
  --radius: 10px;
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #16161a;
    --panel: #1f1f25;
    --panel-2: #26262e;
    --text: #ececf1;
    --muted: #9a9aa8;
    --line: #33333d;
    --accent: #7c8cf8;
    --accent-strong: #6a79e8;
    --ok: #6bd898;
    --ok-bg: #16321f;
    --warn: #ecc46a;
    --warn-bg: #362c12;
    --err: #f08c8c;
    --err-bg: #371919;
    /* Not --err: that is a pale pink here, and white on it is unreadable. */
    --danger-fill: #a83a3a;
    --danger-fill-strong: #bd4545;
  }
}

* { box-sizing: border-box; }

/* The UA hides [hidden] elements, but any author rule that sets `display` beats the UA
   sheet whatever the specificity - so `button { display: inline-block }` quietly
   un-hides every hidden button. This restores the attribute's meaning. */
[hidden] { display: none !important; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  font-size: 15px;
  line-height: 1.45;
}

a { color: var(--accent); }

.wrap { max-width: 980px; margin: 0 auto; padding: 16px; }

/* ---------- top bar ---------- */

.topbar {
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 10px 16px;
  background: var(--panel);
  border-bottom: 1px solid var(--line);
  position: sticky;
  top: 0;
  z-index: 20;
  flex-wrap: wrap;
}
.brand { font-weight: 700; text-decoration: none; color: var(--text); }
.brand span { color: var(--accent); }
.topbar nav { display: flex; gap: 4px; flex: 1; flex-wrap: wrap; }
.topbar nav a {
  padding: 6px 10px;
  border-radius: 6px;
  text-decoration: none;
  color: var(--muted);
  font-size: 0.92em;
}
.topbar nav a:hover { background: var(--panel-2); color: var(--text); }
.topbar nav a.active { background: var(--accent); color: #fff; }
.logout { margin: 0; }
.logout button {
  background: none;
  border: 1px solid var(--line);
  color: var(--muted);
  border-radius: 6px;
  padding: 5px 10px;
  font-size: 0.85em;
  cursor: pointer;
}

/* Four things on one line is more than a phone has room for, and letting them wrap
   where they fall leaves the nav stranded next to the brand. Two deliberate rows
   instead: brand and log-out, then the nav across the full width. */
@media (max-width: 560px) {
  .topbar { gap: 8px 10px; padding: 8px 12px; }
  .topbar nav {
    order: 3;
    flex-basis: 100%;
    gap: 6px;
  }
  .topbar nav a {
    flex: 1;
    text-align: center;
    padding: 7px 4px;
    border: 1px solid var(--line);
  }
  .topbar nav a.active { border-color: var(--accent); }
  /* nav is on its own row here, so it is the brand that has to push the tasks
     indicator and Log out over to the right. */
  .brand { margin-right: auto; }
}

/* ---------- background tasks ---------- */

.tasks { position: relative; display: flex; align-items: center; }

.tasks-toggle {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 3px 8px 3px 4px;
  background: none;
  border: 1px solid var(--line);
  border-radius: 999px;
  color: var(--muted);
}

.tasks-toggle:hover { background: var(--panel-2); }
.tasks-count { font-size: 0.8em; font-weight: 600; }

.ring { width: 22px; height: 22px; transform: rotate(-90deg); flex: 0 0 auto; }
.ring circle { fill: none; stroke-width: 4; }
.ring-track { stroke: var(--line); }

.ring-fill {
  stroke: var(--accent);
  stroke-linecap: round;
  /* Set from the circumference in tasks.js, which owns the radius arithmetic. */
  stroke-dasharray: 97.39;
  stroke-dashoffset: 97.39;
  transition: stroke-dashoffset 0.3s linear;
}

/* Queued with nothing running: there is no progress to draw, so the ring turns
   instead of sitting at zero looking stalled. */
.tasks.waiting .ring { animation: task-spin 1.6s linear infinite; }
.tasks.waiting .ring-fill { stroke-dashoffset: 73; }

@keyframes task-spin {
  from { transform: rotate(-90deg); }
  to   { transform: rotate(270deg); }
}

.tasks-popup {
  display: none;
  position: absolute;
  top: calc(100% + 8px);
  right: 0;
  z-index: 30;
  width: min(320px, calc(100vw - 24px));
  padding: 12px 14px;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: 0 12px 30px rgb(0 0 0 / 22%);
}

/* Opened by a class rather than the hidden attribute: the [hidden] rule at the top of
   this file is !important, so hover could not override it. */
.tasks.open .tasks-popup { display: block; }

/* Hover where hovering exists; the click handler in tasks.js is what makes it
   reachable on a phone. */
@media (hover: hover) {
  .tasks:hover .tasks-popup { display: block; }
}

.tasks-popup h3 { margin: 0 0 8px; font-size: 0.9em; }
.tasks-popup p { margin: 10px 0 0; }
.task-list { list-style: none; margin: 0; padding: 0; }
.task + .task { border-top: 1px solid var(--line); margin-top: 8px; padding-top: 8px; }
.task-head { display: flex; align-items: center; gap: 8px; }
.task-label { flex: 1; min-width: 0; font-weight: 600; font-size: 0.9em; overflow-wrap: anywhere; }
.task-cancel { padding: 2px 8px; }
.task.queued .task-label { font-weight: 400; color: var(--muted); }

.task-bar {
  height: 4px;
  margin-top: 6px;
  border-radius: 2px;
  background: var(--panel-2);
  overflow: hidden;
}

.task-bar span { display: block; height: 100%; background: var(--accent); transition: width 0.3s linear; }

/* Anchored to the button, the 320px popup hangs off the left of a 360px screen: the
   button itself sits well in from the right edge. Dropping .tasks out of the
   positioned chain hands the job to .topbar - which is sticky, so it is a containing
   block - and the popup can then span the bar instead. */
@media (max-width: 560px) {
  .tasks { position: static; }
  .tasks-popup {
    top: calc(100% + 6px);
    left: 12px;
    right: 12px;
    width: auto;
  }
}

@media (prefers-reduced-motion: reduce) {
  .tasks.waiting .ring { animation: none; }
  .ring-fill, .task-bar span { transition: none; }
}

/* ---------- generic blocks ---------- */

h1 { font-size: 1.35em; margin: 0 0 4px; }
h2 { font-size: 1.05em; margin: 24px 0 8px; }
.subtitle { color: var(--muted); margin: 0 0 18px; font-size: 0.9em; }

.panel {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 14px 16px;
  margin-bottom: 12px;
}

.banner {
  border-radius: var(--radius);
  padding: 10px 14px;
  margin: 12px 0;
  font-size: 0.9em;
}
.banner.warn { background: var(--warn-bg); color: var(--warn); }
.banner.err  { background: var(--err-bg);  color: var(--err); }
.banner.ok   { background: var(--ok-bg);   color: var(--ok); }
.banner a { color: inherit; }
.stale-list { margin: 8px 0 0; padding-left: 20px; max-height: 9em; overflow-y: auto; }
.stale-list li { margin: 2px 0; }
.banner p { margin: 0; }
.banner ul + p { margin-top: 8px; }

.muted { color: var(--muted); }
.small { font-size: 0.85em; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
/* A summary is a control, so it should not look like selectable text. */
summary { cursor: pointer; }

.row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.spacer { flex: 1; }

/* ---------- forms ---------- */

label.field { display: block; margin: 12px 0; }
label.field > span { display: block; font-weight: 600; margin-bottom: 4px; font-size: 0.9em; }
label.field .hint { display: block; font-weight: 400; color: var(--muted); font-size: 0.82em; margin-top: 4px; }
/* The rule above targets the label's caption, but it also catches a .row used to put
   a button beside the input - which then loses its flex and drops the button onto the
   next line, because the input is width:100%. */
label.field > span.row { display: flex; font-weight: 400; margin-bottom: 0; font-size: 1em; }

input[type=text], input[type=password], input[type=number], input[type=url], select, textarea {
  width: 100%;
  padding: 8px 10px;
  border: 1px solid var(--line);
  border-radius: 6px;
  background: var(--panel-2);
  color: var(--text);
  font: inherit;
}
input[type=number] { width: 8em; }
textarea { resize: vertical; min-height: 4em; }

button, .btn {
  background: var(--accent);
  color: #fff;
  border: none;
  border-radius: 6px;
  padding: 8px 14px;
  font: inherit;
  font-size: 0.9em;
  cursor: pointer;
  text-decoration: none;
  display: inline-block;
}
button:hover, .btn:hover { background: var(--accent-strong); }
button:disabled { opacity: 0.55; cursor: default; }
button.secondary, .btn.secondary {
  background: var(--panel-2);
  color: var(--text);
  border: 1px solid var(--line);
}
button.secondary:hover { background: var(--line); }

/* Listed after .secondary and with its own :hover, so it wins even on a button that
   carries both classes. */
button.danger, .btn.danger {
  background: var(--danger-fill);
  color: #fff;
  border: 1px solid transparent;
}
button.danger:hover, .btn.danger:hover { background: var(--danger-fill-strong); }

/* ---------- video list ---------- */

/* Three items: frame, text, buttons. Wrapping is what makes it work on a phone - the
   buttons take a full-width line of their own once there is no room beside the text. */
.video-row {
  display: flex;
  align-items: flex-start;
  flex-wrap: wrap;
  gap: 12px 14px;
}

/* Fixed box, so the text column starts at the same place in every row whatever the
   video's aspect ratio. align-items:flex-start on the row then keeps the titles on
   one baseline even when a row carries an extra line. */
.video-thumb {
  flex: 0 0 auto;
  width: 160px;
  height: 90px;
  border-radius: 6px;
  overflow: hidden;
  background: var(--panel-2);
  border: 1px solid var(--line);
  display: flex;
  align-items: center;
  justify-content: center;
}

/* contain, not cover: a phone recording is portrait, and cropping it to 16:9 would
   throw away most of the frame you are trying to recognise. */
.video-thumb img {
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
  display: block;
}

/* Placeholder until the frame arrives, and permanently for audio-only files. :empty
   means the box styles itself - no class juggling needed for the common case. */
.video-thumb:empty::after {
  content: "\1F39E";
  font-size: 22px;
  opacity: 0.35;
}

/* Being generated in the background. */
.video-thumb.pending { animation: thumb-pulse 1.4s ease-in-out infinite; }

@keyframes thumb-pulse {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.55; }
}

@media (prefers-reduced-motion: reduce) {
  .video-thumb.pending { animation: none; }
}

/* The 18em basis is what decides where the layout breaks: below it the actions no
   longer fit alongside and drop to their own line. */
.video-meta {
  flex: 1 1 18em;
  min-width: 0;
}

.video-head { gap: 6px 10px; }

/* Always a line of its own. Left to wrap on its own it went wherever it fitted, so
   whether a row was one line or two depended on the length of the file name and on
   whether the badge said "analysed" or "not analysed" - two rows side by side, laid
   out differently for no reason the reader can see. */
.video-facts { flex: 0 0 100%; gap: 8px; }
.video-target { margin-top: 6px; }
.video-target .mono { min-width: 0; word-break: break-all; }

.video-actions { flex: 0 0 auto; margin-left: auto; gap: 8px; }

@media (max-width: 640px) {
  /* Small enough to sit beside the frame rather than under it - the actions have
     already dropped to their own line by here, so this is the whole row. */
  .video-meta { flex-basis: 8em; }
  .video-actions { flex-basis: 100%; margin-left: 0; }
}

/* Reads as a bold title until you touch it, then becomes a visible field.
   The selector has to carry `input[type=text]` too: the generic form rule above is
   (0,1,1) and would otherwise out-specify a bare class and paint the field chrome
   back in, whatever the source order. */
input[type=text].video-name {
  /* Fills the space beside the facts, and takes a line of its own once there is less
     than 14em for it. Long file names are the norm here. */
  flex: 1 1 14em;
  min-width: 0;
  width: auto;
  font-weight: 700;
  font-size: 1em;
  color: var(--text);
  padding: 3px 6px;
  /* Border stays 1px in every state so nothing shifts when it appears. */
  border: 1px solid transparent;
  border-radius: 5px;
  background: transparent;
  cursor: text;
  text-overflow: ellipsis;
}

input[type=text].video-name:hover {
  border-color: var(--line);
  background: var(--panel-2);
}

input[type=text].video-name:focus {
  border-color: var(--accent);
  background: var(--panel-2);
  outline: none;
  text-overflow: clip;
}

input[type=text].video-name:disabled {
  opacity: 0.6;
  cursor: default;
}

/* Same control as the page title. Sitting inside the <h1>, font-size:1em and
   font-weight:700 already resolve to the heading's own size and weight - it only
   needs the width, since `flex: 1` means nothing outside a flex row. */
input[type=text].video-name.title-name { width: 100%; }
h1 { display: block; }

/* ---------- filter and sort bar ---------- */

.list-controls {
  display: flex;
  align-items: flex-end;
  gap: 12px;
  flex-wrap: wrap;
}

.list-controls .field { margin: 0; }
.filter-field { flex: 2; min-width: 150px; }
/* Wraps: three 130px fields plus their gaps are wider than a phone, and this row
   is the one place they all sit side by side. */
.more-controls { display: flex; align-items: flex-end; flex-wrap: wrap; gap: 12px; flex: 3; }
.more-controls .field { flex: 1; min-width: 130px; }

/* Wide enough for all three side by side, so there is nothing to fold away. Two
   classes because `button.icon` sets display too, and a bare class would lose to it. */
.list-controls .filter-toggle { display: none; }

@media (max-width: 700px) {
  .list-controls .filter-toggle { display: inline-flex; }
  /* Its own line when open, so the two selects get a usable width rather than the
     sliver left beside the filter box. */
  .more-controls { flex-basis: 100%; }
  .list-controls:not(.expanded) .more-controls { display: none; }
}

/* Every pixel here goes to the file name, which is what a row is found by. */
@media (max-width: 560px) {
  .video-thumb { width: 80px; height: 45px; }
}

/* ---------- library ---------- */

/* A gallery, not a list of rows: the cover is what an album is recognised by, and a
   row gave the name whatever width the cover and the numbers left over.
   The minimum column has to grow *smoothly* with the viewport - two columns on a
   phone and four on a desktop need different minima, but stepping between them at a
   breakpoint makes the page lose a column as it gets wider. clamp() ramps between the
   two instead, so the count only ever goes up. */
.album-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(clamp(148px, 19vw, 190px), 1fr));
  gap: 12px;
}

.album-grid .album {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 10px;
  margin-bottom: 0;
}

/* Square, unlike the 16:9 video thumbnail: album art is square, and this doubles as
   the link to the page that made it. */
.album-cover {
  flex: 0 0 auto;
  width: 96px;
  height: 96px;
  border-radius: 6px;
  overflow: hidden;
  background: var(--panel-2);
  border: 1px solid var(--line);
  display: flex;
  align-items: center;
  justify-content: center;
  text-decoration: none;
}

/* In a card it is the whole width of the column, whatever that turns out to be. */
.album-grid .album-cover { width: 100%; height: auto; aspect-ratio: 1; }

.album-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.album-cover:hover { border-color: var(--accent); }

.album-cover.empty::after {
  content: "\1F3B5";
  font-size: 26px;
  opacity: 0.35;
}

/* Fills the rest of the card, so the actions sit on the bottom edge of every card in
   a row however long the names above them are. */
.album-meta {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 6px;
}

/* One line, clipped: it is context, and a card is not where a path gets read. */
.album-parent { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.album-facts { gap: 4px 10px; }
.album-badges { gap: 6px; }
.album-actions { margin-top: auto; gap: 8px; }

/* ---------- one album ---------- */

.album-head { display: flex; align-items: flex-start; gap: 16px; }
.album-head .album-cover { width: 120px; height: 120px; }
.album-head-info { flex: 1; min-width: 0; }

/* The printable label, beside what there is to say about it. */
.album-label { display: flex; align-items: flex-start; gap: 18px; }
.album-label-text { flex: 1; min-width: 0; }

.album-label-art img {
  display: block;
  width: 150px;
  height: auto;
  border: 1px solid var(--line);
  border-radius: 4px;
  background: #fff;
}

/* Cover above the details rather than beside them: at this width the column left for
   the path field was narrower than the paths that go in it. */
@media (max-width: 560px) {
  .album-head { flex-direction: column; align-items: stretch; }
  .album-head .album-cover {
    width: 100%;
    height: auto;
    aspect-ratio: 1;
    max-width: 220px;
    margin: 0 auto;
  }

  /* Same reason: a 150px label beside the text leaves the text a ribbon. */
  .album-label { flex-direction: column; align-items: center; text-align: center; }
  .album-label-text { width: 100%; }
  .album-label .row { justify-content: center; }
}

/* Icon-only action. Square, so it reads as a control rather than a stubby button. */
button.icon, .btn.icon {
  padding: 6px;
  width: 32px;
  height: 32px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  line-height: 0;
}

/* ---------- toggle switch ---------- */

/* A checkbox that looks like a switch. The input stays in the DOM and keeps its
   semantics for keyboard and screen readers; only its rendering is replaced. */
.toggle {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  cursor: pointer;
  user-select: none;
}

.toggle > input[type=checkbox] {
  /* Not display:none - that would take it out of the focus order. */
  position: absolute;
  opacity: 0;
  width: 0;
  height: 0;
}

.toggle-track {
  flex: 0 0 auto;
  position: relative;
  width: 38px;
  height: 22px;
  border-radius: 11px;
  background: var(--panel-2);
  border: 1px solid var(--line);
  transition: background 0.15s ease, border-color 0.15s ease;
}

.toggle-track::after {
  content: "";
  position: absolute;
  top: 2px;
  left: 2px;
  width: 16px;
  height: 16px;
  border-radius: 50%;
  background: var(--muted);
  transition: transform 0.15s ease, background 0.15s ease;
}

.toggle > input:checked + .toggle-track {
  background: var(--accent);
  border-color: var(--accent);
}

.toggle > input:checked + .toggle-track::after {
  transform: translateX(16px);
  background: #fff;
}

.toggle > input:focus-visible + .toggle-track {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
  .toggle-track, .toggle-track::after { transition: none; }
}

/* ---------- file inputs ---------- */

/* The browser default is a bare button plus filename text that matches nothing else
   on the page. Only the button part can be styled, via ::file-selector-button. */
input[type=file] {
  flex: 1;
  min-width: 0;
  font: inherit;
  font-size: 0.92em;
  color: var(--muted);
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: 6px;
  padding: 6px 10px;
}

input[type=file]::file-selector-button {
  font: inherit;
  font-size: 0.92em;
  margin: 0 10px 0 0;
  padding: 5px 12px;
  border: 0;
  border-radius: 5px;
  background: var(--panel);
  border: 1px solid var(--line);
  color: var(--text);
  cursor: pointer;
}

input[type=file]::file-selector-button:hover {
  border-color: var(--accent);
  color: var(--text);
}

/* The mirrored action bar above the waveform. Sticky, because on a long recording the
   controls are otherwise several screens from whatever you are looking at. */
.actions-top {
  position: sticky;
  top: 56px;
  z-index: 10;
}

.hotkeys b {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-weight: 600;
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: 4px;
  padding: 1px 5px;
}

@media (max-width: 700px) { .hotkeys { display: none; } }

/* ---------- dialogs ---------- */

.app-dialog {
  max-width: 30em;
  width: calc(100% - 32px);
  padding: 20px 22px;
  border: 1px solid var(--line);
  border-radius: 10px;
  background: var(--panel);
  color: var(--text);
  box-shadow: 0 18px 50px rgb(0 0 0 / 35%);
}

.app-dialog::backdrop { background: rgb(0 0 0 / 45%); }
.app-dialog h2 { margin: 0 0 12px; font-size: 1.15em; }
.app-dialog p { margin: 0 0 10px; line-height: 1.5; }

/* The first paragraph carries the path being acted on. */
.app-dialog p:first-of-type {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.92em;
  word-break: break-word;
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: 6px;
  padding: 8px 10px;
}

.app-dialog ul {
  margin: 0 0 14px;
  padding-left: 1.2em;
  color: var(--muted);
  font-size: 0.92em;
  line-height: 1.5;
}

.app-dialog li { margin: 4px 0; }
.dialog-actions { margin-top: 16px; }

/* ---------- tag dialog ---------- */

.tag-dialog .tag-dialog-state { gap: 8px; margin-bottom: 8px; }

/* The two playback options, boxed off from the facts above them: these are the only
   things in the dialog that change anything. */
.tag-dialog-options {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 10px 16px;
  margin: 14px 0;
  padding: 12px;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--panel-2);
}

/* ---------- folder picker ---------- */

.folder-picker { max-width: 34em; }

/* The picker's own path line, so it does not pick up the "first paragraph is the
   subject" treatment the confirm dialogs use. */
.folder-picker .crumbs {
  background: none;
  border: 0;
  padding: 0;
  margin: 0 0 10px;
  color: var(--muted);
}

.folder-picker .crumbs .pending { color: var(--accent); font-weight: 600; }

/* `.app-dialog ul` above dresses the bulleted list the confirm dialogs use, and its
   two-part selector outranks a bare `.folder-list` - so this repeats it rather than
   relying on order. That rule's 1.2em padding is what indented the whole listing, and
   its muted colour and 0.92em size are equally wrong for a list of controls. */
.app-dialog ul.folder-list {
  list-style: none;
  margin: 0;
  padding: 0;
  color: inherit;
  font-size: 1em;
  max-height: 40vh;
  overflow-y: auto;
  border: 1px solid var(--line);
  border-radius: 6px;
}

/* Rows are separated by a border, so the 4px the dialog gives list items would only
   break the run of them up. */
.app-dialog ul.folder-list li { margin: 0; }
.folder-list li + li { border-top: 1px solid var(--line); }

.folder-row {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  text-align: left;
  border: 0;
  border-radius: 0;
  background: none;
  padding: 8px 10px;
}

.folder-row:hover { background: var(--panel-2); }
.folder-name { flex: 1; font-weight: 600; }

/* ---------- toasts ---------- */

#toasts {
  position: fixed;
  right: 16px;
  bottom: 16px;
  z-index: 50;
  display: flex;
  flex-direction: column;
  gap: 8px;
  max-width: min(380px, calc(100vw - 32px));
}

.toast {
  padding: 10px 14px;
  border-radius: 8px;
  font-size: 0.9em;
  cursor: pointer;
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
  border: 1px solid transparent;
  animation: toast-in 0.18s ease-out;
  overflow-wrap: anywhere;
}
.toast.err { background: var(--err-bg); color: var(--err); border-color: var(--err); }
.toast.ok  { background: var(--ok-bg);  color: var(--ok);  border-color: var(--ok); }
.toast.leaving { opacity: 0; transition: opacity 0.3s ease-in; }

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

@media (prefers-reduced-motion: reduce) {
  .toast { animation: none; }
}

/* ---------- badges ---------- */

.badge {
  display: inline-block;
  border-radius: 999px;
  padding: 2px 9px;
  font-size: 0.75em;
  font-weight: 600;
  white-space: nowrap;
}
.badge.ok   { background: var(--ok-bg);   color: var(--ok); }
.badge.warn { background: var(--warn-bg); color: var(--warn); }
.badge.err  { background: var(--err-bg);  color: var(--err); }

/* The album the editor just exported, linked to from the export result. It stays
   marked rather than fading: the point is to still find it after scrolling away. */
.album.highlight {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  background: var(--panel-2);
}

/* An image at its own size. `width: fit-content` is load-bearing: the UA gives a
   <dialog> inset-inline 0, so `width: auto` stretches the box the full width of the
   viewport and strands the image at the left edge. No padding, so every click that is
   not on the image lands on the backdrop and closes. */
.app-dialog.image-dialog {
  width: fit-content;
  max-width: calc(100vw - 32px);
  padding: 0;
  line-height: 0;
  overflow: hidden;
}

.image-dialog img {
  display: block;
  width: auto;
  height: auto;
  max-width: calc(100vw - 32px);
  max-height: calc(100vh - 32px);
}

/* The stored cover is capped at 600px anyway; this keeps it to a sensible on-screen
   size rather than filling a large monitor. */
.image-dialog.cover img { max-width: min(512px, calc(100vw - 32px)); max-height: 512px; }

/* The album's own name, not its path: in a card the width is the column width, and a
   path spends all of it on the folders above the one you are looking for. The full
   path is the link's title, and the parent folder sits under it in small type. */
.album-link {
  min-width: 0;
  font-weight: 600;
  line-height: 1.25;
  word-break: break-word;
  color: var(--text);
  text-decoration: none;
}

.album-link:hover { color: var(--accent); text-decoration: underline; }

/* One track on the album page: the file on the first line, its tags on the second. */
.track-row {
  border-top: 1px solid var(--line);
  padding: 8px 0;
}

.track-row:first-child { border-top: 0; }

/* Rows, not bullets: each one carries controls. */
.track-list { list-style: none; margin: 0; padding: 0; }

/* Same shape as the video row: the name is one flex item and everything about it is
   another, so a narrow screen breaks between them rather than through the name. */
.track-head { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 10px; }
.track-name { flex: 1 1 14em; min-width: 0; word-break: break-all; }
.track-head .write-track { margin-left: auto; }

.track-row .track-tags { margin-top: 4px; gap: 14px; }
.track-row .tag-pair { white-space: nowrap; }
.track-row .tag-pair .muted { margin-right: 4px; }

/* Sits above the page title, so it reads as a way back rather than as body text. */
.back-link {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  margin: 0 0 10px;
  padding: 4px 10px 4px 8px;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--panel-2);
  color: var(--muted);
  font-size: 0.85em;
  text-decoration: none;
}

.back-link:hover { color: var(--text); border-color: var(--accent); }
.badge.neutral { background: var(--panel-2); color: var(--muted); border: 1px solid var(--line); }

/* ---------- login ---------- */

.login-card {
  max-width: 380px;
  margin: 12vh auto;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 24px;
}
.login-card h1 { margin-bottom: 12px; }
.login-card button { width: 100%; margin-top: 8px; padding: 10px; }
