/* RawVault — darkroom / warm amber theme.
   Server-rendered, no build step, no CDN. Fonts are self-hosted (woff2). */

/* ---- fonts ----------------------------------------------------------
   The faces are NOT here. They are rows in static/fonts/fonts.tsv and the
   server renders /static/fonts.css from them.

   Two facts make a face: a woff2 and the unicode-range it answers for. Typed
   into a stylesheet they drift, and the drift is silent — the page renders in
   a fallback and looks almost right. This product shipped two of those. The
   Cyrillic file was once listed as a second url() in one src, which is a
   FALLBACK list rather than a subset list, so nobody ever downloaded it. Later
   it was declared once with font-weight:100 900 and Chromium never matched it.
   Both times every test was green and every Russian word was drawn by whatever
   the machine had.

   So the range now comes from the same response as the file
   (scripts/fonts/import.py), and the stylesheet is rendered from that. See
   fontcss.go and static/fonts/README.md. */

/* ---- theme tokens ----
   The muted colours are not chosen by eye. Every text token here clears
   4.5:1 against every surface it is read on, measured by the pixel audit
   (./rawvault.sh ui --audit) and solved for the smallest change in
   lightness that gets there — 143 contrast findings across 22 screens on
   2026-09-06, of which the faint text was most of them. Change one and
   re-run the audit; a token that looks right and measures 3.1:1 is a token
   somebody cannot read. */
:root, :root[data-theme="dark"] {
  --bg: #141210;
  --bg-deep: #100e0c;
  --panel: #19150f;
  --panel-2: #15110d;
  --card: #1b1611;
  --card-hover: #1a1610;
  --border: #2a2620;
  --border-2: #34302a;
  --text: #ece7df;
  --text-dim: #9a9186;
  --text-faint: #898074;
  --input-bg: #0f0d0b;
  --accent: #c8763a;
  --accent-ink: #140f0a;
  --green: #6f8f5f;
  --red: #cf6a5a;
  --amber: #b39a4a;
  --blue: #4a93a8;
  --purple: #92789a;
  --shadow: rgba(0,0,0,0.5);
}

:root[data-theme="light"] {
  --bg: #efe9df;
  --bg-deep: #e6dfd2;
  --panel: #f6f1e8;
  --panel-2: #efe9df;
  --card: #faf6ee;
  --card-hover: #f0e8d9;
  --border: #d8cfbe;
  --border-2: #c8bda8;
  --text: #2a2620;
  --text-dim: #5c554a;
  --text-faint: #6d665b;
  --input-bg: #fffdf8;
  --accent: #9f5324;
  --accent-ink: #fdf6ee;
  --green: #526f43;
  --red: #a9493a;
  --amber: #7c6522;
  --blue: #366e7d;
  --purple: #75577e;
  --shadow: rgba(90,70,45,0.18);
}

/* ======================================================================
   SKIN: clean
   ======================================================================
   The second skin, added 2026-09-03. It is a SKIN, not a palette: it owns
   geometry and type as well as colour, and both of its theme cells are
   written out below.

   Four ladders exist, not two. Skin and theme are orthogonal switches, so
   `vault`x`dark`, `vault`x`light`, `clean`x`dark` and `clean`x`light` are
   four separate cells. Writing dark once and expecting the second skin to
   inherit it is the documented way to ship a skin that only exists on one
   theme — the failure is invisible until somebody renders the cell.

   Where vault is a warm darkroom under a safelight, clean is a cool,
   neutral, roomier surface: grey neutrals with a slight blue bias, a
   restrained indigo accent instead of burnt amber, more air and a slightly
   larger type ramp. */

:root[data-skin="clean"], :root[data-skin="clean"][data-theme="dark"] {
  --bg: #16181c;
  --bg-deep: #101216;
  --panel: #1b1e23;
  --panel-2: #21252b;
  --card: #1b1e23;
  --card-hover: #23272e;
  --border: #2b3038;
  --border-2: #3a4049;
  --text: #e7eaee;
  --text-dim: #aeb4bd;
  --text-faint: #898f98;
  --input-bg: #121419;
  --accent: #788dcd;
  --accent-ink: #0f1117;
  --green: #6f9e7c;
  --red: #cf7070;
  --amber: #c2a05a;
  --blue: #5b93b8;
  --purple: #9587ba;
  --shadow: rgba(0,0,0,0.45);
}

:root[data-skin="clean"][data-theme="light"] {
  --bg: #f4f5f7;
  --bg-deep: #eaecf0;
  --panel: #ffffff;
  --panel-2: #f7f8fa;
  --card: #ffffff;
  --card-hover: #f1f3f6;
  --border: #dde0e6;
  --border-2: #c3c8d1;
  --text: #1b1e24;
  --text-dim: #4e545e;
  --text-faint: #686e78;
  --input-bg: #ffffff;
  --accent: #4257a0;
  --accent-ink: #ffffff;
  --green: #46764f;
  --red: #b04b4b;
  --amber: #866a21;
  --blue: #37718f;
  --purple: #6a589a;
  --shadow: rgba(30,40,60,0.14);
}

/* Geometry and type are written ONCE per skin, not once per cell: a theme
   changes colour and nothing else, so repeating these under each theme
   would be two copies of one decision. */
:root[data-skin="clean"] {
  --border-w: 1px;
  --border-w-em: 1px;

  --sp-1: 6px;
  --sp-2: 8px;
  --sp-3: 12px;
  --sp-4: 16px;
  --sp-5: 20px;
  --sp-6: 28px;

  --t-tiny: 9px;
  --t-xs: 10px;
  --t-sm: 11px;
  --t-base: 12px;
  --t-md: 13px;
  --t-lg: 15px;

  --control-h: 34px;
  --nav-w: 232px;
}

/* ---- geometry and type: the axes a SKIN owns --------------------------
   Declared once, not once per theme: theme changes colour, skin changes
   geometry and type, and the two are orthogonal switches. Before this
   block the project had 20 colour tokens and zero geometry tokens against
   roughly 150 geometry literals — which made it a palette, not a skin.

   --radius is 0 on purpose. Square corners are a decision, not drift: the
   design baseline carries no radius either. It is declared so the value
   is stated and a guard can hold it, rather than being the accidental
   result of nobody writing one. */
:root {
  --radius: 0;
  --border-w: 1px;
  --border-w-em: 1.5px;

  /* Six rungs, closed. The templates used ten distinct spacing values
     (3,4,5,6,8,10,11,12,14,16); 8 earned a rung of its own rather than an
     exception, and the five strays snap to the nearest rung — a shift of
     at most 2px, verified in the shot matrix. A rung is enforceable; an
     exception is a drift seed. */
  --sp-1: 4px;
  --sp-2: 6px;
  --sp-3: 8px;
  --sp-4: 10px;
  --sp-5: 14px;
  --sp-6: 20px;

  --t-tiny: 8px;
  --t-xs: 9px;
  --t-sm: 10px;
  --t-base: 11px;
  --t-md: 12px;
  --t-lg: 14px;

  --control-h: 30px;
  --nav-w: 210px;
}

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
  background: var(--bg);
  color: var(--text);
  font-family: 'Archivo', system-ui, "Segoe UI", Roboto, sans-serif;
  font-size: 14px;
  letter-spacing: 0.1px;
  -webkit-font-smoothing: antialiased;
}
a { color: var(--accent); text-decoration: none; }
a:hover { filter: brightness(1.15); }
.mono { font-family: 'JetBrains Mono', ui-monospace, "SF Mono", monospace; }
::selection { background: color-mix(in srgb, var(--accent) 30%, transparent); color: var(--text); }

/* layout shell */
.shell { display: flex; min-height: 100vh; }
aside.nav {
  width: var(--nav-w); flex: none; background: var(--panel);
  border-right: 1px solid var(--border); display: flex; flex-direction: column;
}
.brand { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-5) var(--sp-4); border-bottom: 1px solid var(--border); }
.brand .logo { width: 21px; height: 21px; border: 1.5px solid var(--accent); display: flex; align-items: center; justify-content: center; }
.brand .logo i { width: 7px; height: 7px; background: var(--accent); display: block; }
.brand b { font-size: 13px; font-weight: 800; letter-spacing: 0.3px; }
.brand small { display: block; font-family: 'JetBrains Mono', monospace; font-size: 8px; letter-spacing: 1.4px; color: var(--text-faint); margin-top: 2px; }
nav.groups { flex: 1; overflow-y: auto; padding: var(--sp-4) 0; }
.group-label { font-family: 'JetBrains Mono', monospace; font-size: 8px; letter-spacing: 1.5px; color: var(--text-faint); padding: 8px 16px 3px; }
nav a.item {
  display: flex; align-items: center; justify-content: space-between;
  padding: var(--sp-2) var(--sp-4); color: var(--text-dim); font-size: var(--t-md); font-weight: 500;
  border-left: 2px solid transparent;
}
nav a.item:hover { background: var(--card-hover); color: var(--text); filter: none; }
nav a.item.active { background: color-mix(in srgb, var(--accent) 10%, transparent); color: var(--text); border-left-color: var(--accent); }
nav a.item .count { font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--text-faint); }
.nav-foot { border-top: var(--border-w) solid var(--border); padding: var(--sp-4) var(--sp-4); font-size: var(--t-sm); color: var(--text-faint); }

main.content { flex: 1; min-width: 0; display: flex; flex-direction: column; }
/* The page header is one height whether or not a screen has a subtitle.
   Measured: 78px with one, 67px without — so the project screen, whose
   subtitle is empty when a job has no client and no notes, started its content
   eleven pixels above every other screen. A person does not read that as
   "this project has no notes"; they read it as the page having moved. */
header.page {
  display: flex; align-items: center; justify-content: space-between; gap: 14px;
  min-height: 78px; box-sizing: border-box;
  padding: var(--sp-5) var(--sp-6); border-bottom: var(--border-w) solid var(--border); background: var(--panel);
}
/* Explicit line boxes, so the header is the same height whatever script the
   title is in. Measured: a Russian project name made the header 80px against
   78 everywhere else, because Archivo ships no Cyrillic and the fallback font
   has its own metrics. The fallback is a separate finding — recorded, not
   papered over — but the page frame must not move for it. */
header.page h1 { margin: 0; font-size: 18px; line-height: 24px; font-weight: 700; letter-spacing: -0.2px; }
header.page .sub { font-family: 'JetBrains Mono', monospace; font-size: 10px; line-height: 14px; color: var(--text-faint); margin-top: 4px; }
/* The page container, and the rhythm inside it.
   Every top-level block on every screen is one gap from the one above it, and
   the gap is HERE rather than in forty-three spacing utilities scattered
   across eighteen templates — which is what it was until 2026-09-06, and why
   two screens had a rhythm nobody chose. A screen that needs a different
   distance is now a decision somebody has to make on purpose, in this file,
   for every screen at once. */
.body { flex: 1; overflow-y: auto; padding: var(--sp-6) var(--sp-6) 40px; background: var(--bg-deep); }
.body > * + * { margin-top: var(--sp-5); }

/* cards / grids */
.cards { display: grid; gap: 14px; }
.cols-3 { grid-template-columns: repeat(3, 1fr); }
.cols-2 { grid-template-columns: repeat(2, 1fr); }
@media (max-width: 1100px) { .cols-3 { grid-template-columns: 1fr; } .cols-2 { grid-template-columns: 1fr; } }
section.card { border: 1px solid var(--border); background: var(--panel-2); }
/* A row, so a card that shows a BOUNDED slice can put the way to the rest at
   the far end of its own title instead of against it. For the seventy-odd
   heads that are a single label this changes nothing. */
section.card > .head {
  display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3);
  padding: var(--sp-3) var(--sp-5); border-bottom: var(--border-w) solid var(--border);
  font-family: 'JetBrains Mono', monospace; font-size: 8.5px; letter-spacing: 1.4px;
  color: var(--text-faint); text-transform: uppercase;
}
section.card > .pad { padding: var(--sp-5) var(--sp-5); }

/* stat strip */
.stats { display: flex; flex-wrap: wrap; border: 1px solid var(--border); background: var(--panel-2); }
.stat { padding: var(--sp-4) var(--sp-6); border-right: 1px solid var(--border); }
.stat .v { font-size: 20px; font-weight: 700; }
.stat .l { font-family: 'JetBrains Mono', monospace; font-size: 8px; letter-spacing: .8px; text-transform: uppercase; color: var(--text-faint); margin-top: 3px; }

/* tables */
table { width: 100%; border-collapse: collapse; }
th { text-align: left; font-family: 'JetBrains Mono', monospace; font-size: 8px; letter-spacing: 1px; color: var(--text-faint); text-transform: uppercase; padding: 8px 12px; background: var(--card-hover); border-bottom: 1px solid var(--border); }
td { padding: var(--sp-3) var(--sp-5); border-bottom: var(--border-w) solid var(--border); font-size: var(--t-md); color: var(--text-dim); }
tr:hover td { background: var(--card-hover); }

/* badges & pills */
.pill { font-size: var(--t-xs); font-weight: 700; text-transform: uppercase; padding: 2px var(--sp-3); border: 1px solid currentColor; display: inline-flex; align-items: center; gap: 5px; }
.dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
.badge-type { font-family: 'JetBrains Mono', monospace; font-size: 8px; font-weight: 700; border: 1px solid currentColor; padding: 0 4px; }

/* modal — one dialog pattern for editing a row without leaving the list.
   Native <dialog>: the browser gives the focus trap, Escape and the backdrop,
   and none of them are worth writing again here. Everything is a token, so the
   dialog follows the skin and the theme like the card it looks like. */
dialog.modal {
  border: 1px solid var(--border); background: var(--panel); color: var(--text);
  padding: 0; width: 460px; max-width: calc(100vw - 32px);
  box-shadow: 0 24px 70px var(--shadow);
}
dialog.modal::backdrop { background: color-mix(in srgb, var(--bg-deep) 78%, transparent); }
/* The titlebar is the handle and the close button lives in it: both are put
   there by chrome.js for every dialog, so a screen gets them by using the
   component rather than by remembering to write them. */
dialog.modal > .head {
  padding: var(--sp-3) var(--sp-5); border-bottom: var(--border-w) solid var(--border);
  font-family: 'JetBrains Mono', monospace; font-size: 8.5px; letter-spacing: 1.4px;
  color: var(--text-faint); text-transform: uppercase;
  display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3);
  cursor: move; user-select: none;
}
dialog.modal > .head .modal-x {
  border: 0; background: none; color: var(--text-faint); cursor: pointer;
  font-family: inherit; font-size: 15px; line-height: 1; padding: 0 2px;
}
dialog.modal > .head .modal-x:hover { color: var(--text); }

/* Capabilities, grouped. Twenty-nine identifiers in one run is a wall; the
   areas are the product's own divisions, so a person reads the four lines
   that concern them instead of scanning an alphabet. */
.cap-area { margin-bottom: var(--sp-4); }
.cap-area:last-child { margin-bottom: 0; }
.cap-area-label {
  font-family: 'JetBrains Mono', monospace; font-size: 8px; letter-spacing: 1.4px;
  text-transform: uppercase; color: var(--text-faint); margin-bottom: var(--sp-2);
}
dialog.modal > .pad { padding: var(--sp-5); }
/* A form inside a dialog: one field per line, every control the same width,
   and the actions on their own row at the bottom.
   It is written here because a dialog is NOT a rail: the filter rail's layout
   is a wrapping row of controls sized to their content, which is right for a
   filter bar and wrong for a form — reused in a 460px dialog it gave every
   field a different width and stacked the buttons into a column. */
dialog.modal .pad > form > div,
dialog.modal .pad > form > label { display: block; }
dialog.modal .pad > form > div { margin-bottom: var(--sp-3); }
/* …except the row the buttons live on, which the rule above had just turned
   into a column. Written with the same reach as the rule it answers, because
   a fix that relies on source order is a fix somebody breaks by tidying. */
dialog.modal .pad > form > .modal-actions,
dialog.modal .pad > form > .pair { display: flex; }
dialog.modal .pad > form > .pair { display: grid; }
dialog.modal input:not([type="checkbox"]):not([type="radio"]),
dialog.modal select,
dialog.modal textarea { width: 100%; box-sizing: border-box; }
dialog.modal .modal-actions {
  display: flex; gap: 8px; justify-content: flex-end;
  margin-top: var(--sp-5); padding-top: var(--sp-4);
  border-top: var(--border-w) solid var(--border);
}
/* Two fields that belong together — a name and its date, a value and its unit
   — may share a line, and nothing else may. */
dialog.modal .pair { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-4); }
dialog.modal .pair > div { margin-bottom: 0; }
dialog.modal .modal-sep {
  border: 0; border-top: var(--border-w) solid var(--border); margin: var(--sp-5) 0 0;
}

/* Checkboxes: the browser's own are 13px and paint themselves the platform
   blue, which is the one colour on the page that belongs to no skin. Sized to
   a real target and tinted with the accent, so a tick looks like this product
   and can be hit. */
input[type="checkbox"], input[type="radio"] {
  width: 16px; height: 16px; accent-color: var(--accent); cursor: pointer;
}

/* The keyboard cursor on a list. Its own token combination rather than the
   hover colour: hover follows a mouse that may be somewhere else entirely,
   and the row somebody is about to act on with a key has to be unambiguous. */
tr.row-cursor > td { background: var(--card-hover); }
tr.row-cursor > td:first-child { box-shadow: inset 2px 0 0 var(--accent); }

/* The filter rail. One row of controls that wraps, rather than a column down
   the side: a photo grid wants its width, and a rail that takes 240px of it
   costs a column of frames on every screen it appears on. */
.rail-form { display: flex; flex-wrap: wrap; align-items: flex-end; gap: var(--sp-3); }
.rail-form > div { display: flex; flex-direction: column; min-width: 130px; }
/* .grow was defined ONLY as a child of .rail-form while seven templates used
   it — six of them got nothing, so a row meant to push its buttons to the far
   edge drew them against the text instead. A utility that silently does
   nothing outside one parent is worse than no utility. */
.grow { flex: 1; min-width: 0; }
.rail-form > div.grow { min-width: 220px; }
.rail-form label { margin: 0 0 3px; }
/* The camera's own facts, folded away under the six controls everybody uses.
   It spans the whole row: a fold that shares a line with a text field opens
   into the middle of the rail and pushes the Apply button somewhere else. */
.rail-more { flex-basis: 100%; margin-top: var(--sp-2); }
.rail-more > summary { cursor: pointer; padding: var(--sp-2) 0; color: var(--text-dim); }
.rail-more[open] > summary { color: var(--text); }
.rail-more .rail-form { padding-top: var(--sp-2); }

.rail-saved { margin-top: var(--sp-4); padding-top: var(--sp-3); border-top: var(--border-w) solid var(--border); }
.rail-view { display: inline-flex; align-items: stretch; }
.rail-view .btn:first-child { border-right: 0; }

/* buttons & forms */
/* min-height, because the chips measured 19px and every hit-target guideline
   asks for more. It is on .btn rather than on each chip size, so a new size
   inherits the floor instead of rediscovering it. */
.btn { font-family: 'Archivo', system-ui, "Segoe UI", Roboto, sans-serif; font-size: var(--t-base); font-weight: 600; padding: var(--sp-2) var(--sp-5); border: 1px solid var(--border-2); background: var(--card); color: var(--text); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 22px; }
.btn:hover { border-color: var(--accent); }
.btn.primary { background: var(--accent); color: var(--accent-ink); border: none; font-weight: 700; }
.btn.danger {
  background: color-mix(in srgb, var(--red) 12%, transparent);
  color: var(--red);
  border-color: color-mix(in srgb, var(--red) 40%, transparent);
}
.btn-row { display: flex; gap: 6px; flex-wrap: wrap; }
input, select, textarea {
  font-family: 'Archivo', system-ui, "Segoe UI", Roboto, sans-serif; font-size: 12px; background: var(--input-bg);
  color: var(--text); border: var(--border-w) solid var(--border-2); padding: var(--sp-3) var(--sp-3); width: 100%;
}
/* The same 22px floor .btn carries, for the same reason: a select is a hit
   target. .chip-xs trims the padding to 2px, which drew the culling bar's
   status, rating and label selects 17px tall. Checkboxes and radios keep
   their own box. */
input:not([type="checkbox"]):not([type="radio"]), select, textarea {
  min-height: 22px; box-sizing: border-box;
}
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); }
label { font-family: 'JetBrains Mono', monospace; font-size: 9px; letter-spacing: 0.5px; text-transform: uppercase; color: var(--text-faint); display: block; margin: 10px 0 4px; }
form.inline { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; }
form.inline > div { flex: 1; min-width: 140px; }
form.inline .btn { flex: none; }
select { appearance: none; -webkit-appearance: none; background-image: linear-gradient(45deg,transparent 50%,var(--text-faint) 50%),linear-gradient(135deg,var(--text-faint) 50%,transparent 50%); background-position: calc(100% - 15px) center, calc(100% - 10px) center; background-size: 5px 5px,5px 5px; background-repeat: no-repeat; padding-right: 26px; }

/* contact sheet */
/* A project is recognised by a photograph from it, not by a row of numbers.
   Wider cells than the contact sheet: this is one card per JOB, and a job has
   four lines to say about itself. */
/* A table of facts about one thing: a narrow label column and the value beside
   it, rather than a header row nobody needs when every row is a different
   kind of fact. */
.kv td.l { width: 160px; white-space: nowrap; vertical-align: top; }
.kv td { padding: var(--sp-2) var(--sp-3) var(--sp-2) 0; border: 0; }

/* The bar above a list of photographs: how to look, and in what order. Its own
   row rather than part of the filter rail, because narrowing WHAT is shown and
   choosing HOW to look at it are different questions and a person changes them
   at different moments. */
.viewbar { display: flex; align-items: center; justify-content: space-between;
           gap: var(--sp-3); flex-wrap: wrap; }

/* The list view: a picture small enough to scan beside the facts, for when the
   NAME is what somebody is hunting and the photograph is the confirmation. */
.flist { display: flex; flex-direction: column; }
.frow { display: flex; align-items: center; gap: var(--sp-3);
        padding: var(--sp-2) var(--sp-5); border-bottom: var(--border-w) solid var(--border); }
.frow:last-child { border-bottom: 0; }
.frow .thumb { display: block; width: 72px; flex: none; aspect-ratio: 3/2;
               position: relative; overflow: hidden; background: var(--panel-2); }

.pgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: var(--sp-3); }
.pcard { background: var(--bg-deep); border: 1px solid var(--border); display: flex; flex-direction: column; }
.pcard:hover { border-color: var(--accent); }
.pcard .thumb { display: block; aspect-ratio: 3/2; position: relative; overflow: hidden; background: var(--panel-2); }
.pcard .meta { padding: var(--sp-3); display: flex; flex-direction: column; gap: 2px; }
.pcard-none { position: absolute; inset: 0; display: flex; align-items: center;
              justify-content: center; color: var(--text-faint); }
.pgrid > .empty { grid-column: 1 / -1; }

.sheet { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
.frame { background: var(--bg-deep); border: 1px solid var(--border); display: flex; flex-direction: column; }
/* display:block, because the thumb became a link to the frame and
   aspect-ratio does nothing to an inline element — the tiles in a row stopped
   lining up the moment the div became an <a>. */
.frame .thumb { display: block; aspect-ratio: 3/2; position: relative; overflow: hidden; }
.frame .meta { padding: 5px 6px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 3px; }
.frame .row { display: flex; align-items: center; justify-content: space-between; }
.frame .name { font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.stars { display: flex; gap: 2px; }
.star { width: 6px; height: 6px; transform: rotate(45deg); background: var(--border); }
.star.on { background: var(--accent); }
/* This one sits on the fixed dark placeholder gradient that stands in for a
   photograph, so it is deliberately not theme-derived — it dies with the
   first route that serves image bytes. */
.thumb .corner { position: absolute; top: 4px; font-family: 'JetBrains Mono', monospace; font-size: var(--t-tiny); color: rgba(255,250,240,.6); }
.thumb .corner.l { left: 5px; } .thumb .corner.r { right: 5px; }
.label-strip { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; }

/* version timeline */
.vrow { display: flex; gap: 12px; margin-bottom: 12px; }
.vrail { width: 14px; display: flex; flex-direction: column; align-items: center; }
.vnode { width: 11px; height: 11px; transform: rotate(45deg); border: 2px solid var(--border-2); margin-top: 4px; }
.vline { width: 1px; flex: 1; background: var(--border-2); }
.vcard { flex: 1; border: 1px solid var(--border); background: var(--card); padding: 10px 13px; }

.muted { color: var(--text-faint); }

/* ---- closed type scale -------------------------------------------------
   The sweep found 67 font-size literals across 22 distinct values, four of
   them half-pixel one-offs. A closed scale is a set a guard can enforce;
   an open one is a habit. Sizes a skin may change live here and nowhere
   else. */
.t-tiny { font-size: var(--t-tiny); }
.t-xs   { font-size: var(--t-xs); }
.t-sm   { font-size: var(--t-sm); }
.t-base { font-size: var(--t-base); }
.t-md   { font-size: var(--t-md); }
.t-lg   { font-size: var(--t-lg); }

/* ---- utilities, all reading the closed ladders above --------------------
   These exist so a template can express a decision without inventing a
   value. Every one is a token read, never a literal: a class whose value
   happens to equal the token today is a copy, and copies drift. */
.mt-1 { margin-top: var(--sp-1); }
.mt-2 { margin-top: var(--sp-2); }
.mt-3 { margin-top: var(--sp-3); }
.mt-4 { margin-top: var(--sp-4); }
.mt-5 { margin-top: var(--sp-5); }
.mb-3 { margin-bottom: var(--sp-3); }
.mb-4 { margin-bottom: var(--sp-4); }
.mb-5 { margin-bottom: var(--sp-5); }

.fg-text   { color: var(--text); }
.fg-dim    { color: var(--text-dim); }
.fg-accent { color: var(--accent); }
.fg-red    { color: var(--red); }
.fg-green  { color: var(--green); }
.fg-amber  { color: var(--amber); }

.fw-600 { font-weight: 600; }
.fw-700 { font-weight: 700; }

.chip    { padding: 3px var(--sp-3); }
.chip-sm { padding: 2px var(--sp-2); }
.chip-xs { padding: 2px; }

.flex-1     { flex: 1; }
.w-full     { width: 100%; }
.w-auto     { width: auto; }
.row-tight  { display: flex; gap: 3px; }
.row-wrap   { flex-wrap: wrap; gap: var(--sp-2); }
.row-between{ justify-content: space-between; }
.items-start{ align-items: flex-start; }
.track-wide { letter-spacing: 2px; }

/* ---- the client gallery ------------------------------------------------
   It used to carry its own <style> block: its own ground, its own grid,
   its own breakpoint and its own monospace stack, with `lang="en"` and no
   data-theme at all. That made the one surface an external client ever
   sees the only surface outside the design system — untranslatable, and
   dark whatever the viewer chose. It reads the same tokens as everything
   else now.

   BREAKPOINTS ARE DECISIONS, and this project has exactly two. They live
   together here because two breakpoints in two files disagreeing about
   what a narrow viewport IS is a duplicated decision nobody can see:
     1100px — where the app shell gives up its fixed sidebar.
     900px  — where the gallery grid, which has no sidebar, halves.
   They differ because they answer different questions; that is the
   justification, and it belongs next to them. */
.gwrap { max-width: 1180px; margin: 0 auto; padding: 34px 40px 80px; }
.ghead h1 { font-size: 38px; margin: 0; }
.gmeta { color: var(--text-dim); font-size: var(--t-md); margin-top: var(--sp-3); }
.ggrid { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--sp-5); margin-top: 24px; }
@media (max-width: 900px) { .ggrid { grid-template-columns: repeat(2, 1fr); } }
.gcard { background: var(--card); position: relative; aspect-ratio: 4/5; outline: 2px solid transparent; }
.gcard.approved { outline-color: var(--green); }
.gactions { position: absolute; right: var(--sp-3); bottom: var(--sp-3); display: flex; gap: var(--sp-3); }
.gactions form { margin: 0; }
.gactions button {
  background: var(--panel); border: none; color: var(--text); cursor: pointer;
  font-size: 16px; padding: var(--sp-1) 7px;
}
.gactions button.on { color: var(--accent); }
.gnum {
  position: absolute; left: 9px; bottom: var(--sp-3);
  font-family: 'JetBrains Mono', ui-monospace, monospace;
  font-size: var(--t-xs); color: var(--text-faint);
}
/* ---- state colour: one class, one custom property ----------------------
   StatusClass() returns one of these; every element that shows the state
   reads --state instead of carrying its own colour. A pill, its dot and a
   progress fill therefore cannot drift apart, and the colour resolves per
   theme (and per skin) because it stays in the stylesheet. Go must never
   return a colour: html/template rewrites var() inside a style attribute
   to ZgotmplZ, and a hex from Go would pin the dark theme everywhere. */
.s-ok     { --state: var(--green); }
.s-bad    { --state: var(--red); }
.s-warn   { --state: var(--amber); }
.s-accent { --state: var(--accent); }
.s-info   { --state: var(--blue); }
.s-edit   { --state: var(--purple); }
.s-muted  { --state: var(--text-faint); }

/* Colour labels are the photographer's own marking, not a state, so they
   get their own family and never borrow the status palette. */
.cl-red    { --state: var(--red); }
.cl-yellow { --state: var(--amber); }
.cl-green  { --state: var(--green); }
.cl-blue   { --state: var(--blue); }
.cl-purple { --state: var(--purple); }

/* Consumers of --state. */
.pill        { color: var(--state, var(--text-faint)); }
.pill .dot   { background: var(--state, var(--text-faint)); }
.state-text  { color: var(--state, var(--text-faint)); }
.vnode       { border-color: var(--state, var(--border-2)); }
.label-strip { background: var(--state, transparent); }

/* ---- progress bar: one component, was four hand-rolled copies ---------
   The four copies had drifted to three heights and two border colours,
   one of which (#221d16) existed nowhere in this file. Height is the only
   axis that legitimately varied, so it is a modifier rather than a fork. */
.bar { height: 5px; background: var(--bg-deep); border: 1px solid var(--border); overflow: hidden; }
.bar > i { display: block; height: 100%; background: var(--state, var(--accent)); }
.bar.bar-sm { height: 4px; }
.bar.bar-lg { height: 6px; }
.bar.bar-w90 { width: 90px; }
.bar.bar-w120 { width: 120px; }
.row-flex { display: flex; align-items: center; gap: 10px; }
.spacer { flex: 1; }
.gallery-link { font-family: 'JetBrains Mono', monospace; font-size: 11px; }
/* An empty state says a screen is EMPTY rather than broken, so it gets a
   frame of its own. Inside a table it stays flat: a dashed box in a row would
   be a second border argued with the table's. */
.empty {
  color: var(--text-faint); font-size: 12px; padding: var(--sp-6); text-align: center;
  border: var(--border-w) dashed var(--border-2); background: var(--panel-2);
}
td.empty { border: 0; background: none; padding: 20px; }
/* Inside a grid it spans the whole row: an empty state that occupies one cell
   of a gallery reads as a single missing photograph rather than as an empty
   screen, which is the opposite of what it is there to say. */
.sheet > .empty, .ggrid > .empty, .grid > .empty,
.cols-2 > .empty, .cols-3 > .empty { grid-column: 1 / -1; }

/* --- toolbar: language select + theme toggle --- */
.toolbar { display: flex; align-items: center; gap: 8px; }
.lang-select { width: auto; font-family: 'JetBrains Mono', monospace; font-size: 10px; padding: 5px 24px 5px 9px; background-color: var(--card); }
.theme-toggle { font-family: 'JetBrains Mono', monospace; font-size: 13px; width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid var(--border-2); background: var(--card); color: var(--text); cursor: pointer; }
.theme-toggle:hover { border-color: var(--accent); }

/* --- auth screens (login / setup) --- */
.auth-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--bg-deep); padding: 20px; }
.auth-card { width: 380px; max-width: 100%; border: 1px solid var(--border); background: var(--panel); padding: 28px 26px 22px; box-shadow: 0 24px 70px var(--shadow); }
.auth-card .logo-row { display: flex; align-items: center; gap: 11px; margin-bottom: 6px; }
.auth-card .logo { width: 24px; height: 24px; border: 1.5px solid var(--accent); display: flex; align-items: center; justify-content: center; flex: none; }
.auth-card .logo i { width: 8px; height: 8px; background: var(--accent); display: block; }
.auth-card h1 { font-size: 19px; font-weight: 700; letter-spacing: -0.2px; margin: 0; }
.auth-card .sub { font-size: 11.5px; line-height: 1.5; color: var(--text-faint); margin: 8px 0 18px; }
.auth-err { background: color-mix(in srgb, var(--red) 12%, transparent); border: var(--border-w) solid color-mix(in srgb, var(--red) 40%, transparent); color: var(--red); font-size: 11.5px; padding: 9px 11px; margin-bottom: 14px; }
.auth-card .btn.primary { width: 100%; justify-content: center; margin-top: 18px; padding: 10px; font-size: 12.5px; }
.auth-foot { display: flex; align-items: center; justify-content: center; gap: 10px; width: 380px; max-width: 100%; margin: 14px auto 0; }
.auth-wrap { flex-direction: column; }

/* --- boot loader (splash before setup/login/app) --- */
.boot-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--bg-deep); padding: 20px; }
.boot-card { width: 340px; max-width: 100%; border: 1px solid var(--border); background: var(--panel); padding: 22px 22px 18px; box-shadow: 0 24px 70px var(--shadow); transition: opacity .22s ease, transform .22s ease; }
.boot-card.leaving { opacity: 0; transform: translateY(-4px); }
.boot-brand { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
.boot-brand .logo { width: 22px; height: 22px; border: 1.5px solid var(--accent); display: flex; align-items: center; justify-content: center; flex: none; }
.boot-brand .logo i { width: 8px; height: 8px; background: var(--accent); display: block; }
.boot-brand b { font-size: 15px; font-weight: 800; letter-spacing: 0.3px; }
.boot-brand small { display: block; font-family: 'JetBrains Mono', monospace; font-size: 8px; letter-spacing: 2px; color: var(--text-faint); margin-top: 2px; }
.boot-row { display: flex; align-items: center; gap: 9px; transition: opacity .16s ease; }
.boot-row.swapping { opacity: 0; }
/* The halo is DERIVED from the same token as the fill, not re-encoded beside
   it. It used to be three literal rgba() copies of --amber/--green/--red at
   16-18% alpha, so retinting a token moved the dot and left its halo behind —
   the first-paint shell is the one surface a token pass reliably misses,
   because it is the thing everybody sees and nobody re-reads. */
.boot-dot {
  width: 8px; height: 8px; border-radius: 50%; flex: none;
  --dot: var(--amber);
  background: var(--dot);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--dot) 17%, transparent);
  animation: boot-pulse 1.1s ease-in-out infinite;
}
.boot-dot.ok  { --dot: var(--green); animation: none; }
.boot-dot.err { --dot: var(--red);   animation: none; }
@keyframes boot-pulse { 0%,100% { opacity: 1; } 50% { opacity: .4; } }
.boot-msg { font-size: 13px; font-weight: 600; color: var(--text); }
.boot-detail { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text-faint); margin-top: 8px; min-height: 13px; }
.boot-actions { margin-top: 14px; display: flex; gap: 8px; }
.boot-actions:empty { margin-top: 0; }

/* --- setup form sections --- */
.auth-card-wide { width: 420px; }
.form-section { font-family: 'JetBrains Mono', monospace; font-size: 8.5px; letter-spacing: 1.4px; text-transform: uppercase; color: var(--text-faint); margin: 20px 0 4px; padding-bottom: 6px; border-bottom: 1px solid var(--border); }
.field-hint { font-size: 10.5px; color: var(--text-faint); margin-top: 4px; line-height: 1.4; }

/* ---- device-local debug overlay ---------------------------------------
   Painted through the same tokens as everything else, so it does not become
   the one panel that ignores the skin. It is hidden unless the device has
   opted in; the switch is localStorage and it records nothing anywhere. */
.dbg {
  position: fixed; right: var(--sp-3); bottom: var(--sp-3); z-index: 9999;
  background: var(--panel); border: var(--border-w-em) solid var(--accent);
  padding: var(--sp-3); min-width: 200px;
  font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: var(--t-xs);
  color: var(--text-dim); box-shadow: 0 10px 30px var(--shadow);
}
.dbg-row { display: flex; justify-content: space-between; gap: var(--sp-3); }
.dbg-row b { color: var(--text-faint); font-weight: 400; }
.dbg-row span { color: var(--text); }
.dbg-actions { display: flex; gap: var(--sp-2); margin-top: var(--sp-3); }
.dbg-actions button {
  flex: 1; font-family: inherit; font-size: var(--t-tiny);
  background: var(--card); color: var(--text-dim);
  border: var(--border-w) solid var(--border-2); padding: var(--sp-1); cursor: pointer;
}
/* Outline mode: a hairline around every box, for the devices with no devtools. */
.dbg-outline * { outline: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); }

/* ---- the shell below its breakpoint -----------------------------------
   ONE threshold, 1100px, the same one the shell already used. Two
   breakpoints disagreeing about what a narrow viewport is, is a duplicated
   decision nobody can see — and it was measurable here: at 390px the
   sidebar still took 232 of 390 pixels and the table was cut after its
   second column, while nothing reported a horizontal overflow.

   Above the threshold the disclosure is inert: the summary is hidden and
   the list is forced open, so desktop behaviour is untouched and no script
   is involved. */
.nav-summary { display: none; }

@media (max-width: 1100px) {
  .shell { flex-direction: column; min-height: 100vh; }
  aside.nav {
    width: 100%;
    border-right: none;
    border-bottom: var(--border-w) solid var(--border);
  }
  .nav-summary {
    display: flex; align-items: center; justify-content: space-between;
    gap: var(--sp-3); cursor: pointer; list-style: none;
    padding: var(--sp-3) var(--sp-4);
    border-top: var(--border-w) solid var(--border);
    color: var(--text); font-size: var(--t-md); font-weight: 600;
  }
  .nav-toggle:not(:checked) ~ .groups { display: none; }
  .nav-toggle:checked ~ .groups { display: block; }
  .nav-caret { color: var(--text-faint); }
  .nav-toggle:checked ~ .nav-summary .nav-caret { transform: rotate(180deg); }
  /* The nav foot is account chrome, not navigation: it follows the list. */
  .nav-toggle:not(:checked) ~ .nav-foot { display: none; }
  /* A wide table gets its own scroller rather than being clipped by the
     column it sits in. Clipped content is a bug; a scroller is a choice.
     min-width:0 is the load-bearing half: a flex item defaults to
     min-width:auto, so without it the table pushes the whole shell wider
     than the viewport and the full-width nav stretches with it — measured
     at 500px inside a 390px window, with nothing reporting an overflow. */
  .body { min-width: 0; }
  section.card { overflow-x: auto; min-width: 0; }
}

/* ---- the gateway: entry tiles, not counters ---------------------------
   The landing screen is a way IN to work. Colour encodes one dimension —
   whether the tile holds anything waiting — and the count annotates the
   link rather than being the point of it. */
.entries { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: var(--sp-3); }
.entry {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: var(--sp-3); padding: var(--sp-4) var(--sp-5);
  background: var(--card); border: var(--border-w) solid var(--border);
  border-left: 3px solid var(--state, var(--border-2));
  color: var(--text);
}
.entry:hover { background: var(--card-hover); }
.entry-primary { border-left-width: 5px; }
.entry-label { font-size: var(--t-md); font-weight: 600; }
.entry-count { font-size: var(--t-lg); color: var(--state, var(--text-faint)); }

/* ---- the trail back up ---- */
.crumbs {
  display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap;
  padding: var(--sp-3) var(--sp-6) 0;
  font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: var(--t-xs);
  color: var(--text-faint);
}
.crumbs a { color: var(--text-dim); }
.crumbs a:hover { color: var(--accent); }
.crumb-here { color: var(--text); }
.nav-prefs { display: block; padding: var(--sp-2) 0; color: var(--text-faint); }
.nav-prefs:hover { color: var(--accent); }
/* Personal settings have no sidebar entry — they have this link, beside the
   reader's own name. It highlights the way a sidebar item does, so the page
   still says where the reader is. */
.nav-prefs.active { color: var(--accent); }

/* The drop zone. Big enough to be an obvious target for a hand dragging a
   folder, and it says what to do rather than relying on the gesture being
   guessed — a bare dashed rectangle teaches nobody. */
.dropzone {
  display: flex; align-items: center; justify-content: center; gap: var(--sp-3);
  min-height: 120px; padding: var(--sp-5);
  border: 2px dashed var(--border-2); color: var(--text-dim);
  transition: border-color .12s, background .12s;
}
.dropzone.over { border-color: var(--accent); background: var(--bg-deep); }
.uplist { margin-top: var(--sp-3); }
.uprow { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-1) 0; }
.upname { flex: 0 0 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.upbar { flex: 1 1 auto; height: 4px; background: var(--border); }
.upfill { height: 100%; width: 0; background: var(--accent); transition: width .1s; }
.upstate { flex: 0 0 110px; text-align: right; }

/* The staged card, as a grid of what is on it.
   A photographer choosing what to import does it by looking, and a list of
   filenames is what a file manager shows. The tile is small on purpose: this
   is for recognising a frame, not for judging it — judging happens on the
   culling screen, at full size, after the frames are in. */
#uploadlist { display: grid; gap: var(--sp-3); }
#uploadlist:has(.uptile) {
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.uptile {
  position: relative;
  display: flex; flex-direction: column; align-items: stretch; gap: var(--sp-1);
  padding: var(--sp-2);
  border: 1px solid var(--border);
  background: var(--bg-raised);
}
.uptile input[type="checkbox"] {
  position: absolute; top: var(--sp-2); left: var(--sp-2); z-index: 2;
  width: 18px; height: 18px; cursor: pointer;
}
/* The picture holds its box whether or not a thumbnail was found, so a grid
   of tiles does not reflow as they arrive one by one — a grid that jumps
   under the cursor is one nobody can tick accurately. */
.upthumb {
  display: block; width: 100%; height: 110px;
  object-fit: cover; background: var(--bg-deep);
}
.uptile.nothumb::before {
  content: ""; display: block; height: 110px; background: var(--bg-deep);
  border: 1px dashed var(--border);
}
.uptile .upname {
  flex: 0 0 auto; font-size: var(--fs-xs); overflow: hidden;
  text-overflow: ellipsis; white-space: nowrap;
}
.uptile .upbar { flex: 0 0 auto; width: 100%; }
.uptile .upstate { flex: 0 0 auto; text-align: left; }
/* A frame the vault already holds is dimmed rather than hidden: it is still
   there to be ticked deliberately, and its absence would read as the card
   being short. */
.uptile:has(input:not(:checked)) { opacity: .45; }

/* The bulk bar sticks to the top of the grid, because the frames a person is
   ticking scroll away from it otherwise and the count is the feedback that
   the ticking is working. */
.bulkbar {
  position: sticky; top: 0; z-index: 2;
  display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap;
  padding: var(--sp-2) var(--sp-3); margin-bottom: var(--sp-3);
  background: var(--bg-panel); border: var(--border-w) solid var(--border);
}
.bulkbar .bulk-tools { display: flex; align-items: center; gap: var(--sp-2);
                       flex-wrap: wrap; margin-left: auto; opacity: .45; }
/* Dimmed until something is ticked: the actions do nothing on an empty
   selection, and a control that looks ready and does nothing is worse than
   one that says it is waiting. */
.bulkbar.armed .bulk-tools { opacity: 1; }
/* The tick lives in the frame's own row, not over its picture: the picture is
   a link to the frame, and a checkbox lying on top of a link is a click
   nobody can aim — the audit measured 24 of them overlapping by 16x16px. */
.pick { display: inline-flex; align-items: center; margin-right: var(--sp-2); }
.frame { position: relative; }
.frame.picked { outline: 2px solid var(--accent); outline-offset: -2px; }

/* The note field sits under a frame's actions, inside the card, because a
   note is about THAT photograph — a single comment box for a whole gallery
   produces "the third one from the left is too warm". */
.gsay { display: flex; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); }
.gsay input { flex: 1 1 auto; min-width: 0; }

/* The gallery's download block. It sits under the title rather than beside
   the photographs, because "take my selection" is one decision about the
   whole set and not an action on any one frame. */
.gget { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap;
        margin-top: var(--sp-4); }
.gget .mono { flex-basis: 100%; }
/* The per-frame arrow lines up with the heart and the tick, so the three
   things a client can do to one photograph read as one row. */
.gact { display: inline-flex; align-items: center; justify-content: center;
        width: 28px; height: 28px; color: var(--text-dim);
        text-decoration: none; }
.gact:hover { color: var(--text); }

/* ---- the loupe: one asset, large --------------------------------------- */
.loupe { display: block; }
.loupe-stage {
  position: relative; aspect-ratio: 3/2; background: var(--bg-deep);
  border-bottom: var(--border-w) solid var(--border);
  display: flex; align-items: center; justify-content: center;
}
/* The step arrows sit over the frame rather than beside it, so the image
   keeps the full width on a laptop screen — which is the whole screen a
   photographer culls on. */
.loupe-step {
  position: absolute; top: 0; bottom: 0; width: 12%;
  display: flex; align-items: center; justify-content: center;
  font-size: 2rem; color: var(--text-faint); text-decoration: none;
  background: transparent; transition: background .12s, color .12s;
}
.loupe-step:hover { background: rgba(0,0,0,.25); color: var(--text); }
.loupe-step.prev { left: 0; }
.loupe-step.next { right: 0; }
.loupe-step.off { opacity: .25; pointer-events: none; }

.loupe-bar {
  display: flex; align-items: center; gap: var(--sp-4); flex-wrap: wrap;
  padding: var(--sp-3) var(--sp-4);
  border-bottom: var(--border-w) solid var(--border);
}
.loupe-id { display: flex; align-items: baseline; gap: var(--sp-3); }
.loupe-marks { display: flex; align-items: center; gap: var(--sp-3); }
.loupe-bar .btn-row { margin-left: auto; }
.loupe-keys { padding: var(--sp-2) var(--sp-4); }
.stars { color: var(--amber); letter-spacing: 1px; }

/* A swatch is the label itself, so it is a coloured square and not a button
   with a word in it. --state is what every state class sets. */
.swatch { width: 22px; padding: 0; background: var(--state, transparent); }

/* The filmstrip is the position indicator. It scrolls horizontally on its
   own; the page body never does. */
.strip {
  display: flex; gap: var(--sp-1); overflow-x: auto;
  padding: var(--sp-3) var(--sp-4);
}
.strip-cell {
  position: relative; flex: 0 0 auto; width: 76px; height: 52px;
  border: 2px solid transparent; display: block;
}
.strip-cell.on { border-color: var(--accent); }
.strip-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.strip-mark {
  position: absolute; left: 0; right: 0; bottom: 0; height: 3px;
  background: var(--state, transparent);
}

/* A mark that did not save has to be visible. The loupe puts the frame back
   the way the catalogue has it and says so here; a silent revert would read
   as a missed keypress. */
.cull-warn {
  position: fixed; left: 50%; bottom: var(--sp-5); transform: translateX(-50%);
  background: var(--red); color: #fff; padding: var(--sp-2) var(--sp-4);
  border-radius: var(--radius); opacity: 0; pointer-events: none;
  transition: opacity .15s;
}
.cull-warn.on { opacity: 1; }
.loupe-empty { color: var(--text-faint); text-align: center; padding: var(--sp-5); }
.star-on { color: var(--amber); }
/* An unfilled star is READ, not merely present: it says "this frame is rated
   two out of five", and at a border colour it measured 1.47:1 — the rating on
   the frame's own screen was a row of shapes nobody could see. The faint text
   token is the smallest change that clears 4.5:1, and it is a text token
   because this is text. */
.star-off { color: var(--text-faint); }
/* The colour-label strip needs its own dimensions: it paints --state as a
   background, so without a size it stretches to whatever box holds it. */
.label-strip { display: inline-block; width: 28px; height: 4px; }

/* ---- the photograph, at last -------------------------------------------
   Every one of these boxes drew a CSS gradient until a route existed that
   serves image bytes. object-fit is what keeps a frame from being distorted
   into its container. */
.loupe-img { width: 100%; height: 100%; object-fit: contain; display: block; }
/* Absolute, like .gcard-img beside it, and for the reason .gcard-img already
   knew: a percentage height against a parent whose height comes from
   aspect-ratio resolves to auto, so the picture sized itself by its own
   proportions and pushed the box past the ratio. A sheet mixing 160x120
   embedded thumbnails with 3000x4000 renders drew tiles 111px and 197px tall
   in the same grid. In flow it never obeyed the ratio at all. */
.thumb-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }
.gcard-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.kw { border: var(--border-w) solid var(--border-2); color: var(--text-dim); }

/* ---- roles: the permission model, one checkbox per capability ------------
   A wrapping grid rather than a table: the list of actions grows with the
   product and a table with twenty-five columns is unreadable at any width.
   Each capability is its own one-button form, so a click sends one change
   instead of a whole set — two administrators editing different roles cannot
   overwrite each other. */
.caps { display: flex; flex-wrap: wrap; gap: 6px; }
.caps .cap { margin: 0; }
.caps .btn { font-family: 'JetBrains Mono', ui-monospace, monospace; }
.caps .btn.on { border-color: var(--accent); color: var(--accent); }

/* ---- handoff: what a retoucher copies out of a work package -------------
   A readonly textarea rather than a styled block, because the text has to be
   selectable by hand: the copy button is a convenience and the selection is
   the thing that always works. */
.handoff { resize: vertical; }
