/* ==========================================================================
   CineScope — themes

   Seven themes, one stylesheet. Nothing below this block names a colour: every
   rule reads a SEMANTIC token (--bg, --surface, --text, --accent, --danger …),
   and those tokens are derived once, here, from twenty-odd PALETTE SLOTS
   (--p-*). A theme is then just those slots filled in.

   That is what keeps a new theme to one short block instead of an audit of two
   thousand lines, and it is why the slots are named --p-* rather than after any
   one palette: "--ctp-teal: #88C0D0" under Nord would be a lie.

   The slots borrow Catppuccin's shape (crust..text for surfaces, then named
   hues) because every palette here has an equivalent for each, and having one
   fixed shape is what lets the derivations below be written once.

   Applied as <html data-theme="…"> by /theme.js, which is a blocking script in
   every page's <head> so the correct theme is on the element before first paint
   — a themed page that flashes the wrong colours first is worse than one theme.

   The one exception is the printed QR placard, which stays black on white
   whatever the theme, because it goes on paper. See "printed placards".
   ========================================================================== */

:root {
  /* Premium pass: the light a glass surface catches on its top edge. */
  --rim: inset 0 1px 0 color-mix(in srgb, white 7%, transparent);
  /* ---------------------------------------------------------------- shared */
  --radius-lg: 16px;
  --radius: 12px;
  --radius-sm: 9px;

  --font:
    ui-sans-serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, Roboto, Helvetica, Arial,
    sans-serif;
  --mono: ui-monospace, SFMono-Regular, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace;

  --ease: cubic-bezier(0.32, 0.72, 0, 1);

  /*
   * For things leaving, which is not the same curve as things arriving.
   *
   * `--ease` above is strongly front-loaded — it covers most of its distance in the first
   * third, which is exactly right for something appearing, because the eye reads the fast
   * start as responsiveness and the slow settle as weight. Run backwards it is wrong: a
   * pop-up closing on it was at 11% opacity two frames in and then spent a tenth of a
   * second being invisible. Leaving should start gently and accelerate away.
   */
  --ease-out: cubic-bezier(0.4, 0, 0.9, 1);

  /*
   * For something appearing from nothing, where the movement has to be *seen*.
   *
   * `--ease` is wrong for this and measurably so. It is front-loaded hard — a quarter of the
   * way through its duration it has covered four fifths of its distance — which is right for
   * an element already on screen that should feel responsive to a press, and wrong for one
   * arriving: the box was at 80% opacity on the first frame anybody could see it, so it read
   * as having simply been there. This spreads the distance across the duration instead, so
   * the middle of the animation is the middle of the movement.
   */
  --ease-arrive: cubic-bezier(0.22, 0.34, 0.28, 1);

  /*
   * The reticle's pupil. The one colour in this stylesheet that is not a theme
   * slot, because it is not decoration — it is the brand, and a mark that
   * changed colour with the theme would not be one. Every other part of the
   * wordmark takes currentColor and recolours freely around it.
   */
  --brand-pupil: #d9922b;
}

/* ----------------------------------------------------------------- semantic
   Written once in terms of the slots, so a theme sets colours and inherits every
   relationship between them. color-mix rather than more slots: the step from a
   surface to its border is a property of the design, not of the palette, and
   should not be rcircuitd six times.

   Also applied to [data-preview], the theme picker's swatches. A custom property
   is substituted where it is DECLARED, so --bg computed on :root keeps :root's
   crust no matter how a descendant redefines --p-crust — the derivations have to
   be re-declared on the card for its slots to reach them. */
:root,
[data-preview] {
  --bg: var(--p-crust);
  --bg-tint: radial-gradient(1200px 600px at 50% -10%, var(--p-tint) 0%, var(--p-crust) 60%);
  --surface: var(--p-mantle);
  --surface-2: var(--p-base);
  --surface-3: color-mix(in oklab, var(--p-base) 80%, var(--p-surface0));
  --border: color-mix(in oklab, var(--p-base) 58%, var(--p-surface0));
  --border-strong: var(--p-surface1);

  --text: var(--p-text);
  --text-dim: var(--p-subtext0);
  --text-faint: var(--p-overlay1);

  --accent: var(--p-accent);
  --accent-hover: color-mix(in oklab, var(--p-accent) 80%, var(--p-text));
  /* Solid fills use a deepened accent: a palette's accent is usually tuned for
     text on the background, and glares when it becomes the background itself. */
  --accent-fill: var(--p-accent-fill);
  --accent-fill-hover: color-mix(in oklab, var(--p-accent-fill) 86%, var(--p-text));
  --accent-ink: var(--p-accent-ink); /* text on top of an accent fill */
  --accent-soft: color-mix(in oklab, var(--p-accent) 13%, var(--p-crust));
  --accent-line: linear-gradient(90deg, var(--p-accent-fill), var(--p-pink));
  --focus: var(--p-lavender);

  --ok: var(--p-green);
  --ok-soft: color-mix(in oklab, var(--p-green) 13%, var(--p-crust));
  --warn: var(--p-yellow);
  --warn-soft: color-mix(in oklab, var(--p-yellow) 13%, var(--p-crust));
  --danger: var(--p-red);
  --danger-soft: color-mix(in oklab, var(--p-red) 13%, var(--p-crust));
  --info: var(--p-blue);
  --info-soft: color-mix(in oklab, var(--p-blue) 13%, var(--p-crust));

  /* Depth comes from a hairline plus a soft drop, not a hard shadow. Both the
     hairline and the drop are slots because a light theme needs a white
     hairline and a far weaker drop than a dark one. */
  /*
   * THE GLASS, named once: the dock's material, promoted to the app's own. Two weights —
   * ordinary surfaces wear --glass, floating chrome (menus, dialogs, sticky bars) wears
   * --glass-strong with a backdrop blur where content genuinely passes beneath. Buttons and
   * cards take the mix WITHOUT blur on purpose: over the app's flat ground the translucency
   * alone gives the look, and a backdrop filter on every card in a long queue is a frame
   * budget spent on nothing a reader can see.
   */
  --glass: color-mix(in srgb, var(--surface-3) 55%, transparent);
  --glass-strong: color-mix(in srgb, var(--surface-3) 80%, transparent);
  --shadow: 0 1px 0 var(--p-hairline) inset, 0 10px 28px -12px var(--p-shadow);
  --shadow-lift: 0 1px 0 var(--p-hairline) inset, 0 18px 40px -16px var(--p-shadow-strong);
}


/* ------------------------------------------------------------------ Dark
   A neutral dark grey with a blue accent, for anyone who wants dark without a
   palette's opinions. Deliberately not a variation on Mocha — offering both
   only earns its place if they look meaningfully different.

   Also the bare :root, which makes it what the page renders with when no
   data-theme has been set at all — so the stylesheet's fallback and
   DEFAULT_THEME in src/db.js are the same look. The attribute selectors are
   more specific, so any theme that IS set still wins over this. */
:root,
:root[data-theme='dark'],
[data-preview='dark'] {
  color-scheme: dark;

  --p-crust: #0e0e11;
  --p-mantle: #16161a;
  --p-base: #1c1c21;
  --p-surface0: #2a2a31;
  --p-surface1: #3a3a44;
  --p-surface2: #4c4c58;
  --p-overlay0: #6a6a78;
  --p-overlay1: #8a8a99;
  --p-subtext0: #b4b4c0;
  --p-subtext1: #d0d0da;
  --p-text: #e8e8ef;

  --p-pink: #e79ec8;
  --p-red: #f2777a;
  --p-peach: #f0a45d;
  --p-yellow: #e8d07a;
  --p-green: #8fd18a;
  --p-teal: #7fd0c4;
  --p-blue: #8ab4f8;
  --p-lavender: #a9b6f0;

  --p-accent: #8ab4f8;
  --p-accent-fill: #5b8dd9;
  --p-accent-ink: #0b1220;

  --p-tint: #17171d;
  --p-hairline: rgba(255, 255, 255, 0.05);
  --p-shadow: rgba(0, 0, 0, 0.7);
  --p-shadow-strong: rgba(0, 0, 0, 0.82);
}

/* ------------------------------------------------------------------ Light
   A proven, contrast-checked light palette rather than one invented here, with
   one change of its own: the accent is the palette's BLUE, not its mauve, so
   Light is Dark in daylight rather than a second purple theme. Picking Light
   should change the time of day, not the accent colour.

   Two things invert. The page sits on Crust, the DARKEST of the three light
   surfaces, so cards on Mantle read as raised rather than sunken. And a solid
   accent fill needs white text on it rather than near-black. */
:root[data-theme='light'],
[data-preview='light'] {
  color-scheme: light;

  --p-crust: #dce0e8;
  --p-mantle: #e6e9ef;
  --p-base: #eff1f5;
  --p-surface0: #ccd0da;
  --p-surface1: #bcc0cc;
  --p-surface2: #acb0be;
  --p-overlay0: #9ca0b0;
  --p-overlay1: #8c8fa1;
  --p-subtext0: #6c6f85;
  --p-subtext1: #5c5f77;
  --p-text: #4c4f69;

  --p-pink: #ea76cb;
  --p-red: #d20f39;
  --p-peach: #fe640b;
  --p-yellow: #df8e1d;
  --p-green: #40a02b;
  --p-teal: #179299;
  --p-blue: #1e66f5;
  --p-lavender: #7287fd;

  /* Deeper than the palette's own blue (#1e66f5, kept above for --info): that one
     only reaches 4.0:1 as text on a card, and --accent IS text here — links, the
     disclosure toggles, the "set to light right now" line. This clears 5:1, and
     white on it as a solid fill clears 6:1. */
  --p-accent: #1657d8;
  --p-accent-fill: #1657d8;
  --p-accent-ink: #ffffff;

  --p-tint: #eceef4;
  --p-hairline: rgba(255, 255, 255, 0.9);
  --p-shadow: rgba(76, 79, 105, 0.16);
  --p-shadow-strong: rgba(76, 79, 105, 0.22);
}

/* ------------------------------------------------------------- Your brand
   The one theme whose colours are not written here.

   A circuit picks ONE hex in Settings, and the blocking theme script writes four
   tokens onto the root from it — the fill as picked, an ink that can be read on
   that fill, a text-legible version of the same hue, and a tint (see brandTokens
   in js/brand-colour.js, which is also what the picker previews with).

   EVERYTHING ELSE IS BORROWED. The neutrals are Dark's or Light's, chosen beside
   the colour, because a picker over all eleven would let a cinema build a page
   nobody can read — and "match our brand" means the colour on the buttons, not a
   bespoke greyscale. The two blocks below are the borrowing, keyed on the base
   the script also writes; the accent slots then take the customer's tokens.

   The var() fallbacks are what a circuit that has picked NO colour gets: the
   ordinary Dark or Light accent, so selecting this theme before setting a colour
   is a no-op rather than a page of black buttons. */
:root[data-theme='custom'][data-brand-base='light'],
[data-preview='custom'][data-brand-base='light'] {
  color-scheme: light;

  --p-crust: #dce0e8;
  --p-mantle: #e6e9ef;
  --p-base: #eff1f5;
  --p-surface0: #e0e3ea;
  --p-surface1: #ccd0da;
  --p-surface2: #bcc0cc;
  --p-overlay0: #9ca0b0;
  --p-overlay1: #8c8fa1;
  --p-subtext0: #6c6f85;
  --p-subtext1: #5c5f77;
  --p-text: #4c4f69;

  --p-pink: #ea76cb;
  --p-red: #d20f39;
  --p-peach: #fe640b;
  --p-yellow: #df8e1d;
  --p-green: #40a02b;
  --p-teal: #179299;
  --p-blue: #1e66f5;
  --p-lavender: #7287fd;

  --p-accent: var(--brand-accent, #1e66f5);
  --p-accent-fill: var(--brand-accent-fill, #1e66f5);
  --p-accent-ink: var(--brand-accent-ink, #ffffff);

  --p-tint: var(--brand-tint, #e6e9ef);
  --p-hairline: rgba(255, 255, 255, 0.9);
  --p-shadow: rgba(76, 79, 105, 0.16);
  --p-shadow-strong: rgba(76, 79, 105, 0.22);
}

:root[data-theme='custom'],
[data-preview='custom'] {
  color-scheme: dark;

  --p-crust: #0e0e11;
  --p-mantle: #16161a;
  --p-base: #1c1c21;
  --p-surface0: #2a2a31;
  --p-surface1: #3a3a44;
  --p-surface2: #4c4c58;
  --p-overlay0: #6a6a78;
  --p-overlay1: #8a8a99;
  --p-subtext0: #b4b4c0;
  --p-subtext1: #d0d0da;
  --p-text: #e8e8ef;

  --p-pink: #e79ec8;
  --p-red: #f2777a;
  --p-peach: #f0a45d;
  --p-yellow: #e8d07a;
  --p-green: #8fd18a;
  --p-teal: #7fd0c4;
  --p-blue: #8ab4f8;
  --p-lavender: #a9b6f0;

  --p-accent: var(--brand-accent, #8ab4f8);
  --p-accent-fill: var(--brand-accent-fill, #5b8dd9);
  --p-accent-ink: var(--brand-accent-ink, #0b1220);

  --p-tint: var(--brand-tint, #17171d);
  --p-hairline: rgba(255, 255, 255, 0.05);
  --p-shadow: rgba(0, 0, 0, 0.7);
  --p-shadow-strong: rgba(0, 0, 0, 0.82);
}

/* ----------------------------------------------------------------- System
   Not a palette: the device's own light/dark setting, resolved to Light or Dark
   by these two media queries.

   No JavaScript and no request — which means it also follows the device LIVE.
   Switching the OS to dark recolours an open page immediately, where a scripted
   version would need a reload or a matchMedia listener on every page.

   The slot values below are copies of the Light and Dark blocks above, because
   plain CSS has no way to say "be that block" and this project has no build step
   to generate it. test/theme.test.js asserts they stay identical, so the drift
   that would otherwise be invisible is a failing test instead.
   ------------------------------------------------------------------------- */
@media (prefers-color-scheme: light) {
  :root[data-theme='system'],
  [data-preview='system'] {
  color-scheme: light;

  --p-crust: #dce0e8;
  --p-mantle: #e6e9ef;
  --p-base: #eff1f5;
  --p-surface0: #ccd0da;
  --p-surface1: #bcc0cc;
  --p-surface2: #acb0be;
  --p-overlay0: #9ca0b0;
  --p-overlay1: #8c8fa1;
  --p-subtext0: #6c6f85;
  --p-subtext1: #5c5f77;
  --p-text: #4c4f69;

  --p-pink: #ea76cb;
  --p-red: #d20f39;
  --p-peach: #fe640b;
  --p-yellow: #df8e1d;
  --p-green: #40a02b;
  --p-teal: #179299;
  --p-blue: #1e66f5;
  --p-lavender: #7287fd;

  --p-accent: #1657d8;
  --p-accent-fill: #1657d8;
  --p-accent-ink: #ffffff;

  --p-tint: #eceef4;
  --p-hairline: rgba(255, 255, 255, 0.9);
  --p-shadow: rgba(76, 79, 105, 0.16);
  --p-shadow-strong: rgba(76, 79, 105, 0.22);
  }
}

@media (prefers-color-scheme: dark) {
  :root[data-theme='system'],
  [data-preview='system'] {
  color-scheme: dark;

  --p-crust: #0e0e11;
  --p-mantle: #16161a;
  --p-base: #1c1c21;
  --p-surface0: #2a2a31;
  --p-surface1: #3a3a44;
  --p-surface2: #4c4c58;
  --p-overlay0: #6a6a78;
  --p-overlay1: #8a8a99;
  --p-subtext0: #b4b4c0;
  --p-subtext1: #d0d0da;
  --p-text: #e8e8ef;

  --p-pink: #e79ec8;
  --p-red: #f2777a;
  --p-peach: #f0a45d;
  --p-yellow: #e8d07a;
  --p-green: #8fd18a;
  --p-teal: #7fd0c4;
  --p-blue: #8ab4f8;
  --p-lavender: #a9b6f0;

  --p-accent: #8ab4f8;
  --p-accent-fill: #5b8dd9;
  --p-accent-ink: #0b1220;

  --p-tint: #17171d;
  --p-hairline: rgba(255, 255, 255, 0.05);
  --p-shadow: rgba(0, 0, 0, 0.7);
  --p-shadow-strong: rgba(0, 0, 0, 0.82);
  }
}




/* ------------------------------------------------------------------- base */
*,
*::before,
*::after {
  box-sizing: border-box;
}

/*
 * The browser hides [hidden] with `display: none` at the lowest specificity, so
 * ANY class rule setting display quietly beats it — and 57 rules here do. Every
 * page uses the hidden attribute to show and hide panels, which makes that a trap
 * waiting for whichever component grows a display rule next. One declaration
 * closes it for all of them.
 */
[hidden] {
  display: none !important;
}

/*
 * The page's ground is on the ROOT, not on body, and that is load-bearing twice over.
 *
 * On a phone it is the fix for a real artefact. Overscroll past the end of a page and the browser
 * rubber-bands, showing an area beyond the document — and what it paints there is the CANVAS, which
 * is the root element's background. With the background on body instead, it reaches the canvas only
 * by the propagation rule (a root with no background of its own borrows body's), and iOS does not
 * carry a `background-attachment: fixed` background through that reliably: the band came up in the
 * UA's own dark grey, which is LIGHTER than Crust, so the whole page appeared to lift and show
 * something paler underneath. Declared here, there is no propagation to get wrong.
 *
 * AND BODY MUST NOT HAVE ONE. That is not tidiness, it is paint order: `.dust` sits at `z-index: -1`,
 * and negative-z-index descendants paint after the root's background but BEFORE the in-flow block
 * backgrounds — so a background on body would paint over the motes and the dust would silently
 * vanish. It works today because body's background is propagated away to the canvas and body paints
 * nothing itself; move it here and that stops being true, so it has to move rather than be copied.
 */
/*
 * A COLOUR ONLY, and that is the fix for a strip along the bottom of every page on a phone.
 *
 * The tint used to be here too, with `background-attachment: fixed`, so its bright edge stayed at
 * the top of the screen rather than scrolling away with the top of the document. The intent was
 * right and the mechanism was not: a fixed background on the ROOT gives Safari a background layer
 * sized to the layout viewport, and the area exposed when the address bar collapses — or by a
 * rubber-band scroll past the end of the document — is outside it. What showed through was the
 * browser's own dark grey, around #2c2c2e against a crust of #11111b: lighter, full width, pinned
 * to the bottom of the window. The dust was visible over it, which is what said it was the page's
 * own background missing rather than browser chrome.
 *
 * A plain `background-color` has none of that. The root's background is the CANVAS background, and
 * its painting area is defined to cover the whole canvas — every overscroll region included. So the
 * colour is here, where it can never fall short, and the tint moved to the pseudo-element below.
 */
html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
  background-color: var(--bg);
}

/*
 * The scrollbar's space is reserved on EVERY page, whether that page scrolls or not.
 *
 * Without this the app moves sideways as you walk through it. The shell and the top bar
 * are both `max-width: 1180px; margin: 0 auto`, so their left edge is half of whatever is
 * left over — and what is left over changes by the width of a scrollbar depending on
 * whether the page you landed on is long enough to need one. Measured, with scrollbars
 * shown: the wordmark sits at x=65 on the overview and Location info, and at x=70 on
 * Settings and Needs review. Five pixels, on every navigation between a long page and a
 * short one, on the one element the eye is anchored to while a new page paints.
 *
 * The reticle is what made it obvious rather than what was wrong. Its ring is a mask on a
 * fractional box (0.82em — 14.95px in the bar), so a shift re-rasterises its edges as well
 * as moving it: it appears to change weight, which reads as the mark adjusting itself
 * rather than as the whole page sliding. The letters beside it re-hint and stay crisp.
 *
 * These scrollbars are CLASSIC ones — see the ::-webkit-scrollbar block below, which takes
 * layout width even on the platforms whose default is an overlay — so this is not a
 * Windows-only nicety. Headless Chromium hides scrollbars by default, which is why no test
 * saw it; test/browser/wordmark.browser.js now launches a browser that shows them.
 *
 * WIDE SCREENS ONLY, and the reason is what a reserved gutter costs. It comes out of the
 * layout viewport, so on a phone it would take ten of the 390 pixels the whole design is
 * cut to, push `calc(100vw - 32px)` dialogs into a sideways scroll, and buy nothing: a
 * phone browser draws overlay scrollbars that never took the width in the first place, so
 * there was no shift there to fix. The fault this cures is a desktop one.
 */
@media (min-width: 700px) {
  html {
    scrollbar-gutter: stable;
  }
}

/*
 * The tint, on a fixed layer of its own.
 *
 * Same result as `background-attachment: fixed` — the gradient is positioned against the viewport,
 * so its bright edge stays at the top of the screen — with the failure mode removed: if this box
 * falls short of the visual viewport during an overscroll, what is behind it is the root's own
 * crust rather than nothing.
 *
 * A pseudo-element rather than the `.dust` div, which is fixed and viewport-sized and would
 * otherwise have been the obvious host. That one is built by js/dust.js, so the tint would arrive
 * after the module ran and the page would visibly warm up a moment after loading. This is in the
 * stylesheet, so it is there for the first paint.
 *
 * `-2` puts it behind the motes (`-1`) and behind everything in the flow. Both are behind content
 * and above the canvas, which is the order the two of them want.
 */
html::before {
  content: '';
  position: fixed;
  inset: 0;
  z-index: -2;
  pointer-events: none;
  background-image: var(--bg-tint);
}

body {
  margin: 0;
  min-height: 100vh;
  font-family: var(--font);
  /*
   * 1rem, not 16px — identical at rest, and the difference is the whole of the text-size
   * accessibility option: every component here sizes itself in rem, so the root is the one
   * dial that scales the app. A hard 16px on the body left the plain prose behind while
   * headings, labels and figures grew around it, which reads as a broken page rather than a
   * larger one. See the accessibility block at the foot of this file.
   */
  font-size: 1rem;
  line-height: 1.6;
  color: var(--text);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  font-feature-settings: 'cv05', 'ss01';
}

/* =================================================================== the dust
 * Motes rising from the bottom of the window, thinning out around halfway up.
 *
 * The whole of what this adds to the page. Behind it is the theme's own tint on `body` and
 * nothing else — two soft washes of colour used to sit between the two, and they are gone:
 * asked for a background animation, what was wanted was dust, and the washes were a second
 * answer to a question nobody had asked.
 *
 * Fourteen small elements rather than a few big ones, and that is the whole performance
 * story. Those washes drifted at first, which is to say they were two window-sized layers
 * being moved by the compositor — cheap on one page and quietly ruinous once several were
 * open, because a full-window layer has to be composited whole every frame. A mote is two
 * pixels across.
 *
 * Measured both ways, on the same machine, with twenty pages of the app open at once: the
 * two drifting layers gave a median frame of 237ms and put 55 of 69 frames over 200ms; the
 * fourteen motes give 48ms and put none of them over. On one page, which is what anybody
 * actually has, both sit at the 17ms the display asks for.
 *
 * Built by public/js/dust.js, which util.js imports on every page, rather than written into
 * fifteen pages' markup — the same reasoning as the nav's menu button.
 *
 * Only `transform` and `opacity` are animated. Both are properties the compositor can do
 * on its own thread without asking the main one for anything, which matters because this
 * is running the whole time somebody is reading a queue.
 */
.dust {
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  /* Premium pass: a whisper of grain, riding the layer that already floats behind every
     page — NOT on body, whose background is forbidden (it would paint over this very
     layer; see test/background.test.js). It is what stops the dark gradients and veils
     banding on OLED, and it gives the glass something to frost. */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.05'/%3E%3C/svg%3E");
  /*
   * `clip` rather than `hidden`: hidden makes this a scroll container, and a scroll
   * container inside a page that already has one is a second thing for a swipe or a
   * sticky bar to interact with. This only has to stop a mote drawing outside the window.
   */
  overflow: clip;
}

.dust__mote {
  /*
   * Accent mixed toward the theme's own ink, which is what makes one rule work on all
   * seven themes: on a dark theme the ink is pale, so the mote is pale against a dark
   * page; on a light theme the ink is near-black and the mote darkens instead. A fixed
   * pale mote would be invisible on Light, and this is the sheet's only element whose
   * whole job is to be *slightly* visible.
   */
  --ink: color-mix(in oklab, var(--p-accent) 58%, var(--p-text));

  position: absolute;
  /* Below the fold at rest, so a mote's first frame happens off screen rather than as a
     dot appearing in mid-air along the bottom edge. */
  bottom: -5vh;
  left: var(--at);
  width: var(--size);
  height: var(--size);
  border-radius: 50%;
  background: var(--ink);
  /* The halo is what makes it dust rather than a pixel. Scaled off the mote so the small
     ones do not end up with the same glow as the large ones. */
  box-shadow: 0 0 calc(var(--size) * 2.4) var(--ink);
  opacity: 0;
  animation: mote-rise var(--for) linear var(--delay) infinite;
}

/*
 * Opacity is 0 at both ends, which is what makes the loop invisible.
 *
 * The transform deliberately does NOT return to where it started — a mote that rose and
 * then slid back down would be the one thing here nobody could stop watching. It does not
 * need to: by 78% the mote has already faded to nothing, so the jump back to the bottom of
 * the window happens while it cannot be seen.
 *
 * The plateau from 14% to 45% is what puts the fade where it was asked for. Without it the
 * opacity falls continuously from the fourteen-percent mark, so a mote is brightest just off
 * the bottom edge and effectively gone by a third of the way up — the field sat in a band
 * along the bottom of the window instead of dispersing around the middle of it. That is the invariant worth keeping, and it
 * is the one the tests check: transparent at 0% and transparent at 100%.
 *
 * The sway is spent by halfway and returns by the end, so the path is a slight S rather
 * than a diagonal. Each property interpolates across only the frames that mention it, so
 * the opacity frames in between leave the transform alone.
 *
 * Under `prefers-reduced-motion` the sheet's blanket rule zeroes the duration; with no
 * `animation-fill-mode` the animation then stops applying and the mote reverts to the
 * `opacity: 0` above. So a reader who asked for no motion gets no dust at all, rather than
 * fourteen dots parked in mid-air. Measured, not assumed.
 */
@keyframes mote-rise {
  0% {
    transform: translate3d(0, 0, 0);
    opacity: 0;
  }
  14% {
    opacity: var(--peak);
  }
  45% {
    opacity: var(--peak);
  }
  50% {
    transform: translate3d(var(--sway), calc(var(--rise) * -0.5), 0);
  }
  78% {
    opacity: 0;
  }
  100% {
    transform: translate3d(0, calc(var(--rise) * -1), 0);
    opacity: 0;
  }
}

/* ================================================================ the network
 * The case monitor's sky while AI TRIAGE works the queue: the dust is put away and a
 * CONSTELLATION takes its place — nodes joined by faint lines, packets of light travelling
 * link to link. The metaphor is the point: the reports are not drifting past anybody, they
 * are being carried through a working system, and the page says so the whole time it is
 * open. Built by weaveTriageNetwork in js/dust.js, only on the case monitor, and only
 * while the agent is genuinely running (the queue response carries triageAvailability's
 * answer, not the bare setting — see review.js).
 *
 * Same performance rules as the dust above, deliberately: hand-placed geometry rather than
 * a canvas doing distance maths every frame, and the only ANIMATED properties are
 * transform and opacity — the two the compositor runs without the main thread. The lines
 * themselves never move; what animates is eleven nodes breathing and fifteen four-pixel
 * packets, which is the same order of work as the fourteen motes it replaces. The wash at
 * the foot is static paint, not the drifting window-sized layers this sheet once removed.
 *
 * Under prefers-reduced-motion the blanket rule zeroes the durations: packets and lines
 * rest at opacity 0 and vanish, and what remains is the faint nodes and the wash — a
 * still sky that still says the state. That is the right trade for a background that is
 * information (the agent has this queue), where the decorative dust rightly vanishes
 * whole.
 */
.network {
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  overflow: clip;
  background: linear-gradient(
    to top,
    color-mix(in oklab, var(--p-accent) 16%, transparent),
    transparent 40%
  );
}

.network__node {
  position: absolute;
  width: var(--size);
  height: var(--size);
  /* Centred on its coordinate, so a node sits ON its lines' meeting point rather than
     hanging below-right of it — the lines are drawn centre to centre. */
  translate: -50% -50%;
  border-radius: 50%;
  background: var(--p-accent);
  /* Softer than the first cut: the nodes are the CONSTANT, there to give a surfacing line
     somewhere to come from, and eleven bright beacons were half of the busyness. */
  box-shadow:
    0 0 calc(var(--size) * 1.5) var(--p-accent),
    0 0 calc(var(--size) * 4) var(--p-accent),
    0 0 1px var(--p-accent);
  opacity: 0.3;
  animation: net-breathe var(--for) ease-in-out var(--delay) infinite;
}

/*
 * A line RESTS DARK. The first cut of this drew all fifteen at once, permanently, and a
 * whole mesh pulsing over the queue was a wall — busy exactly the way a background must
 * not be. Now each line is an EVENT: it fades in, its spark crosses, it fades out, and it
 * is gone for the rest of its cycle. The phasing in js/dust.js guarantees two or three are
 * lit at any moment — never none, never a pile-up — with a fresh one surfacing somewhere
 * every four seconds or so.
 */
.network__link {
  position: absolute;
  height: 1px;
  /* Anchored at its left end and rotated onto its far node; the packet below rides this
     same rotation, which is what lets one translateX travel the whole line. */
  transform-origin: 0 50%;
  transform: rotate(var(--angle));
  background: linear-gradient(
    90deg,
    transparent,
    color-mix(in oklab, var(--p-accent) 55%, transparent) 16%,
    color-mix(in oklab, var(--p-accent) 55%, transparent) 84%,
    transparent
  );
  opacity: 0;
  animation: net-line var(--for) linear var(--delay) infinite;
}

/*
 * The packet: one spark per link, IN STEP with its line by construction — both animations
 * share the same duration and delay, so the spark gathers at the near node while the line
 * fades in (0–4%), crosses while it is fully lit (4–14%), and lands as it starts to fade.
 * A spark on an unlit line would be a dot crossing empty sky.
 */
.network__link::after {
  content: '';
  position: absolute;
  top: -1.5px;
  left: 0;
  width: 4px;
  height: 4px;
  border-radius: 50%;
  background: var(--p-accent);
  box-shadow: 0 0 9px var(--p-accent);
  opacity: 0;
  animation: net-packet var(--for) linear var(--delay) infinite;
}

@keyframes net-breathe {
  0%,
  100% {
    opacity: 0.18;
  }
  50% {
    opacity: 0.4;
  }
}

/* Lit for 18% of the cycle. The bound on how many show at once lives in the LINKS table's
   phases (js/dust.js) — change this window and that arithmetic changes with it. */
@keyframes net-line {
  0% {
    opacity: 0;
  }
  3.5% {
    opacity: 0.3;
  }
  14% {
    opacity: 0.3;
  }
  18% {
    opacity: 0;
  }
  100% {
    opacity: 0;
  }
}

@keyframes net-packet {
  0% {
    transform: translateX(0);
    opacity: 0;
  }
  4% {
    transform: translateX(0);
  }
  5% {
    opacity: 0.85;
  }
  13% {
    opacity: 0.85;
  }
  14% {
    transform: translateX(var(--run));
  }
  15% {
    opacity: 0;
  }
  100% {
    transform: translateX(var(--run));
    opacity: 0;
  }
}

h1,
h2,
h3 {
  line-height: 1.2;
  margin: 0 0 0.4em;
  letter-spacing: -0.02em;
  font-weight: 650;
}

h1 {
  font-size: clamp(1.4rem, 1.2rem + 1vw, 1.85rem);
  font-weight: 750;
  letter-spacing: -0.025em;
}
h2 {
  font-size: 1.15rem;
}
h3 {
  font-size: 1rem;
}

p {
  margin: 0 0 1em;
}

a {
  color: var(--p-lavender);
  text-decoration-color: color-mix(in oklab, var(--p-lavender) 40%, transparent);
  text-underline-offset: 2px;
  transition: color 120ms var(--ease);
}

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

::selection {
  background: color-mix(in oklab, var(--accent) 35%, transparent);
  /* Not a fixed white: on the light theme that is invisible on its own tint. */
  color: var(--text);
}

:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
  border-radius: 4px;
}

/* Slim, unobtrusive scrollbars that match the theme. */
* {
  scrollbar-width: thin;
  scrollbar-color: var(--p-surface1) transparent;
}

*::-webkit-scrollbar {
  width: 10px;
  height: 10px;
}

*::-webkit-scrollbar-thumb {
  background: var(--p-surface1);
  border-radius: 999px;
  border: 2px solid transparent;
  background-clip: content-box;
}

*::-webkit-scrollbar-thumb:hover {
  background: var(--p-surface2);
  background-clip: content-box;
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

/* ----------------------------------------------------------------- layout */
.shell {
  /*
   * Named, because one thing needs to escape it: the guest page's send bar is full
   * bleed and pulls itself back out by exactly this much. The value changes at a
   * breakpoint and again for print, and a negative margin written as a number was off
   * by four pixels on a phone — which is a page that scrolls sideways.
   */
  --shell-pad: 20px;
  max-width: 1180px;
  margin: 0 auto;
  padding: 0 var(--shell-pad) 80px;
}

/*
 * A PAGE WHOSE CARDS SIT EVENLY, however many dialogs are parked between them.
 *
 * The spacing everywhere else comes from `.card + .card`, which is an ADJACENT sibling rule — it
 * only fires when one card directly follows another. The staffing page keeps its four <dialog>
 * elements in the flow between the cards they belong to, and a closed dialog is display:none but
 * is still a sibling, so it breaks the adjacency: three of the cards there had no top margin at
 * all and sat flush against the one above, while the rest had their 16px. Reported as "some are
 * so close the menus are touching, and others are randomly spaced out", which is precisely what
 * a rule that can be interrupted by an invisible element looks like.
 *
 * A flex column with a gap cannot be interrupted that way: a display:none child is not a flex
 * item, so it contributes no gap and no break. The spacing then describes the page rather than
 * the order things happen to be written in.
 */
.shell--stack {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

/* The gap owns the spacing now, so the margin rule must not add to it. */
.shell--stack > .card + .card {
  margin-top: 0;
}

/* The header keeps the 30px it has always had under it: its own 14 plus the column's 16. Written
   as the remainder rather than left at 30, because the gap is now unavoidably part of that sum. */
.shell--stack > .page-head {
  margin-bottom: 14px;
}

.shell--narrow {
  max-width: 640px;
}

/*
 * Settings: wide enough that ten category pills wrap into two rows rather than
 * five, narrow enough that a line of help text stays readable.
 */
.shell--medium {
  max-width: 880px;
}

/*
 * ---------------------------------------------- changing pages by swiping sideways
 *
 * The gesture is in public/js/swipe.js, over the page header only, on a phone only.
 * These are the four states it puts the shell in.
 *
 * The shell rather than the whole page, so the top bar stays exactly where it is: what
 * moves is the page's content, which is what changed. The bar is the frame around it.
 *
 * Everything is transform and opacity, which the compositor can do on its own thread —
 * this animation runs while a document is being fetched and parsed, which is the worst
 * possible moment to be asking the main thread for layout.
 */

/* The spring back when a drag is released without going far enough. */
.shell {
  transition:
    transform 200ms var(--ease),
    opacity 200ms var(--ease);
}

/*
 * Following the finger. No transition, because the transform IS the finger's position —
 * easing it makes the content lag the thumb, which reads as a slow page rather than as a
 * drag.
 *
 * After the rule above and not before it: both selectors are one class, so this only wins
 * on being later. Written the other way round it silently loses, and the only symptom is
 * a drag that trails.
 */
.shell--dragging {
  transition: none;
}

/*
 * Leaving. The page goes the way the finger went, and the arriving page comes in behind it
 * from the other side, so one continuous movement crosses the two documents.
 *
 * Next goes LEFT and previous goes right, because the links in the bar above read left to
 * right and the pages are laid out in that order: moving to the next one slides this one
 * off to the left and brings that one in from the right. See `towards()` in
 * public/js/swipe.js, which is the same decision on the input side — the two have to
 * mirror each other or the page moves one way while the finger goes the other.
 *
 * Shorter and shallower than the entrance: the exit is time the reader is waiting for a
 * page, and the entrance is time they are being shown one.
 */
.shell--leaving-next,
.shell--leaving-prev {
  transition:
    transform 150ms var(--ease),
    opacity 150ms var(--ease);
  opacity: 0;
}

.shell--leaving-next {
  transform: translateX(-10%);
}

.shell--leaving-prev {
  transform: translateX(10%);
}

/*
 * Arriving.
 *
 * An animation rather than a transition, because there is no first state to transition
 * from — the document is new, and an animation carries its own starting point. The
 * attribute is set by /theme.js before first paint (see `themeScript` in src/app.js) and
 * removed by swipe.js once this has finished.
 */
@keyframes shell-swipe-in-next {
  from {
    transform: translateX(13%);
    opacity: 0;
  }
}

@keyframes shell-swipe-in-prev {
  from {
    transform: translateX(-13%);
    opacity: 0;
  }
}

html[data-swipe='next'] .shell {
  animation: shell-swipe-in-next 240ms var(--ease) both;
}

html[data-swipe='prev'] .shell {
  animation: shell-swipe-in-prev 240ms var(--ease) both;
}

/*
 * A shell pushed sideways is wider than the window, which would otherwise be a page that
 * can be scrolled sideways for the length of the animation — or worse, a scrollbar that
 * appears and goes again.
 *
 * `clip` rather than `hidden`: hidden makes this a scroll container, and a scroll
 * container is not something the sticky top bar can stick to. Applied only while
 * something is actually moving, so a legitimately wide child is never clipped at rest.
 */
html[data-swipe] body,
body:has(.shell--dragging),
body:has(.shell--leaving-next),
body:has(.shell--leaving-prev) {
  overflow-x: clip;
}

.topbar {
  position: sticky;
  top: 0;
  z-index: 20;
  background: color-mix(in oklab, var(--p-mantle) 82%, transparent);
  backdrop-filter: saturate(140%) blur(12px);
  -webkit-backdrop-filter: saturate(140%) blur(12px);
  border-bottom: 1px solid var(--border);
}

.topbar__inner {
  max-width: 1180px;
  margin: 0 auto;
  padding: 12px 20px;
  display: flex;
  align-items: center;
  gap: 14px;
  flex-wrap: wrap;
}

.brand {
  display: flex;
  align-items: center;
  gap: 10px;
  font-weight: 650;
  font-size: 1rem;
  letter-spacing: -0.02em;
  text-decoration: none;
  color: var(--text);
}

.brand:hover {
  color: var(--text);
}

/* Whatever follows the wordmark — on a General Manager's bar, their cinema. */
/*
 * HOW BIG THE MARK IS DRAWN, which is not the same number for the two products.
 *
 * CINESC(O)PE is nine glyphs; F(O)YER is five. At one font size their cap heights are identical
 * — same typeface, same weights — but the short lockup covers barely half the width, and every
 * slot in this app was proportioned around the long one. So FOYER at 1.14rem is not smaller
 * type, it is a smaller OBJECT, and it reads as lost in a bar built for its predecessor.
 *
 * Corrected with a multiplier rather than a second set of sizes, so each slot keeps the decision
 * somebody made about it — the guest hero's mark is deliberately quiet, and must stay quiet, just
 * not quieter than CineScope's was there.
 *
 * 1.3 is a compromise, and it is worth being honest about which one. Measured in all three slots,
 * FOYER at 1.0 draws 58% of CINESCOPE's width at exactly its cap height; at 1.3 it draws 76% of
 * the width at 130% of the cap. Matching the width outright would take about 1.7, and a mark
 * two-thirds again as tall as the one it replaces would tower over the bar it sits in. So this
 * closes most of the gap in mass and accepts a slightly taller lockup, which is the trade that
 * looks right rather than the one that measures equal.
 *
 * What it costs, also measured: nothing in the top bar, whose height is fixed by its own padding
 * and does not move at any scale; one pixel on the guest header. In that header the mark ends up
 * at 18.7px against a 25.4px heading, so it is still the quieter of the two, which is the whole
 * point of the note above .guest-hero .wordmark.
 *
 * TWO SLOTS DELIBERATELY DO NOT TAKE IT, and both override the size with a literal below:
 *
 *   - the placard byline, where the mark sits inline in "Powered by ____" and has to match the
 *     words beside it rather than out-size them;
 *   - the sign-in page, whose mark is sized in vw and so already grows to fill its space
 *     whatever the glyph count — which is exactly why that one screen looked right when the
 *     fixed-size slots did not.
 */
:root {
  --mark-scale: 1;
}

html[data-edition='standalone'] {
  --mark-scale: 1.3;
}

/* ------------------------------------------------------------- the wordmark
   CINESC⌖PE — the O of SCOPE is a reticle, so the mark IS the name rather than
   a logo sitting beside it. Set in the UI font at two weights: CINE recedes,
   SCOPE holds, and the reticle lands where the eye is already going.

   The reticle is one empty <span>, not seven SVG elements repeated in eight
   pages: with no build step and no templating, markup duplicated per page is
   markup that drifts. The ring and ticks are a masked pseudo-element, so they
   take currentColor and recolour with the theme; the pupil is the other
   pseudo-element, and the one part of the mark that keeps its own colour. */
.wordmark {
  display: inline-flex;
  align-items: baseline;
  /* Sized for the desktop bar, and stepped down under 700px with the rest of the
     top bar — the phone version shares its row with the account button and has no
     room to grow. Everything in the reticle is em-based, so it follows. */
  font-size: calc(1.14rem * var(--mark-scale));
  letter-spacing: 0.06em;
  line-height: 1.35;
  white-space: nowrap;
  color: var(--text);
}

.wordmark__cine {
  font-weight: 400;
  color: var(--text-dim);
}

.wordmark__scope {
  font-weight: 600;
}

/*
 * Sized in em so it tracks the type it is standing in for, and placed off the
 * BASELINE rather than off the line box.
 *
 * It used to be `align-self: center`, which centres it in the flex line — and the
 * flex line's height is the text's line box rounded to whole device pixels, while
 * the ring is a fractional em. So the two rounded independently and the ring
 * landed a fraction of a pixel out by an amount that changed with the font size:
 * dead on at 110px, half a pixel low at 15px, which is the size the top bar
 * actually uses. On the sign-in page, where the mark is `clamp(1.7rem, 8.5vw, …)`,
 * it moved as the window was resized. Somebody noticed, which is the point at
 * which "close enough" stops being a defensible answer for a company's own name.
 *
 * Type is aligned on the baseline, so this is too:
 *
 *   `align-self: baseline` on an empty box makes its BOTTOM EDGE the baseline it
 *   is aligned by, which puts the ring's middle half its height up — 0.41em.
 *   A round letter's ink in this font runs from 0.7031em above the baseline to
 *   0.0156em below it, so an O's middle is 0.344em up. The difference is the
 *   0.066em it comes back down by.
 *
 * All three numbers are measured from the font rather than guessed, and the whole
 * offset is in em, so it now rounds the same way the letters beside it do at every
 * size. Pinned in test/browser/wordmark.browser.js.
 *
 * The mask is filled white, which shows through under both mask-mode: alpha and
 * luminance — black would be correct under one and invisible under the other.
 */
.wordmark__reticle {
  --reticle: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ccircle cx='50' cy='50' r='38' fill='none' stroke='%23fff' stroke-width='12'/%3E%3Cg stroke='%23fff' stroke-width='8.5' stroke-linecap='round'%3E%3Cpath d='M50 6V29'/%3E%3Cpath d='M50 71V94'/%3E%3Cpath d='M6 50H29'/%3E%3Cpath d='M71 50H94'/%3E%3C/g%3E%3C/svg%3E");

  position: relative;
  display: inline-block;
  width: 0.82em;
  height: 0.82em;
  margin: 0 0.05em;
  align-self: baseline;
  top: 0.066em;
  flex: none;
}

.wordmark__reticle::before {
  content: '';
  position: absolute;
  inset: 0;
  background: currentColor;
  -webkit-mask: var(--reticle) center / contain no-repeat;
  mask: var(--reticle) center / contain no-repeat;
}

.wordmark__reticle::after {
  content: '';
  position: absolute;
  inset: 40%;
  border-radius: 50%;
  background: var(--brand-pupil);
}

/* ------------------------------------------------------------ main nav
   Exactly one link is ever highlighted: the page you are on. Home used to carry
   a permanent accent chip so that "how do I get back" needed no guess, but it
   read as "you are here" from every other page — two links looking picked out
   at once, one of them lying. Its icon is enough to make it findable. */
.nav {
  display: flex;
  align-items: center;
  gap: 4px;
  flex-wrap: wrap;
  padding-left: 6px;
  margin-left: 6px;
  border-left: 1px solid var(--border);
}

/* On desktop this is just a row inside a row, so the bar looks the same as if the
   links were direct children. It earns its keep on a phone — see the mobile block. */
.nav__rest {
  display: flex;
  align-items: center;
  gap: 4px;
}

/*
 * The menu button that folds the second row away, on a phone only.
 *
 * Not shown here: on desktop the links are a row inside a row with room for all of them,
 * so there is nothing to reveal and a button would be a control whose job is already done.
 * Built by wireNav() in js/util.js, which is also the thing that knows whether any links
 * survived the role filter.
 */
.nav__toggle {
  display: none;
}

.nav__link {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 7px 12px;
  border-radius: 8px;
  border: 1px solid transparent;
  font-size: 0.87rem;
  font-weight: 550;
  color: var(--text-dim);
  text-decoration: none;
  white-space: nowrap;
  transition:
    background-color 140ms var(--ease),
    color 140ms var(--ease),
    border-color 140ms var(--ease);
}

/*
 * A ROLE-GATED LINK IS NOT DRAWN UNTIL THE ROLE IS KNOWN.
 *
 * Three pages carry every role's links and let wireNav in js/util.js remove the ones that are not
 * yours — the schedule board, Settings and the manager's dashboard — because one markup serving
 * several roles is one bar to keep right rather than four. The cost was a flash: /api/me answers a
 * few hundred milliseconds after the page paints, so a General Manager opening the showtime board
 * saw Case monitor, Location info and Active users appear and then vanish.
 *
 * Nothing was reachable — the server refuses those pages and their APIs, which is what actually
 * keeps somebody out — but a bar that offers a colleague's tools for half a second and takes them
 * back is the app telling them something untrue about their own account.
 *
 * Hiding them until wireNav had run fixed that and left the opposite complaint: the bar arrived as
 * Home alone and filled in a moment later, so every navigation announced itself as a page being
 * assembled. The links a reader has are the same on every page — they should look like furniture,
 * not like content.
 *
 * So `data-roles` is written onto <html> by the pre-paint script in src/app.js, next to the theme
 * and for the same reason, and these rules draw the right links in the FIRST paint. It carries the
 * set of guards the account satisfies rather than its role, which is why `~=` is the operator here:
 * a dev counts as an admin, and the sheet does not have to know that.
 *
 * `[data-nav='ready']` is still the second way in, and it is not redundant. It is what draws the
 * bar while a dev is borrowing an account, where the pre-paint script deliberately says nothing
 * because it cannot see whose dashboard is being looked at. By then wireNav has removed everything
 * that does not apply, so revealing the rest is safe.
 *
 * FAILS CLOSED either way: no flag and no module means Home alone — fewer links than somebody
 * should have, rather than more.
 */
.nav__link[data-role] {
  display: none;
}

html[data-roles~='admin'] .nav__link[data-role~='admin'],
html[data-roles~='gm'] .nav__link[data-role~='gm'],
html[data-roles~='maintenance'] .nav__link[data-role~='maintenance'],
html[data-nav='ready'] .nav__link[data-role] {
  display: inline-flex;
}

/*
 * A GRANTED link is hidden again unless the grant is on the document, and this rule has to come
 * AFTER the ones above because the specificities tie and order is what decides.
 *
 * `data-grant` marks a link its role alone does not earn — the review queue is a manager's page
 * only for a manager somebody has granted it to. Without this the rule above would draw it to
 * every manager on the first paint and wireNav would take it away a moment later, which is the
 * flash data-roles exists to prevent.
 *
 * It overrides `data-nav='ready'` too, deliberately. That flag means "the module has run and the
 * bar is whatever it made it", and the module has already removed this link if the grant is
 * missing — so the two agree, and on a page where the module never runs the safe answer is still
 * the one that shows fewer links rather than more.
 */
html:not([data-grants~='review']) .nav__link[data-grant='review'] {
  display: none;
}

/* And the link the grant replaces — see data-nogrant in wireNav. Same reasoning, opposite
   direction: without this the old Case monitor link is drawn for a granted manager and taken
   away once /api/me answers, which is the flash from the other side. */
html[data-grants~='review'] .nav__link[data-nogrant='review'] {
  display: none;
}

/*
 * The edition, over the roles. A standalone circuit's scheduler links stay hidden whatever
 * the role rules above decided — same first-paint reasoning as data-roles, with the flag
 * written unconditionally by the pre-paint script because it describes the circuit rather
 * than the account. AFTER the role rules on purpose: the specificities tie, so order is the
 * tiebreak, and this one has to win. wireNav then removes the links outright, which is what
 * keeps the data-nav='ready' fallback above safe on a borrowed view.
 */
html[data-edition='standalone'] .nav__link[data-edition='cinema'] {
  display: none;
}

.nav__link:hover {
  background: var(--surface-3);
  color: var(--text);
}

.nav__icon {
  flex: none;
  display: block;
}

/*
 * The page you are on, and nothing else.
 *
 * The accent rather than a grey chip, now that it is the only mark in the bar:
 * "you are here" is worth being unmistakable, and there is no longer a second
 * accented link for it to be confused with. Not a link offering to take you
 * anywhere either, so hover does not lift.
 */
.nav__link--current {
  color: var(--accent);
  background: color-mix(in oklab, var(--accent) 12%, transparent);
  border-color: color-mix(in oklab, var(--accent) 30%, transparent);
  font-weight: 620;
  cursor: default;
}

.nav__link--current:hover {
  background: color-mix(in oklab, var(--accent) 12%, transparent);
  color: var(--accent);
}

/*
 * THE TABLET'S STRIP, whose links are buttons.
 *
 * The back-office tablet's three destinations are panels on one page rather than pages —
 * the thirty-minute session and the sign-out that ends it belong to that page, and a tab
 * that navigated away would leave somebody's unlocked session on a counter. So its strip is
 * <button>s wearing .nav__link. Every rule that gives the class its look is class-keyed and
 * applies unchanged; these are the declarations a button needs and an anchor never did: the
 * UA's own background, its font — which does not inherit — and the hand an anchor gets free.
 */
.nav--floor .nav__link {
  background: none;
  font-family: inherit;
  cursor: pointer;
}

/* The tab you are on is not offering to take you anywhere. .nav__link--current says so
   already and would otherwise lose the cursor to the rule above on specificity. */
.nav--floor .nav__link--current {
  cursor: default;
}

.topbar__spacer {
  flex: 1 1 auto;
}

.topbar__who {
  font-size: 0.82rem;
  color: var(--text-dim);
  line-height: 1.35;
}

.topbar__who strong {
  display: block;
  color: var(--text);
  font-weight: 600;
}

/* --------------------------------------------------------- account menu
   Who you are, and Sign out. A small trigger in the sticky bar instead of a
   button, so scrolling on a phone does not drag a full-width Sign out along
   the top of the screen. */
.account {
  position: relative;
  flex: 0 0 auto;
}

.account__trigger {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 3px 8px 3px 3px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: var(--surface-2);
  color: var(--text);
  cursor: pointer;
  transition:
    background-color 140ms var(--ease),
    border-color 140ms var(--ease);
}

.account__trigger:hover {
  background: var(--surface-3);
  border-color: var(--border-strong);
}

.account__avatar {
  width: 28px;
  height: 28px;
  border-radius: 50%;
  background: var(--accent-line);
  color: var(--accent-ink);
  display: grid;
  place-items: center;
  font-size: 11px;
  font-weight: 750;
  letter-spacing: -0.02em;
  flex: none;
}

.account__chevron {
  color: var(--text-faint);
  flex: none;
  transition: transform 160ms var(--ease);
}

.account__trigger[aria-expanded='true'] .account__chevron {
  transform: rotate(180deg);
}

.account__menu {
  position: absolute;
  top: calc(100% + 8px);
  right: 0;
  min-width: 235px;
  padding: 6px;
  /*
   * SOLID, unlike every other floating menu — and it cannot be otherwise. This menu
   * lives INSIDE the top bar, and the bar already runs a backdrop blur; backdrop
   * filters do not nest, so this element's own blur samples nothing and quietly does
   * not happen. As glass that left an 80% tint with no blur behind it, and the operator
   * read page text straight through the menu. Every menu that floats OUTSIDE a blurred
   * ancestor (the dock's More, the assistant panel) keeps the true glass; this one
   * wears the opaque surface, which over any page reads the same.
   */
  background: var(--surface-3);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  box-shadow: var(--shadow-lift);
  /* Above the page, but inside the topbar's own stacking context. */
  z-index: 30;
}

.account__menu .topbar__who {
  padding: 8px 10px 10px;
  border-bottom: 1px solid var(--border);
  margin-bottom: 6px;
}

/* A navigation item in the account menu, above Sign out. */
.account__item {
  display: block;
  padding: 9px 10px;
  border-radius: var(--radius-sm);
  color: var(--text);
  font-size: 0.88rem;
  font-weight: 600;
  text-decoration: none;
  transition: background-color 120ms var(--ease);
}

.account__item:hover {
  background: var(--surface-3);
  text-decoration: none;
}

.account__item--current {
  background: var(--surface-3);
  color: var(--accent);
}

/*
 * "Lock now", which is a <button> wearing the dress written for an <a>.
 *
 * It had this class and the class had no rule, so it rendered as what a button is when nobody
 * has said otherwise: Arial instead of the app's face, text centred, shrink-wrapped, and a
 * native grey box behind it — an OS push-button in a column of text links, sitting one row
 * above a Sign out that has had this same reset all along. It is also the deliberate lock,
 * which is a security control, and one that looks broken is one people do not press.
 *
 * The same fix as `button.stat--link` on the overview tiles, and for the same sentence: a
 * button brings its own font, centring and width that none of the <a>s have to undo.
 *
 * `font: inherit` and then the size and weight again, in that order, because the shorthand
 * resets both and this rule is written after `.account__item` — the two have equal specificity,
 * so whatever comes last is what the menu gets.
 */
.account__item--button {
  width: 100%;
  border: 0;
  background: none;
  font: inherit;
  font-size: 0.88rem;
  font-weight: 600;
  text-align: left;
  cursor: pointer;
  appearance: none;
}

/* Divides the destinations above from the one action below. */
.account__item + .account__signout {
  margin-top: 4px;
  padding-top: 12px;
  border-top: 1px solid var(--border);
  border-radius: 0 0 var(--radius-sm) var(--radius-sm);
}

.account__signout {
  display: block;
  width: 100%;
  padding: 9px 10px;
  border: 0;
  border-radius: var(--radius-sm);
  background: none;
  color: var(--danger);
  font: inherit;
  font-size: 0.88rem;
  font-weight: 600;
  text-align: left;
  cursor: pointer;
  transition: background-color 120ms var(--ease);
}

.account__signout:hover {
  background: var(--danger-soft);
}

/*
 * The page header.
 *
 * Centred, with an icon beside the title. It was a bare left-aligned h1 on every page,
 * which is not wrong so much as unfinished — a heading with nothing to anchor it reads
 * like a placeholder somebody meant to come back to, and eight pages of it made the app
 * feel like scaffolding.
 *
 * Centring is the part that earns it. A left-aligned title over left-aligned content is
 * one long ragged column; a centred lockup with an icon is a header, and it gives the
 * page a top edge to start from. The content below stays left-aligned, because that is
 * where reading a list or a table actually begins.
 *
 * It is given room on all three sides. The header used to sit 34px under a sticky bar
 * and 22px above the first control, which on a busy page put the title in a sandwich —
 * close enough to the topbar to read as part of it, close enough to the filters to read
 * as their label. Air above separates it from the chrome; air below, and the rule, make
 * it a header rather than the first row of the content.
 */
.page-head {
  display: grid;
  justify-items: center;
  gap: 12px;
  padding: 46px 0 0;
  /*
   * Nothing below sets a top margin — every block in the shell spaces itself with
   * margin-bottom — so this is the whole gap between the rule and the first control,
   * not half of one.
   */
  margin-bottom: 30px;
  text-align: center;
}

/*
 * The rule under the header.
 *
 * A hairline that is brightest under the title and fades to nothing at both ends. A
 * border would have been the cheap way to do it, but a hard line wall to wall cuts the
 * page in two and makes the header look bolted on; fading out at the edges means the
 * separation is strongest exactly where the centred lockup is and has quietly stopped
 * by the time it reaches the margin.
 *
 * A pseudo-element rather than markup, so the eight pages carry nothing for it and a
 * ninth gets it for free. `justify-self: stretch` because the header centres its
 * children, and this is the one child that should be as wide as the page.
 */
.page-head::after {
  content: '';
  justify-self: stretch;
  height: 1px;
  /* On top of the 12px grid gap: the rule belongs to the space between, not to the
     paragraph above it. */
  margin-top: 14px;
  background: linear-gradient(
    90deg,
    transparent,
    color-mix(in oklab, var(--accent) 26%, var(--border)) 24%,
    color-mix(in oklab, var(--accent) 46%, var(--border)) 50%,
    color-mix(in oklab, var(--accent) 26%, var(--border)) 76%,
    transparent
  );
}

/* The icon and the words are one unit, so they wrap and centre together. */
.page-head__title {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 13px;
  min-width: 0;
}

.page-head__title h1 {
  margin: 0;
}

/*
 * The icon, in a tinted tile.
 *
 * A tile rather than a bare glyph: at this size a 1.7px stroke floating beside a 2rem
 * heading looks like it fell off something, and the tile gives it the same visual weight
 * as the word next to it. Accent-tinted rather than filled, so it sits with the heading
 * rather than competing with the page's one real call to action.
 *
 * Drawn per page and inline, like the nav's home icon — eight different glyphs is eight
 * different files or eight inline SVGs, and inline is the one that costs no request and
 * recolours with the theme for free.
 */
.page-head__icon {
  display: grid;
  place-items: center;
  flex: none;
  width: 42px;
  height: 42px;
  border-radius: 13px;
  color: var(--accent);
  background: color-mix(in oklab, var(--accent) 13%, var(--surface));
  border: 1px solid color-mix(in oklab, var(--accent) 26%, transparent);
  box-shadow: inset 0 1px 0 color-mix(in oklab, var(--accent) 18%, transparent);
}

.page-head p {
  color: var(--text-dim);
  margin: 0;
  /* Centred text needs a shorter measure than left-aligned: both edges are ragged, so a
     long line is harder to come back to at the start of the next one. */
  max-width: 58ch;
}

/*
 * "Show page descriptions", from Settings. Off unless you ask for it.
 *
 * One rule for every page, because every page's blurb is the paragraph under the
 * heading inside .page-head — so this cannot go stale as pages are added, and a new
 * page gets the behaviour without knowing about it. The paragraph stays in the
 * markup: it is what explains the page to somebody new, and a screen reader still
 * reaches it through Settings.
 *
 * Hidden by default rather than shown, because the descriptions earn their space in
 * the first week and cost it every week after. The heading keeps its own bottom
 * margin, so what is left is the title with the page's own spacing rather than a
 * heading jammed against the content.
 *
 * The attribute is written by /theme.js before first paint (see src/app.js), and
 * only for the accounts that turned them on. Applied on load instead, the paragraph
 * would render and then vanish, which is a page that moves under you as you start
 * reading it — and this way round, the flash cannot happen at all for the default.
 */
.page-head p {
  display: none;
}

:root[data-help='on'] .page-head p {
  display: block;
}

/*
 * And the sentence under each SETTING, on the same switch and for the same reason.
 *
 * The settings page carries a paragraph per control — twenty-odd of them, explaining what
 * moving each one does. Read once, they are scrollpast forever after, and a page that is
 * mostly explanation is a page an operator scrolls rather than uses: the label and the
 * bounds on the control say what it is. So they follow "Page descriptions", which already
 * exists, is already off by default, and is already where somebody who wants the detail
 * back would look for it.
 *
 * The hints that are a WARNING rather than a description keep their own class and stay —
 * see .field__hint--bad, which is what a refusal or a consequence is written in.
 */
.setting .field__hint:not(.field__hint--bad) {
  display: none;
}

:root[data-help='on'] .setting .field__hint:not(.field__hint--bad) {
  display: block;
}

/* ------------------------------------------------------------------ cards */
.card {
  background: var(--glass);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow), var(--rim);
  padding: 22px;
}

.card + .card {
  margin-top: 16px;
}

.card > h2:first-child {
  margin-bottom: 16px;
}

/* ------------------------------------------------------------------ forms */
.field {
  margin-bottom: 18px;
}

.field > label,
.field__legend {
  display: block;
  font-weight: 600;
  font-size: 0.9rem;
  margin-bottom: 7px;
  color: var(--p-subtext1);
}

.field__hint {
  font-size: 0.82rem;
  color: var(--text-dim);
  margin: 6px 0 0;
}

/* The same line, when it is a reason the value was refused. For askForText in js/util.js, which
   keeps the box open with the complaint under the field rather than closing and reopening. */
.field__hint--bad {
  color: var(--danger);
  font-weight: 600;
}

/*
 * A yes/no field inside an inline edit row. The label keeps the same position as
 * every text field's so the row's baselines line up, and the box sits under it in
 * place of an input rather than beside the text.
 */
.field--check > label {
  margin-bottom: 7px;
}

/* Matches an input's height so the box centres against the fields beside it. */
.field--check__box {
  display: flex;
  align-items: center;
  min-height: 42px;
}

.field--check input[type='checkbox'] {
  width: 18px;
  height: 18px;
  margin: 0;
  accent-color: var(--accent);
  cursor: pointer;
}

.field__optional {
  font-weight: 400;
  color: var(--text-faint);
}

/*
 * Every control the app has, listed by type rather than as a bare `input`, so a checkbox
 * or a radio is not given a 44px text box to live in.
 *
 * `date` earns a note. A type missing from this list gets no styling at all, which on a
 * phone is not a cosmetic problem: iOS Safari **zooms the page** when focus lands in a
 * field whose font-size is under 16px, and a browser's default for one of these is around
 * 13px. So an unlisted input does not merely look wrong — it throws the viewport at the
 * reader. That is what `input[type='date']` did the day it was added.
 */
input[type='text'],
input[type='email'],
input[type='tel'],
input[type='password'],
input[type='search'],
input[type='number'],
input[type='date'],
input[type='time'],
/* The colour well. In the shared list so it inherits the 16px floor and the focus ring like
   every other control — .brandpick__well then strips the browser's bevel and gives it a fixed
   size, which is the only part of this rule it overrides. */
input[type='color'],
select,
textarea {
  width: 100%;
  font: inherit;
  font-size: 1rem;
  color: var(--text);
  background: var(--surface-2);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  padding: 11px 13px;
  min-height: 44px;
  transition:
    border-color 120ms var(--ease),
    box-shadow 120ms var(--ease),
    background-color 120ms var(--ease);
}

input::placeholder,
textarea::placeholder {
  color: var(--text-faint);
}

input:hover:not(:disabled),
select:hover:not(:disabled),
textarea:hover:not(:disabled) {
  border-color: var(--p-surface2);
}

input:focus,
select:focus,
textarea:focus {
  outline: none;
  background: var(--surface-3);
  border-color: var(--accent);
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 22%, transparent);
}

textarea {
  resize: vertical;
  min-height: 148px;
  line-height: 1.55;
}

select {
  appearance: none;
  background-image: linear-gradient(45deg, transparent 50%, var(--text-dim) 50%),
    linear-gradient(135deg, var(--text-dim) 50%, transparent 50%);
  background-position: right 18px center, right 13px center;
  background-size: 5px 5px, 5px 5px;
  background-repeat: no-repeat;
  padding-right: 36px;
  cursor: pointer;
}

/*
 * The severity dropdown on a report card, which sits in a row of buttons rather than in a form.
 *
 * Shrink-wrapped instead of filling its line, so it reads as one control among the card's actions
 * and not as a field. A class beats the bare `select` selector above, so the narrower padding here
 * still wins.
 */
.severity-select {
  flex: 0 0 auto;
  width: auto;
  min-height: 36px;
  padding: 4px 28px 4px 10px;
  font-size: 0.87rem;
}

/*
 * A date field, made to obey the width it is given.
 *
 * WebKit sizes a native date control from its own content and lets that intrinsic width beat
 * `width: 100%`, so on iOS this came out visibly wider than every other control beside it and
 * gave the form a horizontal scrollbar — a box of stacked fields that slid sideways as well as
 * up and down. Dropping the native appearance is what makes it obey, which is the same thing
 * this sheet already does to `select` a few lines up.
 *
 * `min-width: 0` and `max-width: 100%` are the belt to that: whatever any engine thinks the
 * intrinsic minimum is, the used width cannot exceed the space available, so an overflow is
 * impossible rather than merely unlikely.
 */
input[type='date'],
input[type='time'] {
  appearance: none;
  -webkit-appearance: none;
  min-width: 0;
  max-width: 100%;
}

/*
 * The one part of the native control worth keeping: the button that opens the picker.
 *
 * `appearance: none` takes it away, and on a desktop that is the affordance — clicking the
 * glyph is how most people open a date picker, and without it the field looks like a text box
 * that happens to reject what you type. Its colour comes from `color-scheme`, which each
 * theme sets, so it stays legible on a dark surface without being recoloured here.
 */
input[type='date']::-webkit-calendar-picker-indicator,
input[type='time']::-webkit-calendar-picker-indicator {
  display: block;
  cursor: pointer;
  opacity: 0.7;
  transition: opacity 120ms var(--ease);
}

input[type='date']::-webkit-calendar-picker-indicator:hover,
input[type='time']::-webkit-calendar-picker-indicator:hover {
  opacity: 1;
}

input[aria-invalid='true'],
textarea[aria-invalid='true'],
select[aria-invalid='true'] {
  border-color: var(--danger);
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--danger) 18%, transparent);
}

.counter {
  font-size: 0.78rem;
  color: var(--text-faint);
  text-align: right;
  margin-top: 6px;
  font-variant-numeric: tabular-nums;
}

/* Says what the form covers while only one category is rolled out, standing in
   for the picker rather than leaving an unexplained gap. */
.scope-note {
  margin: 0 0 20px;
  padding: 12px 14px;
  border-radius: var(--radius-sm);
  border: 1px solid color-mix(in oklab, var(--accent) 30%, transparent);
  background: var(--accent-soft);
  color: var(--p-subtext1);
  font-size: 0.9rem;
}

.scope-note[hidden] {
  display: none;
}

/*
 * Radio pill group — big tap targets for a phone held in one hand.
 *
 * A grid of equal tiles rather than a flow of differently-sized ones. Eight categories
 * of very different lengths — "Sound" beside "Concessions / queue" — wrapped into a
 * ragged staircase where no two rows lined up, which is the single thing that made this
 * page look thrown together. Equal columns cost a little width on the short labels and
 * buy an aligned block.
 *
 * `auto-fit` with a floor rather than a fixed count: two columns on a phone, three or
 * four on a tablet, and nothing to change if a category is added.
 */
.pills {
  border: 0;
  padding: 0;
  margin: 0;
  min-width: 0;
}

.pills__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
  /*
   * Every ROW the same height as the tallest, not just every tile within its own row.
   *
   * `height: 100%` on the tile below stretches it to its row, which made the grid look even
   * only for as long as no label wrapped. One long label — "Concessions / queue" at a narrow
   * width — made its row taller than the others and the block went ragged, which is the exact
   * thing the tiles were made uniform to avoid. Found when a longer line elsewhere on the page
   * narrowed the grid by a scrollbar's width and the third row started wrapping.
   */
  grid-auto-rows: 1fr;
  gap: 8px;
}

.pill {
  position: relative;
}

.pill input {
  position: absolute;
  inset: 0;
  opacity: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  cursor: pointer;
}

.pill span {
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  /* Every tile the same height whatever its label wraps to, which is the other half of
     the grid reading as a block rather than as a list. */
  min-height: 52px;
  height: 100%;
  padding: 10px 14px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  font-size: 0.92rem;
  line-height: 1.25;
  background: var(--surface-2);
  color: var(--p-subtext1);
  user-select: none;
  transition:
    background-color 140ms var(--ease),
    border-color 140ms var(--ease),
    color 140ms var(--ease),
    transform 140ms var(--ease);
}

.pill:hover span {
  border-color: var(--p-surface2);
  color: var(--text);
}

.pill input:checked + span {
  background: var(--accent-fill);
  border-color: var(--accent-fill);
  color: var(--accent-ink);
  font-weight: 650;
}

.pill input:active + span {
  transform: scale(0.97);
}

.pill input:focus-visible + span {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* ---------------------------------------------------------------- buttons
   Three weights, so a screen full of actions does not shout:

     .btn            tonal — an accent-tinted fill with accent text. The default
                     primary. Reads clearly as the main action in a row without
                     a saturated block of colour, which matters on the review
                     queue where every complaint carries one.
     .btn--cta       solid fill. Reserved for the single main action on a page
                     (sign in, send my report), where boldness is the point.
     .btn--secondary neutral. Supporting actions.
     .btn--danger    tonal red. Destructive.

   Tonal and danger share a weight on purpose: in a list, "validate" and
   "reject" are peers, and neither should dominate the row.
   -------------------------------------------------------------------------- */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
  font: inherit;
  font-size: 0.94rem;
  font-weight: 620;
  letter-spacing: -0.01em;
  min-height: 44px;
  padding: 11px 18px;
  border-radius: var(--radius-sm);
  /* Glass body, accent voice: the fill is the app's material, the colour stays in the
     text so a button still reads as a button at a glance. */
  background: var(--glass);
  color: var(--accent);
  border: 1px solid var(--border-strong);
  cursor: pointer;
  text-decoration: none;
  white-space: nowrap;
  transition:
    background-color 140ms var(--ease),
    border-color 140ms var(--ease),
    color 140ms var(--ease),
    transform 100ms var(--ease),
    opacity 140ms var(--ease);
}

.btn:hover:not(:disabled) {
  background: color-mix(in oklab, var(--accent) 26%, var(--surface));
  border-color: color-mix(in oklab, var(--accent) 55%, transparent);
  color: var(--accent-hover);
}

.btn:active:not(:disabled) {
  transform: translateY(1px);
}

.btn:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

/* The one bold action on a page. Filled, but deepened off the raw palette
   value so it is confident rather than glaring. */
.btn--cta {
  background: var(--accent-fill);
  color: var(--accent-ink);
  border-color: transparent;
}

.btn--cta:hover:not(:disabled) {
  background: var(--accent-fill-hover);
  border-color: transparent;
  color: var(--accent-ink);
}

.btn--block {
  width: 100%;
}

/*
 * The primary look, named.
 *
 * It is what the base `.btn` already draws, so this restates rather than adds — and that is
 * the point. `btn--primary` was used with no rule behind it (home.js toggles it against
 * btn--secondary for "Edit dashboard"), which worked only because the base happens to be the
 * accent style. Change the base to something neutral one day and the toggle would have
 * silently stopped distinguishing the two modes, with no rule anywhere to notice.
 */
.btn--primary {
  background: color-mix(in oklab, var(--accent) 15%, var(--surface));
  color: var(--accent);
  border-color: color-mix(in oklab, var(--accent) 34%, transparent);
}

.btn--secondary {
  background: var(--surface-2);
  color: var(--p-subtext1);
  border-color: var(--border-strong);
}

/*
 * The quiet one — and until now, the loudest one on the page.
 *
 * `btn--ghost` is used in five modules and had NO RULE ANYWHERE, so every button wearing it
 * fell through to the base `.btn`, which is the accent-tinted primary. The Remove beside each
 * QR placard, Back in the setup walkthrough, Dismiss and Allow-one-export on the console:
 * all of them drawn as the thing you are meant to press. That is what "a large remove button
 * next to it" was, and no test could see it — the class was on the element exactly as the
 * code intended, and the stylesheet simply had nothing to say about it.
 *
 * Found by screenshotting the console strip and noticing the wrong button was shouting.
 */
.btn--ghost {
  background: transparent;
  color: var(--p-subtext0);
  border-color: transparent;
}

.btn--ghost:hover:not(:disabled) {
  background: var(--surface-2);
  border-color: var(--border);
  color: var(--text);
  box-shadow: none;
}

.btn--secondary:hover:not(:disabled) {
  background: var(--surface-3);
  border-color: var(--p-surface2);
  color: var(--text);
  box-shadow: none;
}

/* An icon inside a button. `.btn` is already a flex row with a gap, so the only thing to say is
   that the glyph does not get squashed when the label is the part under pressure. */
.btn__icon {
  flex: 0 0 auto;
}

.btn--danger {
  background: transparent;
  color: var(--danger);
  border-color: color-mix(in oklab, var(--danger) 45%, transparent);
}

.btn--danger:hover:not(:disabled) {
  background: var(--danger-soft);
  border-color: var(--danger);
  box-shadow: none;
}

.btn--sm {
  min-height: 34px;
  padding: 6px 12px;
  font-size: 0.85rem;
  border-radius: 8px;
}

.btn--link {
  background: none;
  border: 0;
  color: var(--p-lavender);
  min-height: 0;
  padding: 0;
  font-weight: 500;
  font-size: 0.87rem;
  text-decoration: underline;
  text-underline-offset: 2px;
}

.btn--link:hover {
  color: var(--accent);
  background: none;
  box-shadow: none;
}

/* ----------------------------------------------------------------- badges */
.badge {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  font-size: 0.72rem;
  font-weight: 650;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  padding: 3px 9px;
  border-radius: 7px;
  border: 1px solid transparent;
  white-space: nowrap;
}

.badge--pending_review,
.badge--open {
  background: var(--info-soft);
  color: var(--info);
  border-color: color-mix(in oklab, var(--info) 35%, transparent);
}

.badge--validated,
.badge--in_progress {
  background: var(--warn-soft);
  color: var(--warn);
  border-color: color-mix(in oklab, var(--warn) 35%, transparent);
}

.badge--released,
.badge--resolved {
  background: var(--ok-soft);
  color: var(--ok);
  border-color: color-mix(in oklab, var(--ok) 35%, transparent);
}

.badge--rejected {
  background: var(--surface-3);
  color: var(--text-faint);
  border-color: var(--border-strong);
}

.badge--high {
  background: var(--danger-soft);
  color: var(--danger);
  border-color: color-mix(in oklab, var(--danger) 40%, transparent);
}

/* A step past high: filled rather than tinted, because it has to win against a card
   that already has three or four badges on it. */
.badge--emergency {
  background: var(--danger);
  color: var(--p-base);
  border-color: var(--danger);
  font-weight: 750;
  letter-spacing: 0.04em;
}

/*
 * A screen that is down, in the queue.
 *
 * The card is already first — the sort sees to that — so this is about being findable
 * when somebody scrolls past it later, not about shouting. A left edge and a tint, the
 * same language the app uses for a spotlighted report.
 *
 * Its colours live with the other report-card rules rather than here, because a card's
 * background is now set in one place and a tint declared up here would be overridden by
 * it further down. See `.complaint--report[data-severity='emergency']`.
 */

/*
 * And on the home page, above everything.
 *
 * Drawn only when a screen is actually down, so it can afford to be loud: an alert that
 * is usually empty is furniture, and this one is never empty for a good reason.
 */
.alarm {
  /*
   * The panel's temperature, as one token.
   *
   * Every rule below reads this rather than `--danger` directly, which is what lets the
   * inspection alert be `.alarm--due` — the same panel in amber — instead of a hundred and
   * fifty lines of near-identical CSS that drift apart the first time either is touched.
   */
  --alarm-ink: var(--danger);
  margin: 0 0 24px;
  padding: 16px 18px;
  border: 1px solid var(--alarm-ink);
  border-left: 4px solid var(--alarm-ink);
  border-radius: var(--radius);
  background: color-mix(in oklab, var(--alarm-ink) 10%, var(--surface));
}

/*
 * Inspections expected soon: the same panel, one temperature down.
 *
 * A screen that is down is red and costs money by the hour. A fire inspection due in a
 * fortnight costs nothing if somebody books it this week, and colouring the two the same
 * would teach a reader that red on this page does not necessarily mean now.
 */
.alarm--due {
  --alarm-ink: var(--warn);
}

/*
 * A manager's copy does not fold, so it has no handle and needs no room for a chevron.
 * One line or three is not worth hiding, and a disclosure with two items in it is a
 * control that exists to be clicked twice.
 *
 * Two classes deep, both of them. `.alarm__head` sets the padding and draws the chevron
 * further down this file, so at equal specificity it won on source order and this left a
 * manager's panel indented behind an arrow that did nothing — the same trap `.modal--narrow`
 * fell into. A modifier beating its base should not depend on which came first.
 */
.alarm__head.alarm__head--static {
  padding-left: 0;
  cursor: default;
}

.alarm__head.alarm__head--static::before {
  content: none;
}

/*
 * Overdue is the one number in this panel that has stopped being a warning.
 *
 * Two classes, like every other modifier in this block: `.alarm__age` sets the colour
 * further down the file, so at equal specificity it won on source order and this shipped
 * bold but not amber — the weight came through because nothing else declares it, which is
 * exactly how the bug hides.
 */
.alarm__age.alarm__age--late {
  color: var(--alarm-ink);
  font-weight: 650;
}

/* Where a predicted date came from. Quieter than a reported fault, because it is the
   panel explaining itself rather than telling the reader something new. Two classes for
   the same reason as above. */
.alarm__what.alarm__what--quiet {
  color: var(--text-dim);
  font-size: 0.82rem;
}

/*
 * The headline, which is also the fold's handle.
 *
 * A <summary>, so the whole strip is the target rather than a small chevron beside it —
 * this is a panel somebody folds away once they have read it, and hunting for a 12px arrow
 * to do that is worse than not offering it. The native marker goes and a chevron of our
 * own takes its place, exactly as the auditorium lists and the settings sections do.
 *
 * No bottom margin while shut: the margin belongs between the headline and the first case,
 * and folded there is no first case — it was leaving a strip of empty red.
 */
.alarm__head {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 10px;
  padding-left: 26px;
  position: relative;
  cursor: pointer;
  user-select: none;
  list-style: none;
}

.alarm[open] > .alarm__head {
  margin-bottom: 10px;
}

.alarm__head::-webkit-details-marker {
  display: none;
}

.alarm__head::before {
  content: '';
  position: absolute;
  left: 4px;
  top: 50%;
  width: 7px;
  height: 7px;
  margin-top: -5px;
  border-right: 2px solid var(--alarm-ink);
  border-bottom: 2px solid var(--alarm-ink);
  transform: rotate(-45deg);
  transition: transform 160ms var(--ease);
}

.alarm[open] > .alarm__head::before {
  transform: rotate(45deg);
  margin-top: -3px;
}

/* The focus ring goes round the strip, since the strip is the control. */
.alarm__head:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 3px;
  border-radius: 6px;
}

.alarm__tag {
  padding: 3px 10px;
  border-radius: 999px;
  background: var(--alarm-ink);
  color: var(--p-base);
  font-size: 0.72rem;
  font-weight: 750;
  text-transform: uppercase;
  letter-spacing: 0.06em;
}

.alarm__title {
  margin: 0;
  font-size: 1.02rem;
}

/*
 * One screen that is down: where it is, what is wrong, and where that has got to.
 *
 * A block rather than a row, because those are three different facts and the two that
 * matter most are sentences. Squeezed onto one line they each got a third of the width
 * and all three were ellipsed to uselessness.
 */
.alarm__case {
  padding: 10px 0 12px;
  border-top: 1px solid color-mix(in oklab, var(--alarm-ink) 25%, transparent);
}

/* The last one has no case under it to be separated from. */
.alarm__case:last-child {
  padding-bottom: 0;
}

.alarm__row {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 6px 12px;
}

.alarm__where {
  font-weight: 640;
  flex: 1 1 auto;
  min-width: 0;
}

/* How long it has been dark, which is the number that decides what happens next. */
.alarm__age {
  flex: 0 0 auto;
  font-size: 0.83rem;
  color: var(--text-dim);
}

/*
 * The action, and it is the same button on every line whatever is beside it.
 *
 * Both halves of that matter. `flex: 0 0 auto` keeps it its own size — see the narrow
 * breakpoint, where the blanket `.btn { flex: 1 1 auto }` had otherwise made it as wide
 * as the leftovers. `margin-left: auto` keeps it flush right whether or not the row
 * wrapped, which it does when a cinema's name is long: without it the button landed
 * wherever the text before it ended, so a panel with three screens down had three
 * buttons at three different places down the page.
 */
.alarm__row .btn {
  flex: 0 0 auto;
  margin-left: auto;
}

/*
 * What was reported, and what is being done about it.
 *
 * Two lines each at most. A fault worth a whole paragraph is rare and a single clipped
 * clause — "the projector will not" — helps nobody, so these get room to be a sentence
 * and are then cut. `line-clamp` rather than a character count, because the cut then
 * follows the width the reader actually has; the full text is on the element's title,
 * so a truncation is never a deletion.
 */
.alarm__what,
.alarm__update {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
  margin: 5px 0 0;
  font-size: 0.86rem;
  line-height: 1.45;
}

.alarm__what {
  color: var(--text);
}

/*
 * The update reads as a reply to the line above it, so it is indented behind a rule in
 * the alert's own colour rather than being a second paragraph of equal weight.
 */
.alarm__update {
  padding-left: 10px;
  border-left: 2px solid color-mix(in oklab, var(--alarm-ink) 40%, transparent);
  color: var(--text-dim);
  font-size: 0.82rem;
}

/* Nobody having written anything is a status too, and it should look like the absence
   it is rather than like a note somebody left. */
.alarm__update--none {
  font-style: italic;
  border-left-color: color-mix(in oklab, var(--alarm-ink) 18%, transparent);
}

/*
 * The "is it down?" box in the report dialog.
 *
 * A checkbox rather than a fourth entry in the Impact select: it is not another shade
 * of bad, it is a different question — is this room usable — and a question deserves to
 * look like one.
 */
.down {
  border: 1px solid color-mix(in oklab, var(--danger) 35%, var(--border));
  border-radius: var(--radius);
  background: color-mix(in oklab, var(--danger) 6%, transparent);
  padding: 10px 12px;
}

/* Scoped to .down so it out-ranks `.field > label`, which is display:block and would
   otherwise stack the box above its own wording. */
.down .down__row {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  cursor: pointer;
  margin: 0;
  font-weight: 400;
  font-size: 0.92rem;
  color: var(--text);
  text-transform: none;
  letter-spacing: normal;
}

.down__row input {
  flex: 0 0 auto;
  width: 18px;
  height: 18px;
  margin: 2px 0 0;
  accent-color: var(--danger);
  cursor: pointer;
}

.down__label {
  display: block;
  min-width: 0;
}

.down__hint {
  display: block;
  margin-top: 2px;
  font-size: 0.8rem;
  color: var(--text-faint);
  font-weight: 400;
}

/*
 * One row per circuit on the deployment: what it is called, where it answers, the way in.
 *
 * Deliberately plain. This is a list a dev reads a few times a year, and it is one page's worth
 * of furniture away from the settings it sits beside — the same card, the same rhythm as a row of
 * facts, rather than a dashboard of its own.
 */
.circuits {
  display: grid;
  gap: 8px;
}

.circuit {
  display: flex;
  align-items: center;
  gap: 12px;
  justify-content: space-between;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
}

/* The one you are standing in, marked the way the accounts list marks "you". */
.circuit--here {
  background: color-mix(in oklab, var(--accent) 5%, transparent);
  border-color: color-mix(in oklab, var(--accent) 35%, var(--border));
}

.circuit__identity {
  display: grid;
  gap: 2px;
  min-width: 0;
}

/* Open, and Unmap where it is offered — beside each other rather than stacked, since the row is
   one line of text tall. */
.circuit__actions {
  display: flex;
  align-items: center;
  gap: 8px;
  flex: 0 0 auto;
}

/* Several hosts can name one circuit, so this line wraps rather than truncating one away. */
.circuit__identity .field__hint {
  overflow-wrap: anywhere;
}

/*
 * "Is complete?" — the same box, the opposite meaning.
 *
 * Built like the one above rather than sharing its class, because the colour is the whole
 * difference between them: red for a room out of service, green for a job already done.
 * Two boxes that look alike and mean opposite things at the end of one form is how
 * somebody files an emergency they meant to close.
 */
.done {
  border: 1px solid color-mix(in oklab, var(--ok) 35%, var(--border));
  border-radius: var(--radius);
  background: color-mix(in oklab, var(--ok) 6%, transparent);
  padding: 10px 12px;
}

/* Scoped for the same reason .down is: `.field > label` is display:block and would stack
   the tick above its own wording. */
.done .done__row {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  cursor: pointer;
  margin: 0;
  font-weight: 400;
  font-size: 0.92rem;
  color: var(--text);
  text-transform: none;
  letter-spacing: normal;
}

.done__row input {
  flex: 0 0 auto;
  width: 18px;
  height: 18px;
  margin: 2px 0 0;
  accent-color: var(--ok);
  cursor: pointer;
}

.done__label {
  display: block;
  min-width: 0;
}

.done__hint {
  display: block;
  margin-top: 2px;
  font-size: 0.8rem;
  color: var(--text-faint);
  font-weight: 400;
}

/*
 * The confirmation is one short question, so it does not need the width of a form.
 *
 * Two classes deep, not one. `.modal` sets the default width and is declared further down
 * the file, so at equal specificity it won on source order and this rule did nothing at all —
 * for the screen-down confirmation, the report picker and the inspection form alike, all
 * three of which had been quietly 660px. Specificity rather than moving the rule, because
 * "the modifier beats the base" should not depend on which order the two happen to be in.
 */
.modal.modal--narrow {
  width: min(460px, calc(100vw - 32px));
}

.badge--medium {
  background: color-mix(in oklab, var(--p-peach) 12%, var(--surface));
  color: var(--p-peach);
  border-color: color-mix(in oklab, var(--p-peach) 32%, transparent);
}

.badge--low {
  background: var(--surface-3);
  color: var(--text-dim);
  border-color: var(--border-strong);
}

/*
 * "Set here" / "From the environment" — sentences, so they keep their own casing.
 * Every other badge is a one-word status that reads well shouted; these do not.
 */
.badge--source {
  background: var(--surface-3);
  color: var(--text-dim);
  border-color: var(--border-strong);
  text-transform: none;
  letter-spacing: 0;
  font-weight: 600;
}

.badge--source-saved {
  background: color-mix(in oklab, var(--p-teal) 10%, var(--surface));
  color: var(--p-teal);
  border-color: color-mix(in oklab, var(--p-teal) 28%, transparent);
}

.badge--cat {
  background: color-mix(in oklab, var(--p-teal) 10%, var(--surface));
  color: var(--p-teal);
  border-color: color-mix(in oklab, var(--p-teal) 28%, transparent);
  text-transform: capitalize;
  letter-spacing: 0;
  font-weight: 600;
}

/*
 * "AI reviewed": the AI Reviewer Agent read this report before a person confirmed it.
 *
 * FILLED, in the assistant launcher's own colour, and the fill is the load-bearing part.
 *
 * Every other badge on a card answers a question about the REPORT — how bad, what kind, what
 * stage — and wears the same soft tint with a matching outline. This one answers a question
 * about how the report was HANDLED, so it is a different KIND of chip rather than another
 * colour of the same one. That distinction has to survive the palette: in the light themes
 * --accent and --info are both blue within a few hex points of each other, so a tinted accent
 * badge would sit beside "Open" looking like a second status. Solid against outlined reads
 * apart at any hue, in all eight themes, without asking the palette for a colour it does not
 * have spare.
 *
 * Sentence case with the letter-spacing relaxed: "AI REVIEWED" tracked out like a status shouts,
 * and the fill is already doing the work of being seen.
 *
 * `cursor: help` for the same reason the 3D badge has it: it carries a tooltip saying what the
 * agent did and did not decide, and nothing happens if you click it.
 */
.badge--ai {
  background: var(--accent-fill);
  color: var(--accent-ink);
  border-color: transparent;
  text-transform: none;
  letter-spacing: 0.01em;
  font-weight: 600;
  cursor: help;
}

/*
 * The fold count: how many other reports this card answers.
 *
 * Quiet on purpose — an outline rather than a fill. It is a fact about the card's SIZE, not
 * about its urgency, and it sits in a row where severity and status are already competing to
 * be seen. `cursor: help` because it carries the sentence explaining what folding does, and
 * nothing happens if you click it.
 */
.badge--folded {
  background: transparent;
  border-color: var(--accent);
  color: var(--accent);
  text-transform: none;
  letter-spacing: 0.01em;
  font-weight: 600;
  cursor: help;
}

/* The star keeps its size wherever it sits — a flex item shrinks by default, and a squashed
   four-point spark reads as a smudge. `.btn` and `.badge` are both inline-flex with a gap
   already, so this one declaration is the whole of what the icon needs in either. */
.spark,
.badge__spark {
  flex: none;
}

/*
 * 3D capability, next to the Flat/Scope badge. A distinct colour because it
 * answers a different question — masking versus what kit is in the booth — and
 * two teal badges side by side would read as one fact split in half.
 *
 * Only rendered for a screen that has it, so its absence is the "not 3D" state
 * and there is nothing to hover on a screen that is not.
 */
.badge--3d {
  background: color-mix(in oklab, var(--p-pink) 12%, var(--surface));
  color: var(--p-pink);
  border-color: color-mix(in oklab, var(--p-pink) 32%, transparent);
  letter-spacing: 0.04em;
  font-weight: 700;
  /* It carries a tooltip and nothing else, so the cursor should not promise a click. */
  cursor: help;
}

/* ---------------------------------------------------------------- notices */
.notice {
  border: 1px solid var(--border-strong);
  border-left: 3px solid var(--border-strong);
  border-radius: var(--radius-sm);
  padding: 13px 15px;
  margin-bottom: 18px;
  font-size: 0.92rem;
}

/*
 * A page-level notice follows the reader down the page.
 *
 * Settings is one long column of collapsed cards, and its page-level message sits at the top
 * where the heading is. So a save that failed three cards down said so somewhere the reader
 * could not see: the button went back to normal, nothing else happened, and the only evidence
 * was a red box a full screen above them. People pressed it again.
 *
 * Sticky rather than a floating toast, because the message is often long — a licence refusal,
 * a restore that names four accounts — and a toast sized for a sentence either truncates those
 * or covers the form they are about. Pinned under the top bar it stays exactly as readable as
 * it was, in the place the reader already associates with "what just happened", and it is
 * still in the flow so nothing underneath is hidden by it.
 *
 * Opt-in per page rather than applied to every `.notice` in the app: most of them are already
 * beside the control they belong to, where following the reader would be following them away
 * from the thing the message is about.
 *
 * NOT called `notice--page`, and that is not a style preference. showNotice() in js/util.js
 * swaps the variant by stripping every class starting `notice--` and adding the new one, so a
 * placement class in that namespace would be deleted the first time a message was shown — the
 * sticky would work until it was needed. The helper's own comment describes it happening to
 * `modal__notice` once already.
 */
.page-notice {
  position: sticky;
  /* Under the top bar, not behind it. 63px is the bar MEASURED — 12px of padding either side of
     its row, plus the hairline — and the first guess at it was 56, which tucked the message
     seven pixels behind the thing it was supposed to sit below. The browser test asserts the
     message clears the bar's real bottom edge, so a bar that grows fails a test rather than
     quietly hiding a message again. */
  top: 71px;
  /* Below the bar (20) and above the cards, so it is never the thing a card scrolls over. */
  z-index: 15;
  box-shadow: var(--shadow-lift);
}

.notice--error {
  background: var(--danger-soft);
  border-color: color-mix(in oklab, var(--danger) 35%, transparent);
  border-left-color: var(--danger);
  color: var(--text);
}

.notice--ok {
  background: var(--ok-soft);
  border-color: color-mix(in oklab, var(--ok) 35%, transparent);
  border-left-color: var(--ok);
}

.notice--info {
  background: var(--info-soft);
  border-color: color-mix(in oklab, var(--info) 35%, transparent);
  border-left-color: var(--info);
}

/* A warning: not a failure and not good news — something the reader has to do for the thing to work. */
.notice--warn {
  background: var(--warn-soft);
  border-color: color-mix(in oklab, var(--warn) 45%, transparent);
  border-left-color: var(--warn);
}

/*
 * A line of live FACT under a control — what was last read, what a file did — set in a hint's
 * type but never folded away with the page descriptions: a description says what a control is
 * for, and this says what it did, which is the one line a reader deciding opens the card for.
 */
.setting__note {
  font-size: 0.82rem;
  color: var(--text-dim);
  margin: 6px 0 0;
}

/* The CSV import on the box office card: its own line under the read-now button, named. */
.box-office-import {
  display: grid;
  gap: 6px;
}

/* A checkbox with its words beside it, inside a dialog's field. The app's other
   checkboxes are pills in a chooser or rows in the settings schema; this is the
   plain one — a single yes/no next to a sentence.

   Written as `.field > label.check` rather than `.check`, and that is the whole
   reason it works: `.field > label` above sets `display: block` at the same
   specificity a bare class has, wins on source order, and turned the flex row into
   a stacked block with the box jammed against its own words. A screenshot caught it.
   The margin goes too — the hint underneath supplies the spacing here. */
.field > label.check,
label.check {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 0;
  font-weight: 500;
  cursor: pointer;
}

.field > label.check input,
label.check input {
  flex: none;
  margin: 0;
}

/* The sentence under a tick box, which explains the box rather than the field. */
label.check + .field__hint {
  margin-top: 6px;
}

/* ------------------------------------------------------------- the four pools

   One row per slot on the Thank-you codes card: what the slot is, how many codes
   are left in it, and the two things you can do about that. The count is the whole
   point of the card — a pool that empties takes the feature down silently, with the
   guest seeing the confirmation they always saw — so it gets a state of its own the
   moment a LIVE slot drops under the floor. */
.discount-pools {
  display: grid;
  gap: 10px;
  margin-top: 4px;
}

.discount-pools__row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 10px 14px;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
}

.discount-pools__what {
  display: grid;
  gap: 2px;
  min-width: 0;
}

.discount-pools__label {
  font-weight: 600;
}

.discount-pools__count {
  font-size: 0.86rem;
  color: var(--text-dim);
  font-variant-numeric: tabular-nums;
}

/* A live slot with nothing left to give. The one state on this card worth interrupting for. */
.discount-pools__count--low {
  color: var(--warn);
  font-weight: 600;
}

.discount-pools__acts {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
}

.discount-pools__load {
  display: grid;
  gap: 6px;
}

.discount-find__head {
  margin-top: 16px;
}

.discount-find {
  display: flex;
  gap: 8px;
  align-items: center;
  flex-wrap: wrap;
}

.discount-find .input {
  flex: 1 1 220px;
  min-width: 0;
}

.notice[hidden] {
  display: none;
}

/* ------------------------------------------------------------ stat strip */
.stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
  gap: 12px;
  margin-bottom: 24px;
}

/*
 * CENTRED, and every tile the same.
 *
 * The row grew from six tiles to nine as sales arrived, and each one had ragged left-aligned
 * text of a different length sitting at a different height — a figure, a two-line label, a
 * note some tiles have and others do not. Left-aligned text only looks tidy when the blocks
 * are the same shape, and these never will be: "292" and "Auditorium 1" are not the same
 * width and no amount of care makes them look deliberate side by side. Centring both axes
 * makes every tile the same object regardless of what is in it — which is what the strip is,
 * a row of one kind of thing.
 */
.stat {
  position: relative;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  background: var(--glass);
  box-shadow: var(--rim);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  /* The extra at the bottom is the chevron's room, given to EVERY tile rather than only the
     ones that have a chevron: centred text runs to both edges, so a linked tile alone in
     wearing the padding would sit a few pixels higher than its neighbours in the same row. */
  padding: 14px 16px 20px;
  /* Background and transform for the three that are links — see .stat--link below. Listed
     here rather than there so a tile leaving a hover eases out as well as in. */
  transition:
    border-color 140ms var(--ease),
    background-color 140ms var(--ease),
    transform 110ms var(--ease);
}

/* A row with every tile hidden is no row at all, and an empty grid would still hold its
   bottom margin — a gap above the charts with nothing to explain it. */
.stats:empty {
  display: none;
}

/* A hairline of accent along the top edge, so the strip reads as one unit. */
.stat::before {
  content: '';
  position: absolute;
  inset: 0 0 auto 0;
  height: 2px;
  background: var(--accent-line);
  opacity: 0.5;
}

/*
 * ------------------------------------------------------- the three tiles that go somewhere
 *
 * Awaiting review, Open with managers and Oldest still unreviewed link to exactly the
 * reports they count. The other three cannot — see renderStats() in public/js/home.js, which
 * explains why — so everything that says "press me" belongs to this class alone.
 *
 * That is a correction as much as an addition: `.stat:hover` used to lift the border on all
 * six, which promised interaction on three tiles that had none. A hover state on something
 * inert is a worse lie than no hover state at all, because it is only discovered by trying.
 */
.stat--link {
  color: inherit;
  text-decoration: none;
  cursor: pointer;
}

/*
 * The occupancy tile is a <button> — it opens a popup on this page rather than going
 * somewhere — and a button brings its own font, centring and width that none of the
 * <a> tiles have to undo. Same dress, either element.
 */
button.stat--link {
  font: inherit;
  text-align: center;
  width: 100%;
  appearance: none;
}

.stat--link:hover {
  border-color: color-mix(in oklab, var(--accent) 45%, var(--border));
  background: color-mix(in oklab, var(--accent) 4%, var(--surface));
  /* Held against the global `a:hover`, which would otherwise recolour everything inside that
     has not set a colour of its own. See the value rule below — that one is deliberate. */
  color: inherit;
}

/*
 * The number goes accent on hover, and only the number.
 *
 * It is the thing being acted on, and the label and note keep their own dimmer colour so the
 * tile does not light up all over. This started as an accident — `a:hover` sets a colour, the
 * value is the one child that inherits it — and an inherited colour that happens to land in
 * the right place is not a decision, so it is stated. Focus gets it too, or a keyboard reader
 * would be the only one without the cue.
 */
.stat--link:hover .stat__value,
.stat--link:focus-visible .stat__value {
  color: var(--accent);
}

/* The accent hairline along the top edge is at half strength at rest; on hover it is the
   cheapest way to say the whole tile is one control. */
.stat--link:hover::before {
  opacity: 1;
}

.stat--link:focus-visible {
  /* Inside the tile rather than outside it, so a ring on a tile at the edge of the grid is
     not clipped by the shell's padding. */
  outline-offset: -3px;
}

/*
 * A chevron, bottom right, and only on the ones that move.
 *
 * At rest it is faint rather than absent: a control that only announces itself on hover
 * announces itself to nobody on a touchscreen, where there is no hover at all.
 */
.stat--link::after {
  content: '';
  position: absolute;
  right: 13px;
  bottom: 13px;
  width: 7px;
  height: 7px;
  border-right: 1.5px solid var(--text-faint);
  border-bottom: 1.5px solid var(--text-faint);
  transform: rotate(-45deg);
  opacity: 0.5;
  transition:
    opacity 140ms var(--ease),
    border-color 140ms var(--ease),
    transform 140ms var(--ease);
}

.stat--link:hover::after,
.stat--link:focus-visible::after {
  opacity: 1;
  border-right-color: var(--accent);
  border-bottom-color: var(--accent);
  /* A nudge in the direction it points, which is the direction it takes you. */
  transform: rotate(-45deg) translate(1.5px, 1.5px);
}

/* Pressed, on a finger: the tile takes the press rather than only the colour changing, which
   on a touchscreen is the whole of the feedback since there was never a hover. */
.stat--link:active {
  transform: scale(0.985);
}

/*
 * Proportional figures, not tabular. Equal-width digits are for columns that have
 * to line up vertically; at this size they make a number like 121 read loose,
 * because every digit takes a zero's width.
 */
.stat__value {
  font-size: 2.15rem;
  font-weight: 780;
  line-height: 1.05;
  letter-spacing: -0.045em;
  font-variant-numeric: tabular-nums;
  /* For the accent on a linked tile's hover; the global `a` transition covers the anchor, not
     a child that sets its own colour. */
  transition: color 140ms var(--ease);
}

/*
 * A tile whose value is a NAME rather than a number — Most problematic says "Auditorium 4",
 * not 4. At the number's size and tracking it overflowed the tile, because a room's name is
 * three times the width of the widest figure any other tile shows. Smaller, allowed to wrap,
 * and without tabular figures, which only make sense on digits.
 */
.stat__value--text {
  font-size: 1.35rem;
  letter-spacing: -0.02em;
  line-height: 1.15;
  font-variant-numeric: normal;
  overflow-wrap: anywhere;
  text-wrap: balance;
}

.stat__label {
  font-size: 0.72rem;
  color: var(--text-dim);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  font-weight: 600;
  margin-top: 4px;
}

/* The one line of context a number needs — what it is measured against. */
.stat__note {
  font-size: 0.78rem;
  color: var(--text-dim);
  margin-top: 6px;
}

.stat__note--warn {
  color: var(--warn);
  font-weight: 600;
}

/* The freshness line under a live figure — Current patrons says when its numbers were
   last synced. Fainter and smaller than the note above it: it is a caveat, not a fact
   of its own, and a timestamp that shouted would compete with the number it qualifies. */
.stat__sync {
  font-size: 0.68rem;
  color: var(--text-faint);
  margin-top: 4px;
}

/*
 * ============================================================ editing the dashboard
 *
 * One mode, both rows. A reader whose job is the till and a reader whose job is the queue
 * want different halves of this page, and the honest answer to that is not a shorter page
 * for everybody — it is letting each of them put away what they do not use.
 *
 * Everything here is scoped to `.is-editing` on <body> or to the edit-mode classes home.js
 * adds, so a dashboard being read carries none of it: no grips, no ✕, no dashed outlines.
 * The tray's + card is the one piece that only exists while editing, because a control that
 * re-adds hidden cards has nothing to say to somebody who is not hiding any.
 */

/* The card being edited: still readable, plainly in hand. The dashed edge is the one signal
   that says "this can be moved" without needing a hover to discover it. */
.stat--edit,
.is-editing .chart {
  border-style: dashed;
  border-color: color-mix(in oklab, var(--accent) 40%, var(--border));
}

/* Room at the top for the grip and the ✕, which are absolute and would otherwise sit on
   the figure. Only while editing, so a tile being read is never padded for controls it
   does not have. */
.stat--edit {
  padding-top: 32px;
}

/* The grip on a tile — the chart's own grip, at a tile's scale, in its top-left corner.
   Absolute, so it takes no room from the centred figure it sits beside. */
.stat__grip {
  position: absolute;
  top: 4px;
  left: 5px;
  display: grid;
  place-items: center;
  width: 24px;
  height: 24px;
  padding: 0;
  border: 1px solid transparent;
  border-radius: 7px;
  background: none;
  font: inherit;
  font-size: 0.9rem;
  line-height: 1;
  color: var(--text-faint);
  cursor: grab;
  /* Stops a drag on a touchscreen from scrolling the page instead. */
  touch-action: none;
  transition:
    background-color 140ms var(--ease),
    color 140ms var(--ease);
}

.stat__grip:hover,
.stat__grip:focus-visible {
  background: var(--surface-3);
  border-color: var(--border);
  color: var(--text);
}

.stat__grip:active {
  cursor: grabbing;
}

/*
 * The ✕ that puts a card away.
 *
 * Shared by the tiles and the charts, which is why it is `card__hide` and not either one's
 * class: the gesture is the dashboard's, and one spelling of it means one place to change
 * how hiding looks. On a tile it sits opposite the grip; on a chart it joins the head row
 * beside the grip that is already there.
 */
.card__hide {
  display: grid;
  place-items: center;
  width: 24px;
  height: 24px;
  flex: none;
  padding: 0;
  border: 1px solid transparent;
  border-radius: 7px;
  background: none;
  font: inherit;
  font-size: 0.78rem;
  line-height: 1;
  color: var(--text-faint);
  cursor: pointer;
  transition:
    background-color 140ms var(--ease),
    border-color 140ms var(--ease),
    color 140ms var(--ease);
}

.stat--edit .card__hide {
  position: absolute;
  top: 4px;
  right: 5px;
}

/* Hiding is destructive-ish — recoverable, but it takes something off the page — so the
   hover is the app's warn colour rather than its accent. */
.card__hide:hover,
.card__hide:focus-visible {
  background: color-mix(in oklab, var(--warn) 12%, var(--surface));
  border-color: color-mix(in oklab, var(--warn) 45%, var(--border));
  color: var(--warn);
}

/*
 * A chart's grip is full strength while editing rather than waiting for a hover.
 *
 * It is NOT hidden outside edit mode, and that is deliberate rather than an omission: the
 * same grip and the same reorder.js serve the manager's and engineer's overview, which has
 * no edit mode of its own — gating it on this class would take drag-to-rearrange away from
 * those pages entirely. So the charts keep the affordance they have always had, and edit
 * mode adds what only it can offer: the ✕, and the same grip on the tiles.
 */
.is-editing .chart__grip {
  opacity: 1;
}

/* ------------------------------------------------------------------- the tray */

/*
 * The + at the end of a row: a card-shaped disclosure holding what has been put away.
 *
 * A <details> because the app's other menus are, and because the keyboard and Escape come
 * free with it. Sized like the cards it sits among so the row keeps its rhythm — in the
 * charts grid it spans nothing and simply takes the next slot.
 */
.dash-add {
  position: relative;
  border: 1px dashed color-mix(in oklab, var(--accent) 35%, var(--border));
  border-radius: var(--radius);
  background: color-mix(in oklab, var(--accent) 3%, var(--surface));
  min-height: 84px;
}

.dash-add__summary {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 2px;
  height: 100%;
  min-height: 84px;
  padding: 10px;
  cursor: pointer;
  color: var(--text-dim);
  list-style: none;
  text-align: center;
}

/* Safari draws its own triangle unless both of these are said. */
.dash-add__summary::-webkit-details-marker {
  display: none;
}

.dash-add__summary::marker {
  content: '';
}

.dash-add__summary:hover {
  color: var(--text);
  background: color-mix(in oklab, var(--accent) 6%, transparent);
  border-radius: var(--radius);
}

.dash-add__sign {
  font-size: 1.5rem;
  font-weight: 600;
  line-height: 1;
  color: var(--accent);
}

.dash-add__count {
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  font-weight: 600;
}

/*
 * The menu itself, over the page rather than in the flow: a list that pushed the grid
 * around as it opened would move the very cards somebody is arranging.
 */
.dash-add__menu {
  position: absolute;
  z-index: 5;
  top: calc(100% + 6px);
  left: 0;
  min-width: max(100%, 220px);
  max-width: 320px;
  max-height: 320px;
  overflow-y: auto;
  padding: 6px;
  background: var(--surface-2, var(--surface));
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: 0 14px 34px rgb(0 0 0 / 28%);
}

/* Right-hand end of a row: a menu anchored left would hang off the page. */
.dash-add:last-child .dash-add__menu {
  left: auto;
  right: 0;
}

.dash-add__item {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  padding: 8px 10px;
  border: 1px solid transparent;
  border-radius: 8px;
  background: none;
  font: inherit;
  font-size: 0.86rem;
  color: var(--text);
  text-align: left;
  cursor: pointer;
  transition:
    background-color 120ms var(--ease),
    border-color 120ms var(--ease);
}

.dash-add__item:hover,
.dash-add__item:focus-visible {
  background: color-mix(in oklab, var(--accent) 8%, transparent);
  border-color: color-mix(in oklab, var(--accent) 40%, var(--border));
}

.dash-add__plus {
  color: var(--accent);
  font-weight: 600;
}

.dash-add__empty {
  margin: 0;
  padding: 8px 10px;
  font-size: 0.8rem;
  color: var(--text-dim);
}

/* ============================================================ home page
   Charts are hand-rolled SVG and HTML rather than a library, so their chrome
   lives here with everything else. Every mark reads a semantic token, which is
   what lets one chart look right in seven themes without any of them knowing.

   The rules the marks follow, in one place so they are not re-decided per chart:
   thin marks, hairline solid grid one step off the surface, a 2px surface GAP
   between touching fills rather than a stroke around them, and text in text
   tokens — never in the data colour. */

/* -------------------------------------------------------------- range bar */
.rangebar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px;
  background: var(--glass);
  box-shadow: var(--rim);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  padding: 10px 14px;
  margin-bottom: 20px;
}

/*
 * THE RANGE, FOLDED AWAY ON A PHONE.
 *
 * Seven windows wrap to three rows — 115px of chips standing above every number on the
 * overview — and the operator's reading of it was the right one: somebody checking today's
 * figures through the day scrolls past a control they are not using, all day. On a phone the
 * chips go behind a summary that names the window showing, so the one fact the block carried
 * for a reader who is not changing anything survives the fold.
 *
 * On anything wider this element DISSOLVES. `display: contents` drops the details out of the
 * box tree and leaves the chips as direct children of the bar's flex row, laid out exactly as
 * they were before the fold existed — which is what keeps a mobile affordance from becoming a
 * desktop redesign. The summary is not drawn there at all.
 *
 * The markup ships `open` and js/range-fold.js closes it on a phone, rather than the other way
 * round: a shut <details> hides its children through the browser's own slot, and dissolving the
 * host with `display: contents` is not a dependable way to overrule that.
 */
.rangebar__fold {
  display: contents;
}

.rangebar__summary {
  display: none;
}

@media (max-width: 560px) {
  .rangebar__fold {
    display: block;
    /* Its own line in the bar's wrap, so the buttons beside it keep their places. */
    flex: 1 1 100%;
    min-width: 0;
  }

  .rangebar__summary {
    display: flex;
    align-items: center;
    gap: 8px;
    position: relative;
    /* Room on the right for the chevron, which holds that corner here exactly as it does
       on a location heading — one place for a fold's handle, page to page. */
    padding: 8px 32px 8px 12px;
    border: 1px solid var(--border-strong);
    border-radius: var(--radius);
    background: var(--surface-2);
    color: var(--text);
    font-size: 0.86rem;
    cursor: pointer;
    user-select: none;
    list-style: none;
  }

  .rangebar__summary::-webkit-details-marker {
    display: none;
  }

  .rangebar__summary::after {
    content: '';
    position: absolute;
    right: 13px;
    top: 50%;
    width: 6px;
    height: 6px;
    margin-top: -5px;
    border-right: 2px solid var(--text-dim);
    border-bottom: 2px solid var(--text-dim);
    transform: rotate(45deg);
    transition: transform 160ms var(--ease);
  }

  .rangebar__fold[open] > .rangebar__summary::after {
    transform: rotate(-135deg);
    margin-top: -2px;
  }

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

  /* The window showing, which is the whole reason this is a fold and not a hiding place. */
  .rangebar__current {
    font-weight: 600;
  }

  .rangebar__current[hidden] {
    display: none;
  }

  /* Only while open, so a shut fold is exactly one control tall. */
  .rangebar__fold[open] > .rangebar__options {
    margin-top: 10px;
  }

  /*
   * The fiscal quarters fold away with the chips.
   *
   * They are the same question — which window — and left outside the fold they were a row of
   * quarters standing permanently above the numbers on a phone, with no way back out of a
   * quarter that did not start by opening the fold to reach the chips. The operator found
   * both halves of that on their own phone.
   *
   * A SIBLING RULE rather than markup inside the fold, and the reason is worth writing down:
   * Chrome reports `display: contents` on a <details> and generates a box for it anyway, so a
   * fiscal row moved inside stopped being the bar's own full-width row on a wide screen and
   * became a line inside the fold's box with the buttons floating beside both. The row stays
   * where it was in the DOM — desktop untouched — and the phone hides it with the chips.
   */
  .rangebar__fold:not([open]) ~ #range-fiscal {
    display: none;
  }

  /* Open, it belongs with the windows rather than after the actions: the fold, its chips and
     the quarters read as one control, and Edit dashboard has nothing to do with any of them. */
  #range-fiscal {
    order: 0;
  }

  .rangebar .btn {
    order: 1;
  }

  /*
   * The bar's three actions keep ONE row on a phone.
   *
   * Edit dashboard, Detailed report and Refresh belong together — they are the three things
   * this bar does — and at the shared button padding the third wrapped onto a line of its
   * own, which read as Refresh having been separated from the other two. Trimming the side
   * padding is enough for all three to fit from 375px up (measured: every current iPhone,
   * and Android at 384 and wider), and they keep their size, weight and order — the same
   * format, on one line. Narrower than that they wrap again, which is the honest answer:
   * the labels are what they are, and clipping "Detailed report" to hold a row would cost
   * more than the row is worth. A fourth button (Reset layout, once a dashboard has been
   * arranged) wraps for the same reason.
   */
  .rangebar .btn {
    padding-left: 8px;
    padding-right: 8px;
  }
}

/* The narrow phones (360px and down) need the last few pixels to hold that one row: a
   tighter gap between the bar's own items. Below this the three wrap honestly rather than
   being squeezed into labels nobody can read. */
@media (max-width: 400px) {
  .rangebar {
    gap: 8px;
  }

  .rangebar .btn {
    padding-left: 6px;
    padding-right: 6px;
  }
}

.rangebar__options {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
}

.rangebar__option {
  border: 1px solid transparent;
  background: none;
  border-radius: 8px;
  padding: 6px 11px;
  font: inherit;
  font-size: 0.84rem;
  font-weight: 550;
  color: var(--text-dim);
  cursor: pointer;
  white-space: nowrap;
  transition:
    background-color 140ms var(--ease),
    color 140ms var(--ease);
}

.rangebar__option:hover {
  background: var(--surface-3);
  color: var(--text);
}

.rangebar__option--on {
  color: var(--accent);
  background: color-mix(in oklab, var(--accent) 12%, transparent);
  border-color: color-mix(in oklab, var(--accent) 30%, transparent);
  font-weight: 620;
}

/*
 * A quarter nobody has reached yet.
 *
 * Shown and refused rather than hidden: the row is the circuit's year, and a year missing
 * its last two quarters reads as a broken control instead of as a year still running. The
 * cursor and the fade say it is not for pressing; the title says why.
 */
.rangebar__option[disabled] {
  opacity: 0.42;
  cursor: not-allowed;
}

.rangebar__option[disabled]:hover {
  background: none;
  color: var(--text-dim);
}

/*
 * The quarter the circuit is in right now, marked whether or not it is the one selected —
 * "which of these is now" is a different question from "which am I looking at", and on a
 * row of four identical chips there is otherwise nothing to answer it.
 */
.rangebar__option--now::after {
  content: '';
  display: inline-block;
  width: 5px;
  height: 5px;
  margin-left: 6px;
  border-radius: 50%;
  vertical-align: 1px;
  background: var(--accent);
}

/*
 * A full-width row inside the bar, for the two controls that only sometimes apply.
 *
 * `flex-basis: 100%` rather than a separate container, so they sit inside the bar's own
 * border and read as part of the same control — a second bordered box under the first
 * would look like a different setting.
 */
.rangebar__row {
  flex-basis: 100%;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  padding-top: 10px;
  border-top: 1px solid var(--border);
}

.rangebar__row[hidden] {
  display: none;
}

/* The year's own name, leading its quarters: "FY26 · Q1 Q2 Q3 Q4". */
.rangebar__lead {
  font-size: 0.78rem;
  font-weight: 600;
  color: var(--text-dim);
  text-transform: uppercase;
  letter-spacing: 0.04em;
  margin-right: 2px;
}

.rangebar__date {
  display: flex;
  align-items: center;
  gap: 7px;
  font-size: 0.82rem;
  font-weight: 550;
  color: var(--text-dim);
}

.rangebar__date input {
  font: inherit;
  font-size: 0.84rem;
  color: var(--text);
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 5px 8px;
}

.rangebar__date input:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

/*
 * The cinema picker on the programming page: a labelled select on the bar's own row,
 * dressed like the date boxes beside it rather than like a form field. The shared `select`
 * rule above makes every dropdown a 44px full-width control, which is right in a form and
 * wrong in a toolbar; the class wins over the element selector, and the chevron is moved in
 * to sit inside the smaller padding.
 */
.rangebar__where {
  display: flex;
  align-items: center;
  gap: 7px;
  font-size: 0.82rem;
  font-weight: 550;
  color: var(--text-dim);
}

.rangebar__where[hidden] {
  display: none;
}

.rangebar__where select {
  width: auto;
  max-width: 260px;
  min-height: 32px;
  font-size: 0.84rem;
  border-radius: 8px;
  border-color: var(--border);
  padding: 5px 28px 5px 8px;
  background-position: right 14px center, right 9px center;
}

@media (max-width: 560px) {
  /* Under the folded range, sharing a line with Refresh: the select takes what the label
     and the button leave, so a long cinema name has the width of the phone to be read in.
     A basis of ZERO, not auto: a select is as wide as its widest option, and on a circuit
     of twenty that is a name like "Station Square 4 · STA04" — measured from the content,
     the picker claimed the line and pushed Refresh onto a third row of the bar. Measured
     from nothing, it grows into whatever the button leaves and clips the name inside. */
  .rangebar__where {
    flex: 1 1 0;
    min-width: 0;
  }

  .rangebar__where select {
    flex: 1;
    min-width: 0;
    max-width: none;
  }

  /* Refresh keeps its own size beside a picker that is showing. The phone width's blanket
     `.btn { flex: 1 1 auto }` (below) would otherwise split the line's leftover with the
     picker — a 190px Refresh next to a 74px select holding a cinema's name. On a circuit
     of one the picker is hidden and the button stretches as it does on every other bar. */
  .rangebar__where:not([hidden]) + .btn {
    flex: 0 0 auto;
  }
}

/* Why the two boxes are not yet a range. Empty until there is something to say. */
.rangebar__hint {
  margin: 0;
  font-size: 0.8rem;
  color: var(--warn);
}

.rangebar__hint:empty {
  display: none;
}

/*
 * The same periods, in the detailed report's dialog.
 *
 * A column of full-width choices rather than the bar's row of chips: this one is being
 * read and decided on rather than flicked through, the labels are the only content of
 * the dialog, and a row of five would wrap to two ragged lines in a narrow modal.
 */
.rangepick {
  display: grid;
  gap: 6px;
}

/*
 * The dialog is one explanatory line, one choice and one note, and the paragraphs above
 * and below the choice have no margin of their own — so the label sat straight on the
 * sentence before it. Spaced here rather than on `.field__hint` everywhere, because
 * every other use of that class is a hint sitting deliberately close to its control.
 */
#report-dialog .field {
  margin: 14px 0 4px;
}

/*
 * The fiscal row and the date boxes, inside the dialog rather than the range bar. Same
 * chips, but the bar's full-width rule and its divider are the bar's own: here they are
 * already the only thing on their line, and a rule between two halves of one control
 * would read as two settings.
 */
.rangebar__row--flush {
  flex-basis: auto;
  border-top: none;
  padding-top: 8px;
}

.rangepick__option {
  border: 1px solid var(--border);
  background: var(--surface-2);
  border-radius: 10px;
  padding: 10px 12px;
  font: inherit;
  font-size: 0.88rem;
  font-weight: 550;
  color: var(--text-dim);
  cursor: pointer;
  text-align: left;
  transition:
    background-color 140ms var(--ease),
    border-color 140ms var(--ease),
    color 140ms var(--ease);
}

.rangepick__option:hover {
  background: var(--surface-3);
  color: var(--text);
}

.rangepick__option--on {
  color: var(--accent);
  background: color-mix(in oklab, var(--accent) 12%, transparent);
  border-color: color-mix(in oklab, var(--accent) 45%, transparent);
  font-weight: 620;
}

/*
 * The buttons sit right, however many of them are showing. Pushed by the options
 * rather than pulled by the buttons: Reset layout appears and disappears, and two
 * elements each claiming `margin-left: auto` split the free space between them
 * instead of both going to the end.
 */
.rangebar__options {
  margin-right: auto;
}

/* ------------------------------------------------------- needs attention
   A .section disclosure like the Settings cards, but built open: this one is the
   page telling you something is wrong, and a warning you have to click to read is
   not a warning. Its rows are full-bleed and tinted, so they sit directly in the
   details rather than in a padded .section__body. */
.attention {
  margin-bottom: 24px;
}

.attention__row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 12px;
  /* 22px to line up with the .section__body padding used elsewhere. */
  padding: 13px 22px;
  border-left: 3px solid transparent;
}

.attention__row + .attention__row {
  border-top: 1px solid var(--border);
}

.attention__row--danger {
  border-left-color: var(--danger);
  background: var(--danger-soft);
}

.attention__row--warn {
  border-left-color: var(--warn);
  background: var(--warn-soft);
}

.attention__text {
  flex: 1 1 320px;
  font-size: 0.9rem;
}

/* ----------------------------------------------------------- chart layout */
.charts {
  display: grid;
  /* Wide enough that a 12-month trend's labels do not collide; anything narrower
     goes full width rather than being squeezed into a column. */
  /* min() so the track can go narrower than 340px when that is all there is: a
     flat 340px minimum overflowed a 320px phone by 36px and scrolled the page
     sideways. */
  grid-template-columns: repeat(auto-fit, minmax(min(340px, 100%), 1fr));
  gap: 16px;
  margin-bottom: 24px;
  /*
   * ONE SIZE OF CARD, which is the whole reason this row exists rather than `align-items:
   * start`.
   *
   * That was the right call when a card was as tall as its contents and stretching one meant
   * two-thirds of it being empty. The plots are now capped at a standard height (--plot-cap
   * below) and a chart with more to show offers to expand instead of growing, so the contents
   * of every card are the same height to within a line of subtitle — and stretching closes
   * that last gap instead of opening a big one. A grid of same-sized boxes is the thing that
   * makes this page scannable; ragged ones read as a pile.
   */
  align-items: stretch;
  /*
   * And a standard height for the ROW, so the promise holds down the page as well as across
   * it. Stretch alone matches the cards beside each other; without a floor, a row of two short
   * charts still sat sixty pixels shorter than the row above it. The floor is what a full card
   * comes to — head, a capped plot, and the numbers line — and `auto` above it is what lets an
   * expanded card, or the wide ticker, take the room it actually needs.
   */
  grid-auto-rows: minmax(340px, auto);
}

/* The trend is the page's one time series and reads across the full width. */
.chart--wide {
  grid-column: 1 / -1;
}

.chart {
  position: relative;
  margin: 0;
  /* A column, so the numbers disclosure can be pinned to the bottom of a stretched card
     rather than floating in the middle of whatever space is left over. */
  display: flex;
  flex-direction: column;
  background: var(--glass);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow);
  padding: 18px 20px 16px;
}

/*
 * How tall a plot is — every plot, in every card.
 *
 * The number is the column charts' own geometry: 8px of headroom, 150px of plot, 26px of axis
 * (COL in js/charts.js, which a test pins to this value). The bar charts used to be as tall as
 * their row count, which is what made a grid of cards ragged; they are clamped to this and
 * offer "Show all N" when there is more, so the standard size is the default and a taller card
 * is something the reader asked for.
 */
:root {
  --plot-cap: 184px;
}

.chart__plot--clamp {
  max-height: var(--plot-cap);
  overflow: hidden;
  /* Opening and shutting MOVES, like everything else on this page that changes size. The
     duration is here rather than in the script so one place decides it — which is also what
     makes reduced motion work on this without the script knowing that reduced motion exists. */
  transition: max-height 240ms var(--ease);
}

.chart__plot--open {
  max-height: none;
}

/*
 * The expander itself: quiet, full width, at the foot of the chart it belongs to.
 *
 * Not a link and not a chip — it does not go anywhere and it is not a filter. It reads as the
 * bottom edge of the card lifting, which is what it does.
 */
.chart__more {
  margin-top: 10px;
  padding: 6px 0;
  border: 0;
  border-top: 1px solid var(--border);
  background: none;
  font: inherit;
  font-size: 0.8rem;
  font-weight: 550;
  color: var(--text-dim);
  cursor: pointer;
  text-align: left;
  transition: color 140ms var(--ease);
}

.chart__more:hover,
.chart__more:focus-visible {
  color: var(--accent);
}

.chart__head {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  margin-bottom: 16px;
}

.chart__heading {
  min-width: 0;
  flex: 1 1 auto;
}

/*
 * How the live ticker stands against the same minute yesterday, at the top right of its card.
 *
 * A headcount on its own says nothing anybody can act on — eight hundred people is a good night
 * or a bad one depending entirely on what yesterday held. This is the half that makes it news,
 * so it sits where the eye lands after the title rather than under the plot.
 */
.chart__delta {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 1px;
  flex: 0 0 auto;
  text-align: right;
}

/* Two chips side by side in the one badge slot: the day's peak, and how now compares. */
.chart__chips {
  display: flex;
  align-items: flex-start;
  gap: 14px;
  flex: 0 0 auto;
}

.chart__peak {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 1px;
  flex: 0 0 auto;
  text-align: right;
}

.chart__peakfigure {
  font-size: 1.05rem;
  line-height: 1.1;
  font-variant-numeric: tabular-nums;
  color: var(--text);
}

.chart__peaklabel {
  font-size: 0.68rem;
  letter-spacing: 0.03em;
  text-transform: uppercase;
  color: var(--text-faint);
}

/*
 * The measure switch, for a chart that can be read more than one way.
 *
 * In the head rather than under the plot, so it reads as part of the card's title — "how
 * full each cinema runs, BY tickets" — and so it cannot be mistaken for a control that
 * filters the page.
 */
.chart__modes {
  display: inline-flex;
  flex: 0 0 auto;
  padding: 2px;
  gap: 2px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
}

.chart__mode {
  font: inherit;
  font-size: 0.76rem;
  padding: 4px 10px;
  border: 0;
  border-radius: calc(var(--radius-sm) - 3px);
  background: none;
  color: var(--text-dim);
  cursor: pointer;
  transition:
    background-color 140ms var(--ease),
    color 140ms var(--ease);
}

.chart__mode:hover {
  color: var(--text);
}

.chart__mode--on {
  background: var(--surface);
  color: var(--text);
  box-shadow: var(--shadow-soft, 0 1px 2px rgb(0 0 0 / 0.25));
}

.chart__deltafigure {
  font-size: 1.05rem;
  line-height: 1.1;
  font-variant-numeric: tabular-nums;
}

.chart__deltalabel {
  font-size: 0.68rem;
  color: var(--text-faint);
}

/*
 * Colour SECONDS the sign, never carries it alone. The figure already reads +240 or −180, so a
 * reader on the colour-vision palette — or anybody glancing at a phone in a foyer — gets the
 * direction from the character whatever the hue is doing.
 */
.chart__delta--up .chart__deltafigure {
  color: var(--ok);
}

.chart__delta--down .chart__deltafigure {
  color: var(--danger);
}

.chart__delta--level .chart__deltafigure {
  color: var(--text-dim);
}

/* ----------------------------------------------------------- the drag grip
   Faint until the card is hovered or the grip itself is focused: five grips at
   full strength turn a page of charts into a page of controls. It stays reachable
   by keyboard the whole time — only its opacity changes, never its display. */
.chart__grip {
  flex: none;
  display: grid;
  place-items: center;
  width: 28px;
  height: 28px;
  margin: -4px -6px 0 0;
  padding: 0;
  border: 1px solid transparent;
  border-radius: 7px;
  background: none;
  color: var(--text-faint);
  cursor: grab;
  opacity: 0.45;
  /* Stops a drag on a touchscreen from scrolling the page instead. */
  touch-action: none;
  transition:
    opacity 140ms var(--ease),
    background-color 140ms var(--ease),
    color 140ms var(--ease);
}

.chart:hover .chart__grip,
.chart__grip:focus-visible {
  opacity: 1;
}

.chart__grip:hover {
  background: var(--surface-3);
  border-color: var(--border);
  color: var(--text);
}

.chart__grip:active {
  cursor: grabbing;
}

/*
 * The card being dragged.
 *
 * It stays in the flow and hops between slots rather than being carried under the
 * cursor — see reorder.js. Pointer-transparent so elementFromPoint finds the card
 * UNDERNEATH it, which is the whole mechanism for working out where to drop.
 */
/*
 * The card in hand: lifted off the page and carried by the pointer (see carry() in
 * js/reorder.js, which writes the translate and the two variables below).
 *
 * Solid rather than dimmed, and drawn over its neighbours, because it is the thing being
 * moved — a translucent rectangle reads as a card being deleted. It keeps its grid slot
 * while it travels, so the gap the other cards flow around is the real destination.
 */
.card--dragging {
  pointer-events: none;
  position: relative;
  z-index: 5;
  /*
   * OPAQUE while it is in the air, and this is not a style choice. Every card on this page
   * is glass — 55% of a surface over whatever is behind it — which is right for a card lying
   * on the page and wrong for one held over another card: the text underneath reads straight
   * through it and the thing in your hand looks like a hole. The glass is composited over a
   * solid ground rather than replaced, so the colour is the one the theme actually chose,
   * including the high-contrast setting that has already solidified the token.
   */
  background: linear-gradient(var(--glass), var(--glass)), var(--surface-2);
  border-color: var(--accent);
  box-shadow: 0 18px 38px -14px rgb(0 0 0 / 0.5);
  cursor: grabbing;
}

/*
 * And the outline left behind in the slot it came out of.
 *
 * The same box, moved back by exactly what the carry moved the card forward — so it is the
 * card's own geometry rather than a placeholder element that has to be kept in step with it.
 * Nothing is inserted into the grid, which is what keeps the column count from changing
 * under the reader mid-drag.
 */
.card--dragging::after {
  content: '';
  position: absolute;
  inset: 0;
  transform: translate(calc(var(--carry-x, 0px) * -1), calc(var(--carry-y, 0px) * -1));
  border: 1px dashed var(--accent);
  border-radius: inherit;
  opacity: 0.65;
  pointer-events: none;
}

/*
 * A card travelling to the slot a reorder gave it — see slide() in js/reorder.js, which puts
 * this on for exactly the length of one journey.
 *
 * Only `transform`, and that is what keeps it honest: the grid has already placed every card
 * before this rule sees any of them, so the animation cannot disagree with where a card
 * actually is. Short, and on the front-loaded curve the rest of the app's direct manipulation
 * uses — a reorder is a response to a hand that is still moving, not an entrance.
 */
.card--sliding {
  transition: transform 180ms var(--ease);
}

/* While a drag is live, nothing else on the page should look clickable or
   selectable — a half-selected chart title mid-drag reads as a bug. */
body.is-dragging {
  user-select: none;
  cursor: grabbing;
}

body.is-dragging .chart__tip {
  display: none;
}

.chart__title {
  margin: 0;
  font-size: 0.98rem;
}

.chart__sub {
  margin: 3px 0 0;
  font-size: 0.82rem;
  color: var(--text-dim);
}

.chart__empty {
  margin: 0;
  padding: 22px 0;
  text-align: center;
  font-size: 0.86rem;
  color: var(--text-faint);
}

/*
 * What a chart could not draw, under the chart.
 *
 * Dimmer than the marks and above the numbers table, because it is a caveat on the figures
 * rather than one of them — but it is not decoration either: this is the line that stops a
 * ranked list of nine cinemas from reading like a complete circuit of nine.
 */
.chart__footnote {
  margin: 10px 0 0;
  font-size: 0.78rem;
  line-height: 1.45;
  color: var(--text-dim);
}

/*
 * The plot is sized to include its own x-axis band. A container height that fits
 * only the marks gives the card a tiny nested scrollbar for its axis labels.
 */
/*
 * Height comes from the SVG, which is drawn at the container's real pixel size —
 * see columns() in charts.js. Fixing a height here and scaling into it is what
 * squashed the axis labels on a phone.
 */
.chart__plot--cols .plot {
  display: block;
  max-width: 100%;
}

.plot__grid {
  stroke: var(--border);
  stroke-width: 1;
  /* Solid, never dashed: dashing reads as a threshold or a projection when it is
     only a grid. */
  shape-rendering: crispEdges;
}

.plot__tick,
.plot__label {
  fill: var(--text-faint);
  font-size: 10px;
  font-family: var(--font);
  font-variant-numeric: tabular-nums;
}

.plot__col {
  fill: var(--accent-fill);
  transition: fill 140ms var(--ease);
}

/* The period still in progress. Lighter, so it is visibly not comparable to the
   completed ones beside it — the same hue, so it is still the same series. */
.plot__col--partial {
  fill: color-mix(in oklab, var(--accent-fill) 42%, var(--surface));
}

/* -------------------------------------------------------------- the live ticker
 *
 * The patrons line (ticker() in js/charts.js): the day so far as one stroke, a soft
 * area under it for presence, and an orb on the newest point whose halo pulses —
 * the one deliberate piece of motion on the page, because this is the one number
 * that is genuinely moving while you watch. Same accent tokens as every other mark,
 * so the themes never have to know the ticker exists.
 */
.plot__line {
  fill: none;
  stroke: var(--accent);
  stroke-width: 2;
  stroke-linejoin: round;
  stroke-linecap: round;
}

.plot__area {
  fill: color-mix(in oklab, var(--accent-fill) 22%, transparent);
  stroke: none;
}

.plot__orb {
  fill: var(--accent);
}

.plot__orb-halo {
  fill: var(--accent);
  opacity: 0.45;
  transform-origin: center;
  transform-box: fill-box;
  animation: orb-pulse 2s var(--ease) infinite;
}

@keyframes orb-pulse {
  0% { transform: scale(1); opacity: 0.45; }
  70% { transform: scale(2.6); opacity: 0; }
  100% { transform: scale(2.6); opacity: 0; }
}

/* A reader who asked for no motion gets a still halo on the same spot — the chart
   itself, not a lesser one. The same preference every dialog on the page honours. */
@media (prefers-reduced-motion: reduce) {
  .plot__orb-halo {
    animation: none;
    opacity: 0.3;
  }
}

/* The band is the hit target, not the column: readers aim at a date. */
.plot__hit {
  fill: transparent;
  cursor: default;
}

.plot__hit:focus {
  outline: none;
}

/* The hovered column lifts — the mark responds, rather than every other one dimming. */
.plot__band:hover .plot__col,
.plot__band:focus-within .plot__col {
  fill: var(--accent);
}

/* Premium pass: carries each band's first-draw rise off its baseline — see the
   entrance block in js/charts.js. Redraws are born at scale 1, so nothing re-performs. */
.plot__band {
  transition: transform 540ms var(--ease);
}

/* Keyboard focus still needs a visible ring; the fill change alone is not enough. */
.plot__band:focus-within .plot__hit {
  stroke: var(--focus);
  stroke-width: 2;
}

/* ------------------------------------------------------- horizontal bars */
.bars {
  display: grid;
  gap: 3px;
}

.bars__row {
  display: grid;
  grid-template-columns: minmax(84px, 30%) 1fr auto;
  align-items: center;
  gap: 12px;
  /* Padding, so the hit and focus area is comfortably larger than the 10px bar. */
  padding: 5px 6px;
  margin: 0 -6px;
  border-radius: 7px;
  transition: background-color 120ms var(--ease);
}

.bars__row:hover,
.bars__row:focus-visible {
  background: var(--surface-3);
}

.bars__label {
  font-size: 0.85rem;
  color: var(--text-dim);
  overflow-wrap: anywhere;
}

.bars__track {
  height: 10px;
  border-radius: 5px;
  background: color-mix(in oklab, var(--p-surface0) 60%, transparent);
  overflow: hidden;
}

.bars__fill {
  height: 100%;
  /* Rounded at the data end, square at the baseline, so the bar stays anchored. */
  border-radius: 0 4px 4px 0;
  background: var(--accent-fill);
  transition: width 260ms var(--ease);
}

/*
 * The folded tail, as a line of text rather than a bar. Drawn as one it set the
 * scale — eight cinemas add up to more than the busiest single one — and squashed
 * every real bar against it.
 */
.bars__more {
  display: flex;
  align-items: baseline;
  gap: 12px;
  margin: 8px 0 0;
  padding-top: 9px;
  border-top: 1px solid var(--border);
  font-size: 0.82rem;
  color: var(--text-faint);
}

.bars__more .bars__value {
  margin-left: auto;
  color: var(--text-dim);
}

.bars__value {
  font-size: 0.85rem;
  font-weight: 620;
  font-variant-numeric: tabular-nums;
  min-width: 2.5ch;
  text-align: right;
}

/* --------------------------------------------------------- stacked strip */
.stack {
  display: flex;
  height: 14px;
  border-radius: 7px;
  overflow: hidden;
  /* The gap between segments is the surface showing through, not a stroke around
     each one — a border would add data-weight ink that is not data. */
  gap: 2px;
  background: var(--surface);
}

.stack__part {
  min-width: 3px;
  transition: filter 140ms var(--ease);
}

.stack__part:hover,
.stack__part:focus-visible {
  filter: brightness(1.15);
  outline: none;
}

.stack__part--danger {
  background: var(--danger);
}
.stack__part--warn {
  background: var(--warn);
}
.stack__part--ok {
  background: var(--ok);
}
.stack__part--accent {
  background: var(--accent-fill);
}
.stack__part--accent-soft {
  background: color-mix(in oklab, var(--accent-fill) 42%, var(--surface));
}

/* Always present, so identity never rests on colour alone. */
.stack__legend {
  list-style: none;
  margin: 14px 0 0;
  padding: 0;
  display: grid;
  gap: 7px;
}

.stack__key {
  display: flex;
  align-items: center;
  gap: 9px;
  font-size: 0.85rem;
}

.stack__swatch {
  width: 10px;
  height: 10px;
  border-radius: 3px;
  flex: none;
}

.stack__swatch--danger {
  background: var(--danger);
}
.stack__swatch--warn {
  background: var(--warn);
}
.stack__swatch--ok {
  background: var(--ok);
}
.stack__swatch--accent {
  background: var(--accent-fill);
}
.stack__swatch--accent-soft {
  background: color-mix(in oklab, var(--accent-fill) 42%, var(--surface));
}

.stack__key-label {
  color: var(--text-dim);
}

.stack__key-value {
  margin-left: auto;
  font-weight: 620;
  font-variant-numeric: tabular-nums;
}

/* ------------------------------------------------------------ token bar
 *
 * How many AI tokens are left, as one track that empties.
 *
 * It borrows the stacked strip's shape above rather than inventing a second bar language on
 * the same page — same rounded track, same swatch-and-number legend — but it is NOT a stack:
 * a stack draws parts of a whole that is always full, and this one is deliberately not full.
 * The gap on the right IS the reading. So the fills are laid at the left of a track that
 * shows through, instead of dividing it up.
 */
.tokenbar__body {
  /*
   * Centred in what the head leaves behind. The cards in this grid share a floor of 340px so
   * a row of them lines up; a bar is a shorter thing than a plot, and pinned to the top it
   * would sit in a card that was three-quarters empty. See grid-auto-rows on #charts.
   */
  margin: auto 0;
}

.tokenbar__track {
  display: flex;
  height: 18px;
  border-radius: 9px;
  overflow: hidden;
  /*
   * The empty part, and the only part of this card that is load-bearing on its own. Darker
   * than the card so the boundary between spent and left is a real edge rather than a change
   * of tint — on a glass card, a fill against a near-identical ground reads as full.
   */
  background: var(--surface);
  box-shadow: 0 0 0 1px var(--border) inset;
}

.tokenbar__fill {
  /*
   * Both fills grow from the left of the same track, so the bought pool sits directly against
   * the included one and the two together read as a single remaining length. Widths are set
   * in the script from the month's capacity.
   */
  min-width: 0;
  /* MOVES when the number moves — the dashboard redraws on a poll, and a bar that jumped
     would be the only thing on the page that did. */
  transition: width 320ms var(--ease);
}

/* What the licence includes: the accent, because it is the ordinary state. */
.tokenbar__fill--included {
  background: var(--accent-fill);
}

/*
 * What somebody bought: a second colour, which is the whole point of the two pools being
 * drawn apart. Green rather than a paler accent — a tint of the same hue would read as
 * "some of the accent" at a glance, and these are two different things with two different
 * rules (one resets, one never expires).
 */
.tokenbar__fill--bought {
  background: var(--ok);
}

/*
 * Rounded on the OUTSIDE edges only, so the pair looks like one bar and not two pills. The
 * last fill in the track is whichever one is actually drawn — the bought span is left out at
 * zero (see token-bar.js), which is what lets `:last-child` mean "the end of the bar" rather
 * than "the bought pool, whether or not it exists".
 */
.tokenbar__fill:first-child {
  border-start-start-radius: 9px;
  border-end-start-radius: 9px;
}

.tokenbar__fill:last-child {
  border-start-end-radius: 9px;
  border-end-end-radius: 9px;
}

/* Names both colours, so identity never rests on colour alone — as the stack legend does. */
.tokenbar__legend {
  list-style: none;
  margin: 14px 0 0;
  padding: 0;
  display: grid;
  gap: 7px;
}

.tokenbar__key {
  display: flex;
  align-items: center;
  gap: 9px;
  font-size: 0.85rem;
}

.tokenbar__swatch {
  width: 10px;
  height: 10px;
  border-radius: 3px;
  flex: none;
}

.tokenbar__swatch--included {
  background: var(--accent-fill);
}

.tokenbar__swatch--bought {
  background: var(--ok);
}

.tokenbar__key-label {
  color: var(--text-dim);
}

.tokenbar__key-value {
  margin-left: auto;
  font-weight: 620;
  font-variant-numeric: tabular-nums;
}

/*
 * The bottom of the card: the one rule the bar cannot draw, and the way to buy more when
 * there is a reason to. Pinned to the foot rather than following the legend, so the card has
 * a bottom edge whether or not the button is there.
 */
.tokenbar__foot {
  margin-top: auto;
  padding-top: 14px;
  display: flex;
  align-items: flex-end;
  gap: 12px;
}

.tokenbar__reset {
  margin: 0;
  font-size: 0.78rem;
  line-height: 1.45;
  color: var(--text-dim);
}

.tokenbar__top-up {
  flex: none;
  margin-left: auto;
}

/* --------------------------------------------------------- the pack picker
 *
 * What a circuit can buy, when the bar has raised the question. Each pack is a link to a
 * Stripe Payment Link — see js/token-packs.js — so these are anchors that look like choices
 * rather than buttons that take a payment.
 */
.packs {
  display: grid;
  gap: 10px;
  margin: 16px 0;
}

.pack {
  display: flex;
  align-items: baseline;
  gap: 12px;
  padding: 14px 16px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--glass);
  color: var(--text);
  text-decoration: none;
  transition: border-color 140ms var(--ease), background 140ms var(--ease),
    transform 140ms var(--ease);
}

.pack:hover,
.pack:focus-visible {
  border-color: var(--accent);
  background: var(--accent-soft);
  /* A pack lifts under the pointer the way every other pressable row in the app does. */
  transform: translateY(-1px);
  outline: none;
}

.pack__tokens {
  font-weight: 620;
  font-variant-numeric: tabular-nums;
}

.pack__price {
  margin-left: auto;
  color: var(--text-dim);
  font-variant-numeric: tabular-nums;
}

@media (prefers-reduced-motion: reduce) {
  .tokenbar__fill {
    transition: none;
  }

  .pack {
    transition: none;
  }

  .pack:hover,
  .pack:focus-visible {
    transform: none;
  }
}

/* --------------------------------------------------------------- tooltip */
/*
 * Enhances, never gates: everything it says is also in the table twin below the
 * chart, so a reader who cannot hover loses nothing.
 */
.chart__tip {
  position: absolute;
  z-index: 5;
  pointer-events: none;
  background: var(--surface-3);
  border: 1px solid var(--border-strong);
  border-radius: 8px;
  box-shadow: var(--shadow-lift);
  padding: 6px 10px;
  display: flex;
  flex-direction: column;
  gap: 1px;
  white-space: nowrap;
}

/* The value leads — the reader already knows which mark they are pointing at. */
.chart__tip-value {
  font-size: 0.88rem;
  font-weight: 650;
}

.chart__tip-label {
  font-size: 0.76rem;
  color: var(--text-dim);
}

/* ------------------------------------------------------- the table twin */
.chart__data {
  /* `auto` rather than a fixed margin: on a stretched card the slack goes here, which puts the
     disclosure on the bottom edge of every card in a row instead of leaving each one a
     different gap underneath it. */
  margin-top: auto;
  padding-top: 10px;
  border-top: 1px solid var(--border);
}

/* Except directly under the expander, which already drew the line. */
.chart__more + .chart__data {
  margin-top: 0;
  border-top: 0;
  padding-top: 0;
}

.chart__data > summary {
  list-style: none;
  cursor: pointer;
  font-size: 0.8rem;
  color: var(--text-dim);
  padding: 2px 0;
}

.chart__data > summary::-webkit-details-marker {
  display: none;
}

.chart__data > summary:hover {
  color: var(--text);
}

.chart__scroll {
  /* Wide content scrolls inside its own box; the page never scrolls sideways. */
  overflow-x: auto;
  margin-top: 8px;
  max-height: 260px;
  overflow-y: auto;
}

.table {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.82rem;
}

.table th,
.table td {
  text-align: left;
  padding: 6px 10px 6px 0;
  border-bottom: 1px solid var(--border);
  font-weight: 500;
}

.table thead th {
  position: sticky;
  top: 0;
  background: var(--surface);
  color: var(--text-dim);
  font-size: 0.74rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  font-weight: 650;
}

.table tbody th {
  color: var(--text-dim);
  font-weight: 500;
}

.table td {
  font-variant-numeric: tabular-nums;
  font-weight: 620;
}

/* Refetch holds the previous render rather than flashing a skeleton — no layout
   jump, and the numbers you were reading stay put while the new ones arrive. */
body.is-loading .stats,
body.is-loading .charts,
body.is-loading #attention {
  opacity: 0.55;
  transition: opacity 120ms var(--ease);
}

/* --------------------------------------------------------- filter strip */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  align-items: flex-end;
  background: var(--glass);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  padding: 16px;
  margin-bottom: 24px;
}

.toolbar .field {
  margin: 0;
  flex: 1 1 190px;
  min-width: 0;
}

/*
 * A tickbox in a toolbar, which the ordinary .field is not shaped for: that stacks a label above
 * its control, and a checkbox wants its label beside it. It also does not stretch — a tickbox
 * given a third of the bar puts its label a hand's width from the box it belongs to.
 *
 * `align-items: flex-end` on the toolbar lines it up with the bottom of the select beside it,
 * which is where the button sits too, so the row reads as one line of controls.
 */
.field--check {
  display: flex;
  flex: 0 0 auto;
  align-items: center;
  gap: 8px;
  padding-bottom: 9px;
}

/*
 * Sentence case, against the small-caps the toolbar gives every other label.
 *
 * Those are captions naming a control below them — "CINEMA", over a select. This one is the
 * control's own words, read as a sentence next to the box you tick: "CODE AND NAME ONLY" beside
 * a checkbox reads as another caption for something that is not there.
 *
 * Both classes in the selector on purpose. `.toolbar .field > label` is the small-caps rule and
 * it is written LOWER DOWN this file, so at equal specificity it would win on source order — and
 * did, until this had the extra class on it.
 */
.toolbar .field.field--check > label {
  margin: 0;
  font-weight: 500;
  font-size: 0.88rem;
  letter-spacing: normal;
  text-transform: none;
  color: var(--text-dim);
  cursor: pointer;
}

.field--check > input {
  width: 17px;
  height: 17px;
  accent-color: var(--accent);
  cursor: pointer;
  flex: none;
}

/* Long option labels ("Validated, not yet sent") clip rather than overflow, and
   read as deliberately truncated instead of cut off. */
.toolbar select {
  text-overflow: ellipsis;
}

.toolbar .field > label {
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-faint);
  font-weight: 600;
}

/* ------------------------------------------------------------ account rows
   One row per staff account on Active users. Same card shell as a cinema, so
   the page reads as part of the same app rather than a bolted-on table. */

/*
 * What a role group says when nobody is in it. Both groups are always drawn, because
 * each one holds the button that creates its own kind of account — so the group that
 * is empty is exactly the group somebody is about to need.
 */
.group__empty {
  margin: 0;
  padding: 18px 20px;
  color: var(--text-dim);
  font-size: 0.92rem;
}


.person {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px 20px;
  border-bottom: 1px solid var(--border);
  flex-wrap: wrap;
}

.person:last-child {
  border-bottom: 0;
}

/* A suspended account is dimmed rather than hidden: it still holds history, and
   an admin looking for "why can this person not sign in" needs to find it. */
.person--suspended {
  background: color-mix(in oklab, var(--danger) 4%, transparent);
}

.person--suspended .person__avatar {
  background: var(--p-surface1);
  color: var(--text-dim);
}

.person--suspended .person__identity {
  opacity: 0.72;
}

.person__avatar {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  background: var(--accent-line);
  color: var(--accent-ink);
  display: grid;
  place-items: center;
  font-size: 12px;
  font-weight: 750;
  letter-spacing: -0.02em;
  flex: none;
}

.person__identity {
  flex: 1 1 220px;
  min-width: 0;
}

.person__name {
  display: flex;
  align-items: center;
  gap: 8px;
  font-weight: 620;
  font-size: 0.95rem;
}

.person__email {
  font-family: var(--mono);
  font-size: 0.78rem;
  color: var(--text-dim);
  text-decoration: none;
  overflow-wrap: anywhere;
}

.person__email:hover {
  color: var(--p-lavender);
}

/* The actions on an account row — View dashboard, then Edit — and the panel Edit opens. */
.person__actions {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  gap: 8px;
}

.person--editing {
  display: block;
  background: var(--surface-2);
}

.person--editing .toolbar--bare {
  align-items: flex-end;
}

.person--editing .field {
  margin: 0;
  flex: 1 1 200px;
  max-width: 320px;
  min-width: 0;
}

.person--editing .field > label {
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-faint);
  font-weight: 600;
}

/*
 * Which issue types an account is emailed about, inside its edit form.
 *
 * Its own block under the name and email rather than another field on that row: ten
 * pills do not belong in a line of text inputs, and the choice is a different kind of
 * thing from the account's identity.
 */
.notify {
  margin-top: 14px;
  padding-top: 14px;
  border-top: 1px solid var(--border);
}

/*
 * What a maintenance account covers: locations, then issue types.
 *
 * Two halves of one answer, so they are one block with a rule between them rather than
 * two settings that happen to sit together — an account is defined by the pair, and
 * reading one without the other describes it wrongly.
 */
.scope__half + .scope__half {
  margin-top: 16px;
  padding-top: 16px;
  border-top: 1px dashed var(--border);
}

/*
 * The "every location" / "every issue type" toggle, on the same row as the label it
 * qualifies. It is not one of the pills below it: those are a list of things, and this is
 * the answer that makes the list unnecessary — see scopePicker() for why an open-ended
 * "every" is not the same as ticking all of them today.
 */
.scope__all {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  font-size: 0.87rem;
  color: var(--text-dim);
  cursor: pointer;
  user-select: none;
}

.scope__all input {
  margin: 0;
}

.notify__head {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 10px;
  margin-bottom: 10px;
}

.notify__label {
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-faint);
  font-weight: 600;
}

/* "3 of 10 issue types" — the answer without counting ticked pills. */
.notify__summary {
  flex: 1 1 auto;
  font-size: 0.83rem;
  color: var(--text-dim);
}

/* -------------------------------------------------------------- photographs */

/*
 * Choosing one, in a dialog.
 *
 * The <input type=file> itself is never shown — every browser draws it differently and
 * none of them draw it like the rest of this app. The <label for> beside it is the
 * button, which is a plain HTML behaviour rather than a click handler that has to
 * remember to fire.
 */
.photo__input {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
  pointer-events: none;
}

.photo__row {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
}

/* A label is not a button, so it needs telling that it behaves like one. */
.photo__pick {
  cursor: pointer;
}

.photo__size {
  font-size: 0.78rem;
  color: var(--text-faint);
}

/* Big enough to check you photographed the right thing, small enough to sit in a form. */
.photo__thumb {
  display: block;
  margin-top: 10px;
  max-width: 100%;
  max-height: 180px;
  border-radius: var(--radius);
  border: 1px solid var(--border-strong);
}

/*
 * The pictures on a report card, grouped by what they are of.
 *
 * Fixed-height thumbnails in a row that scrolls sideways rather than wrapping into a
 * grid: the card is a paragraph of text with an attachment, not a gallery, and three
 * photographs should not push the buttons off the bottom of it.
 */
.shots {
  display: flex;
  flex-wrap: wrap;
  gap: 14px;
  margin-top: 12px;
}

.shots__group {
  min-width: 0;
}

.shots__label {
  display: block;
  margin-bottom: 6px;
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-faint);
}

.shots__row {
  display: flex;
  gap: 8px;
  overflow-x: auto;
  padding-bottom: 2px;
}

.shots__link {
  flex: none;
  /*
   * A COLUMN now, not a bare block: the thumbnail with what the check said under it. Capped at
   * the thumbnail's own widest, so a sentence wraps under its picture instead of stretching the
   * strip and pushing the next photo off the row.
   */
  display: flex;
  flex-direction: column;
  gap: 5px;
  max-width: 160px;
  border-radius: var(--radius);
  outline-offset: 3px;
  /*
   * ON THE ANCHOR, not on the caption inside it. Text decoration is drawn by the ancestor that
   * declares it and a descendant cannot cancel it — so `text-decoration: none` on the caption
   * span did nothing at all, and the description under each thumbnail was drawn underlined as
   * though the sentence were itself a link somewhere. The tile used to hold only an image, which
   * is why the link never needed this before.
   */
  text-decoration: none;
  /* It opens the viewer rather than following the href, so say so under the pointer. */
  cursor: zoom-in;
}

/*
 * What the check made of the picture — see shotSays in js/tree.js.
 *
 * It lives INSIDE the thumbnail's anchor, so it inherits the link's underline unless told not
 * to — which drew the description as though the sentence itself were a second link to somewhere
 * else. The whole tile stays clickable (the words belong to the picture above them, and a target
 * that includes its own caption is easier to hit on a phone); it just stops looking like prose
 * that has been linked.
 */
.shots__says {
  display: flex;
  flex-direction: column;
  gap: 3px;
  text-decoration: none;
}

.shots__what {
  font-size: 0.78rem;
  line-height: 1.35;
  color: var(--text-dim);
}

/*
 * The hint, in the app's own severity colours but NEVER the report's badge shape.
 *
 * complaints.severity is a person's decision and wears a filled chip; this is a model's read of
 * one photograph and wears a bare dot and a word. Different enough that nobody scanning a card
 * mistakes the second for the first — which matters, because the two can legitimately disagree
 * and the person is right.
 */
.shots__level {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.01em;
  color: var(--text-dim);
}

.shots__level::before {
  content: '';
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: currentColor;
}

.shots__level--low {
  color: var(--text-dim);
}

.shots__level--medium {
  color: var(--warn);
}

.shots__level--high {
  color: var(--danger);
}

.shots__thumb {
  display: block;
  height: 92px;
  width: auto;
  max-width: 160px;
  object-fit: cover;
  border-radius: var(--radius);
  border: 1px solid var(--border-strong);
  background: var(--surface-3);
  transition: border-color 140ms var(--ease), transform 140ms var(--ease);
}

.shots__link:hover .shots__thumb {
  border-color: var(--accent-line);
  transform: translateY(-1px);
}

/*
 * The photo viewer: a picture over the page, rather than a trip to another tab.
 *
 * Bigger than .modal and shaped by the image rather than by a column width — this is
 * one thing to look at, not a form. `display` is on [open] only, for the same reason it
 * is on .modal[open]: an author's `display` outranks the browser's own
 * `dialog:not([open]) { display: none }`, and getting that wrong leaves the viewer
 * unfolded at the bottom of every page.
 */
.viewer {
  width: min(1100px, calc(100vw - 32px));
  max-height: min(92vh, 900px);
  max-height: min(92dvh, 900px);
  padding: 0;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-lg);
  background: var(--glass-strong);
  -webkit-backdrop-filter: blur(16px);
  backdrop-filter: blur(16px);
  color: var(--text);
  box-shadow: var(--shadow-lift);
  overflow: hidden;
}

.viewer[open] {
  display: flex;
  flex-direction: column;
}

.viewer::backdrop {
  background: rgba(6, 6, 10, 0.82);
  backdrop-filter: blur(4px);
  -webkit-backdrop-filter: blur(4px);
}

/* Dark behind the picture whatever the theme: a photograph of a dim auditorium on a
   white page reads as a mistake, and letterboxing has to look deliberate. */
.viewer__frame {
  position: relative;
  flex: 1 1 auto;
  /* min-height: 0 lets it shrink to the room left over, and the hidden overflow is the
     guarantee: a portrait photograph letterboxes inside the frame instead of growing it
     and pushing the caption bar off the bottom of the dialog. */
  min-height: 0;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #0b0b10;
}

/* Against the frame rather than against a guess at what is left after the bar — which
   is what the first version did, and it cropped every portrait photo. */
.viewer__img {
  display: block;
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
}

.viewer__x {
  position: absolute;
  top: 8px;
  right: 8px;
  background: rgba(10, 10, 16, 0.6);
  color: #f4f4f6;
}

.viewer__x:hover {
  background: rgba(10, 10, 16, 0.85);
  color: #ffffff;
}

/* Arrows only when there is more than one, and out of the way of the picture. */
.viewer__step {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  width: 44px;
  height: 64px;
  display: grid;
  place-items: center;
  font-size: 30px;
  line-height: 1;
  border: 0;
  border-radius: 10px;
  background: rgba(10, 10, 16, 0.55);
  color: #f4f4f6;
  cursor: pointer;
  transition: background-color 140ms var(--ease);
}

.viewer__step:hover {
  background: rgba(10, 10, 16, 0.85);
}

.viewer__step--prev {
  left: 8px;
}

.viewer__step--next {
  right: 8px;
}

.viewer__bar {
  flex: none;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px 16px;
  border-top: 1px solid var(--border);
  background: var(--surface-2);
  font-size: 0.83rem;
}

.viewer__caption {
  flex: 1 1 auto;
  margin: 0;
  color: var(--text-dim);
  min-width: 0;
}

.viewer__count {
  flex: none;
  color: var(--text-faint);
  font-variant-numeric: tabular-nums;
}

/* Still offered, for somebody who wants the file itself. */
.viewer__full {
  flex: none;
  color: var(--accent);
}

@media (max-width: 560px) {
  .viewer__step {
    width: 36px;
    height: 52px;
    font-size: 24px;
  }

  /* The count is the one thing that can go: the caption says what you are looking at. */
  .viewer__count {
    display: none;
  }
}

.notify__quick {
  display: flex;
  gap: 8px;
  flex: 0 0 auto;
}

/* Smaller than the settings page's, because there are ten of them inside a row. */
.notify__pills .pill span {
  padding: 7px 12px;
  font-size: 0.85rem;
}

/* On a collapsed row: nothing for the ordinary case, a line when it is not. Its own
   line, because the email above it is a link and running the two together reads as
   one long address. */
.person__notify {
  display: block;
  margin-top: 2px;
  font-size: 0.78rem;
  color: var(--text-faint);
  font-style: italic;
}

/* The address is the thing above it, so it needs to end. */
.person__email {
  display: inline-block;
}

/* "also at Aviator 10" — one manager, several buildings. */
.manager__also {
  font-size: 0.78rem;
  color: var(--text-faint);
  font-style: italic;
}

.person__scope {
  flex: 0 1 190px;
  font-size: 0.83rem;
  color: var(--text-dim);
}

.person__state {
  display: flex;
  align-items: center;
  gap: 6px;
  flex: 0 0 auto;
}

/*
 * An ordinary account has nothing to say here, and an empty box is not nothing.
 *
 * `.person` is a flex row with a 12px gap, so a zero-width child still costs a gap on each
 * side — which would leave the spacing between the scope and the last-seen column different
 * on the rows that carry a badge from the rows that do not. Since dropping the permanent
 * "Active" badge made most rows empty here, that would be most of the list.
 */
.person__state:empty {
  display: none;
}

.person__seen {
  flex: 0 0 130px;
  text-align: right;
  font-size: 0.8rem;
  color: var(--text-faint);
  font-variant-numeric: tabular-nums;
}

/* Distinct from "Active": one is whether the account works, the other is
   whether they are actually holding a session right now. */
.badge--live {
  background: color-mix(in oklab, var(--info) 18%, transparent);
  border-color: color-mix(in oklab, var(--info) 45%, transparent);
  color: var(--info);
}

.person__never {
  color: var(--warn);
}

/* ------------------------------------------------------------------- tabs
   Where a report is in its life, as the case monitor's main axis. This was an
   option in a select inside the collapsed panel below — fine once you know the
   app, no help at all before that. The count on each is what earns the space:
   "nothing is waiting" should be readable without clicking anything. */
/*
 * The day strip with a week arrow at each end.
 *
 * The arrows keep their size and the strip between them takes the rest — which is what makes them
 * usable on a phone, where the seven day tabs scroll: the way to next week does not scroll away
 * with them.
 */
.weeknav {
  display: flex;
  align-items: stretch;
  gap: 4px;
}

/* --------------------------------------------------------------- the agent row
 *
 * The AI Reviewer Agent, on a line between the filter controls and the tab strip.
 *
 * LEFT-ALIGNED, under Advanced filters rather than under Refresh: the eye comes down the
 * left edge from the filters button, and a lone right-aligned button reads as a footer to
 * the controls above rather than a thing about the list below.
 *
 * The .filters block owns 24px of margin under itself, which is the gap between the controls
 * and whatever follows. This row lives inside that gap, so it pulls up into it and keeps its
 * own smaller breath above the tabs — otherwise the button floated in the middle of a
 * two-row space, attached to neither.
 */
.agentrow {
  display: flex;
  /*
   * Closer to the controls above than to the tabs below, because that is which group it is in:
   * the eye comes down the left edge from Advanced filters and finds this on the next line.
   *
   * Both numbers are MEASURED rather than reasoned about, and they had to be re-measured: this
   * whole rule was dead until now — an orphaned `.weeknav` selector above the comment block had
   * silently turned it into `.weeknav .agentrow`, which matches nothing — so the row had been
   * laying itself out as a plain block with no margins at all, flush against the tab strip. The
   * -14px top written for it had never once been applied, and applying it put the button hard
   * against Advanced filters. 4px leaves ten clear pixels above.
   *
   * The 18 below is the one the reader notices: the tab strip's hover and selected states paint
   * a filled box that starts 10px above the tab's own text, so at the old spacing that fill met
   * the button's border with nothing between them and the two read as one shape.
   */
  margin: -4px 0 18px;
}

.agentrow[hidden] {
  display: none;
}

/* The spark sits with the words rather than beside them: a gap the button's own padding
   does not already provide would read as two controls that happen to touch. */
.agentrow__btn {
  display: inline-flex;
  align-items: center;
  gap: 7px;
}

/*
 * And it wears the accent, like everything else generative in this app.
 *
 * It was `btn--secondary` — the neutral grey every supporting control on the page uses — which
 * put the one AI control on the case monitor in the same clothes as Refresh. The assistant
 * launcher is accent, the "AI reviewed" tag is accent, the Auto-translated tag is accent; a
 * reader who has learned that colour means "a model does this" was getting no help at all from
 * the button that runs one.
 *
 * A TINT rather than the solid fill the tags wear, because this is a button among buttons and
 * a solid accent chip is what `btn--cta` means — the thing you are meant to press. This one is
 * offered, not urged, so it takes the accent's quieter register: the same recipe as
 * `.btn--primary`, reached through its own class so the two can part company later without
 * either of them silently changing.
 */
.agentrow__btn.btn--secondary {
  background: color-mix(in oklab, var(--accent) 13%, var(--surface));
  color: var(--accent);
  border-color: color-mix(in oklab, var(--accent) 32%, transparent);
}

.agentrow__btn.btn--secondary:hover {
  background: color-mix(in oklab, var(--accent) 20%, var(--surface));
  border-color: color-mix(in oklab, var(--accent) 45%, transparent);
}

/*
 * The same button while AI TRIAGE runs, when its words change to say so (dressForTriage in
 * js/review.js). One register up from the tint — a firmer border and a soft glow, the
 * button-sized echo of the network behind it — and still short of `btn--cta`, because it is
 * a state being worn, not a press being urged. It still opens the same box: the reports
 * triage defers are in there waiting for exactly this person.
 */
.agentrow__btn--live.btn--secondary {
  background: color-mix(in oklab, var(--accent) 22%, var(--surface));
  border-color: color-mix(in oklab, var(--accent) 60%, transparent);
  /*
   * Lit from WITHIN, not haloed.
   *
   * The glow was `0 0 14px` and spread that far past every edge, including the left — and
   * an eye reads the halo's edge as the button's edge, so this row looked misaligned with
   * Advanced filters directly above it even though both boxes start at exactly the same x
   * (measured: 130px each). The operator saw it as two controls that would not line up.
   * Inset keeps the lit state and keeps the edge: nothing is painted outside the border box,
   * so the two rows agree by construction rather than by luck.
   */
  box-shadow: inset 0 0 12px color-mix(in oklab, var(--accent) 30%, transparent);
}

.agentrow__spark {
  flex: none;
}

.tabs {
  flex: 1 1 auto;
  min-width: 0;
}

.weeknav__arrow {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: center;
  /* 40px: a comfortable thumb target that still reads as part of the strip rather than as a
     button parked beside it. */
  width: 40px;
  margin-bottom: 6px;
  padding: 0;
  background: none;
  border: 0;
  border-bottom: 1px solid var(--border);
  border-radius: 0;
  color: var(--text-dim);
  cursor: pointer;
}

.weeknav__arrow:hover {
  color: var(--accent);
}

.weeknav__arrow:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

.tabs {
  display: flex;
  gap: 6px;
  margin-bottom: 6px;
  padding-bottom: 2px;
  border-bottom: 1px solid var(--border);
  /* Four tabs with counts do not fit 360px, and wrapping them onto two lines makes
     the strip read as two groups. It scrolls instead. */
  overflow-x: auto;
  scrollbar-width: none;
}

.tabs::-webkit-scrollbar {
  display: none;
}

.tab {
  flex: none;
  position: relative;
  /* Clips the glow below to the tab's own box, exactly as the dock's pill clips its
     light: what shows is the upper slice of something big and soft rising from the
     bottom edge, never the outline of a shape. */
  overflow: hidden;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 10px 14px;
  border: 0;
  border-bottom: 2px solid transparent;
  border-radius: var(--radius) var(--radius) 0 0;
  background: none;
  color: var(--text-dim);
  font: inherit;
  font-weight: 600;
  font-size: 0.92rem;
  cursor: pointer;
  white-space: nowrap;
  transition:
    color 140ms var(--ease),
    border-color 140ms var(--ease);
}

/* The dock's under-rim light, at a tab's scale — the same soft ellipse whose centre sits
   below the bottom edge, so the light rises off the underline rather than pooling behind
   the words. One drawing shared by both states below; only its strength differs. */
.tab::after {
  content: '';
  position: absolute;
  left: 50%;
  bottom: -30px;
  transform: translateX(-50%);
  width: 120%;
  height: 52px;
  border-radius: 50%;
  background: radial-gradient(
    closest-side,
    color-mix(in srgb, var(--text) 15%, transparent),
    transparent
  );
  opacity: 0;
  transition: opacity 160ms var(--ease);
  pointer-events: none;
}

/* Hover borrows the glow at part strength — an invitation, not a claim. Gated on a
   device that can actually hover, because iOS leaves :hover asserted after a tap until
   the next touch — the wash this replaces used to sit lit on the tab somebody had just
   pressed. Same rule the list rows state at their own hover. */
@media (hover: hover) {
  .tab:hover {
    color: var(--text);
  }

  .tab:hover::after {
    opacity: 0.6;
  }
}

/* The line is what says "you are here" — the page's own ink, not the accent, with the
   glow at full strength rising off it: the dock's language for the lit tab, spoken by
   the strip. */
.tab--on {
  color: var(--text);
  border-bottom-color: var(--text);
}

.tab--on::after {
  opacity: 1;
}

.tab__count {
  padding: 1px 8px;
  border-radius: 999px;
  background: var(--surface-3);
  color: var(--text-dim);
  font-size: 0.78rem;
  /* Tabular, so 9 becoming 10 is the only thing that changes width — and 11 becoming 12
     changes nothing at all. */
  font-variant-numeric: tabular-nums;
  transition: opacity 160ms var(--ease);
}

/* The count on the lit tab wears the same quiet pill as every other — chosen by the
   operator over an accent circle (which fought the monochrome line and glow) and over an
   ink chip (louder than a number needs to be). Only the numeral brightens, the same step
   the tab's own words take. */
.tab--on .tab__count {
  background: var(--surface-3);
  color: var(--text);
}

/* Nothing in this stage is worth saying quietly rather than not at all — a missing
   number reads as "not loaded yet", which is a different thing. */
.tab__count--zero {
  opacity: 0.55;
}

/*
 * THE RECOMMENDATION DOT: this tab has something suggested on it.
 *
 * The scheduler's day strip wears it while an overlay is on the grid, because the grid draws one
 * day and the overlay argues about seven — without it, a plan whose changes fall on Tuesday and
 * Thursday is invisible from Monday and reads as though the plan did nothing.
 *
 * A DOT rather than a second number, and the accent rather than the strip's own ink. The count
 * beside it is the day's showings, which is a fact about the schedule; this is a suggestion about
 * it, and the two should not look like two of the same thing. Its text is the number in words for
 * a reader who cannot see colour at all — clipped rather than hidden, so it is announced with the
 * day's name instead of being a coloured circle that says nothing.
 */
.tab__dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--accent);
  /* The words inside, off the screen and still in the accessibility tree. */
  overflow: hidden;
  white-space: nowrap;
  text-indent: 100%;
  flex: none;
}

/* The day itself brightens a step, so the strip reads at a glance rather than by hunting for
   six pixels — and the dot above is what says which of the bright ones is which. */
.tab--advised {
  color: var(--text);
}

/*
 * A count that is being recalculated: the number it had, dimmed, until the new one arrives.
 *
 * It used to be taken off the strip while the request was out, which made all four badges
 * disappear and come back on every tab change — the bar shrank and grew again, and on the
 * control people press most often that reads as a glitch rather than as loading. Holding
 * the old number costs nothing in truthfulness on a tab change (switching tab does not
 * change any of these counts) and one dim digit is the honest signal while a filter is.
 *
 * The fade is on `.tab__count` itself, above, so this is one badge changing rather than two
 * swapping — and it lands after `--zero`, so an empty stage that is also being recalculated
 * reads as recalculating.
 */
.tab__count--stale {
  opacity: 0.4;
}

/* The line under the tabs, and the fold control at the other end of it. */
/* Wrapping, because this row grew a third thing in it.
   A hint that takes the slack, then Collapse all, the Cards / List switch and Case history. Two
   of those fitted beside the sentence on a phone; four do not, and without a wrap they simply
   overflowed — the switch came to rest ON TOP of the Case history button, which stayed visible
   and enabled and could no longer be clicked. Caught by the phone-width case history test in
   test/browser/flows.browser.js, which is the only place that presses that button at 400px. */
/* Centred, not baseline-aligned: the row is buttons now, and two of them — the plain
   Collapse all and the bordered Cards / List group — have different box chrome around
   the same size of text, so lining up their BASELINES left their boxes visibly off by
   the difference. Centring their boxes is what reads as aligned on a row of controls;
   the hint is one line of text and centres against them the same way.
   The top margin is the row's own air under the tab strip — the strip only carries 6px
   of its own, cut for the pages where nothing follows it this closely. */
.tabs__foot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 12px;
  margin: 8px 0 16px;
}

/* One line saying what is in the tab you are on. The page description above says how
   the queue works; this says what you are looking at right now. */
.tabs__hint {
  flex: 1 1 auto;
  margin: 0;
  font-size: 0.86rem;
  color: var(--text-faint);
}

.tabs__foot .btn {
  flex: 0 0 auto;
}

/* ------------------------------------------------------- collapsible filters
   The review-queue filters, folded into a single bar. The <summary> is the whole
   bar rather than just a label, so it can carry the state of the filters and the
   Refresh button while the controls themselves are put away. */
.filters {
  background: var(--glass);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  margin-bottom: 24px;
}

.filters__summary {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 16px 10px 14px;
  cursor: pointer;
  user-select: none;
  list-style: none;
  border-radius: var(--radius);
  transition: background-color 140ms var(--ease);
}

.filters__summary::-webkit-details-marker {
  display: none;
}

/*
 * The case monitor's variant: a button rather than a bar.
 *
 * Cinema, issue type, severity and search cut across every stage and most visits use
 * none of them, so a full-width strip above the queue was paying rent on something
 * rarely read. The frame comes off, the label becomes a button, and the row carries
 * only what has to stay visible: whether anything is filtering, and the way to undo it.
 */
.filters--advanced {
  background: none;
  border: 0;
  border-radius: 0;
  margin-bottom: 14px;
}

.filters--advanced > .filters__summary {
  padding: 0;
  gap: 12px;
}

.filters--advanced > .filters__summary:hover,
/*
 * Two `.filters` classes deep, not one. `.filters[open] > .filters__summary` puts a rule
 * under the strip further down the file and matches with the same specificity, so it won on
 * source order — and the advanced bar has been carrying a border it was written not to have
 * on the manager and maintenance pages ever since. The `background: none` beside it always
 * worked, because nothing else declares that: a half-applied rule is how this hides.
 */
.filters.filters--advanced[open] > .filters__summary {
  background: none;
  border-bottom: 0;
}

.filters--advanced > .filters__summary:focus-visible {
  outline: none;
}

/* The part that looks and behaves like a button. Focus lands here rather than on the
   whole row, because the row is as wide as the page and a ring around it says nothing
   about what pressing Enter would do. */
.filters__button {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 7px 13px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface-2);
  color: var(--text);
  font-size: 0.86rem;
  transition:
    background-color 140ms var(--ease),
    border-color 140ms var(--ease),
    color 140ms var(--ease);
}

.filters__summary:hover .filters__button {
  border-color: var(--p-surface2);
  background: var(--surface-3);
}

.filters__summary:focus-visible .filters__button {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* Open, or filtering: either way the button says so without being read. */
.filters--advanced[open] .filters__button,
.filters--advanced .filters__button:has(.filters__count:not([hidden])) {
  border-color: var(--accent);
  color: var(--accent);
}

.filters--advanced .filters__body {
  margin-top: 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
}

.filters__summary:hover {
  background: var(--surface-3);
}

.filters__summary:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: -3px;
}

.filters[open] > .filters__summary {
  border-bottom: 1px solid var(--border);
  border-radius: var(--radius) var(--radius) 0 0;
}

.filters__icon {
  color: var(--text-faint);
  flex: 0 0 auto;
  transition: color 140ms var(--ease), transform 160ms var(--ease);
}

.filters__summary:hover .filters__icon,
.filters[open] > .filters__summary .filters__icon {
  color: var(--accent);
}

.filters__label {
  font-weight: 640;
  font-size: 0.9rem;
  flex: 0 0 auto;
}

/* How many controls are narrowing the queue right now. */
.filters__count {
  flex: 0 0 auto;
  min-width: 18px;
  padding: 1px 6px;
  border-radius: 999px;
  background: color-mix(in oklab, var(--accent) 24%, transparent);
  border: 1px solid color-mix(in oklab, var(--accent) 45%, transparent);
  color: var(--text);
  font-size: 0.72rem;
  font-weight: 700;
  text-align: center;
  font-variant-numeric: tabular-nums;
}

/* Reads out what is filtering the queue while the panel is shut. Truncates
   rather than wrapping, so the bar stays one line however many filters are on. */
.filters__active {
  flex: 1 1 auto;
  min-width: 0;
  font-size: 0.83rem;
  color: var(--text-dim);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.filters__summary .btn {
  flex: 0 0 auto;
}

/*
 * The summary's buttons hold the RIGHT EDGE, whatever else is on the row.
 *
 * They used to be pushed there by the active-filter label, which grows to fill — so once
 * that label went silent at rest there was nothing doing the pushing, and Create new and
 * Refresh sat halfway across the row beside the filters button. Turning one filter on then
 * moved them seven hundred pixels. The operator's rule for this app, given for the fold
 * glyph and applied here: a control lives in ONE place, so that whatever is happening, the
 * reader knows without looking where Create new and Refresh are.
 *
 * The auto margin is claimed by the first button actually RENDERED and dropped by every
 * button after it, so the group travels together whether or not Clear filters is showing.
 * `:not([hidden])` is the whole trick — an earlier note here rejected auto margins because
 * "two elements each claiming it would split the free space", which is true of two VISIBLE
 * claimants and is exactly what the hidden test rules out.
 */
.filters--advanced > .filters__summary > .btn {
  margin-left: auto;
}

.filters--advanced > .filters__summary > .btn:not([hidden]) ~ .btn {
  margin-left: 0;
}

.filters__body {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  align-items: flex-end;
  padding: 16px;
}

.filters__body .field {
  margin: 0;
  flex: 1 1 190px;
  min-width: 0;
}

.filters__body select {
  text-overflow: ellipsis;
}

.filters__body .field > label {
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-faint);
  font-weight: 600;
}

/* A toolbar with no chrome of its own, for forms nested inside a card. */
.toolbar--bare {
  border: 0;
  padding: 0;
  margin: 0;
  box-shadow: none;
  background: none;
}

/* --------------------------------------- cinema > auditorium > complaint */
.theatre {
  background: var(--glass);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow);
  margin-bottom: 20px;
  overflow: hidden;
}

.theatre__head {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 16px 20px;
  background: linear-gradient(180deg, var(--surface-3), var(--surface-2));
  border-bottom: 1px solid var(--border);
  flex-wrap: wrap;
}

.theatre__name {
  margin: 0;
  font-size: 1.1rem;
}

.theatre__meta {
  font-size: 0.83rem;
  color: var(--text-dim);
}

.theatre__count {
  margin-left: auto;
  font-size: 0.83rem;
  color: var(--text-faint);
  font-variant-numeric: tabular-nums;
}

/* Each cinema is a disclosure on the case monitor, which spans every building.
   The header keeps the same layout as the static version — it is a <summary>
   rather than a <div>, so the native marker goes and a chevron takes its place. */
.theatre--toggle > .theatre__head {
  border-bottom: 0;
}

.theatre--toggle[open] > .theatre__head {
  border-bottom: 1px solid var(--border);
}

.theatre__head--toggle {
  padding-left: 42px;
  position: relative;
  cursor: pointer;
  user-select: none;
  list-style: none;
  transition: background-color 140ms var(--ease);
}

.theatre__head--toggle::-webkit-details-marker {
  display: none;
}

.theatre__head--toggle::before {
  content: '';
  position: absolute;
  left: 20px;
  top: 50%;
  width: 7px;
  height: 7px;
  margin-top: -5px;
  border-right: 2px solid var(--text-dim);
  border-bottom: 2px solid var(--text-dim);
  transform: rotate(-45deg);
  transition: transform 160ms var(--ease);
}

.theatre--toggle[open] > .theatre__head--toggle::before {
  transform: rotate(45deg);
  margin-top: -6px;
}

/* Gated on `hover: hover` — see the note on the case rows below. A tap on iOS asserts
   :hover and leaves it asserted, so an unguarded wash here left every cinema somebody had
   opened looking selected until they touched something else. */
@media (hover: hover) {
  .theatre__head--toggle:hover {
    background: var(--surface-3);
  }
}

.theatre__head--toggle:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: -3px;
}

/* Address and phone. Takes the full width below the cinema name so a long
   street address never squeezes the counts out of the header. */
.contact {
  flex: 1 0 100%;
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 6px 18px;
  margin-top: -2px;
  font-size: 0.83rem;
}

.contact__link {
  color: var(--text-dim);
  text-decoration: none;
  border-bottom: 1px solid transparent;
  transition: color 120ms var(--ease), border-color 120ms var(--ease);
}

.contact__link:hover {
  color: var(--text);
  border-bottom-color: var(--border-strong);
}

.contact__link:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
  border-radius: 3px;
}

.contact__link--phone {
  font-variant-numeric: tabular-nums;
}

.auditorium {
  border-bottom: 1px solid var(--border);
}

.auditorium:last-child {
  border-bottom: 0;
}

.auditorium__head {
  display: flex;
  align-items: baseline;
  gap: 10px;
  padding: 11px 20px;
  background: color-mix(in oklab, var(--p-base) 45%, transparent);
  border-bottom: 1px solid var(--border);
}

/*
 * The area's header as a disclosure summary.
 *
 * Mirrors .theatre__head--toggle above it rather than sharing a class: the two sit at different
 * depths with different padding and different type, and one selector serving both would be a rule
 * full of exceptions. What they do share is the affordance — a chevron on the left that turns —
 * because a reader should not have to learn that two folds in one page work differently.
 *
 * `padding-left` alone rather than a fresh `padding`, so the base rule above stays in charge of the
 * other three sides. Declared after it, which is what lets it win.
 */
.auditorium__head--toggle {
  padding-left: 42px;
  position: relative;
  cursor: pointer;
  user-select: none;
  /* The browser's own triangle, removed in both spellings — `list-style` covers the standard
     marker, the pseudo-element covers WebKit's. */
  list-style: none;
  transition: background-color 140ms var(--ease);
}

.auditorium__head--toggle::-webkit-details-marker {
  display: none;
}

.auditorium__head--toggle::before {
  content: '';
  position: absolute;
  left: 22px;
  top: 50%;
  width: 6px;
  height: 6px;
  margin-top: -4px;
  border-right: 2px solid var(--text-dim);
  border-bottom: 2px solid var(--text-dim);
  transform: rotate(-45deg);
  transition: transform 160ms var(--ease);
}

.auditorium--toggle[open] > .auditorium__head--toggle::before {
  transform: rotate(45deg);
  margin-top: -6px;
}

/* Gated with the cinema's, and for the same reason: a room is the other thing a finger
   opens on this page, and it stuck exactly the same way. */
@media (hover: hover) {
  .auditorium__head--toggle:hover {
    background: color-mix(in oklab, var(--p-base) 70%, transparent);
  }
}

/* A shut area has no body to draw a line under, so the header's own line would hang. */
.auditorium--toggle:not([open]) > .auditorium__head--toggle {
  border-bottom: 0;
}

.auditorium__number {
  font-weight: 680;
  font-size: 0.95rem;
  letter-spacing: -0.01em;
  font-variant-numeric: tabular-nums;
}

.auditorium__meta {
  font-size: 0.8rem;
  color: var(--text-faint);
}

.auditorium__count {
  margin-left: auto;
  font-size: 0.8rem;
  color: var(--text-dim);
  font-variant-numeric: tabular-nums;
}

.complaint {
  padding: 16px 20px;
  border-bottom: 1px solid var(--border);
  transition: background-color 140ms var(--ease);
}

.complaint:last-child {
  border-bottom: 0;
}

/* ------------------------------------------------------------- report cards
 * A report hangs off its RAIL — the severity edge grown into the card's one piece of
 * structure — and the queue reads as a timeline of them.
 *
 * This is the queue's calm-down, chosen by the operator from four mocked skins. The well
 * and the border that used to frame each card carried real weight (the notes on the old
 * design were right that a page needs to say where one report ends), but they carried it
 * with chrome; the rail carries it with the one colour the card was already wearing. The
 * body fades to a faint tint — enough to hold the card together as a surface — and the
 * separating is done by the rail, the gap, and the asymmetric radius that makes each card
 * visibly hang from its left edge.
 */
.auditorium__body {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 8px 4px;
  background: transparent;
}

.auditorium__body:empty {
  display: none;
}

/* ------------------------------------------------------------- the glass rows
 *
 * On the case pages a location is a ROW OF GLASS — the dock's own material, as a slim
 * contained strip — and each auditorium is still an island of its own beneath it.
 *
 * Second treatment to hold this ground. The first (the hanging tree) took the name OUT of
 * every container and set it straight on the page, which read as confident against the flat
 * page it was designed on — and read as unfinished the day the dock, the bar and the
 * assistant's tile all became glass objects. The operator said so, looking at a collapsed
 * queue: bare names with the background showing through them looked cheap beside the new
 * chrome. Containing them in the same glass closes that gap, and keeps what the hanging
 * style was actually FOR: a location can never be mistaken for a report, because the row
 * and the island are different weights of the same material.
 *
 * Scoped to `[data-caseview]`, which renderTree stamps on every container it draws —
 * and nothing else does. That boundary matters: the Location info page borrows the same
 * .theatre__head classes for its management rows and edit-in-place forms, and those keep
 * the boxed look these rules would break.
 */
[data-caseview] .theatre {
  background: none;
  border: 0;
  border-radius: 0;
  box-shadow: none;
  overflow: visible;
  margin-bottom: 18px;
}

[data-caseview] .theatre > .theatre__head {
  background: var(--glass);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  box-shadow: var(--rim);
  border: 1px solid var(--border-strong);
  border-radius: 14px;
  /*
   * ONE padding declaration for this head, and the right inset is the chevron's lane —
   * the rule that survives from the hanging era, because the bug it pins (two paddings
   * disagreeing, the name sliding under the glyph) does not care what the head looks like.
   */
  padding: 14px 44px 14px 18px;
}

/* Open, the row becomes the cap of what it holds: square shoulders toward its islands,
   and a small step of air before the first one. */
/* Open, the row and its reports are ONE object: the cap squares its shoulders and sits
   flush on the stack — the operator disliked the air that used to float between them, and
   the seam is the cap's own bottom border. */
[data-caseview] .theatre--toggle[open] > .theatre__head {
  border-radius: 14px 14px 0 0;
  margin-bottom: 0;
}

[data-caseview] .theatre__name {
  font-size: 1.35rem;
}

/*
 * The chevron holds the top-right corner of the row — the same side as a card's own
 * control, with a fixed top rather than centring: this heading wraps on a phone, and a
 * centred glyph would drift to the middle of the block instead of holding the corner.
 */
[data-caseview] .theatre__head--toggle::before {
  left: auto;
  right: 16px;
  top: 21px;
  margin-top: 0;
}

/* The open state's nudge belonged to the old centring; against a fixed top it would only
   push the glyph off its line. */
[data-caseview] .theatre--toggle[open] > .theatre__head--toggle::before {
  margin-top: 0;
}

/*
 * The hover wash: the glass fills in a step, nothing more — and gated on `hover: hover`,
 * like the case rows further down this file. iOS leaves :hover asserted after a tap until
 * something else is touched, so an unguarded wash left every opened cinema looking
 * selected. A device that cannot hover should never enter a hover state at all.
 */
@media (hover: hover) {
  [data-caseview] .theatre__head--toggle:hover {
    background: color-mix(in srgb, var(--surface-3) 78%, transparent);
  }
}

/* Each auditorium is the island it became under the hanging style, unchanged: the row and
   the island are different weights of one material, which is the whole hierarchy. */
[data-caseview] .auditorium {
  background: var(--glass);
  border: 1px solid var(--border-strong);
  border-top: 0;
  border-radius: 0;
  box-shadow: var(--shadow), var(--rim);
  margin-bottom: 0;
  overflow: hidden;
}

/* The stack ends the way the cap began: rounded, closed, one object. */
[data-caseview] .auditorium:last-child {
  border-bottom: 1px solid var(--border-strong);
  border-radius: 0 0 var(--radius-lg) var(--radius-lg);
  margin-bottom: 0;
}

/* No phone block of its own, as before: one set of rules serves every width, so the two
   copies that once disagreed about the chevron cannot come back. */
/*
 * One exception to the no-phone-block rule, and it is DENSITY, not geometry: the same glass
 * row, tighter. At 390px the head's pieces stack, and desktop's padding turned a two-line
 * row into a slab — the operator preferred the narrow footprint the hanging head had. The
 * chevron moves with the padding, in the same declaration block, so the two cannot disagree
 * the way the old phone copy famously did.
 */
@media (max-width: 700px) {
  [data-caseview] .theatre > .theatre__head {
    padding: 10px 38px 10px 14px;
    row-gap: 2px;
  }

  [data-caseview] .theatre__name {
    font-size: 1.15rem;
  }

  [data-caseview] .theatre__head--toggle::before {
    top: 14px;
    right: 12px;
  }
}

.complaint--report {
  background-color: color-mix(in oklab, var(--surface-2) 55%, transparent);
  border: 0;
  /* Premium pass: the rail as a FALL of the severity's light: full colour where
     the card hangs, letting go toward its foot. Painted over a transparent border so the
     geometry (and the uneven radius that makes the card hang) is exactly as before. */
  border-left: 4px solid transparent;
  background-image: linear-gradient(
    to bottom,
    var(--card-edge, var(--border-strong)) 0%,
    color-mix(in srgb, var(--card-edge, var(--border-strong)) 50%, transparent) 60%,
    transparent 94%
  );
  background-origin: border-box;
  background-clip: border-box;
  background-size: 4px 100%;
  background-repeat: no-repeat;
  border-radius: 6px 12px 12px 6px;
  padding: 12px 16px 10px;
}

.complaint--report[data-severity='low'] {
  --card-edge: var(--border-strong);
}

.complaint--report[data-severity='medium'] {
  --card-edge: var(--p-peach);
}

.complaint--report[data-severity='high'] {
  --card-edge: var(--danger);
}

/* A screen down takes the same edge as high impact — there is no colour past red — and
   keeps it whatever became of the report, because the badge still says "Screen down". */
.complaint--report[data-severity='emergency'] {
  --card-edge: var(--danger);
}

/*
 * The tint, though, is only for a screen that is down NOW. `--emergency` is the server's
 * answer to that question — severity is emergency AND the report is neither resolved nor
 * rejected — so a room that came back keeps its red edge and its badge but stops being
 * coloured like an alarm. Keying the tint off the severity attribute instead would leave
 * a fixed screen looking like a broken one for as long as the record exists.
 */
.complaint--report.complaint--emergency {
  /* Mixed toward transparent rather than toward a surface: the rail card's own body is
     translucent, and an opaque alarm block among translucent neighbours would read as a
     different KIND of card rather than a card in a different state. The rail is already
     full danger; this is the wash behind it. */
  background: color-mix(in oklab, var(--danger) 11%, transparent);
}

/* The review note keeps its panel — it is a quoted block, and quoting needs a surface.
   The progress run gave its box up for a rail of its own; see .progress below. */
.complaint--report .complaint__note {
  background: var(--surface-3);
}

/* ------------------------------------------------------------- deep links
   Arriving from a link that named one report — Needs attention on the home page
   does — the queue scrolls to it and marks it. Inset, so the ring reads as being
   around this row rather than as a border between two of them.

   It fades after a few seconds (removed by review.js): the mark says where to
   look on arrival, and a highlight that never leaves becomes furniture. */
.complaint--spotlight {
  background: var(--accent-soft);
  box-shadow: inset 0 0 0 2px var(--accent);
  border-radius: var(--radius);
}

/*
 * Why the queue is narrowed, when the reader did not narrow it themselves. The
 * collapsed filter bar already names the active filters, but somebody who got here
 * by clicking "Review now" did not choose them — and a short queue with no
 * explanation reads as an empty one.
 */
/*
 * "3 new reports — show them."
 *
 * Deliberately quieter than an alert and louder than a hint: something has changed and
 * it is worth knowing, but nothing is wrong and nobody has to act now. It sits above the
 * list rather than floating over it, so it pushes the content down once and then stays
 * still — a toast that appears over the first card would cover the row somebody is
 * reading, which is the thing this whole approach exists to avoid.
 */
.fresh {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px 14px;
  margin-bottom: 16px;
  padding: 10px 16px;
  border: 1px solid color-mix(in oklab, var(--ok) 40%, transparent);
  border-left: 3px solid var(--ok);
  border-radius: var(--radius);
  background: color-mix(in oklab, var(--ok) 10%, var(--surface));
}

.fresh[hidden] {
  display: none;
}

.fresh__text {
  flex: 1 1 220px;
  font-size: 0.88rem;
  font-weight: 550;
}

.deeplink {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 12px;
  margin-bottom: 16px;
  padding: 12px 18px;
  border: 1px solid color-mix(in oklab, var(--accent) 35%, transparent);
  border-left: 3px solid var(--accent);
  border-radius: var(--radius);
  background: var(--accent-soft);
}

.deeplink__text {
  flex: 1 1 300px;
  font-size: 0.88rem;
}

.complaint__top {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
  margin-bottom: 7px;
}

/*
 * The meta line: what used to be a row of pills, as one run of quiet words.
 *
 * Part of the queue's calm-down. Five pills per card — each its own colour, fill and
 * size — made every report a small argument for attention. The words carry the same
 * facts in one size and one ink, dotted apart, and exactly one of them keeps a colour:
 * the severity, wearing the hue of the card's own rail, so a card has one accent and a
 * reader scanning for how bad still gets the answer at arm's length twice over — the
 * rail for the glance, the word for the confirmation.
 */
.complaint__meta {
  display: flex;
  align-items: baseline;
  gap: 8px;
  flex-wrap: wrap;
  min-width: 0;
  font-size: 0.78rem;
  color: var(--text-dim);
  letter-spacing: 0.015em;
}

.meta__word {
  white-space: nowrap;
}

.meta__word--severity {
  font-size: 0.72rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  /* The card's own rail colour, inherited from the [data-severity] rules above. The
     fallback is for the one place a meta line renders outside a report card. */
  color: var(--card-edge, var(--text-dim));
}

/* Carries the folded-count tooltip, so the cursor says there is one. */
.meta__word--folded {
  cursor: help;
}

/*
 * Fixed on the floor, waiting for the cinema to sign it off. Coloured, unlike its neighbours
 * in the meta line, because it is the one word there that means somebody is waiting on the
 * READER — the rest describe the case. The tooltip names who, so the cursor says there is one.
 */
.meta__word--fixed {
  color: var(--p-green, var(--success, var(--accent)));
  font-weight: 650;
  cursor: help;
}

.meta__sep {
  opacity: 0.45;
}

/* The agent's mark: the spark alone, riding with the reference. Provenance, not status —
   the title carries the sentence for whoever hovers to ask. */
.complaint__spark {
  display: inline-flex;
  color: var(--accent);
  margin-right: 2px;
  vertical-align: -2px;
  cursor: help;
}

.complaint__ref {
  font-family: var(--mono);
  font-size: 0.78rem;
  color: var(--text-faint);
  letter-spacing: 0.02em;
}

.complaint__when {
  margin-left: auto;
  font-size: 0.78rem;
  color: var(--text-faint);
}

/* Reference and time as one group at the end of the badge row: two small grey things
   that answer "which report, and when" belong next to each other, and together they
   wrap onto one line instead of two. The `auto` margin moves from the time to the
   group, so the pair sits right while the badges stay left. */
.complaint__id {
  display: flex;
  align-items: baseline;
  gap: 10px;
  margin-left: auto;
}

.complaint__id .complaint__when {
  margin-left: 0;
}

.complaint__body {
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  margin: 0 0 12px;
  color: var(--text);
}

.complaint__facts {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 18px;
  font-size: 0.84rem;
  color: var(--text-dim);
  margin-bottom: 12px;
}

/*
 * The run of notes on a released report.
 *
 * One panel with a line per note, rather than a stack of note boxes: this is a
 * conversation about one job — "awaiting parts", then "engineer booked" — and boxing
 * each remark separately would make three sentences look like three events.
 */
/*
 * The progress run: a rail of its own instead of a boxed panel.
 *
 * The box was a card inside a card — a second border, a second background, a second
 * radius, all inside a card that just gave up its own. The notes hang off a thin rail
 * now, tinted faintly with the card's edge colour so the story visibly belongs to the
 * report it advances, and the block's weight is its words.
 */
.progress {
  background: transparent;
  border: 0;
  border-left: 2px solid color-mix(in oklab, var(--card-edge, var(--border)) 45%, transparent);
  border-radius: 0;
  padding: 2px 0 2px 14px;
  margin: 0 0 10px 2px;
  font-size: 0.88rem;
}

/* The tag on a status-change line — "Started", "Resolved" — as a quiet word, not a chip:
   it labels the line, it does not compete with it. */
.progress__tag {
  font-size: 0.72rem;
  font-weight: 650;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--text-dim);
  flex: none;
}

/*
 * The earlier notes, folded. What a card answers in passing is "where is this NOW", and
 * that is always the latest line — so it stands alone and the rest of the story waits
 * behind a disclosure that says how much of it there is. The summary is the app's own
 * quiet-disclosure shape (see .complaint__original): a muted line, a turning arrow.
 */
.progress__earlier {
  margin: 2px 0 6px;
}

.progress__earlier > summary {
  cursor: pointer;
  list-style: none;
  font-size: 0.78rem;
  color: var(--text-dim);
}

.progress__earlier > summary::-webkit-details-marker {
  display: none;
}

.progress__earlier > summary::before {
  content: '▸ ';
}

.progress__earlier[open] > summary::before {
  content: '▾ ';
}

.progress__label {
  display: inline-block;
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-faint);
  margin-bottom: 6px;
}

/* Said plainly rather than hidden: a card showing four of eleven notes without
   mentioning the other seven is the wrong kind of quiet. */
.progress__more {
  margin-left: 8px;
  font-size: 0.72rem;
  color: var(--text-faint);
  font-style: italic;
}

.progress__row {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 4px 8px;
  padding: 4px 0;
  border-top: 1px solid var(--border);
}

.progress__row:first-of-type {
  border-top: 0;
}

.progress__text {
  flex: 1 1 240px;
  min-width: 0;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

/* Who and when, at the end of the line and out of the way of the words. */
.progress__by {
  flex: 0 0 auto;
  font-size: 0.76rem;
  color: var(--text-faint);
}

.complaint__note {
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-left: 3px solid var(--p-surface2);
  border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
  padding: 10px 14px;
  font-size: 0.88rem;
  margin: 0 0 12px;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

.complaint__note strong {
  display: block;
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-faint);
  font-weight: 650;
  margin-bottom: 2px;
}

.complaint__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
  /* A gap of its own, so the buttons are not tight against whatever the card ended
     with — which is a paragraph on most cards and a row of photographs on some. */
  margin-top: 12px;
}

/*
 * The row's geography: doing on the left, exits on the right.
 *
 * Same fault and Reject take a report somewhere ELSE — into another report's job, or out
 * of the queue — and they used to stand shoulder to shoulder with Add note and Mark
 * resolved, four buttons in one strip with the destructive one a slot from the primary
 * one. The first `.act--away` takes the free space as its margin and carries the rest of
 * the exits with it; on a narrow screen the row wraps into a doing line and an exits
 * line, which is the same statement made vertically.
 */
.complaint__actions > .act--away {
  margin-left: auto;
}

.complaint__actions > .act--away ~ .act--away {
  margin-left: 0;
}

/* The fold icon: Same fault with its words in the accessible name instead of on the
   button. Square-ish and small — its rarity is the reason it stopped being a worded
   button, and a glyph that still shouted would have missed the point. */
.complaint__fold-btn {
  padding: 6px 9px;
}

.complaint__fold-btn svg {
  display: block;
}

/* PINNED to the card's top-right corner, just left of the collapse chevron — the same
   spot on every card, whatever the words on it do. It stood at the end of the meta words
   first, and the operator's verdict was exact: a control that shifts with each card's
   words cannot be aimed for without reading, so it holds the corner where the card's
   OTHER control already lives, centered on the chevron's line. The "+N same fault" word
   in the meta line goes on carrying the count for readers; in the DOM the button still
   rides that line, which keeps it beside the words in reading order. Absolute also puts
   it beyond the reach of the phone width's generic `.btn { flex: 1 1 auto }` stretch,
   which once grew it into the line's leftover width. */
.complaint__meta > .complaint__fold-btn {
  padding: 2px 7px;
  position: absolute;
  top: 1px;
  right: 24px;
  /* The .btn family's tap-target minimum would make this a 34px slab beside 17px words —
     visibly hanging below the line it belongs to. Hugging the glyph, its centre sits on
     the words' own centre, level with the chevron beside it. */
  min-height: 0;
}

/* The corner is the glyph's now, so the top row's own content stops short of it — the
   face's 22px already clears the chevron; this clears the button beside it. */
.complaint__top {
  padding-right: 36px;
}

/*
 * The released row on a phone: Add note full-width on a line of its own, then the two
 * verdicts — Mark resolved and Reject — sharing the line under it in equal halves. The
 * doing-left / exits-right geography needs width to say anything; at phone width the
 * wrap landed arbitrarily, and the operator chose this arrangement instead.
 */
@media (max-width: 560px) {
  .complaint__actions > .act--note {
    flex-basis: 100%;
  }

  .complaint__actions > .act--pair {
    flex: 1 1 0;
    margin-left: 0;
  }
}

/* The record line — who sent this where — full-width under the buttons rather than
   jammed beside them at button height. `order` keeps it last however the row wraps. */
.complaint__actions > .complaint__sent {
  flex-basis: 100%;
  order: 10;
  margin: 2px 0 0;
}

/*
 * Translate, as a reading aid rather than a peer of the case buttons.
 *
 * It acts on the WORDS — the one control here that does — so it takes a quiet full-width
 * line above the row instead of standing in it: on a phone it was the fifth button that
 * turned the geography back into a wall. Accent ink and the spark say what it is (the
 * control that spends on a model), the stripped box says what it is not (a lifecycle
 * action). `order: -1` puts it first however the row wraps, and it is rare besides — the
 * short-English fix keeps it off plain English, so what remains is the held tab and the
 * genuinely ambiguous.
 */
.complaint__actions > .complaint__translate {
  flex-basis: 100%;
  order: -1;
  justify-content: flex-start;
  gap: 6px;
  margin: 0;
  padding: 2px 0;
  background: transparent;
  border: 0;
  box-shadow: none;
  color: var(--accent);
  font-weight: 600;
}

.complaint__actions > .complaint__translate:hover:not(:disabled) {
  background: transparent;
  text-decoration: underline;
}

/* ------------------------------------------------------- the card that folds
 * A report card shut is its badges, its reference, its time and two lines of what
 * happened. Everything else — the guest's details, the notes, the photographs, the
 * buttons — is behind the fold.
 *
 * This is the other half of the page-height fix (see PAGE_SIZE in src/complaints.js).
 * Five hundred open cards was 191,982px on a phone: 246 screens. A third of that was
 * progress notes and action buttons, neither of which anybody reads while scrolling.
 *
 * Cards only ARRIVE shut past CARDS_OPEN_UPTO in public/js/tree.js. A short list is
 * somebody working through it, and shut cards would be a click each to see what was
 * already visible.
 */
.complaint--fold > .complaint__face {
  position: relative;
  cursor: pointer;
  /* Room for the chevron, so a long badge row cannot run underneath it. */
  padding-right: 22px;
  /* NOT user-select: none, unlike the cinema header above. The face carries the
     reference and the description, which are the two things people copy out of this
     app — so the text stays selectable, and disclose.js ignores a click that ends a
     selection rather than folding the card away mid-copy. */
  list-style: none;
}

.complaint--fold > .complaint__face::-webkit-details-marker {
  display: none;
}

.complaint--fold > .complaint__face::after {
  content: '';
  position: absolute;
  right: 2px;
  top: 9px;
  width: 6px;
  height: 6px;
  border-right: 2px solid var(--text-faint);
  border-bottom: 2px solid var(--text-faint);
  transform: rotate(45deg);
  transition: transform 160ms var(--ease);
}

.complaint--fold[open] > .complaint__face::after {
  transform: rotate(-135deg);
}

/* Two lines of the description while shut, and no gap under it — the card ends there. */
.complaint--fold:not([open]) > .complaint__face > .complaint__body {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
  margin-bottom: 0;
}

/* Shut, the badge row's own gap is the only space inside the card. */
.complaint--fold:not([open]) > .complaint__face > .complaint__top {
  margin-bottom: 6px;
}

/* The last thing in the fold should not add a gap below itself — the card's padding is
   already there, and doubling it made every card taller than it reads. */
.complaint__rest > :last-child {
  margin-bottom: 0;
}

.complaint--busy {
  opacity: 0.45;
  pointer-events: none;
}

/* ============================================== the list view: reports as rows

   The other arrangement of the same report — see renderComplaint in js/tree.js, and
   js/view-mode.js for why the app offers two.

   ## Everything here is behind one attribute

   `data-caseview` is written on the results container by renderTree, and every rule below is
   scoped under `='list'`. Cards are what nearly everybody sees and they should not start
   paying for a second arrangement existing: in that mode not one selector here matches, and
   the card rules above are reached exactly as they were before this section was written.

   ## Why the columns are fixed widths

   Because each row is its own grid. Two sibling grids do not share track sizes, so
   `max-content` would size every row to its own contents and the colours would stagger down
   the page — which is precisely the thing rows exist to avoid. Stated lengths are what makes
   a severity at the top of the list sit above the severity below it, and that alignment is
   the entire feature. `subgrid` would express it more honestly and is not used: the summary
   is two levels below the well, so it would need the <details> to be a subgrid item too, and
   that is a lot of fragility to buy a tidier declaration.

   The last track is `minmax(0, 1fr)`, not `1fr` — a bare `1fr` has a min of auto, so a long
   description would refuse to shrink and push the row wider than its container. */

/* The well stops being a tray of cards and becomes a table body: flush, no gaps, hairlines
   between rows.

   This used to carry `container-type: inline-size`, so the rows answered to the width of the WELL
   rather than of the window — because the same tree is drawn inside the case history dialog. It is
   gone, for two reasons. It was not needed: the dialog is `min(1040px, 100vw - 32px)`, so it is
   narrow exactly when the window is, and a media query gets the same answer at every width (
   measured — a 1440px window gives that dialog a 956px well, and a phone gives it a narrow one).
   And `container-type` is not free: it applies `contain: layout style inline-size`, which makes
   this a containment context and changes how the rows inside it are composited while scrolling.
   That is the leading suspect for the severity edge blinking in and out on a phone, which is a
   list-only symptom and this was a list-only rule. */
[data-caseview='list'] .auditorium__body {
  gap: 0;
  padding: 0;
  background: var(--surface-2);
}

/* The card's own frame goes; the hairline above each row replaces it.
   The severity edge stays, because reading down a column of coloured edges is the one card
   affordance that gets better in a list — but it is drawn differently here. See below. */
[data-caseview='list'] .complaint--report {
  padding: 0;
  border: 0;
  border-top: 1px solid var(--border);
  border-radius: 0;
  background-color: transparent;
  /*
   * The severity edge as a BACKGROUND rather than an inset shadow.
   *
   * `.complaint--report` draws it with `box-shadow: inset 3px 0 0`, which is right on a card: the
   * card has its own opaque background, so the shadow composites against something. A row does
   * not — it is transparent, sitting on the well — so the same shadow has to be composited
   * against whatever is behind it on every frame, and on a phone that showed as the coloured edge
   * blinking in and out while scrolling. A gradient is part of the element's own background and is
   * painted with it.
   *
   * Written with four stops rather than the two-position shorthand, which is newer syntax than
   * anything else this stylesheet relies on.
   */
  background-image: linear-gradient(
    to right,
    var(--card-edge, transparent) 0,
    var(--card-edge, transparent) 3px,
    transparent 3px
  );
  box-shadow: none;
}

[data-caseview='list'] .auditorium__body > .complaint--report:first-child {
  border-top: 0;
}

/* A SCREEN THAT IS DOWN keeps its tint, which the rule above had quietly taken away.
   `.complaint--report.complaint--emergency` and `[data-caseview='list'] .complaint--report` are
   both two-selector-units of specificity, so the tie went to whichever came later in the file —
   and that is this section. The alarm colour is the one thing on a report that must survive a
   change of arrangement: an auditorium nobody can watch a film in is exactly what somebody
   scanning forty rows is scanning FOR, and it was coming out the same colour as a cold room.

   Only the live ones, as in card view: `--emergency` is the server's answer to "is this screen
   down NOW", so a room that came back keeps its red edge and its badge and stops being coloured
   like an alarm.

   `background-color`, not the `background` shorthand: the shorthand would reset the edge gradient
   above, so a screen that is down would be the one row in the list with no severity edge — the
   opposite of what this rule is for. */
[data-caseview='list'] .complaint--report.complaint--emergency {
  background-color: color-mix(in oklab, var(--danger) 9%, var(--surface-2));
  border-top-color: color-mix(in oklab, var(--danger) 45%, var(--border));
}

/* A row is a control, so it has to answer the pointer. Cards say so with their own edges and
   their spacing; a run of flush rows needs the hover to show which one a click would open.

   ## Two things here were wrong, and this is what they looked like

   Reported from a phone: expand a case and the coloured edge "jumps down to the expanded part",
   then comes back the moment you touch the screen to scroll. Both halves are this rule.

   The edge is drawn on the BLOCK, and the row is a child of it, so an opaque background on the row
   paints straight over it — for exactly the row's own height, which is why the colour appeared to
   start below the fold and not above it. An earlier comment here claimed `background-color` rather
   than the `background` shorthand kept the edge visible underneath; that was simply wrong.
   Shorthand or longhand makes no difference when the thing covering it is a child. So the row
   redraws the same edge on top of its own hover colour: `background-color` paints first,
   `background-image` over it, both on one element. `--card-edge` is a custom property, so it
   inherits from the block and the two gradients cannot disagree.

   And it is gated on `hover: hover`, which is what makes the phone right rather than merely
   consistent. iOS leaves `:hover` asserted after a tap until you touch something else — so
   expanding a case left the row hovered, and scrolling was what cleared it. A device that cannot
   hover should never enter a hover state at all. */
@media (hover: hover) {
  [data-caseview='list'] .complaint--fold > .complaint__row:hover {
    background-color: var(--surface-3);
    background-image: linear-gradient(
      to right,
      var(--card-edge, transparent) 0,
      var(--card-edge, transparent) 3px,
      transparent 3px
    );
  }
}

/* What is behind the fold lines up with the row that opened it.
   A card pads its whole box, so its face and its innards share one left edge for free. A row
   pads itself instead — the block has to be flush for the hairlines to run the width of the
   list — which leaves the notes and the buttons starting fifteen pixels to the left of the
   badges above them, reading as a second column that is not one. */
[data-caseview='list'] .complaint__rest {
  padding: 0 24px 12px 15px;
}

.complaint__row {
  display: grid;
  /* Severity, issue type, stage, reference, when, the marks, and the words.
   *
   * FIXED, and every number below was measured rather than guessed — see the note at the end of
   * this comment for why it cannot be `fit-content` or `auto`.
   *
   * The widths these replace were each cut for the worst case a column could hold, estimated by
   * eye, and measuring them found the estimates wrong in both directions at once:
   *
   *   - Issue type was 132px for a longest label that measures 108 and a typical one that
   *     measures 56. Most of why a page of ordinary reports read as five badges strung across a
   *     thousand pixels rather than as a row.
   *   - Stage was 106px for a "Pending review" badge that measures 128, so the one tab where
   *     that badge distinguishes anything printed "PENDING R…" on every single row. Shortened
   *     to "Pending" in this view rather than paid for in width — see complaintParts in
   *     js/tree.js — which drops the column's worst case to "In progress" at 106.
   *   - The reference was 88px for twelve monospaced characters that need 104, so the thing a
   *     reader would copy to name a case was never shown whole.
   *
   * So: the slack comes out, both truncations go, and the gap drops from 14 to 10. The badges
   * sit as a row now instead of as five things that happen to share a line.
   *
   * Each track is its column's measured worst case plus a few pixels — not an estimate of one.
   * The estimates these replace were wrong in both directions at once, which is what an
   * estimate of a text width usually is.
   *
   * WHY NOT fit-content. It is the obvious answer — size each track to the content actually in
   * it — and it is wrong here for a structural reason: every `.complaint__row` is its OWN grid
   * container, one per report, so a content-sized track is sized per row rather than per column.
   * Tried and screenshotted: the badges pack tightly and the columns stop lining up down the
   * page, each row starting its second badge wherever its first one happened to end. Reading
   * DOWN a column is the entire reason this view exists; a packed ragged list is the card view
   * with worse manners. Fixed tracks are what make the columns columns. */
  /*      severity  type    stage   ref     when     marks           the words
     worst   95       90      88     104      95   — re-measured when the cells stopped
     holding badges and started holding words: the ~18px of pill padding and border came
     out of the first three tracks, and the columns closed up by exactly the chrome the
     quiet-card change removed. The reference and time were never badges and keep their
     measure. */
  grid-template-columns: 98px 94px 92px 104px 98px minmax(0, auto) minmax(0, 1fr);
  align-items: center;
  /* 10px, matching the flowed layout under 660px below. */
  gap: 0 10px;
  /* Right padding clears the chevron, which is positioned against this box. */
  padding: 7px 24px 7px 15px;
}

/* Each badge starts at its cell's left edge instead of wherever its own text ends. This is
   the line that makes the columns line up. */
[data-caseview='list'] .complaint__cell {
  display: block;
  min-width: 0;
  overflow: hidden;
}

/* A renamed issue type can be longer than its column. Clipped at the cell edge with an
   ellipsis rather than allowed to overlap the next one — the full text is in the card view
   and in the report itself, and a column that shoves its neighbour sideways has stopped
   being a column.

   `inline-block`, and that word is the whole rule. A badge is `inline-flex`, and the cell is
   a block: `display: block` here stretched every badge to the full width of its track, so the
   list came out as a grid of empty outlined boxes with a word at the left of each. Shrink-wrap
   is what makes a badge read as a badge. It also has to be a block container of SOME kind for
   `text-overflow` to mean anything — an ellipsis does not apply to the anonymous text item
   inside a flex box — so `inline-block` is the one value that satisfies both. */
[data-caseview='list'] .complaint__cell > .badge {
  display: inline-block;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  vertical-align: middle;
}

/* The marks cell can hold two badges, so it lays them out rather than leaving them to sit
   against each other. Capped, because its track is `auto` and a triage reason runs to a
   sentence — uncapped, "Held: contains a link and three phone numbers" would take its width
   out of the description, which is the column actually being read. */
[data-caseview='list'] .complaint__cell--marks {
  display: flex;
  align-items: center;
  gap: 6px;
  max-width: 260px;
}

/* Undoing the inline-block above for the agent's mark: the spark is an icon, and an
   inline-block box would leave it sitting on the text baseline instead of centred
   against it. It is small and never needs the ellipsis the rule exists for. */
[data-caseview='list'] .complaint__cell--marks > .complaint__spark {
  display: inline-flex;
  overflow: visible;
}

/* The reference and the time are their own columns here rather than a pair at the end of the
   badge row, so `.complaint__id`'s auto margin is not involved and neither needs the group. */
[data-caseview='list'] .complaint__row > .complaint__when {
  margin-left: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

[data-caseview='list'] .complaint__row > .complaint__ref {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* One line while shut, ellipsised — the row's height is what keeps forty of them scannable,
   so the description gets exactly the space left over. Beats the card's two-line clamp above
   on specificity and on source order, and unsets it explicitly rather than relying on that:
   `-webkit-box` plus a line clamp is a different layout mode, and leaving it on would give a
   box that ignores `text-overflow`. */
[data-caseview='list'] .complaint--fold:not([open]) > .complaint__row > .complaint__body,
[data-caseview='list'] .complaint__row > .complaint__body {
  /* THE LAST TRACK, named from the end so it stays right if the columns are ever re-cut.
     Explicit rather than left to auto-placement, because the marks cell before it is only
     sometimes there: without this the description would sit in the marks track on an ordinary
     report and in the next row on a held one, so the list's own column would move depending on
     what the triage happened to think of the report. */
  grid-column: -2 / -1;
  display: block;
  margin: 0;
  -webkit-line-clamp: none;
  line-clamp: none;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  font-size: 0.88rem;
}

/* Open, the description is the thing being read, so it wraps in full. The row grows and the
   cells move to the top of it — badges centred against a five-line paragraph would leave the
   severity floating in the middle of nothing. */
[data-caseview='list'] .complaint--fold[open] > .complaint__row {
  align-items: start;
}

[data-caseview='list'] .complaint--fold[open] > .complaint__row > .complaint__body {
  white-space: pre-wrap;
  overflow: visible;
  text-overflow: clip;
}

/* The chevron is positioned against the row box, whose height changes with the fold, so it is
   pinned rather than centred: `50%` of an open row holding a long description would put it
   halfway down a paragraph. 14px is the middle of a shut row, and stays beside the first line
   of an open one. */
[data-caseview='list'] .complaint--fold > .complaint__row::after {
  top: 14px;
}

/* A well too narrow for six columns goes back to flowing, which is a card in all but name —
   and that is the honest answer rather than six columns of four characters each.

   660px of WINDOW, which is about 620px of well: the shell and the panel take ~40px between them
   at every width that matters. Measured against the container query this replaces, so the
   behaviour is the same on both sides of it — a 640px window flows and a 720px window keeps its
   columns, exactly as before. The case history dialog needs no special case: it is
   `min(1040px, 100vw - 32px)`, so it is narrow precisely when the window is. */
@media (max-width: 660px) {
  .complaint__row {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 6px 10px;
    padding: 10px 24px 10px 15px;
  }

  /* The description takes its own line under the badges. */
  [data-caseview='list'] .complaint__row > .complaint__body {
    flex: 1 0 100%;
  }

  /* Packed left beside the reference rather than pushed to the right edge. `margin-left: auto`
     would be right if the badges left room on the line and wrong when they do not — and at
     this width they usually do not, so it bought a line of its own for four words. */
  [data-caseview='list'] .complaint__row > .complaint__when {
    margin-left: 0;
  }

  /* The Same fault glyph holds the top-right corner here too — the same spot the card
     view pins it to, for the same reason: a control that never moves can be aimed for
     without reading. At this width the row flows like a card in all but name and the
     action row wraps, which dropped the glyph BETWEEN Mark resolved and Reject — a
     one-glyph button adrift in the middle of the verdicts, reported from the operator's
     phone. The button still lives in the opened actions in the DOM (a shut row has no
     room on its face), so it appears only while the row is open — pinned against the
     report's own box, level with the chevron beside it. */
  [data-caseview='list'] .complaint--fold {
    position: relative;
  }

  [data-caseview='list'] .complaint__actions > .complaint__fold-btn {
    position: absolute;
    top: 6px;
    right: 14px;
    margin-left: 0;
    padding: 2px 7px;
    /* Same exception as the card pin above: the .btn tap-target minimum would hang a
       34px slab beside the row's first line instead of sitting level with it. */
    min-height: 0;
  }

  /* And the open row's first line stops short of the corner the glyph now owns — the
     24px that clears the chevron does not clear a button beside it. */
  [data-caseview='list'] .complaint--fold[open] > .complaint__row {
    padding-right: 56px;
  }

  /* Reject keeps the row's right edge. Above this width the GLYPH carries the row's
     `margin-left: auto` and Reject rides flush behind it (see the act--away pair rule);
     with the glyph pinned out of the flow its auto margin pushes nothing, and Reject —
     still matching `.act--away ~ .act--away` in the DOM — packed in on the left with the
     doing buttons. Handing the auto margin to Reject itself restores the exits-right
     geography. Harmless on the released row's phone halves: both verdicts are
     `flex: 1 1 0`, so the grow absorbs the line's free space before an auto margin can. */
  [data-caseview='list'] .complaint__actions > .complaint__fold-btn ~ .act--away {
    margin-left: auto;
  }
}

/* -------------------------------------------------------- the Cards / List switch

   A segmented pair rather than one button that toggles. A single control reading "List view"
   cannot say whether that is what you are looking at or what you would get by pressing it,
   and a reader who has to press it to find out has been handed a puzzle. */
.viewswitch {
  display: flex;
  flex: 0 0 auto;
  gap: 2px;
  padding: 2px;
  border: 1px solid var(--border);
  border-radius: 9px;
  background: var(--surface-2);
}

.viewswitch[hidden] {
  display: none;
}

.viewswitch__option {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  border: 0;
  border-radius: 7px;
  padding: 5px 10px;
  background: none;
  font: inherit;
  font-size: 0.8rem;
  font-weight: 550;
  color: var(--text-dim);
  cursor: pointer;
  white-space: nowrap;
  transition:
    background-color 140ms var(--ease),
    color 140ms var(--ease);
}

.viewswitch__option:hover {
  color: var(--text);
}

/* The pressed half, in the accent — the same language the range bar on the home page uses for
   the period that is showing, so a reader who has met one control has met both. Colour-mixed
   against the theme's own surface rather than given a fixed shadow: this app ships seven
   themes, three of them light, and a hardcoded shadow is legible in about half of them. */
.viewswitch__option--on {
  background: color-mix(in oklab, var(--accent) 14%, var(--surface));
  color: var(--accent);
  font-weight: 620;
}

.viewswitch__icon {
  flex: 0 0 auto;
}

/* --------------------------------------------------- archived / editing */

/* An archived cinema or screen is dimmed but still fully readable — its
   complaint history is intact and admins still need to work with it. */
.theatre--archived .theatre__head {
  background: repeating-linear-gradient(
    135deg,
    var(--surface-2),
    var(--surface-2) 10px,
    var(--surface) 10px,
    var(--surface) 20px
  );
}

.theatre--archived .theatre__name {
  color: var(--text-dim);
}

/* Inline edit rows: the form takes the place of the row it replaces. */
.theatre__head--editing,
.complaint--editing {
  background: var(--accent-soft);
  border-bottom: 1px solid var(--border);
  display: block;
}

.theatre__head--editing {
  display: flex;
  align-items: flex-end;
  gap: 12px;
  flex-wrap: wrap;
}

.theatre__head--editing .field {
  margin: 0;
  flex: 1 1 150px;
  /* Without a cap, whichever field lands alone on a wrapped row stretches to
     the full width and stops looking like a sibling of the others. */
  max-width: 280px;
  min-width: 0;
}

/* A street address needs more room than a ZIP or a region. */
.theatre__head--editing .field--wide {
  flex: 2 1 220px;
  max-width: 380px;
}

.theatre__head--editing .field > label,
.toolbar--bare .field > label {
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-faint);
  font-weight: 600;
}

.theatre__head--editing input,
.toolbar--bare input {
  min-height: 38px;
  padding: 7px 11px;
  font-size: 0.95rem;
}

/* ------------------------------------------------------- page head + modal */

/* Heading on the left, the page's one create action on the right. */
/*
 * The page's own action, under the header rather than opposite it.
 *
 * There was a `--split` modifier that put the title left and the button right. With the
 * title centred that arrangement has nothing to balance against, and a button pinned to
 * one corner of a symmetrical header is the thing that would look unfinished. Centred
 * below, it reads as the page's one obvious next step.
 *
 * Below the rule, not above it. `order` rather than moving it in the markup, because the
 * heading has to come first in the document for a reader going through it in order —
 * what the rule separates is the page's name from the page's work, and a button is work.
 * Sitting above the line it read as a stray piece of the title block.
 *
 * `.page-head > .btn` as well as the wrapper: two pages put the button in the header
 * directly rather than in a group of one.
 */
.page-head__actions,
.page-head > .btn {
  order: 1;
  display: flex;
  justify-content: center;
  gap: 10px;
  flex-wrap: wrap;
  /* On top of the 12px grid gap. Nearer the rule than the content is to it, so the
     button belongs to the page rather than to whatever list starts underneath. */
  margin-top: 8px;
}

/* The "Add new…" dialog. Native <dialog>, so focus trapping, Escape and
   inertness of the page behind come from the browser. */
.modal {
  width: min(660px, calc(100vw - 32px));
  max-height: min(86vh, 760px);
  /* dvh where it exists: on a phone, vh is the tallest the viewport ever gets, so a
     dialog sized in vh hides its own bottom edge behind the browser's toolbar. */
  max-height: min(86dvh, 760px);
  padding: 0;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-lg);
  background: var(--glass-strong);
  -webkit-backdrop-filter: blur(16px);
  backdrop-filter: blur(16px);
  color: var(--text);
  box-shadow: var(--shadow-lift);
  overflow: hidden;
}

/*
 * Case history: the same box, given the width its contents need.
 *
 * After `.modal` rather than before it, and that ordering is the whole rule — both are a
 * single class, so specificity ties and source order decides. Written above, it lost to the
 * width it was meant to override and the box opened at 660px.
 *
 * 660px is right for a form; a column of fields reads badly wider. This one holds the queue's
 * own report cards, whose head is a row of badges plus a reference and a time, and at 660px
 * that wrapped onto three ragged lines on every card.
 */
.modal--history {
  width: min(1040px, calc(100vw - 32px));
}

/* How many closures are on screen. Between the filters and the list, so a narrowed view says
   what it narrowed to rather than leaving the reader to count cards. */
.history__count {
  margin: 0 0 4px;
}

/*
 * A column, so the head, the scrolling middle and the footer each get a row of their own.
 * Before this the body carried `max-height: calc(86vh - 150px)` — a guess at the height of
 * everything else in the box — and a form taller than the guess put its sticky footer on
 * top of a field instead of below it.
 *
 * On `[open]`, and this is not a detail. The browser hides a closed dialog with
 * `dialog:not([open]) { display: none }` from its own stylesheet, which any `display` an
 * author sets on `.modal` outranks — so declaring `display: flex` unconditionally left
 * every dialog on the page permanently unfolded at the bottom of it.
 */
.modal[open] {
  display: flex;
  flex-direction: column;
}

.modal::backdrop {
  background: rgba(10, 10, 16, 0.66);
  backdrop-filter: blur(3px);
  -webkit-backdrop-filter: blur(3px);
}

/*
 * No ring around the box itself.
 *
 * A pop-up whose first field is a `<select>` holds focus on the box rather than the control —
 * see `arrive()` in raise.js, where a ringed select read as an already-open menu. The catch is
 * that a 2px outline around a 660px box is far louder than the thing it was avoiding, and
 * Chromium matches `:focus-visible` for this programmatic focus, so `:not(:focus-visible)`
 * does not get rid of it (measured — that was the first attempt).
 *
 * Unconditional is safe here rather than the usual anti-pattern, for a reason specific to this
 * element: the dialog carries `tabindex="-1"`, so it is not in the tab order and no keyboard
 * reader can ever arrive on it — the only thing that focuses it is the line above. Every
 * control inside keeps its ring, and the box being modal is what says where you are.
 */
dialog:focus {
  outline: none;
}

/*
 * ------------------------------------------------- every pop-up box, arriving and leaving
 *
 * Driven by public/js/dialogs.js, which wraps showModal/close to put these classes on and —
 * for the way out — to hold the native close until the animation has finished. A box that
 * has already been hidden cannot be watched leaving.
 *
 * `dialog` rather than `.modal`, so the photo viewer is included and so is any box added
 * later. There is exactly one mechanism for all of them.
 *
 * A short rise and a small scale rather than a bigger one: these boxes are up to 660px wide
 * and 86vh tall, and a box that size growing from 90% reads as a zoom rather than as
 * something arriving. 3% and 8px is enough to say which direction it came from.
 *
 * The backdrop fades on its own timing. Sharing the box's would have the page behind darken
 * in step with the box growing, which is two things happening at once; the dark arriving
 * first is what makes the box look like it is on top of something.
 */
/*
 * The starting state, applied for one frame before the animation begins — see ARM in
 * dialogs.js for why. This and the keyframe's `from` below must stay identical: a difference
 * between them is a visible jump on the frame the two swap over.
 */
dialog.is-arming {
  opacity: 0;
  transform: translateY(12px) scale(0.96);
}

dialog.is-arming::backdrop {
  opacity: 0;
}

/*
 * No blur until the box has landed, and this is the single biggest thing about how the
 * entrance reads.
 *
 * `backdrop-filter` on a full-viewport pseudo-element has to sample and blur the whole page,
 * and the first frame of it measured 44ms on this app — three frames, gone, out of an
 * animation that is fifteen. With the blur deferred the entry runs at a frame every 16ms
 * from the first one; with it on, the box was invisible and then most of the way there.
 *
 * The blur arrives when the class comes off, a frame after the movement has finished, where
 * paying for it costs nothing anybody can see: a stutter matters while something is moving
 * and not at all once it has stopped.
 *
 * The exit is on the list for a reason that is not obvious and was measured, not assumed. The
 * backdrop is long since composited by then, so the blur itself is free — but the exit
 * animates its *opacity*, and a blurred layer changing opacity has to be re-blurred every
 * frame. Left on, the close stalled for 72ms and on one run showed no intermediate frames at
 * all. Under a dark layer that is on its way out, nobody can tell it stopped blurring.
 *
 * And taking the blur OFF is itself a repaint of the whole page behind the box, which is why
 * the exit is armed for a frame (is-leaving, in dialogs.js) exactly as the entry is: the box
 * sits still at full opacity while that repaint is paid, and the fade starts on the frame
 * after. Measured frame by frame, the fade started 40 to 110ms late without it — often after
 * the timer had already shut the box.
 */
dialog.is-arming::backdrop,
dialog.is-leaving::backdrop,
dialog.is-opening::backdrop,
dialog.is-closing::backdrop {
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}

/*
 * Nothing styles the box itself under is-leaving, and a test pins that nothing ever does: a
 * visible change there would be a jump on the frame the classes swap. (A will-change hint
 * was tried here and measured: it moved nothing, because the time after the class lands is
 * the display compositor drawing the frame, not the box being rasterised — so it is not
 * here, and the fade is allowed its own clock in dialogs.js instead.)
 */

@keyframes dialog-in {
  from {
    opacity: 0;
    transform: translateY(12px) scale(0.96);
  }
}

@keyframes dialog-out {
  to {
    opacity: 0;
    transform: translateY(4px) scale(0.985);
  }
}

@keyframes dialog-backdrop-in {
  from {
    opacity: 0;
  }
}

@keyframes dialog-backdrop-out {
  to {
    opacity: 0;
  }
}

/* 240ms and 140ms are also in dialogs.js, where the timer for the close lives. A test fails
   if the two ever disagree. Longer than the exit, and longer than it was: an entry that has to
   be noticed needs more room than one the reader has already decided about. */
dialog.is-opening {
  animation: dialog-in 240ms var(--ease-arrive) both;
}

dialog.is-closing {
  animation: dialog-out 140ms var(--ease-out) both;
}

/* Slightly ahead of the box on the way in, and behind it on the way out, so the page is
   already dim when the box lands and still dim as it goes. */
dialog.is-opening::backdrop {
  animation: dialog-backdrop-in 190ms var(--ease-arrive) both;
}

dialog.is-closing::backdrop {
  animation: dialog-backdrop-out 190ms var(--ease-out) both;
}

.modal__head {
  flex: none;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 18px 20px;
  border-bottom: 1px solid var(--border);
  background: linear-gradient(180deg, var(--surface-3), var(--surface-2));
}

.modal__title {
  margin: 0;
  font-size: 1.05rem;
  flex: 1 1 auto;
}

/*
 * The guest form's one dialog: the personal-information refusal.
 *
 * Its own layout rather than the head/body/foot the staff dialogs are built from, because it
 * has none of those parts — it is a heading, two sentences and the way back. `.modal` sets
 * `padding: 0` for a dialog with its own scrolling body, so the padding is put back here.
 *
 * Sized for a phone held one-handed in a dark auditorium, which is where every one of these
 * is read.
 */
.modal--privacy {
  width: min(420px, calc(100vw - 32px));
  padding: 22px;
  text-align: center;
}

.modal--privacy .modal__title {
  margin-bottom: 10px;
  font-size: 1.15rem;
  color: var(--warn);
}

.modal--privacy .modal__body {
  padding: 0 0 14px;
  overflow: visible;
  font-size: 0.9rem;
  line-height: 1.5;
  color: var(--text-dim);
}

/*
 * The rule said where it would be broken.
 *
 * Warm rather than red: this is guidance being read BEFORE anything has gone wrong, and a
 * form that opens already showing an error-coloured line reads as broken. The refusal itself
 * is where the alarm belongs.
 */
.field__hint--privacy {
  margin-top: 6px;
  color: var(--warn);
}

.modal__x {
  flex: none;
  width: 32px;
  height: 32px;
  display: grid;
  place-items: center;
  font-size: 22px;
  line-height: 1;
  border-radius: 8px;
  border: 1px solid transparent;
  background: none;
  color: var(--text-dim);
  cursor: pointer;
  transition:
    background-color 140ms var(--ease),
    color 140ms var(--ease);
}

.modal__x:hover {
  background: var(--surface-3);
  color: var(--text);
}

.modal__notice {
  flex: none;
  margin: 16px 20px 0;
}

/* The middle row: it takes what is left and scrolls, rather than being told in advance
   how much room there is. min-height: 0 is what lets a flex item shrink below its
   content — without it the form keeps its full height and pushes the footer out. */
.modal__body {
  flex: 1 1 auto;
  min-height: 0;
  padding: 20px;
  overflow-y: auto;
  /*
   * Defence, not a fix for anything observed here.
   *
   * A flick that outruns the content can hand the remainder to whatever is behind the dialog, so
   * the page moves under a box that has supposedly taken over. Chromium under a synthetic wheel
   * would not reproduce it either way, so this is unproven rather than verified — kept because it
   * is the standard guard for a scroll region inside a modal and costs nothing. The scroll bug
   * this app actually had was structural: see the note in public/js/case-history.js.
   */
  overscroll-behavior: contain;
}

.modal__body[hidden] {
  display: none;
}

/*
 * A form whose footer must never sit on a field.
 *
 * The fields scroll inside `.modal__scroll` and the footer is a row of the form rather
 * than something floating over the end of it. Sticky was fine for the short add-a-cinema
 * form and wrong for the six-field report form, where it covered whatever field happened
 * to be last on screen.
 */
.modal__body--flow {
  display: flex;
  flex-direction: column;
  padding: 0;
  overflow: hidden;
}

.modal__scroll {
  flex: 1 1 auto;
  min-height: 0;
  padding: 20px;
  overflow-y: auto;
  /* The same unproven guard as `.modal__body` above. */
  overscroll-behavior: contain;
}

.modal__body--flow .modal__foot {
  position: static;
  flex: none;
  margin: 0;
  padding: 16px 20px;
}

/* ------------------------------------------------------- the layout preset box
 *
 * Three stacked choices, each a button carrying its own name and the sentence that says what
 * it does. Full-width and stacked rather than three across: the hints are the point — a row of
 * three bare words would be asking somebody to guess what "Review focus" puts away — and three
 * columns of prose on a phone is three columns of one word each.
 *
 * Left-aligned text inside a button, which the app's buttons otherwise centre. These are
 * choices to read rather than actions to press, and centred prose in a 400px box wanders.
 */
.preset__choices {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.preset__choice {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 2px;
  width: 100%;
  padding: 12px 14px;
  text-align: left;
}

.preset__label {
  font-weight: 600;
}

.preset__hint {
  color: var(--text-dim);
  font-size: 0.9em;
  font-weight: 400;
  /* Wraps rather than ellipsing: a hint cut off mid-sentence is worse than a taller button. */
  white-space: normal;
  line-height: 1.35;
}

/* The promise, spaced away from the choices so it reads as a note about all three. */
.preset__promise {
  margin-top: 16px;
}

/* ------------------------------------------------- occupancy, year over year
 *
 * The occupancy tile's popup: two tables and a caption. The change chips carry the one
 * judgement the reader came for — up or down on last year — so they get the two status
 * colours; everything else is the table dress the rest of the app already wears.
 */
.compare__table {
  width: 100%;
  margin-top: 12px;
}

.compare__heading {
  margin: 20px 0 0;
  font-size: 0.95rem;
  color: var(--text-dim);
}

.compare__empty {
  margin: 14px 0 0;
  color: var(--text-dim);
}

.compare__shift {
  color: var(--text-dim);
  font-variant-numeric: tabular-nums;
}

.compare__shift--up {
  color: var(--ok);
}

.compare__shift--down {
  color: var(--warn);
}

/* The background sweep's health line on the POS card (js/settings.js): grey when the
   clock is delivering, the warn voice when it is late — late is the one state that
   changes what every number on the dashboard means. */
.pos-sweep--late {
  color: var(--warn);
  font-weight: 600;
}

/* ------------------------------------------------------------- the case peek
 *
 * The quick look behind a dashboard tile (js/case-peek.js): the reports a number counts,
 * as a list of rows in the app's own dress. Only the hot-impact chip gets colour, and it
 * is the warn token the tiles themselves use — a peek that shouted louder than the queue
 * would misreport the queue.
 *
 * ## Why this is not a table any more
 *
 * It was: seven nowrap columns, which is a shape that cannot be made to fit. A 660px
 * dialog gave it a horizontal scrollbar and the reader a sideways drag to see the last
 * three facts of every row; on a phone the same drag in a third of the width. Worse, it
 * did not LOOK like this app — every other list here is a stack of rows with a border
 * between them, and a spreadsheet inside a card reads as something borrowed from
 * elsewhere. So the seven facts stack the way `.attn` and `.oblig` stack theirs, three
 * lines to a case, and the row reflows instead of scrolling at every width there is.
 *
 * `.peek__table` and `.peek__hot` stay: AI verification's own table still wears them.
 */
.peek__table {
  width: 100%;
  margin-top: 4px;
}

.peek__table td,
.peek__table th {
  white-space: nowrap;
}

.peeklist {
  margin-top: 4px;
}

/* Between rows and not above the first: the hint over this list is often empty, and a
   border under nothing is a line the reader has to account for. */
.peekcase {
  padding: 11px 0;
}

.peekcase + .peekcase {
  border-top: 1px solid var(--border);
}

/* The reference leads and the two chips sit at the far end, so a column of rows has one
   ragged edge rather than two. Wraps on a narrow phone, at which point the chips take the
   next line whole instead of squeezing the reference to three characters. */
.peekcase__top {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 4px 12px;
}

.peekcase__ref {
  font-family: var(--mono);
  font-size: 0.82rem;
  font-weight: 600;
  overflow-wrap: anywhere;
}

.peekcase__tags {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

/* The pill this app uses everywhere something is being labelled rather than said. */
.peekcase__tag {
  padding: 2px 9px;
  border-radius: 999px;
  background: var(--surface-3);
  color: var(--text-dim);
  font-size: 0.7rem;
  font-weight: 700;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  white-space: nowrap;
}

.peekcase__tag--hot {
  background: var(--warn-soft);
  color: var(--warn);
}

.peekcase__where {
  margin: 3px 0 0;
  font-size: 0.9rem;
  font-weight: 600;
}

/* What it is, and how long it has been waiting — the two facts that answer "does this
   need me now", on one line with the age pushed to the end of it. */
.peekcase__what {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 2px 12px;
  margin: 2px 0 0;
  color: var(--text-dim);
  font-size: 0.86rem;
}

.peekcase__when {
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/*
 * Three buttons in a footer built for two.
 *
 * The agent takes the left end and the two ways out keep the right, because it is a
 * different KIND of thing from Close and Open in Case monitor — it acts on the list you
 * are looking at rather than deciding where you go next, and a row of three identical
 * right-aligned buttons hides that. `margin-right: auto` on the first rather than
 * `space-between` on the row, so the layout is unchanged on every peek that has no agent
 * button: two buttons, right, exactly as before.
 *
 * And it wraps, because at 390px "AI Reviewer Agent" + "Close" + "Open in Case monitor" is
 * wider than the phone. Wrapped, the agent gets a line and the exits keep theirs.
 */
.peek__foot {
  flex-wrap: wrap;
}

.peek__foot .agentrow__btn {
  margin-right: auto;
}

/*
 * The AI Reviewer Agent popup. Wider than the standard dialog because it is a seven-column
 * table being read across, and .modal's 660px folded the reason column to a word a line.
 * Everything else — colors, radius, the open animation — is .modal's own.
 *
 * ITS OWN NAME, and that is the point of this rule rather than an incidental detail. This
 * dialog opened life as `.modal--wide`, which the starter's seven-tile chooser also claims —
 * and that one is written as `.modal.modal--wide` a few thousand lines further down, so it won
 * on specificity regardless of order and this box quietly opened at 820px from the day it
 * shipped. "Wide" is not a name; it is an adjective two unrelated dialogs will both reach for
 * and then disagree about. Named for the thing it is, it cannot be taken.
 */
.modal--verify {
  width: min(1080px, calc(100vw - 32px));
}

/*
 * Where and Issue type are facts that fit on one line; the two prose columns are the reading.
 *
 * Auto table layout gives a nowrap cell whatever width its longest content wants, so
 * "Auditorium 1 · Alpha Cinema 4" was claiming a quarter of the dialog while "What it says"
 * and "Agent's reason" folded to three words a line beside it — the two columns an admin is
 * actually here to read, squeezed by the two they glance at. Fixed layout with declared widths
 * puts the space where the reading is, and holds the columns still from row to row.
 */
/*
 * The widths belong to the TABLE, so they are declared only where there is one.
 *
 * Below the breakpoint each row becomes a card and every cell becomes a block — at which point
 * a `width: 20%` stops meaning "a fifth of the table" and starts meaning "a fifth of the card",
 * which is 60px, which is one word per line. The phone layout set `width: auto` on `th` and
 * lost to `th:nth-child(1)` on specificity, so "Auditorium 1 · Alpha Cinema 4" came down as a
 * five-line ribbon. Fencing the widths off by width is the fix that cannot be out-specified.
 */
@media (min-width: 701px) {
  .verify__table {
    table-layout: fixed;
  }

  /*
   * Rebalanced when the reason column stopped being one sentence.
   *
   * On the group list that cell now carries four things — which job this rides with, whether
   * that job is already out with the cinema, what the job itself says, and the agent's reason
   * — and at 20% the quoted line broke to three. The width comes off the four columns that are
   * glanced at rather than read; "What it says" keeps most of its own, because the two prose
   * columns are what the decision is actually made from.
   */
  .verify__table th:nth-child(1) { width: 17%; }  /* Where */
  .verify__table th:nth-child(2) { width: 11%; }  /* Issue type */
  .verify__table th:nth-child(3) { width: 8%; }   /* Impact */
  .verify__table th:nth-child(4) { width: 25%; }  /* What it says */
  .verify__table th:nth-child(5) { width: 28%; }  /* Agent’s reason */
  .verify__table th:nth-child(6) { width: 11%; }  /* the Move button */

  /* Fixed layout would otherwise clip these two mid-word: they are the columns that wrap. */
  .verify__table th.verify__where,
  .verify__table td.verify__type {
    white-space: normal;
  }
}

/*
 * ---------------------------------------------------- the same list, as cards, on a phone
 *
 * Six columns of prose do not survive 390px, and the first attempt proved it: the header ran
 * together into "CASWHERE ISSUEIMPWHAT IT SAYSAGENT'S RI", the description came down in a
 * three-character ribbon, and the Move button hung off the right edge of the dialog.
 *
 * A table is a shape for COMPARING rows — read down a column, spot the outlier — and that is a
 * thing a phone cannot offer at any font size. So on a phone it stops pretending: each report
 * becomes a card that is read on its own, in the order the eye wants it. Where it happened,
 * what kind and how bad, what the guest said, what the agent made of it, and the one button
 * that overrules it.
 *
 * 700px rather than the app's usual 660: this table is six columns of sentences where the case
 * list's six are short values, and it was already tight at 660 before it broke.
 */
@media (max-width: 700px) {
  .verify__table,
  .verify__table tbody,
  .verify__table tr,
  .verify__table th,
  .verify__table td {
    display: block;
    width: auto;
  }

  /* The headings are the card's own layout now — "Auditorium 2 · Durango 9" needs no label
     saying "Where", and six labels per card is more furniture than content. */
  .verify__table thead {
    display: none;
  }

  .verify__table tbody tr {
    padding: 12px 14px;
    margin: 0 0 10px;
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    background: var(--surface-2, var(--surface));
  }

  /* Nothing is nowrap in a card: every cell here is prose and gets the width it needs. */
  .verify__table th,
  .verify__table td {
    white-space: normal;
    padding: 0;
    border: 0;
  }

  .verify__table th.verify__where {
    font-weight: 650;
    color: var(--text);
    margin-bottom: 4px;
  }

  /* The two short facts share a line — stacked, they were two lines saying three words. */
  .verify__table td.verify__type,
  .verify__table td.verify__impact {
    display: inline-block;
  }

  .verify__table td.verify__type::after {
    content: '·';
    margin: 0 7px;
    opacity: 0.5;
  }

  .verify__table td.verify__said {
    margin: 8px 0 0;
    color: var(--text);
  }

  /*
   * Labelled, because "Off-topic comment, no cinema fault named" under a guest's own words is
   * otherwise two paragraphs with no indication of who wrote which. The column heading did
   * this job on a desktop; on a card the prefix does it.
   *
   * On the REASON, not on the cell around it. The cell holds three voices now — which job this
   * rides with, where that job already is, and what the job itself says — and a prefix on the
   * cell put "Agent:" above another guest's quoted words, which is the one thing it must not
   * claim. See the group list in js/ai-verify.js.
   */
  .verify__table td.verify__why .verify__reason::before {
    content: 'Agent: ';
    font-weight: 650;
    color: var(--text-muted, var(--text));
  }

  .verify__table td.verify__why {
    margin-top: 6px;
  }

  .verify__table td.verify__actions {
    margin-top: 10px;
    text-align: left;
  }

  .verify__table td.verify__actions .btn {
    width: 100%;
  }
}

.verify__tabs {
  display: flex;
  gap: 8px;
  margin: 4px 0 10px;
}

/* The two text columns wrap; the fact columns stay one line like the peek's. The widths are
   declared with the table's fixed layout above — a max-width here would fight it. */
.verify__table td.verify__said,
.verify__table td.verify__why {
  white-space: normal;
}

.verify__why {
  color: var(--text-dim);
}

/*
 * The agent's own sentence, as its own line.
 *
 * A bare span would run on from the quoted words above it — "…queues for the doors." Same
 * floor by the same doors — which is two people's writing in one paragraph. It is also what
 * the phone layout hangs its "Agent:" prefix on, so it has to be a block for the prefix to
 * lead a line rather than appear mid-sentence.
 */
.verify__reason {
  display: block;
}

/*
 * "Same fault as CQ-…", above the agent's own words rather than beside them.
 *
 * A block, because the two spans in this cell answer different questions — WHICH job this
 * rides with, and WHY the agent thought so — and inline they ran together into one unreadable
 * string ("CQ-DEMO-6Same floor, same evening"). The reference is the part an admin acts on,
 * so it leads, in the app's own accent.
 */
.verify__fold {
  display: block;
  color: var(--accent);
  font-weight: 600;
}

/* -------------------------------------------------- the agent, visibly at work
 *
 * The model reasons over every waiting report at high effort, which on a morning queue is tens
 * of seconds. What stood here was one line of static text over an empty box, and a still page
 * during a long wait does not read as "working" — it reads as broken.
 *
 * So the app's own spark breathes: the same mark on the button that opened the box and on the
 * tag every report leaves wearing, scaled and dimmed on a slow cycle with a halo behind it on
 * a longer one, so the two never sit in step and the whole thing never looks like a spinner
 * counting down to something.
 */

.verify__working {
  display: grid;
  justify-items: center;
  gap: 4px;
  padding: 54px 20px 58px;
  text-align: center;
}

.verify__working-mark {
  position: relative;
  display: grid;
  place-items: center;
  width: 76px;
  height: 76px;
  margin-bottom: 10px;
  color: var(--accent);
}

/* The halo: a soft disc that swells behind the star, on a longer cycle than the star's own. */
.verify__working-mark::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: radial-gradient(
    circle,
    color-mix(in oklab, var(--accent) 30%, transparent),
    transparent 68%
  );
  animation: verify-halo 2.9s ease-in-out infinite;
}

.verify__working-spark {
  position: relative;
  animation: verify-spark 1.7s ease-in-out infinite;
}

@keyframes verify-spark {
  0%,
  100% {
    transform: scale(0.88) rotate(-6deg);
    opacity: 0.62;
  }
  50% {
    transform: scale(1.06) rotate(6deg);
    opacity: 1;
  }
}

@keyframes verify-halo {
  0%,
  100% {
    transform: scale(0.72);
    opacity: 0.25;
  }
  50% {
    transform: scale(1);
    opacity: 0.6;
  }
}

.verify__working-said {
  margin: 0;
  font-size: 1rem;
  font-weight: 650;
  color: var(--text);
}

.verify__working-note {
  margin: 0;
  font-size: 0.88rem;
  color: var(--text-dim);
}

/*
 * A reader who asked for no motion gets the same star on the same spot, still and legible —
 * not a lesser screen. The sentence under it was always the thing carrying the meaning.
 */
@media (prefers-reduced-motion: reduce) {
  .verify__working-spark,
  .verify__working-mark::before {
    animation: none;
  }

  .verify__working-mark::before {
    opacity: 0.35;
  }
}

/*
 * "Already sent to the cinema", under the reference it names.
 *
 * A different fact from the two spans around it — not which job, and not why the agent thinks
 * so, but WHERE that job already is — so it gets its own line rather than joining either. In
 * the warning colour and not the accent, because this is the row on the list an admin should
 * read twice before a bulk Confirm: agreeing hands a guest's report to a building that may be
 * halfway through the repair.
 */
.verify__open {
  display: block;
  color: var(--warn);
  font-weight: 600;
  font-size: 0.85em;
}

/*
 * What the job it would be folded into actually says, quoted.
 *
 * A reference alone asked an admin to trust the grouping or go and look the case up in another
 * tab — and the one they most need to check is the one they cannot see, because a job already
 * out with the cinema is on no list in this box. Set apart from the agent's own reason under it
 * by the quotation marks and the italic: three different voices share this cell — which job,
 * where that job is, and what the agent made of it — and a reader has to tell them apart at a
 * glance.
 */
.verify__quote {
  display: block;
  margin: 2px 0;
  color: var(--text);
  font-style: italic;
}

.verify__actions {
  text-align: right;
}

.peek__hot {
  color: var(--warn);
  font-weight: 600;
}

.peek__empty {
  margin: 14px 0 0;
  color: var(--text-dim);
}

/* ---------------------------------------------------------------------------------------
   The Same fault box: folding two reports of one fault together by hand (js/same-fault.js).

   A LIST, not a table, and that is a decision rather than laziness. The agent's box beside it
   is a table because every row there carries the same five facts and the reader is comparing
   them down a column. This one is read row by row — "is THIS the report I am looking at" —
   and the answer is a room, a sentence, and a button. A table would have bought column
   alignment nobody needs and a phone layout that has to be rebuilt cell by cell (see the
   .verify__table media query above, which is 70 lines of exactly that).
   --------------------------------------------------------------------------------------- */

.fold__heading {
  margin: 18px 0 8px;
  font-size: 0.95rem;
  font-weight: 650;
}

/* The first heading follows the explanation rather than a list, so it needs less air above
   it — but not none, or the two paragraphs read as one. */
.fold__heading:first-child {
  margin-top: 10px;
}

.fold__find {
  width: 100%;
  margin: 0 0 10px;
}

.fold__list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.fold__row {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface-2, var(--surface));
}

/* The words take the room; the button takes what it needs. Without min-width a long
   description refuses to wrap and pushes the button off the edge of the dialog. */
.fold__what {
  flex: 1;
  min-width: 0;
}

.fold__where {
  margin: 0;
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 6px;
  font-weight: 600;
}

.fold__sep {
  opacity: 0.45;
  font-weight: 400;
}

/* Already carrying riders — the same words as the card badge, in the same accent, so the
   two are recognisably one fact in two places. */
.fold__riders {
  color: var(--accent);
  font-weight: 600;
  font-size: 0.85em;
}

/*
 * The guest's own words, clamped to two lines.
 *
 * Full text would make one long report taller than the six around it and turn a scan into a
 * scroll — and the point of this list is to find a match in it. Two lines is enough to
 * recognise a fault somebody has already read once today.
 */
.fold__said {
  margin: 4px 0 0;
  color: var(--text);
  display: -webkit-box;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.fold__when {
  margin: 4px 0 0;
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  color: var(--text-dim);
  font-size: 0.85em;
}

.fold__ref {
  font-variant-numeric: tabular-nums;
}

.fold__go {
  flex: none;
}

/*
 * "One more report is folded in that is still with head office for review."
 *
 * A note about the LIST rather than a row in it, so it reads as one: dimmed and set slightly
 * back from the rows below. It appears only on a manager's copy of this box, where the count
 * in the heading above is larger than the number of rows underneath — see the same-fault route
 * in src/routes/gm.js for why they are shown a number and not the report.
 */
.fold__hidden {
  margin: 0 0 10px;
  color: var(--text-dim);
  font-size: 0.88rem;
}

.fold__already {
  margin: 4px 0 10px;
  color: var(--text);
}

.fold__standalone {
  margin: 0 0 4px;
}

/* On a phone the button goes under the words at full width, the same shape every other
   action in the app takes down there. */
@media (max-width: 700px) {
  .fold__row {
    flex-direction: column;
    align-items: stretch;
  }

  .fold__go .btn {
    width: 100%;
  }
}

.modal__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: 0 18px;
}

.modal__grid .field:last-child {
  grid-column: 1 / -1;
}

/* Sticky, so the primary action stays reachable when a form scrolls — which it
   does on a phone. */
.modal__foot {
  position: sticky;
  bottom: -20px;
  display: flex;
  justify-content: flex-end;
  gap: 10px;
  margin-top: 8px;
  padding: 16px 0;
  border-top: 1px solid var(--border);
  background: var(--surface);
}

/*
 * A footer that is the dialog's own child, not the body's.
 *
 * The zero horizontal padding above assumes the foot sits INSIDE .modal__body and inherits
 * its 20px inset — true of the confirm boxes, and not of the dialogs built head/body/foot
 * (the case peek, AI verification): there the buttons sat flush against the dialog border.
 * Scoped with a child combinator so the nested feet, which are already inset by the body,
 * are not indented twice.
 */
.modal > .modal__foot {
  padding: 16px 20px;
  margin-top: 0;
  bottom: 0;
}

/* ------------------------------------------------------ inspection compliance
 *
 * Which yearly inspections a building has passed, in the pop-up the Location info
 * button opens. Two columns, because that is the question the page is asked: was this done
 * last year, and has it been done this year.
 *
 * A grid rather than a <table>, and for one reason: on a phone each inspection has to stop
 * being a row of three cells and become a small block with the two years stacked and
 * labelled. A table cannot do that without `display: block` on every part of it, which
 * throws away the semantics the table was for; a grid of three columns collapsing to one is
 * the same layout said in a way that reflows.
 */
.compliance {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin: 4px 0 16px;
}

.compliance__summary {
  margin: 0 0 12px;
  font-size: 0.88rem;
  color: var(--text-dim);
}

/* The one number in the box worth a colour: how much is missing. */
.compliance__summary--due {
  color: var(--warn);
  font-weight: 550;
}

.compliance__head,
.compliance__row {
  display: grid;
  grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr) minmax(0, 1fr);
  gap: 10px;
  align-items: center;
}

.compliance__head {
  padding: 0 12px;
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-faint);
  font-weight: 650;
}

.compliance__row {
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
}

.compliance__name {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 4px 8px;
  font-weight: 600;
  font-size: 0.9rem;
}

/* A row with both years empty but records further back is not the same as one that has
   never been inspected, and the grid has no third column to say so. */
.compliance__earlier {
  font-size: 0.72rem;
  font-weight: 500;
  color: var(--text-faint);
}

.compliance__cell {
  display: flex;
  flex-direction: column;
  gap: 1px;
  min-width: 0;
}

.compliance__date {
  font-variant-numeric: tabular-nums;
  font-size: 0.88rem;
}

.compliance__cert {
  font-family: var(--mono);
  font-size: 0.72rem;
  color: var(--text-faint);
  overflow-wrap: anywhere;
}

/* Sized to the words rather than stretched across the cell: a full-width button in each of
   ten cells is a wall of buttons, and this one is an invitation, not the row's purpose. */
.compliance__add {
  align-self: start;
  flex: 0 0 auto;
}

/*
 * The form is its own box now, so it needs almost nothing here — `.modal` and its parts do
 * the work, and it is `--narrow` because four short fields do not want the width of the grid
 * behind them.
 *
 * It began as a panel revealed inside the compliance box, and on a phone that was wrong
 * twice: it appeared below a grid taller than the screen, so opening it landed somewhere in
 * the middle of itself, and it shared a background with the rows above so the whole box read
 * as one long thing. Both are gone with the backdrop between them.
 */
.compliance__form-year {
  font-weight: 500;
  color: var(--text-dim);
}

/*
 * The notes field is short here, unlike every other textarea in the app.
 *
 * The app-wide `min-height: 148px` is right for the ones it was written for — a guest
 * describing what went wrong, a manager writing up progress — where somebody is there to
 * type a paragraph and a box that starts small says "keep it brief". This one is an aside on
 * a form whose point is a date, and at full height it was the tallest thing in a box that
 * already needed two screens of scrolling on a phone.
 *
 * Scoped to the dialog rather than made a modifier class, following `#report-dialog .field`
 * further up: it is one box's opinion about its own contents, not a kind of field.
 */
#inspection-form-dialog textarea {
  min-height: 84px;
}

/*
 * And the closing line, for the same reason and by the same route.
 *
 * It sits directly under the team's note in the "Mark resolved" dialog, and at the app-wide
 * height the two were identical boxes — which invites the same kind of writing in both, when
 * the whole point of there being two is that one is a paragraph for colleagues and the other is
 * one sentence for a stranger. Capped at 240 characters server-side (LIMITS.guestOutcome); a box
 * about three lines tall is what says so before anybody hits the cap.
 */
#progress-outcome {
  min-height: 76px;
}

/* Two fields to a row where they fit, one where they do not. Not `.modal__grid`: that
   deliberately spans its last child, which is right for a form ending in a description and
   wrong for one whose last field is a reference number. */
.compliance__form-row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
  gap: 0 16px;
}

/* Every record, behind a disclosure — shut, because the grid is what the box was opened
   for and eleven years of records above it would push the answer off the screen. */
.record-list {
  border-top: 1px solid var(--border);
  margin-top: 4px;
}

.record-list[hidden] {
  display: none;
}

.record-list__summary {
  padding: 12px 2px;
  cursor: pointer;
  list-style: none;
  position: relative;
  padding-left: 20px;
}

.record-list__summary::-webkit-details-marker {
  display: none;
}

.record-list__summary::before {
  content: '';
  position: absolute;
  left: 3px;
  top: 50%;
  width: 6px;
  height: 6px;
  margin-top: -4px;
  border-right: 2px solid var(--text-dim);
  border-bottom: 2px solid var(--text-dim);
  transform: rotate(-45deg);
  transition: transform 160ms var(--ease);
}

.record-list[open] > .record-list__summary::before {
  transform: rotate(45deg);
  margin-top: -5px;
}

.record-list__label {
  font-size: 0.88rem;
  font-weight: 600;
}

.record-list__body {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 0 0 12px;
}

.record {
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
}

.record__main {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 4px 10px;
}

.record__year {
  font-variant-numeric: tabular-nums;
  font-weight: 700;
  font-size: 0.9rem;
}

.record__name {
  font-weight: 550;
  font-size: 0.9rem;
}

.record__date {
  font-variant-numeric: tabular-nums;
  font-size: 0.82rem;
  color: var(--text-dim);
}

.record__cert {
  font-family: var(--mono);
  font-size: 0.74rem;
  color: var(--text-faint);
}

.record__notes {
  margin: 6px 0 0;
  font-size: 0.85rem;
  color: var(--text-dim);
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

.record__foot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-top: 8px;
}

.record__by {
  font-size: 0.76rem;
  color: var(--text-faint);
}

/* A count riding on a button, the same badge the tab strip uses. */
.btn__count {
  margin-left: 7px;
  padding: 1px 7px;
  border-radius: 999px;
  background: var(--warn-soft);
  color: var(--warn);
  font-size: 0.74rem;
  font-weight: 650;
  font-variant-numeric: tabular-nums;
}

/* Step 1 — the two things you can add. */
.choice {
  display: block;
  width: 100%;
  text-align: left;
  font: inherit;
  padding: 16px 18px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface-2);
  color: var(--text);
  cursor: pointer;
  transition:
    background-color 140ms var(--ease),
    border-color 140ms var(--ease),
    transform 100ms var(--ease);
}

.choice + .choice {
  margin-top: 12px;
}

.choice:hover {
  background: var(--accent-soft);
  border-color: color-mix(in oklab, var(--accent) 50%, transparent);
}

.choice:active {
  transform: translateY(1px);
}

.choice__title {
  display: block;
  font-weight: 650;
  font-size: 1rem;
  letter-spacing: -0.01em;
  margin-bottom: 3px;
}

.choice__sub {
  display: block;
  font-size: 0.87rem;
  color: var(--text-dim);
  line-height: 1.45;
}

@media (max-width: 560px) {
  /* The tile and the gap give up a few pixels, so "Printable QR placards" and its icon
     still sit inside a 320px phone with something either side of them. */
  .page-head__title {
    gap: 10px;
  }

  .page-head__icon {
    width: 36px;
    height: 36px;
    border-radius: 11px;
  }

  .page-head__icon svg {
    width: 19px;
    height: 19px;
  }

  /* Full width on a phone, which is what a page's one action should be there. Stacked
     rather than side by side: two full-width buttons on one row is two half-width
     buttons, and the labels wrap to three lines each. */
  .page-head__actions,
  .page-head > .btn {
    width: 100%;
    flex-direction: column;
  }

  .page-head__actions .btn,
  .page-head > .btn {
    width: 100%;
  }
}

/* --------------------------------------------- collapsible screens section
   A 16-screen cinema would push everything below it off the page, so the
   auditorium list is a disclosure that starts closed. */
.screens {
  border-bottom: 1px solid var(--border);
}

.screens:last-child {
  border-bottom: 0;
}

.screens__summary {
  display: flex;
  align-items: baseline;
  gap: 10px;
  flex-wrap: wrap;
  padding: 12px 20px 12px 42px;
  position: relative;
  cursor: pointer;
  background: color-mix(in oklab, var(--p-base) 45%, transparent);
  user-select: none;
  transition: background-color 140ms var(--ease);
}

/* Replace the native triangle with a chevron we can animate. */
.screens__summary::-webkit-details-marker {
  display: none;
}

.screens__summary {
  list-style: none;
}

.screens__summary::before {
  content: '';
  position: absolute;
  left: 20px;
  top: 50%;
  width: 7px;
  height: 7px;
  margin-top: -5px;
  border-right: 2px solid var(--text-dim);
  border-bottom: 2px solid var(--text-dim);
  transform: rotate(-45deg);
  transition: transform 160ms var(--ease);
}

.screens[open] > .screens__summary::before {
  transform: rotate(45deg);
  margin-top: -6px;
}

.screens__summary:hover {
  background: var(--surface-3);
}

.screens[open] > .screens__summary {
  border-bottom: 1px solid var(--border);
}

.screens__title {
  font-weight: 680;
  font-size: 0.95rem;
  letter-spacing: -0.01em;
}

.screens__meta {
  font-size: 0.83rem;
  color: var(--text-dim);
  font-variant-numeric: tabular-nums;
}

/* Unsaved deletions are the one thing that must be obvious while closed. */
.screens__meta--warn {
  color: var(--danger);
  font-weight: 600;
}

/* Guidance only matters once the list is open. */
.screens__hint {
  margin-left: auto;
  display: none;
}

.screens[open] > .screens__summary .screens__hint {
  display: inline;
}

/* ------------------------------------------------------ the holiday shelf
   The rule days as a ticked list a year, the typed ranges under them, and the
   form. A disclosure like the auditoriums, because a year of holidays under
   every cinema is a page nobody asked for. See holidayList in js/locations.js. */
.holidays__list {
  list-style: none;
  margin: 8px 0 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
  gap: 4px 18px;
}

.holidays__list--ranges {
  grid-template-columns: 1fr;
}

.holidays__row {
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: 28px;
  font-size: 0.9rem;
}

.holidays__tick {
  display: flex;
  align-items: center;
  gap: 10px;
  cursor: pointer;
}

.holidays__tick input {
  margin: 0;
  accent-color: var(--accent);
}

.holidays__day {
  color: var(--text-dim);
  font-variant-numeric: tabular-nums;
  min-width: 6.5em;
}

/* A day this building does not observe stays listed — struck, so the tick can go back on. */
.holidays__row--skipped .holidays__name {
  text-decoration: line-through;
  color: var(--text-faint);
}

.holidays__row .btn {
  margin-left: auto;
}

.holidays__form {
  padding: 14px 20px 18px;
}

.holidays__fields {
  display: flex;
  flex-wrap: wrap;
  gap: 12px 16px;
  align-items: flex-end;
  margin-top: 10px;
}

.holidays__fields .field {
  margin: 0;
  min-width: 160px;
  flex: 1 1 160px;
}

.holidays__fields select {
  width: 100%;
}

.holidays__submit {
  flex: 0 0 auto;
  min-width: 0;
}

/* ------------------------------------------------- revealed edit actions
   Rows show a single Edit button. The panel it opens holds the fields plus
   everything else you can do to that row, separated by a rule so the
   destructive options are not mistaken for part of the form. */
.edit-more {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  width: 100%;
  margin-top: 14px;
  padding-top: 14px;
  border-top: 1px dashed var(--border-strong);
}

.edit-more__label {
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  font-weight: 600;
  color: var(--text-faint);
  margin-right: 2px;
}

/* -------------------------------------------------- managers on a cinema */
.managers {
  border-bottom: 1px solid var(--border);
}

/* A cinema with nobody assigned is a real problem — validated reports would
   have nowhere to go — so it is called out rather than stated flatly. */
.managers__warn {
  color: var(--warn);
  font-weight: 600;
}

.managers__head {
  display: flex;
  align-items: baseline;
  gap: 10px;
  flex-wrap: wrap;
  padding: 11px 20px;
  background: color-mix(in oklab, var(--p-base) 45%, transparent);
  border-bottom: 1px solid var(--border);
}

/* A select used as an action inside a row of buttons, rather than a form field:
   sized to its content and matched to .btn--sm. */
.inline-select {
  width: auto;
  flex: 0 0 auto;
  min-height: 34px;
  padding: 5px 30px 5px 11px;
  font-size: 0.85rem;
  border-radius: 8px;
  background-position: right 13px center, right 8px center;
}

.inline-select:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

/* ------------------------------------------- staged (unsaved) deletions */

/* A row marked for deletion but not yet saved. Tinted and struck through so it
   is obviously different from a row that is simply archived. */
.complaint--staged {
  background: var(--danger-soft);
}

.auditorium__number--struck {
  text-decoration: line-through;
  text-decoration-thickness: 2px;
  text-decoration-color: color-mix(in oklab, var(--danger) 70%, transparent);
  color: var(--text-dim);
}

.badge--pending-delete {
  background: transparent;
  color: var(--danger);
  border-color: var(--danger);
  border-style: dashed;
}

/* The commit point for staged deletions. */
.savebar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 14px;
  padding: 16px 20px;
  background: linear-gradient(180deg, var(--danger-soft), var(--surface-2));
  border-top: 1px solid color-mix(in oklab, var(--danger) 45%, transparent);
}

.savebar__text {
  flex: 1 1 260px;
  min-width: 0;
}

/*
 * The Settings page's save bar follows you down the page. Its neutral tint is not
 * the dashed-danger one the locations page uses — nothing here is destructive,
 * and borrowing the red would cry wolf.
 */
.savebar--sticky {
  position: sticky;
  /* Clear of the very bottom so it reads as floating over the page, not welded on. */
  bottom: 16px;
  z-index: 20;
  margin-top: 16px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-lg);
  background: var(--glass-strong);
  -webkit-backdrop-filter: blur(14px);
  backdrop-filter: blur(14px);
  box-shadow: var(--shadow-lift);
}

/*
 * Room to scroll the last row clear of the pinned bar — but only while there IS a
 * bar. It appears on the first change and nowhere else, so holding 180px of empty
 * page open for it the rest of the time would be worse than the bar was.
 *
 * A sticky element still occupies its place in the flow, so the page has to be
 * scrollable far enough to reach it, or the bar stays stuck over the content above
 * it. Sized for its tallest form, the phone layout where the text and buttons wrap
 * onto separate rows.
 */
.shell--medium:has(#savebar:not([hidden])) {
  padding-bottom: 180px;
}

/* ------------------------------------------------- settings: one shelf at a time
 *
 * The rail — shared with the console, see .railed further down — chooses a group, and
 * everything not on it comes off the page rather than being dimmed. A settings page's
 * problem IS how much is on it, and a greyed row still costs a line to read past.
 *
 * Two rules and no layout in JavaScript. The shell widens by exactly the rail's width
 * plus its gap, so the column the settings live in stays the 880px it has always been
 * instead of losing 200 of them to the menu. And a reader with only one shelf — a
 * general manager has only their own — gets no rail and no column, which is precisely
 * the page they had before any of this existed.
 */
.shell--medium:has(.rail:not([hidden])) {
  max-width: 1100px;
}

.railed:has(> .rail[hidden]) {
  grid-template-columns: minmax(0, 1fr);
}

/*
 * Not the `hidden` attribute, deliberately. That one is how a ROLE takes a section away
 * entirely — a manager has no business seeing Circuits — and it is read all over
 * settings.js to mean exactly that. Two mechanisms writing one attribute would let
 * "you may not see this" and "this is on another shelf" overwrite each other, and the
 * loser would be a section somebody is permitted to reach and cannot.
 */
.section--elsewhere {
  display: none;
}

/* ------------------------------------------------------------- settings page */
/*
 * Every section of the page is a disclosure, closed by default.
 *
 * Open all at once, the page is something you scroll to read rather than something
 * you use: the theme swatches alone are two rows tall, and what you came for is
 * usually one row in one section. Closed, the page is a short list of what is here,
 * and each summary carries a count so a shut section still says how much is inside.
 *
 * Native <details>, the same as the auditorium list on the locations page — so
 * keyboard support, find-in-page opening the right section, and the browser's own
 * expand-all come for free rather than being reimplemented.
 */
.section {
  /* The summary tints the full width, so the card's padding moves inwards to it. */
  padding: 0;
  /* Clips that tint to the rounded corners. */
  overflow: hidden;
}

.section__summary {
  list-style: none;
  display: flex;
  align-items: baseline;
  gap: 10px;
  flex-wrap: wrap;
  padding: 16px 22px 16px 44px;
  position: relative;
  cursor: pointer;
  user-select: none;
  transition: background-color 140ms var(--ease);
}

/* Replace the native triangle with a chevron we can animate. */
.section__summary::-webkit-details-marker {
  display: none;
}

.section__summary::before {
  content: '';
  position: absolute;
  left: 22px;
  top: 50%;
  width: 7px;
  height: 7px;
  margin-top: -5px;
  border-right: 2px solid var(--text-dim);
  border-bottom: 2px solid var(--text-dim);
  transform: rotate(-45deg);
  transition: transform 160ms var(--ease);
}

.section[open] > .section__summary::before {
  transform: rotate(45deg);
  margin-top: -6px;
}

.section__summary:hover {
  background: var(--surface-3);
}

/* Inset, because the card clips its overflow and a +2px offset ring would be cut
   off on three sides. */
.section__summary:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: -3px;
}

.section[open] > .section__summary {
  border-bottom: 1px solid var(--border);
}

/*
 * Still a real <h2> inside the summary — which HTML allows — so a screen reader
 * user can jump between sections by heading instead of tabbing through five
 * disclosures to find the one they want.
 */
.section__title {
  margin: 0;
  font-weight: 680;
  font-size: 1.05rem;
  letter-spacing: -0.01em;
}

.section__meta {
  font-size: 0.83rem;
  color: var(--text-dim);
  font-variant-numeric: tabular-nums;
}

/*
 * An edit hidden inside a closed section is the one thing collapsing must not do
 * quietly, so it is marked on the summary as well as counted in the save bar.
 * Accent rather than danger: nothing here is destructive, and the locations page's
 * red belongs to staged deletions.
 */
.section__meta--dirty {
  color: var(--accent);
  font-weight: 600;
}

/*
 * How many things are waiting, on the home page's Needs attention panel — the one
 * part of a closed disclosure that has to carry weight, since closing it is how you
 * put the warnings away. Declared here beside the other modifier rather than with
 * the panel: both are one class deep, so the plain .section__meta below would win
 * on source order and quietly grey it out.
 */
.section__meta--alert {
  color: var(--warn);
  font-weight: 620;
}

.section__body {
  padding: 20px 22px 22px;
}

#settings-form {
  margin: 0;
}

/*
 * `.card + .card` cannot reach across the form the schema's sections live in, so the
 * gap around it is stated. Only while it holds sections: a General Manager's form is
 * empty, and an empty form should take up no room at all.
 */
#settings-form:has(.section) {
  margin: 16px 0;
}

.setting + .setting,
/* The theme card's circuit-wide rows sit in a wrapper of their own, so the divider
   between "Your theme" and what follows has to cross it — going in, and coming out
   again to whatever the card ends with. Adjacency cannot see through an element, and
   the row after that wrapper is a sibling of the WRAPPER rather than of a setting. */
#theme-circuit > .setting:first-child,
#theme-circuit + .setting {
  margin-top: 22px;
  padding-top: 22px;
  border-top: 1px solid var(--border);
}

/* A section that opens with a line about itself before its first control. Without
   this the intro and the first setting's label stack as one four-line paragraph. */
.section__body > .field__hint:first-child + .setting {
  margin-top: 18px;
}

.setting__head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px;
  margin-bottom: 4px;
}

.setting__label {
  font-weight: 650;
  font-size: 0.95rem;
  color: var(--text);
}

/* Pushed to the right so the labels down the page stay a readable column. */
.setting__source {
  margin-left: auto;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  white-space: nowrap;
}

.setting__reset {
  border: 0;
  background: none;
  padding: 2px 4px;
  color: var(--text-dim);
  font: inherit;
  font-size: 0.8rem;
  text-decoration: underline;
  text-decoration-color: var(--border-strong);
  cursor: pointer;
}

.setting__reset:hover {
  color: var(--accent);
}

.setting__control {
  margin-top: 10px;
}

.setting__control input[type='text'],
.setting__control input[type='email'] {
  width: 100%;
}

.setting__control--inline {
  display: flex;
  align-items: center;
  gap: 9px;
}

.setting__control--inline input[type='checkbox'] {
  width: 18px;
  height: 18px;
  margin: 0;
  accent-color: var(--accent);
  cursor: pointer;
}

.setting__inline-label {
  font-size: 0.9rem;
  color: var(--text-dim);
  cursor: pointer;
}

.setting__pills {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

/*
 * The last remaining issue type is disabled: a form with nothing to submit is a
 * broken form, and finding that out from a guest holding a phone is too late.
 */
.pill input:disabled + span {
  cursor: not-allowed;
}

.pill input:disabled:checked + span {
  opacity: 0.75;
}

/* ------------------------------------------------------------ theme picker
   Swatches, not a dropdown: the thing being chosen is how it looks, and a list of
   names tells you nothing about which one you want.

   Each card carries data-preview="<id>", and the theme blocks at the top of this
   file are re-applied to it below — so a preview is drawn with the real theme's
   own tokens and cannot drift from what selecting it does. */
/*
 * A ROW OF CHIPS, where this was a grid of cards.
 *
 * It held eight themes, each a card with a swatch and a sentence of prose — a block of reading
 * standing between a reader and a choice they make once. Four remain, the names say what they
 * are, and the chip paints itself in the theme it offers, which was always the part that
 * actually answered the question. Both theme controls on the page use it: see themeSwatch.
 */
.themes {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 12px;
}

.theme {
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 8px 11px;
  text-align: left;
  font: inherit;
  cursor: pointer;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  /* The card paints itself in the theme it offers. */
  background: var(--bg);
  color: var(--text);
  transition:
    border-color 140ms var(--ease),
    transform 140ms var(--ease);
}

.theme:hover {
  border-color: var(--p-accent);
  transform: translateY(-1px);
}

.theme--chosen {
  border-color: var(--p-accent);
  box-shadow: 0 0 0 1px var(--p-accent) inset;
}

.theme__swatch {
  display: flex;
  gap: 3px;
}

.theme__chip {
  width: 15px;
  height: 15px;
  border-radius: 5px;
  border: 1px solid var(--border-strong);
}

.theme__chip--surface {
  background: var(--surface-2);
}

.theme__chip--accent {
  background: var(--p-accent-fill);
  border-color: var(--p-accent-fill);
}

.theme__chip--text {
  background: var(--p-text);
  border-color: var(--p-text);
}

.theme__name {
  display: flex;
  align-items: center;
  gap: 8px;
  font-weight: 650;
  font-size: 0.92rem;
}

.theme__default {
  font-size: 0.68rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  padding: 2px 6px;
  border-radius: 5px;
  color: var(--text-dim);
  background: var(--surface-3);
  border: 1px solid var(--border-strong);
}

.theme__note {
  font-size: 0.8rem;
  color: var(--text-dim);
  line-height: 1.45;
}

/* Which half of System the chip happens to be showing — the one theme that cannot preview
   itself, since it is whichever of the other two the device is in. */
.theme__note--live {
  color: var(--p-accent);
  font-weight: 600;
  font-size: 0.76rem;
}


/* Not a preview of anything, so it keeps the page's own colours. */
.theme--reset {
  align-items: flex-start;
  justify-content: center;
  background: var(--surface-2);
  color: var(--text-dim);
  border-style: dashed;
  font-size: 0.86rem;
  font-weight: 600;
}

.theme--reset:hover {
  color: var(--text);
}

/* --------------------------------------------------------- read-only facts */
.facts {
  margin: 16px 0 0;
}

.facts__row {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 16px;
  padding: 9px 0;
  border-top: 1px solid var(--border);
}

.facts dt {
  flex: 0 0 200px;
  color: var(--text-dim);
  font-size: 0.88rem;
}

.facts dd {
  flex: 1 1 200px;
  margin: 0;
  font-size: 0.88rem;
  font-weight: 600;
  overflow-wrap: anywhere;
}

.facts__warn {
  color: var(--warn);
}

.savebar__text strong {
  display: block;
  font-size: 0.95rem;
}

.savebar .field__hint {
  margin: 3px 0 0;
}

/* ---------------------------------------------------------------- states */
.empty {
  text-align: center;
  /* Premium pass: the brand's own mark where there is nothing to do — the
     moments the queue is clear are exactly where personality is affordable. */
  --reticle: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cg stroke='black' fill='none' stroke-linecap='round'%3E%3Ccircle cx='16' cy='16' r='9' stroke-width='3'/%3E%3Cpath d='M16 3.4V7.4' stroke-width='2.4'/%3E%3Cpath d='M16 24.6V28.6' stroke-width='2.4'/%3E%3Cpath d='M3.4 16H7.4' stroke-width='2.4'/%3E%3Cpath d='M24.6 16H28.6' stroke-width='2.4'/%3E%3C/g%3E%3Ccircle cx='16' cy='16' r='3.2' fill='black'/%3E%3C/svg%3E");
  padding: 56px 24px;
  color: var(--text-dim);
  background: var(--glass);
  box-shadow: var(--rim);
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius-lg);
}

.empty::before {
  content: '';
  display: block;
  width: 42px;
  height: 42px;
  margin: 0 auto 16px;
  background-color: var(--text-faint);
  -webkit-mask: var(--reticle) center / contain no-repeat;
  mask: var(--reticle) center / contain no-repeat;
}

.empty h2 {
  color: var(--text);
}

.empty p {
  margin: 0 auto;
  max-width: 46ch;
}

/*
 * The 404 page, where this box is the whole page rather than a gap in a list.
 *
 * A class rather than the `style="margin-top: 64px"` it used to be. That one attribute was the
 * only thing on any served page that needed 'unsafe-inline' on style-src, so it cost the entire
 * app the tightest directive in its CSP — see SECURITY_HEADERS in src/app.js.
 */
.empty--standalone {
  margin-top: 64px;
}

/* -------------------------------------------------------------- skeleton loaders
   What a page shows while its data is on the wire: grey shapes where the content will
   be, not a spinner beside where it will not. The shapes are honest about layout — a
   queue draws card-shaped ghosts, a table row-shaped ones — so the page does not jump
   when the real thing lands, and the reader already knows what kind of thing is coming.

   The shimmer is one sweeping gradient, and it inherits the app-wide reduced-motion
   rule further down this file: for readers who asked for stillness it degrades to a
   plain grey shape, which is the whole point of doing this in CSS.

   The wrapper carries role="status" and an aria-label, because the shapes themselves
   are silence: a screen reader gets the same "loading" a sighted reader infers. */

.skeleton {
  display: grid;
  gap: 16px;
}

.skel {
  position: relative;
  overflow: hidden;
  height: 12px;
  border-radius: 7px;
  background: color-mix(in oklab, var(--text) 9%, var(--surface-2));
}

.skel::after {
  content: '';
  position: absolute;
  inset: 0;
  transform: translateX(-100%);
  background: linear-gradient(
    90deg,
    transparent,
    color-mix(in oklab, var(--text) 8%, transparent),
    transparent
  );
  animation: skel-sweep 1.5s ease-in-out infinite;
}

@keyframes skel-sweep {
  to {
    transform: translateX(100%);
  }
}

/* The vocabulary: a title, lines of two lengths, a stat-sized block, an icon chip.
   Widths are classes rather than style attributes because the CSP refuses inline
   styles — and a skeleton is the one thing guaranteed to render before any script. */
.skel--title {
  height: 15px;
  width: 38%;
  max-width: 220px;
}

.skel--line-mid {
  width: 65%;
}

.skel--line-short {
  width: 40%;
}

.skel--chip {
  width: 40px;
  height: 40px;
  border-radius: 10px;
}

.skel--tile {
  height: 54px;
}

/* A ghost of a card, holding ghost lines the way the card will hold text. */
.skelcard {
  display: grid;
  gap: 12px;
  padding: 18px 20px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow);
}

/* A chip beside lines — the shape of a stat card or an account row. */
.skelcard--row {
  grid-template-columns: auto 1fr;
  align-items: center;
}

/* Lines stacked inside a card sit closer than cards sit to each other. */
.skelcard .skeleton {
  gap: 8px;
}

/* A grid of small tiles — the shape of a fact grid or a sheet of QR codes. */
.skel-tiles {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
  gap: 10px;
}

@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }

  /*
   * Delays too, not only durations.
   *
   * The nav strip's `visibility` is transitioned with a delay rather than a duration, so
   * that it stays visible for the length of the close and the motion is actually seen.
   * Zeroing durations leaves that delay in place, which would keep hidden links in the
   * tab order for a fifth of a second after a reader who asked for no motion had already
   * seen them go.
   */
  * {
    transition-delay: 0s !important;
  }
}

.timeline {
  list-style: none;
  margin: 0;
  padding: 0;
  font-size: 0.85rem;
}

.timeline li {
  padding: 7px 0 7px 14px;
  border-left: 2px solid var(--border-strong);
  color: var(--text-dim);
}

.timeline li strong {
  color: var(--text);
}

details.reveal {
  margin-bottom: 12px;
  border-top: 1px solid var(--border);
  padding-top: 12px;
}

details.reveal > summary {
  cursor: pointer;
  font-size: 0.88rem;
  color: var(--p-lavender);
  font-weight: 550;
  padding: 2px 0;
}

details.reveal > summary:hover {
  color: var(--accent);
}

details.reveal > div {
  padding-top: 14px;
}

/*
 * --------------------------------------------- every collapsing box, while it is moving
 *
 * The height is animated in public/js/disclose.js, which cannot be done in CSS alone yet:
 * `interpolate-size` is Chromium-only and `grid-template-rows: 0fr → 1fr` needs Safari
 * 17.5. What CSS does own is the fade, and it is worth having — a box that only grows
 * reveals its contents by uncovering them, which reads as the panel sliding out from
 * behind the header rather than as the panel arriving.
 *
 * `> :not(summary)` rather than a wrapper, because these eight boxes do not agree on
 * having one: a settings section has a single `.section__body`, but a cinema in the tree
 * has one child per auditorium and the alert has one per screen that is down. One class on
 * the details, and the engine fades however many children there turn out to be.
 *
 * Opacity only — no lift. A transform here would make each child a containing block for
 * the length of the animation, and some of them hold a photograph that opens over the page.
 * The box growing is already the movement; this only has to keep the contents from being
 * fully legible before there is room for them.
 */
@keyframes disclose-in {
  from {
    opacity: 0;
  }
}

@keyframes disclose-out {
  to {
    opacity: 0;
  }
}

details.is-expanding > :not(summary) {
  /* Shorter than the box, and `both`, so the contents are solid a moment before the
     movement stops rather than still arriving after it has. */
  animation: disclose-in 160ms var(--ease) both;
}

details.is-collapsing > :not(summary) {
  /* `--ease-out`, not `--ease`: run forwards, `--ease` puts a 130ms fade at 11% after two
     frames, so the contents were gone before the box had started moving. */
  animation: disclose-out 130ms var(--ease-out) both;
}

/* ------------------------------------------------------- guest form page

   The one page a member of the public ever sees, on a phone, usually in the dark,
   usually annoyed. Everything below is in service of two things: looking like somebody
   built it on purpose, and being usable one-handed without reading anything twice.

   It is deliberately the most designed page in the app. A staff page is furniture for
   somebody who is here every day; this is the whole of a cinema's software as far as
   the person holding it is concerned, and a plain stack of grey boxes says the cinema
   does not much care what they thought. */

.guest {
  /* The hero is a full-bleed band, so the page's own padding cannot indent it. */
  display: flex;
  flex-direction: column;
  min-height: 100dvh;
}

.guest .shell {
  flex: 1 0 auto;
}

.guest-hero {
  position: relative;
  overflow: hidden;
  /*
   * Trimmed once already. At 26px/24px with a larger heading the band took a fifth of a
   * phone screen before the first question, which made the form under it feel cramped
   * even though the form itself had not changed. The mark and the location chip are what
   * this band is for; the space around them was not carrying anything.
   */
  padding: 18px 20px 16px;
  /*
   * Two washes rather than one — a strong one from the top left and a fainter one from
   * the right — which keeps a large flat area from banding on an OLED phone.
   *
   * Both are the accent. The second was a different palette slot for one revision, and
   * the theme test caught it: a slot the stylesheet reads has to exist in all seven
   * themes, and inventing one here would have left a hole in six of them.
   */
  background:
    radial-gradient(760px 300px at 8% -40%, color-mix(in oklab, var(--accent) 30%, transparent), transparent),
    radial-gradient(520px 240px at 108% 10%, color-mix(in oklab, var(--accent) 13%, transparent), transparent),
    linear-gradient(180deg, var(--surface-2), var(--bg));
  border-bottom: 1px solid var(--border);
}

/* Gradient rule along the bottom edge, in place of a heavy colour band. */
.guest-hero::after {
  content: '';
  position: absolute;
  inset: auto 0 0 0;
  height: 2px;
  background: var(--accent-line);
}

.guest-hero__inner {
  position: relative;
  max-width: 640px;
  margin: 0 auto;
}

/*
 * The mark, small. It is here so a guest can tell what they are using, not so the
 * software can introduce itself — the heading below it is what the page is about, and
 * the mark should not out-shout it.
 */
.guest-hero .wordmark {
  font-size: calc(0.9rem * var(--mark-scale));
  letter-spacing: 0.1em;
  opacity: 0.85;
}

.guest-hero h1 {
  margin: 11px 0 10px;
  font-size: clamp(1.3rem, 1.15rem + 1.8vw, 1.6rem);
  letter-spacing: -0.02em;
  line-height: 1.15;
}

/*
 * Where they are, as a chip.
 *
 * The placard has already told us the cinema and the screen, and showing that back is
 * the thing that makes the rest of the form feel short — the reader can see we know
 * where they are, so they do not have to explain it.
 */
.guest-hero__where {
  display: inline-flex;
  /* Its own space, so the chip is not read as attached to whatever is above it. */
  margin-top: 2px;
  align-items: center;
  gap: 8px;
  margin: 0;
  padding: 6px 13px 6px 10px;
  border-radius: 999px;
  border: 1px solid color-mix(in oklab, var(--accent) 30%, var(--border));
  background: color-mix(in oklab, var(--accent) 10%, var(--surface));
  font-size: 0.86rem;
  font-weight: 550;
  color: var(--text);
}

/*
 * What this cinema's own record says — the one line on the page that is about the BUILDING
 * rather than about the form. See src/record.js.
 *
 * Deliberately quieter than the chip above it. The place chip is orientation: it tells a guest
 * we know where they are, and it has to be read. This is a reason to bother, which is a
 * different job — read once, believed, and then not competing with the heading somebody came
 * here to answer. It reads as a caption on the chip, which is what it is.
 *
 * No colour of its own, no icon, no box. A claim about somebody's responsiveness dressed up as
 * a badge starts looking like an advertisement, and an advertisement is the one thing that
 * would make a guest doubt it.
 */
.guest-hero__record {
  margin: 9px 0 0;
  font-size: 0.82rem;
  line-height: 1.35;
  color: var(--text-dim);
}

/* The same line after the send, where it answers "did that go anywhere" instead of "is this
   worth thirty seconds". Set on its own row under the note that says nobody will be in touch,
   so the two are read together rather than one qualifying the other. */
/* What became of the photograph, under the heading and before the reference — one line, quiet,
   and never a reason to doubt the report it sits on. */
.thanks__photo {
  margin: 4px 0 12px;
  font-size: 0.9rem;
  color: var(--text-dim);
}

.thanks__record {
  margin: 10px 0 0;
  font-size: 0.82rem;
  line-height: 1.35;
  color: var(--text-dim);
}

/* ------------------------------------------------------- a guest watching their own report
 *
 * Three steps, as a list rather than a progress bar. A report is not sixty per cent done, and a
 * bar invites somebody to read a rate out of it — "it moved a third in two days, so…" — which is
 * a promise this page must not make. A step has happened or it has not.
 *
 * The line down the left is drawn on the dots rather than as a border on the list, so the last
 * step has nothing hanging below it.
 */
/*
 * The trace page sits in the middle of what it has, rather than under the header.
 *
 * It is one short panel on an otherwise empty screen. Pinned to the top it reads as the top of
 * something longer that failed to load — the eye keeps going down the page looking for the rest
 * of it. This is the whole document, so it should look like the whole document.
 *
 * `svh` rather than `vh`: on a phone `vh` is the height with the address bar HIDDEN, so a page
 * measured in it is taller than the screen until you scroll, and the thing you centred starts
 * slightly below the middle and jumps as the bar collapses. `svh` is the smallest viewport, which
 * is the one that is actually there when the page loads. `vh` first, for anything that has not
 * heard of `svh`.
 *
 * Centred with auto margins rather than `justify-content`, and that is not a preference: a flex
 * item centred by `justify-content` that grows taller than its container overflows in BOTH
 * directions, and the top of it cannot be scrolled to. Auto margins collapse to nothing in that
 * case and the panel simply scrolls, which is what a long panel on a small phone needs to do.
 */
.guest--trace {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
  min-height: 100svh;
}

.guest--trace .guest-hero {
  flex: none;
}

.guest--trace #main {
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

/*
 * Above centre, by SPLITTING WHAT IS SPARE rather than by reserving a fixed amount below.
 *
 * Two flexible spacers, one either side, growing at 1 and 1.7. Whatever room the panel does not
 * need is divided in that ratio, so the panel sits a little above the middle at every height and
 * the gap above never collapses.
 *
 * A fixed `padding-bottom` was the first attempt and it is wrong in a way worth writing down: it
 * does not scale. Tuned to look right on a tall phone it was 190px, which on a 667px screen is
 * most of the space there is — the panel ended up jammed under the header with a void beneath
 * it, which is the same complaint it was meant to fix, upside down. A ratio has no such tuning
 * to get wrong.
 *
 * `flex: N 0 0` — grow, never shrink, no basis — so when there is nothing spare both spacers are
 * zero and the panel simply scrolls, which is what a long panel on a short phone must do.
 */
.guest--trace #main::before,
.guest--trace #main::after {
  content: '';
  flex: 1 0 0;
}

/*
 * A floor under the gap above, not just a share of what is spare.
 *
 * The ratio alone is only as good as there being something to divide: adding the case number
 * made the panel taller than a 667px phone has room for, the spare space went to zero, and the
 * panel sat one pixel under the header again — the exact complaint, reintroduced by adding
 * content rather than by changing the layout. Its own test caught it.
 *
 * `flex-shrink: 0` above means this floor holds: when the page no longer fits, it scrolls rather
 * than closing the gap, which is the right way round.
 */
.guest--trace #main::before {
  min-height: 28px;
}

/*
 * 1 : 2.2, chosen by measuring rather than by taste. On the phone heights people actually hold
 * — 667 to 900 CSS pixels — that puts the panel's middle between 45% and 51% of the screen:
 * above centre on every one of them, and never so high it reads as pushed. A larger ratio was
 * tried and lands a tall phone at 43%, which starts to look like a mistake in the other
 * direction.
 */
.guest--trace #main::after {
  flex-grow: 2.2;
}

.guest--trace #main > .card {
  width: 100%;
  padding: 26px 22px 22px;
}

/* =========================================================================
   The tracker on a guest's own report

   Three states drawn as a rail: an orb per step, and a thread between two orbs
   that EXISTS ONLY once the second of them has been reached.

   That is the whole idea. A greyed-out line to a step that has not happened
   draws the future as though it were already half there — the eye reads a
   four-fifths-complete rail and the words underneath have to argue it back.
   Nothing ahead is drawn at all, so the line a guest sees is exactly the
   distance their report has actually travelled.

   One orb pulses: the step it is on now. The halo is the ticker's, keyframes
   and all — see .plot__orb-halo, which is the live patron line on the staff
   overview. It is the same statement in both places: this is the part that is
   moving while you look at it. Borrowing it rather than writing a second one
   means there is one piece of deliberate motion in this product and one place
   to change its mind about it.

   Resolved recolours the whole rail green, top to bottom, and the last orb
   keeps pulsing. Green is doing real work here rather than decorating: it is
   the only state a guest is hoping for, and it is legible at arm's length on a
   phone held by somebody who is walking.
   ========================================================================= */
/*
 * The rail's geometry, on the CARD rather than on the list.
 *
 * It was on the list, which was right while the list was the only thing that used it. The
 * closing line underneath has to indent to the same text column, and a sibling cannot read a
 * custom property declared on the <ol> — so it would have been a second copy of the rail width,
 * silently diverging the first time anybody tuned one of them.
 */
.trace-card {
  /* The rail's width, the orb, and the ink. Named so the connector geometry
     below is arithmetic rather than four numbers that have to be kept in step. */
  --trace-rail: 30px;
  --trace-orb: 14px;
  --trace-ink: var(--accent);
  /* How far the thread stops short of an orb, so the two read as joined rather
     than as one shape. */
  --trace-clear: 5px;
  /* The gap between the rail column and the words, shared by the steps' grid and by the
     closing line's indent — the two have to line up and this is the one place it is set. */
  --trace-gap: 12px;
}

.trace {
  list-style: none;
  margin: 0 0 4px;
  padding: 0;
  display: grid;
  /* No gap. The steps' own padding sets the spacing, and the two halves of a
     connector have to meet exactly at the row boundary — a gap between rows is
     a hairline of background straight through the middle of the thread. */
  gap: 0;
}

/* Resolved: the same rail in the one colour a guest was hoping for. */
.trace--complete {
  --trace-ink: var(--ok);
}

.trace__step {
  position: relative;
  display: grid;
  grid-template-columns: var(--trace-rail) 1fr auto;
  align-items: center;
  column-gap: var(--trace-gap);
  padding: 15px 0;
  color: var(--text-faint);
}

/*
 * The orb. Hollow until its step happens, and it is the ONLY thing drawn for a
 * step that has not.
 */
.trace__orb {
  position: relative;
  justify-self: center;
  width: var(--trace-orb);
  height: var(--trace-orb);
  border-radius: 50%;
  border: 2px solid var(--border);
  background: var(--surface);
}

.trace__step--done {
  color: var(--text);
}

.trace__step--done .trace__orb {
  border-color: var(--trace-ink);
  background: var(--trace-ink);
}

/*
 * The thread, in two halves that meet at an orb's row boundary.
 *
 * Two halves rather than one line hung off a single step, because a step's
 * height is not a number this file gets to know: the label wraps on a narrow
 * phone and in a language with longer words. Each step draws the half inside
 * its own box, so the join lands correctly at any height.
 *
 * `--done` draws the half ABOVE its orb, which is what makes the thread appear
 * only when the step it leads to has been reached. `--joined` draws the half
 * below, and is set on a step whose successor is done. Both are set in
 * public/js/trace.js, deliberately, rather than derived here with :has() — a
 * guest's phone may be old, and a selector that silently does nothing on it
 * would leave the rail in pieces on exactly the devices least able to say so.
 */
.trace__step--done:not(:first-child)::before,
.trace__step--joined:not(:last-child)::after {
  content: '';
  position: absolute;
  left: calc(var(--trace-rail) / 2);
  width: 2px;
  transform: translateX(-50%);
  border-radius: 1px;
  background: var(--trace-ink);
}

/*
 * The two halves overlap by a pixel where they meet, and that pixel is not cosmetic.
 *
 * A step boundary lands wherever the type puts it — 427.9px on the phone this was drawn on —
 * so both halves antialias their own edge against a fraction of a device pixel, and the two
 * partial coverages do not add up to a solid one. The result is a faint notch in the middle of
 * every connector: invisible at 1×, plain at 3×, and exactly the sort of thing that looks like
 * a rendering fault on somebody's good phone.
 *
 * Overlapping is the fix rather than rounding the layout, because the layout has no business
 * being on whole pixels: the rail follows the text, and the text is where it is. Both halves
 * are the same opaque colour, so an overlap cannot show.
 */
.trace__step--done:not(:first-child)::before {
  top: -1px;
  bottom: calc(50% + var(--trace-orb) / 2 + var(--trace-clear));
}

.trace__step--joined:not(:last-child)::after {
  top: calc(50% + var(--trace-orb) / 2 + var(--trace-clear));
  bottom: -1px;
}

/*
 * The step it is on now, and only that one.
 *
 * The halo is a sibling of the orb rather than a shadow on it, because it has
 * to scale past its own box without pushing anything: an absolutely positioned
 * ::after inside a 14px orb can grow to 2.6× and disturb no layout at all.
 */
.trace__step--current .trace__orb::after {
  content: '';
  position: absolute;
  inset: -2px;
  border-radius: 50%;
  background: var(--trace-ink);
  opacity: 0.45;
  animation: orb-pulse 2s var(--ease) infinite;
}

/* The same answer the ticker gives: a still halo in the same place, not a
   lesser tracker. Somebody who asked for no motion still gets to see which
   step they are on. */
@media (prefers-reduced-motion: reduce) {
  .trace__step--current .trace__orb::after {
    animation: none;
    opacity: 0.28;
  }
}

.trace__what {
  font-weight: 600;
  font-size: 0.95rem;
}

/* The step being worked on now, said in weight rather than in another colour —
   the orb is already carrying the colour, and two signals for one fact is how a
   panel starts looking busy. */
.trace__step--current .trace__what {
  font-weight: 700;
}

.trace__when {
  font-size: 0.8rem;
  color: var(--text-faint);
  font-variant-numeric: tabular-nums;
}

/*
 * The closing line, under the rail and lined up with the words rather than with the orbs.
 *
 * The indent is the rail width plus the step's own column gap, which is what puts it under
 * "Resolved" instead of under the thread — the arithmetic reads off the same two values the
 * steps use, so the three of them cannot drift apart.
 *
 * No border, no tint, no card. It is one sentence in a panel that is already a list of short
 * lines, and giving it a box of its own would make the smallest thing on the page the loudest.
 */
.trace__outcome {
  /* Room underneath, because what follows is the anonymity note — a different voice entirely,
     and the two run together at the rail's own spacing. */
  margin: 2px 0 14px;
  padding-left: calc(var(--trace-rail) + var(--trace-gap));
}

/* The label reads as a caption, not as a fourth step — smaller, uppercase, and in the faint
   ink the dates use, so nothing about it competes with the three words above. */
.trace__outcomeLabel {
  display: block;
  font-size: 0.7rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.trace__outcomeText {
  margin: 3px 0 0;
  font-size: 0.9rem;
  line-height: 1.45;
  color: var(--text);
  /* A sentence somebody typed into a dashboard, on a phone. Long words break rather than
     push the panel sideways — the one thing a guest's page must never do. */
  overflow-wrap: anywhere;
}

/*
 * The link back, on the confirmation. Set apart by a rule rather than a tint: it is the one
 * thing on that panel somebody has to act on, and it is asking them to keep something.
 */
.thanks__trace {
  margin-top: 16px;
  padding-top: 16px;
  border-top: 1px solid var(--border);
}

.thanks__trace .field__hint {
  margin: 9px 0 0;
}

/* ---------------------------------------------------------------- thank-you code

   The customer's own discount code, on the four pages that can hand one over: the
   report confirmation, the film card, the exit-door card and the tracking page.

   Deliberately the loudest thing in its panel and deliberately not a button. It is
   read aloud at a counter or typed into a checkout, so it wants the mono face, room
   to breathe and selectable text — and nothing that looks pressable, because there
   is nowhere for a press to go. The accent tint is what makes it findable again on
   a tracking page somebody reopened a week later looking for exactly this. */
.thanks__code {
  margin-top: 16px;
  padding: 14px 16px;
  border-radius: var(--radius);
  border: 1px solid color-mix(in oklab, var(--accent) 34%, var(--border));
  background: var(--accent-soft);
  display: grid;
  gap: 8px;
  text-align: center;
}

/* The caption over a thank-you code. Reads exactly like .trace__outcomeLabel beside it on the
   tracking page, and is its OWN class rather than that one reused — a test selects that class
   expecting the single label above the closing line, and borrowing it made two. Shared looks are
   not a reason to share a name. */
.thanks__code-label {
  display: block;
  font-size: 0.7rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.thanks__code-words {
  margin: 0;
  font-size: 0.94rem;
  color: var(--text);
}

/* Two codes on a tracking page — one from filing, one from the fix — stack with the same
   gap the panel uses between its own rows. */
.thanks__code-list {
  display: grid;
  gap: 8px;
}

.thanks__code-value {
  margin: 0;
  font-family: var(--mono);
  font-size: 1.34rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  /* --accent, not --accent-fill: the accent is the slot tuned for text on the background, and
     this sits on --accent-soft which is that background with a little of the accent in it. */
  color: var(--accent);
  /* Long codes exist and a phone is narrow. Breaking anywhere beats overflowing the card. */
  overflow-wrap: anywhere;
  /* The one place in the app where selecting the text IS the interaction. */
  user-select: all;
  -webkit-user-select: all;
}

/* A map pin, drawn rather than fetched: one more request on a phone in a cinema's
   basement wifi is one more thing that can fail to arrive. */
.guest-hero__pin {
  width: 9px;
  height: 9px;
  flex: none;
  border-radius: 50% 50% 50% 0;
  transform: rotate(-45deg);
  background: var(--accent);
}

/* --------------------------------------------------------------- the form */

.guest-form {
  counter-reset: step;
  display: grid;
  gap: 26px;
  /*
   * A little air under the hero. The shell has no top padding, so the first question sat
   * directly against the band's edge — some of what the band gave up is better spent
   * here than left in it.
   */
  padding-top: 20px;
}

/*
 * A numbered step.
 *
 * Counted in CSS rather than written into the markup, because two of them come out
 * depending on the placard: a restroom has no seat, and a cinema still rolling out one
 * category at a time has nothing to choose. Hard-coded numbers would read 1, 3 — and a
 * page that cannot count is the opposite of the impression this is for. An element with
 * `display: none` generates no box and so increments nothing, which is exactly the rule
 * this leans on.
 */
.step {
  counter-increment: step;
  border: 0;
  padding: 0;
  margin: 0;
  min-width: 0;
}

.step[hidden] {
  display: none;
}

.step__title {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 0;
  margin: 0 0 12px;
  font-size: 1rem;
  font-weight: 620;
  letter-spacing: -0.01em;
  color: var(--text);
}

.step__title label {
  margin: 0;
  font: inherit;
  color: inherit;
}

.step__title::before {
  content: counter(step);
  flex: none;
  width: 24px;
  height: 24px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  background: color-mix(in oklab, var(--accent) 18%, var(--surface-3));
  border: 1px solid color-mix(in oklab, var(--accent) 30%, transparent);
  color: var(--accent);
  font-size: 0.78rem;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}

/* The fields are the page here, not rows inside a card, so they carry the weight the
   card used to. Bigger targets, and a focus ring that is obvious in a dark room. */
.guest-form input[type='text'],
.guest-form textarea {
  width: 100%;
  padding: 14px 15px;
  font-size: 1rem;
  border-radius: var(--radius);
  border: 1px solid var(--border-strong);
  background: var(--surface);
  color: var(--text);
  transition:
    border-color 140ms var(--ease),
    box-shadow 140ms var(--ease);
}

.guest-form textarea {
  min-height: 132px;
  line-height: 1.5;
  resize: vertical;
}

.guest-form input[type='text']:focus,
.guest-form textarea:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 22%, transparent);
}

/* The hint and the counter share a line: two stacked rows of small grey text under a
   textarea is most of a screen's height spent on furniture. */
.guest-form__meta {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  margin-top: 8px;
}

.guest-form__meta .field__hint {
  margin: 0;
  flex: 1 1 auto;
}

.guest-form__meta .counter {
  flex: none;
}

.guest-form .reveal {
  margin-top: 18px;
}

/* The grid gap is doing the spacing here, so the note drops its own — otherwise the
   restroom form opens with forty-six pixels of nothing under its first sentence. */
.guest-form .scope-note {
  margin: 0;
}

/* --------------------------------------------------------------- sending */

/*
 * The send button rides the bottom of the screen.
 *
 * On a phone this form is two screens long, so the action spent the whole time somebody
 * was filling it in below the fold — and a button you have to go looking for reads as a
 * page that is not sure it wants your report.
 *
 * `sticky`, not `fixed`: it stays in the flow, so it cannot cover the last field, and
 * the page's own scroll end is still the end of the form. The safe-area padding is what
 * keeps it clear of an iPhone's home indicator.
 */
.guest-send {
  position: sticky;
  bottom: 0;
  z-index: 2;
  margin: 6px calc(-1 * var(--shell-pad)) 0;
  padding: 26px var(--shell-pad) calc(14px + env(safe-area-inset-bottom, 0px));
  /*
   * A long fade rather than a short one. Reaching full opacity a fifth of the way down
   * put a hard edge across whatever heading happened to be behind it, which reads as a
   * rendering fault; over half the height it reads as what it is, content passing under
   * a bar.
   */
  background: linear-gradient(180deg, transparent, color-mix(in oklab, var(--bg) 88%, transparent) 42%, var(--bg) 72%);
}

.guest-send__note {
  margin: 9px 0 0;
  text-align: center;
  font-size: 0.78rem;
  color: var(--text-dim);
}

/* ----------------------------------------------------------- the thank you */

.thanks {
  text-align: center;
  padding: 30px 22px 26px;
}

.thanks__mark {
  width: 62px;
  height: 62px;
  border-radius: 50%;
  background: var(--ok-soft);
  color: var(--ok);
  border: 1px solid color-mix(in oklab, var(--ok) 40%, transparent);
  display: grid;
  place-items: center;
  margin: 0 auto 18px;
  box-shadow: 0 0 0 9px color-mix(in oklab, var(--ok) 7%, transparent);
}

.thanks .btn {
  margin-top: 18px;
}

.thanks h2 {
  margin: 0 0 6px;
  font-size: 1.45rem;
  letter-spacing: -0.02em;
}

.thanks__where {
  margin: 0 0 20px;
  color: var(--text-dim);
  font-size: 0.92rem;
}

/*
 * The reference, as something that looks kept.
 *
 * A guest may well show this to whoever is on the desk on the way out, so it is a block
 * with a label and a copy button rather than a line of monospace in a paragraph — which
 * reads as output, not as a thing to hold on to.
 */
.ticket {
  display: grid;
  gap: 8px;
  justify-items: center;
  margin: 0 0 20px;
  padding: 16px;
  border-radius: var(--radius);
  border: 1px dashed color-mix(in oklab, var(--accent) 45%, transparent);
  background: var(--accent-soft);
}

.ticket__label {
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-dim);
}

.thanks__ref {
  font-family: var(--mono);
  font-size: 1.35rem;
  font-weight: 650;
  letter-spacing: 0.08em;
  color: var(--accent);
  /* Selectable in one gesture, for the browsers with no clipboard API and the people
     who would rather not press a button they do not recognise. */
  user-select: all;
}

.ticket__copy {
  border: 1px solid color-mix(in oklab, var(--accent) 34%, transparent);
  background: none;
  border-radius: 999px;
  padding: 5px 14px;
  font: inherit;
  font-size: 0.8rem;
  font-weight: 600;
  color: var(--accent);
  cursor: pointer;
  transition:
    background-color 140ms var(--ease),
    color 140ms var(--ease);
}

.ticket__copy:hover {
  background: color-mix(in oklab, var(--accent) 14%, transparent);
}

.ticket__copy--done {
  color: var(--ok);
  border-color: color-mix(in oklab, var(--ok) 40%, transparent);
}

.ticket__copy[hidden] {
  display: none;
}

/* ---------------------------------------------------------------- signed
 *
 * The credit lives under the send button now — see `.guest-send__by`. It was a footer at the
 * foot of the document, which on a form this short meant below the fold and read by nobody.
 */

/* --------------------------------------------------------------- mobile */
@media (max-width: 700px) {
  .wordmark {
    font-size: calc(0.95rem * var(--mark-scale));
  }

  /*
   * The header is the swipe surface — see public/js/swipe.js, which gates on this same
   * 700px so the declaration and the gesture cannot come apart.
   *
   * `pan-y` hands the browser the vertical and keeps the horizontal for us, which is what
   * lets the drag move the page without cancelling a single event: there is no scroll
   * left to compete with. `pinch-zoom` is spelled out because `pan-y` alone would also
   * take zooming away, and zooming is somebody reading.
   */
  .page-head {
    touch-action: pan-y pinch-zoom;
  }

  /*
   * Home joins the wordmark on the first row; the rest goes on one line below it.
   *
   * The nav used to drop to its own line whole, which put Home on a second row and
   * the remaining links on a third — three rows of sticky top bar covering the
   * reports somebody came to read. Home is the one link that belongs beside the
   * logo anyway: it is where the logo goes.
   *
   * `display: contents` is how one child of the nav can be lifted onto the row
   * above it — the nav's box disappears and its links become flex items of the top
   * bar, so each can be ordered on its own. The explicit role="navigation" in the
   * markup is what keeps the landmark: an element with no box has, in some
   * browsers, lost the role its tag name implied.
   *
   * Losing the nav's box also loses its left divider, which is the point: on this
   * row Home sits WITH the wordmark, not across a rule from it.
   */
  .nav {
    display: contents;
  }

  #nav-home {
    order: 1;
  }

  /* Right of the first row, opposite the brand. The auto margin also eats the free
     space, which is what pushes the strip below onto its own row. */
  .account {
    order: 2;
    margin-left: auto;
  }

  /*
   * A full-width item of no height: it wraps to a row of its own, so everything after it
   * starts below. Pure layout, and deliberately without a border.
   *
   * It used to carry the rule under the first row, which was right while the strip below
   * was permanent and wrong the moment it could fold: collapsed, that rule sat a dozen
   * pixels above the top bar's own bottom border and the two read as a double line with a
   * band of nothing between them. The rule belongs to the strip it separates, so it moved
   * there — see .nav__rest — and folded there is one line, which is the bar's own.
   */
  .topbar__spacer {
    order: 3;
    flex: 0 0 100%;
    height: 0;
  }

  /*
   * The rest of the nav, on one line of its own.
   *
   * Wrapping was the alternative and it defeated the point: at 390px the three of
   * them need more width than there is, so they took two rows and the bar was as
   * tall as it had been before Home moved. One row that scrolls when it has to is a
   * shorter bar at every width — and the thin scrollbar the stylesheet already gives
   * everything is the affordance that says there is more.
   */
  /*
   * The button, immediately right of Home — see wireNav(), which inserts it there.
   *
   * Sized like a nav link rather than like an icon button, so the row reads as two
   * controls of the same kind rather than a link with a widget bolted beside it. The tap
   * target is the full 34px whatever the glyph inside measures.
   */
  .nav__toggle {
    order: 1;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 36px;
    height: 34px;
    padding: 0;
    border: 1px solid transparent;
    border-radius: 8px;
    background: none;
    color: var(--text-dim);
    cursor: pointer;
    transition:
      background-color 140ms var(--ease),
      border-color 140ms var(--ease),
      color 140ms var(--ease);
  }

  .nav__toggle:hover {
    color: var(--text);
    background: var(--surface-2);
  }

  .nav__toggle:focus-visible {
    outline: 2px solid var(--focus);
    outline-offset: 2px;
  }

  /* Open, it reads as the pressed control it is — the same treatment the current nav
     link gets, so "this is on" looks the same everywhere in the bar. */
  .nav__toggle[aria-expanded='true'] {
    color: var(--accent);
    background: var(--accent-soft);
    border-color: color-mix(in oklab, var(--accent) 40%, transparent);
  }

  /*
   * The rest of the nav, on one line of its own — folded away until the button is pressed.
   *
   * Wrapping was the alternative to one scrolling line and it defeated the point: at 390px
   * the three of them need more width than there is, so they took two rows and the bar was
   * as tall as it had been before Home moved. One row that scrolls when it has to is a
   * shorter bar at every width — and the thin scrollbar the stylesheet already gives
   * everything is the affordance that says there is more.
   *
   * `max-height` rather than `grid-template-rows: 0fr → 1fr`, which is the tidier modern
   * way to animate to a content height: that needs Safari 17.5, and this is the part of
   * the app most likely to be opened on an older phone. The strip is always exactly one
   * line, so a fixed ceiling is not a guess — and the opacity and the lift are what the
   * eye actually reads as the motion, which is why they carry the shorter duration.
   *
   * `visibility` rather than opacity alone: a strip at zero opacity is still in the tab
   * order, and a keyboard reaching links nobody can see is worse than no animation. It
   * flips at the end of the close so the transition still plays, and at the start of the
   * open so the links are focusable the moment they move.
   */
  .nav__rest {
    order: 4;
    flex: 0 0 100%;
    gap: 2px;
    overflow-x: auto;
    /* Vertical is never wanted here, and `auto` on one axis implies it on the other
       unless it is said. */
    overflow-y: hidden;

    max-height: 0;
    /* Every pixel of the gap between this and the row above lives here, so the fold takes
       it with it — the row-gap is 0 at this width, further down this block. */
    margin-top: 0;
    padding-top: 0;
    padding-bottom: 0;
    /* The rule under the first row. Zero-width rather than transparent so a folded strip
       is exactly nothing, and animated so the line arrives with the links rather than
       snapping in ahead of them. */
    border-top: 0 solid var(--border);
    opacity: 0;
    visibility: hidden;
    transform: translateY(-4px);
    transition:
      max-height 220ms var(--ease),
      margin-top 220ms var(--ease),
      padding-top 220ms var(--ease),
      padding-bottom 220ms var(--ease),
      border-top-width 220ms var(--ease),
      opacity 150ms var(--ease),
      transform 180ms var(--ease),
      visibility 0s linear 220ms;
  }

  .nav__rest--open {
    /* Covers a 34px line plus its padding with room to spare, and nothing more: the
       ceiling only has to be reachable, and a generous one makes the last few frames of
       the open animation move nothing. */
    max-height: 3.25rem;
    /* Tight, because the strip is a drawer in the bar rather than a second bar. The 7 and
       the 6 are the gap above and below the rule; together they are the whole distance
       between Home and the links. */
    margin-top: 7px;
    padding-top: 6px;
    /* Room for the scrollbar, so it does not sit on top of the link text. */
    padding-bottom: 2px;
    border-top-width: 1px;
    opacity: 1;
    visibility: visible;
    transform: none;
    transition:
      max-height 220ms var(--ease),
      margin-top 220ms var(--ease),
      padding-top 220ms var(--ease),
      padding-bottom 220ms var(--ease),
      border-top-width 220ms var(--ease),
      opacity 180ms var(--ease) 40ms,
      transform 200ms var(--ease),
      visibility 0s linear;
  }

  /*
   * The links are the whole width of this strip, so they must not be squeezed — and
   * they are tighter here than on desktop, because the three of them plus 12px of
   * padding each came to 23px more than a 390px phone has. Trimmed to 8px they fit
   * that width exactly, which is the one most people are holding; narrower phones
   * scroll the last one into view.
   *
   * 5px vertical rather than the 7px they get everywhere else, which makes each link
   * exactly 34px — the height of the button that reveals them, so the drawer is one
   * control tall. It is also the shortest this can go and still be a tap target: 34px
   * is what the toggle settled on for the same reason.
   */
  .nav__rest .nav__link {
    flex: 0 0 auto;
    padding: 5px 8px;
  }

  .topbar__inner {
    /*
     * No gap between rows at all — every pixel between the first row and the strip below
     * it is the strip's own margin, so it grows and shrinks with the fold.
     *
     * It was 9px, which was right while the strip was permanent. Folded, that 9px stayed
     * behind as a band of nothing between the first row and the bar's own bottom border,
     * which is half of what read as a double line.
     */
    row-gap: 0;
    /* Home reads as part of the brand at this width rather than as the first of a
       list it is no longer next to. */
    column-gap: 10px;
  }
}

@media (max-width: 560px) {
  .shell {
    --shell-pad: 16px;
    padding-left: var(--shell-pad);
    padding-right: var(--shell-pad);
  }

  .theatre__head,
  .auditorium__head,
  .complaint,
  .savebar {
    padding-left: 16px;
    padding-right: 16px;
  }

  .person {
    padding-left: 16px;
    padding-right: 16px;
    row-gap: 8px;
  }

  .person__scope,
  .person__seen {
    flex: 1 1 100%;
    text-align: left;
  }

  .filters__summary {
    flex-wrap: wrap;
    padding-left: 12px;
    padding-right: 12px;
  }

  /* The buttons keep the first row; what is filtering the queue gets its own line
     rather than being truncated to an ellipsis on a narrow screen.
     Buttons stretch at this width, which would leave the bar itself — whose job
     is to open the filters — a sliver to tap. These stay their own size.
     Two classes deep on purpose: the generic `.btn { flex: 1 1 auto }` below
     shares this rule's specificity and would win on source order. */
  .filters__summary .btn {
    order: 1;
    flex: 0 0 auto;
  }

  .filters__active {
    order: 2;
    flex: 1 1 100%;
    white-space: normal;
  }

  .filters__body {
    padding-left: 12px;
    padding-right: 12px;
  }

  /* Still has to clear the chevron. The header wraps to three lines at this
     width, so the chevron sits against the cinema name rather than floating in
     the middle of the block. */
  .theatre__head--toggle {
    padding-left: 38px;
  }

  .theatre__head--toggle::before {
    left: 16px;
    top: 28px;
  }

  .complaint__when {
    margin-left: 0;
    width: 100%;
  }

  /* A report card's reference and time take a line of their own below the badges, left
     aligned under them — but ONE line, as a pair. The rule above is for the Location
     info rows, where `__when` holds a button that does want the full width; inside a
     group the time must not, or it would push the reference off the line. */
  .complaint__id {
    margin-left: 0;
    width: 100%;
  }

  .complaint__id .complaint__when {
    width: auto;
  }

  /*
   * The compliance grid stops being a grid.
   *
   * Three columns in a 400px box gives each year about 110px, which is a date and nothing
   * else — no certificate under it, and "Add inspection" wrapped onto three lines. So each
   * inspection becomes a block with its two years stacked, and each year carries its own
   * label, because a bare date with no column heading above it does not say which year it
   * belongs to.
   */
  .compliance__head {
    display: none;
  }

  /* Two columns rather than one: the years are short, and a reader looking at one
     inspection on a phone wants to compare them, which is the whole point of the pair. */
  .compliance__row {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    gap: 8px;
    align-items: start;
  }

  .compliance__name {
    grid-column: 1 / -1;
  }

  .compliance__cell::before {
    content: attr(data-label);
    display: block;
    font-size: 0.68rem;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: var(--text-faint);
    font-weight: 650;
    margin-bottom: 2px;
  }

  /* The well gives up most of its padding. What the reader needs on a narrow screen is
     the card's own inset; a tray showing 12px down each side of it costs 24px of text
     width to say something the card's border already says. Enough is left for the well
     to read as a surface behind the cards rather than as a line beside them. */
  .auditorium__body {
    padding: 7px;
    gap: 8px;
  }

  .theatre__count {
    margin-left: 0;
    width: 100%;
  }

  .btn {
    flex: 1 1 auto;
  }

  /*
   * The location takes a line of its own, so every alert has the same shape.
   *
   * Left to wrap naturally the row broke wherever the name happened to run out: a
   * cinema called Rex kept its age on the first line and pushed the button alone onto
   * the second, while a long one wrapped the name and put age and button together. Same
   * button, same place, but three different stacks down one panel. Forcing the break
   * makes it name / age-and-action / what / update every time.
   */
  .alarm__where {
    flex: 1 1 100%;
  }

  /* Still roomier than it was, but a phone has a fifth of the height to spend and the
     header is not what anybody scrolled to. */
  .page-head {
    padding-top: 30px;
    margin-bottom: 22px;
  }

  .page-head::after {
    margin-top: 10px;
  }
}

/* =========================================================================
   The showtime scheduler

   Rooms down the side, the clock across the top — the layout a TMS uses, which
   is not flattery: the two windows are open side by side in a projection booth
   and a page laid out the other way round would have to be translated rather
   than copied.

   EVERYTHING HERE IS POSITIONED BY MINUTE. `--from` and `--span` are set on the
   grid by js/schedule-grid.js, `--at` and `--for` on each pill, all in minutes;
   `--minute` is the one thing that turns those into pixels.

   Its value below is a STARTING POINT, not the answer: fitDay() measures the box
   and sets `--minute` so the whole day fits on the screen, because a day you have
   to scroll sideways through is the question this page exists to answer, asked
   again. The floor is in the module — under about 0.8px a minute the pills stop
   being readable, so a phone scrolls instead of shrinking further.
   ========================================================================= */
.sched__range {
  margin: 0;
  align-self: flex-end;
  flex: 1 1 200px;
  font-size: 0.86rem;
  color: var(--text-dim);
}

/* Whose clock the times are on — drawn only for a reader outside the building's zone, so it
   is a quiet line rather than a banner. See zoneNote in js/schedule-grid.js. */
.sched__zone {
  margin: 0;
  flex: 1 1 100%;
  font-size: 0.8rem;
  color: var(--text-faint);
}

.sched__zone[hidden] {
  display: none;
}

.sched__actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin: 0 0 12px;
}

/*
 * 12h / 24h — two ways of being right, so a segmented pair rather than a switch.
 *
 * A checkbox would make one of them the absence of the other, and neither is: the marquee outside
 * says 7:30pm and the TMS in the booth says 19:30. Both buttons are always visible with one pressed,
 * which is also what tells a reader the control exists at all — a toggle showing only the state it
 * is in has to be guessed at.
 *
 * Sized to sit in the schedule's toolbar beside the POS badge, so it reads as one row of small
 * controls rather than a thing of its own.
 */
.seg {
  display: inline-flex;
  padding: 2px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--surface-2);
}

.seg__opt {
  padding: 4px 12px;
  border: 0;
  border-radius: 999px;
  background: none;
  color: var(--text-dim);
  font: inherit;
  font-size: 0.76rem;
  font-weight: 600;
  cursor: pointer;
  /* The pressed pill slides rather than blinks, like every other state change in the app. */
  transition:
    background 160ms var(--ease),
    color 160ms var(--ease);
}

.seg__opt:hover {
  color: var(--text);
}

.seg__opt--on {
  background: var(--accent-fill);
  color: var(--accent-ink);
}

/*
 * "POS Sync active · last sync 9 min ago" — the badge that stands where the Features button
 * does on a cinema whose schedule is written by its POS.
 *
 * A quiet chip rather than the full-width info notice this replaced: on a circuit that runs a
 * POS, synced is the ordinary state of every cinema, and a paragraph explaining the ordinary
 * every time you open the page is a paragraph that pushes the schedule down and gets skipped.
 * The dot is the one live thing about it — the same reading as the dashboard's pulse: something
 * out there is still talking to us.
 */
.pos-state {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  margin: 0;
  padding: 6px 12px;
  border: 1px solid color-mix(in oklab, var(--accent) 35%, var(--border));
  border-radius: 999px;
  background: color-mix(in oklab, var(--accent) 6%, var(--surface));
  font-size: 0.78rem;
  font-weight: 550;
  color: var(--text-dim);
  cursor: default;
}

.pos-state::before {
  content: '';
  width: 7px;
  height: 7px;
  flex: none;
  border-radius: 50%;
  background: var(--accent);
}

/* A sync that could not reach the till still says so, in the app's own warn colour rather than
   in a second sentence: the schedule on screen is real, it is just not this minute's. */
.pos-state--warn {
  border-color: color-mix(in oklab, var(--warn) 45%, var(--border));
  background: color-mix(in oklab, var(--warn) 8%, var(--surface));
  color: var(--warn);
}

.pos-state--warn::before {
  background: var(--warn);
}

/*
 * The box the day scrolls sideways in.
 *
 * A nineteen-hour day does not fit on a screen at any sensible scale, so something has to
 * scroll — and it is this box rather than the page. A page that scrolled sideways would take
 * the top bar and the heading with it, and the room names down the left are the labels that
 * must never leave: they are `position: sticky` against THIS scrollport.
 */
.sched__scroll {
  overflow-x: auto;
  overflow-y: hidden;
  background: var(--glass);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  /*
   * NO LEFT PADDING, and that is a fix rather than a style.
   *
   * A sticky element sticks to the scrollport's CONTENT edge, so 12px of left padding here was a
   * 12px strip that the day scrolled through and nothing could cover: a time label or the tail of a
   * pill slid into it and appeared to the left of the room names, sliced. On a phone, where the day
   * never fits and there is always something scrolled off, "08" sat against the word AUDITORIUM.
   *
   * Measured before and after by sampling elementFromPoint across the edge — the topmost thing in
   * the first ten pixels was `grid__hour`, and the corner only took over at twelve.
   *
   * The inset the padding was there for moves onto the sticky column's own padding, which travels
   * with it. Right padding stays: the sticky column is on the left, so nothing scrolls through the
   * far end, and it keeps the last hour of the day off the card's border.
   */
  padding: 12px 12px 12px 0;
  margin-bottom: 24px;
}

.grid {
  --minute: 1.9px;
  --lane: 58px;
  --room: 150px;
  /* The containing block for the "now" line, which crosses every lane and so cannot live in one. */
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 6px;
  /* min-content, so the flex children below keep their computed widths instead of being
     squeezed to fit the box — being wider than the box is the point. */
  min-width: min-content;
}

.grid__ruler {
  display: flex;
  align-items: flex-end;
}

.grid__corner,
.grid__room {
  /* Held against the left edge while the day scrolls under it. Opaque, or the pills would
     slide visibly beneath the names. */
  position: sticky;
  left: 0;
  z-index: 2;
  flex: 0 0 var(--room);
  width: var(--room);
  background: var(--surface);
  /*
   * A soft edge on the right, so a pill passing behind the names reads as going UNDER something
   * rather than being cut in half. The border alone was not enough: a title clipped mid-letter hard
   * against a hairline looks like a rendering fault, and on a phone — where the day never fits and
   * there is always a pill under the column — that is most of what somebody sees.
   */
  box-shadow: 8px 0 10px -8px var(--p-shadow);
}

.grid__corner {
  /* The inset the scroll box used to provide with padding, on the element that keeps it while
     scrolling. Matched to .grid__room below so the heading sits over the names. */
  padding-left: 12px;
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-faint);
  font-weight: 600;
  padding-bottom: 4px;
}

.grid__hours,
.grid__track {
  position: relative;
  flex: 0 0 calc(var(--span) * var(--minute));
}

.grid__hours {
  height: 20px;
}

.grid__hour {
  position: absolute;
  left: calc((var(--at) - var(--from)) * var(--minute));
  transform: translateX(-50%);
  /* Doubled classes on the two below: they override this rule's own `transform`, and a modifier
     that only wins because of where it sits in the file is the bug test/stylesheet.test.js is for. */
  font-size: 0.72rem;
  font-variant-numeric: tabular-nums;
  color: var(--text-faint);
  white-space: nowrap;
}

/* The ends are flush rather than centred on their tick. Visible overflow counts towards a scroll
   container's width, so a half-label hanging off the right would put a scrollbar under a day that
   otherwise fits exactly. */
.grid__hour.grid__hour--first {
  transform: none;
}

.grid__hour.grid__hour--last {
  transform: translateX(-100%);
}

/* A label there is no room for at this scale — see thinHours in js/schedule-grid.js, which
   measures rather than guesses. Kept in the row rather than removed so a widened window can
   bring it back without a repaint. */
.grid__hour--off {
  display: none;
}

/* The hour the NOW badge is painted over — see hideHourUnderNow in js/schedule-grid.js. Its own
   class rather than the one above, because thinning and the now-line run on different clocks and
   would otherwise undo each other. */
.grid__hour--covered {
  display: none;
}

.grid__lane {
  display: flex;
  align-items: stretch;
}

.grid__lane--archived {
  opacity: 0.55;
}

/*
 * A screen that is DOWN — an open emergency case on the case monitor, surfaced where the
 * decision gets made. The whole timeline tints rather than only a badge appearing, because the
 * reader is scanning lanes, not reading words: red under the pills says "these shows have no
 * room" before a single name is read. The gridlines stay visible through the tint (they are a
 * gradient painted over this colour), so the shows being rescued can still be read against the
 * clock.
 */
.grid__lane--down .grid__track {
  background-color: var(--danger-soft);
  box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--danger) 40%, transparent);
}

.grid__lane--down .grid__number {
  color: var(--danger);
}

/* The word beside the name, for the reader the colour alone would leave guessing. */
.grid__downbadge {
  display: inline-block;
  margin-left: 6px;
  padding: 1px 7px;
  border-radius: 999px;
  font-size: 0.62rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  background: var(--danger);
  color: var(--p-base);
  vertical-align: 2px;
}

.grid__room {
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 2px;
  /* 12px on the left rather than 2px: it replaces the scroll box's left padding, which had to go —
     see .sched__scroll above. */
  padding: 4px 12px;
  border-right: 1px solid var(--border-strong);
}

.grid__number {
  font-weight: 600;
  font-size: 0.88rem;
  line-height: 1.2;
}

/* A room's name holds its seat count on hover (see seatNote in js/schedule-grid.js). The cursor is
   the only thing that says so — a tooltip nobody knows to hover for is a tooltip nobody reads. */
.grid__number[title] {
  cursor: help;
}

.grid__facts {
  font-size: 0.72rem;
  color: var(--text-faint);
}

/* The keyboard's way onto the grid. Dragging a film out of the panel is the quick way and it
   is not the only way — see the note at the top of js/showtimes.js. */
.grid__add {
  align-self: flex-start;
  margin-top: 2px;
  font: inherit;
  font-size: 0.72rem;
  color: var(--text-dim);
  background: none;
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius-sm);
  padding: 1px 7px;
  cursor: pointer;
}

.grid__add:hover {
  color: var(--accent);
  border-color: var(--accent);
}

/*
 * One room's day.
 *
 * The hour and half-hour lines are a background rather than thirty-eight more elements: a
 * gradient re-scales with `--minute` for free, and nobody reads a gridline. The window always
 * starts on the hour — see windowFor in js/schedule-grid.js — so the first line of the pattern
 * lands on a labelled tick.
 */
.grid__track {
  height: var(--lane);
  border-radius: var(--radius-sm);
  background:
    repeating-linear-gradient(
      90deg,
      var(--border-strong) 0 1px,
      transparent 1px calc(30 * var(--minute)),
      var(--border) calc(30 * var(--minute)) calc(30 * var(--minute) + 1px),
      transparent calc(30 * var(--minute) + 1px) calc(60 * var(--minute))
    ),
    var(--surface-2);
}

.grid__empty {
  margin: 8px 0 0;
  color: var(--text-dim);
  font-size: 0.9rem;
}

/*
 * Where now is: one line down the whole grid, labelled.
 *
 * A child of the grid rather than one per lane, so it is a single element crossing every room —
 * which is what makes it readable as a time rather than as ten marks that happen to line up. It
 * sits in the grid's own coordinate space, so it scrolls with the day; `--room` is added because
 * that space starts at the left edge of the room column, not of the first track.
 *
 * Below the room names in the stack (they are z-index 2) so a scrolled grid slides it under the
 * labels rather than across them, and above the pills' background but not their text.
 */
.grid__now {
  position: absolute;
  top: 18px;
  bottom: 0;
  left: calc(var(--room) + (var(--at) - var(--from)) * var(--minute));
  width: 2px;
  z-index: 1;
  background: var(--danger);
  pointer-events: none;
}

/* The label, so a line on a grid of times is not something to guess at. */
.grid__now::before {
  content: 'now';
  position: absolute;
  top: -15px;
  left: -1px;
  padding: 0 5px;
  border-radius: 3px;
  background: var(--danger);
  color: var(--bg);
  font-size: 0.62rem;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  line-height: 1.5;
}

/* Near the right edge the label reads to the LEFT of its line — see markNow in
   js/schedule-grid.js: read rightward there, it pokes past the grid and mints a
   sideways scrollbar on a day that fits. */
.grid__now--flip::before {
  left: auto;
  right: -1px;
}

/*
 * A booking.
 *
 * The time is above the name because the time is what is being checked; the name is how you
 * know which line you are on. A pill can be narrower than its own label — a short film at this
 * scale is about 200px, a trailer reel far less — so the name truncates and the `title` carries
 * the whole thing.
 */
.showing {
  position: absolute;
  top: 4px;
  bottom: 4px;
  left: calc((var(--at) - var(--from)) * var(--minute));
  width: calc(var(--for) * var(--minute));
  min-width: 58px;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 1px;
  overflow: hidden;
  text-align: left;
  padding: 4px 8px;
  border: 1px solid color-mix(in oklab, var(--accent) 45%, var(--border-strong));
  border-left: 3px solid var(--accent);
  border-radius: var(--radius-sm);
  background: var(--accent-soft);
  color: var(--text);
  cursor: grab;
  /* The gesture must not also scroll the day sideways on a touchscreen, and this is the
     modern way to say so — see js/showtimes.js, which does NOT preventDefault on pointerdown
     because the pill is also a button somebody clicks. */
  touch-action: none;
}

.showing:hover {
  border-color: var(--accent);
  box-shadow: var(--shadow);
}

/*
 * The editor's pill is a wrapper holding two buttons — the face, and the quick-remove × —
 * because a button cannot legally hold another. The board's pills and the drag ghost are
 * still single elements, so the layout above stays on .showing itself and these two sit
 * absolutely inside it.
 */
.showing__open {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 1px;
  min-width: 0;
  padding: 4px 8px;
  overflow: hidden;
  text-align: left;
  font: inherit;
  color: inherit;
  background: none;
  border: 0;
  border-radius: inherit;
  cursor: inherit;
}

.showing__x {
  position: absolute;
  top: 1px;
  right: 1px;
  z-index: 1;
  width: 18px;
  height: 18px;
  display: grid;
  place-items: center;
  padding: 0;
  font-size: 0.85rem;
  line-height: 1;
  color: var(--text-dim);
  background: color-mix(in oklab, var(--surface) 55%, transparent);
  border: 0;
  border-radius: 5px;
  cursor: pointer;
  opacity: 0.55;
}

.showing:hover .showing__x,
.showing__x:focus-visible {
  opacity: 1;
}

.showing__x:hover {
  color: var(--danger);
  background: var(--surface);
}

.showing__time {
  font-size: 0.74rem;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: var(--accent);
  white-space: nowrap;
}

.showing__name {
  font-size: 0.8rem;
  font-weight: 600;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* A film that finishes after midnight, marked on the edge it runs off. The grid already draws
   past 24:00 so the end time is visible; this is what says the date has changed. */
.showing.showing--overnight {
  border-right: 3px solid var(--warn);
}

/*
 * The occupancy gauge: the pill fills bottom-to-top with how sold the show is, `--fill` being
 * sold/(sold+remaining) — see gauge() in js/schedule-grid.js. A BACKGROUND-IMAGE rather than an
 * extra element, because a background can never sit on top of the times and the class composes
 * with every pill variant (flat, overnight, synced) without a stacking rule anywhere. The hard
 * stop in the gradient is the waterline.
 */
.showing--gauge {
  background-image: linear-gradient(
    to top,
    color-mix(in oklab, var(--accent) 30%, transparent) 0 calc(var(--fill) * 100%),
    transparent calc(var(--fill) * 100%)
  );
}

/* The read-only board's pills are divs, so nothing about them should suggest a control. */
/* The board's pills open the details box now, and so do the editor's while the POS owns its
   grid — in both places the hand that grabs would be a lie, and the pointer is the truth. */
.showing.showing--flat,
.grid--pos .showing {
  cursor: pointer;
}

/* Where a booking was, while it is being dragged. Pointer-transparent, so elementFromPoint
   finds the lane underneath rather than the thing being carried over it. */
.showing.showing--dragging {
  opacity: 0.45;
  border-style: dashed;
  box-shadow: none;
  pointer-events: none;
}

/* A film out of the panel, carried under the cursor. Fixed rather than in a lane: it has no
   place on the grid yet, which is what is being decided. */
.showing.showing--ghost {
  position: fixed;
  top: 0;
  bottom: auto;
  left: 0;
  width: auto;
  min-width: 132px;
  max-width: 240px;
  height: auto;
  z-index: 70;
  transform: translate(-50%, -50%);
  background: var(--surface);
  box-shadow: var(--shadow-lift);
  pointer-events: none;
}

/* Not over a lane, so there is nowhere for it to land. */
.showing.showing--ghost-off {
  opacity: 0.6;
  border-color: var(--border-strong);
}

/*
 * RECOMMENDATIONS, SKETCHED ONTO THE GRID — see the `advice` argument of paintDay in
 * js/schedule-grid.js and paintAdvice in js/showtimes.js.
 *
 * A slot that changes film is drawn as a SPLIT PILL: the showing as it stands in the top half,
 * struck through and dimmed, and the film the advice would put there in the bottom half, dashed,
 * because it does not exist. One slot, two films, read top to bottom as "this becomes that" —
 * which is the whole of what a move is. A showing that would move ROOMS is the same picture with
 * both halves saying where from and where to. A showing worth cutting with nothing to put in its
 * place keeps the whole pill, struck.
 *
 * The sketch never takes the pointer: the real pill underneath keeps its click and its drag,
 * and the ghost is a drawing over it.
 */
.showing.showing--cutting,
.showing.showing--moving {
  opacity: 0.55;
  border-style: dashed;
}

.showing.showing--cutting {
  border-left-color: var(--danger);
}

/* The name is struck, and only the name: the words the advice adds after it are not. The
   sketch wraps the name's text in its own span for exactly this — text-decoration reaches
   every in-flow child, so a strike on the name line would strike the advice too. */
.showing.showing--cutting .showing__name-text {
  text-decoration: line-through;
}

/* The top half, when something is drawn underneath it. Half a lane holds one line, and the line
   that matters here is the NAME with the advice after it — "Cascade Point · → 2 · The Roxy" —
   so the time goes: the ghost sits in the same slot, and the slot's time is on the ruler. */
.showing.showing--halved {
  bottom: 50%;
}

.showing.showing--halved .showing__time {
  display: none;
}

.showing.showing--advised {
  top: 50%;
  bottom: 3px;
  z-index: 2;
  justify-content: center;
  padding-top: 2px;
  padding-bottom: 2px;
  border: 1px dashed var(--accent);
  border-left-width: 3px;
  background: color-mix(in oklab, var(--accent) 16%, var(--surface));
  cursor: default;
  pointer-events: none;
}

/* A cut with nothing to put in its place keeps the whole pill — there is no bottom half. */
.showing.showing--advised .showing__name {
  font-size: 0.76rem;
}

/* The advice's words ride on the name line — "→ Cascade Point", "from Screen 1" — rather than
   in a third line a lane has no height for. On the struck top half the words are NOT struck. */
.showing__name[data-advice]::after {
  content: ' · ' attr(data-advice);
  font-weight: 500;
  font-size: 0.72rem;
  color: var(--text-dim);
}

/* The band that says the sketch is on, and what the day's changes are. Kin to .planbar. */
.advicebar {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin: 0 0 12px;
}

.advicebar--on {
  padding: 10px 14px;
  background: var(--accent-soft);
  border: 1px solid var(--border-strong);
  border-left: 3px solid var(--accent);
  border-radius: var(--radius);
}

.advicebar__head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 16px;
}

.advicebar__text {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.advicebar__lines {
  margin: 0;
  padding: 0 0 0 18px;
  font-size: 0.82rem;
  color: var(--text);
}

.advicebar__lines li + li {
  margin-top: 2px;
}

.advicebar__lines .advicebar__quiet {
  color: var(--text-dim);
  list-style: none;
  margin-left: -18px;
}

/*
 * WHERE THE REST OF IT IS: one chip per day the drawn picture would change, and a way there.
 *
 * The band above argues about the day on screen; a plan argues about the week, and most of what
 * it proposes is therefore on a day the reader is not looking at. Reported from a phone, of two
 * plans that differed: "the different plans have different suggestions, having it visually show
 * it on the schedule too would be beneficial" — the overlay WAS following the chosen plan, but
 * from a day it left alone the two plans looked identical.
 *
 * Only the days with something on them: a row of seven chips where five say nought is a worse
 * index than a row of two that say where to look. The day strip above the grid carries a dot on
 * the same days; this is the same fact in words, with the number on it.
 */
.advicebar__jumps {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px;
  font-size: 0.8rem;
}

.advicebar__jumpsaid {
  color: var(--text-dim);
}

.advicebar__jump {
  font: inherit;
  font-size: 0.8rem;
  padding: 2px 9px;
  border-radius: 999px;
  border: 1px solid var(--border-strong);
  background: transparent;
  color: var(--text);
  cursor: pointer;
  font-variant-numeric: tabular-nums;
}

/* The day already on screen is not a place to go, so it reads as a marker rather than a
   control — outlined in the accent, with the page's own ink. */
.advicebar__jump--here {
  border-color: var(--accent);
  color: var(--accent);
}

.advicebar__jump:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/*
 * The write-up under the band: the pager between the sketch and every plan written for the
 * week, the press, and the model's prose drawn as prose. Its own dashed rule, because above it
 * is what the rules argue and below it is what the assistant argued, and a reader should be
 * able to tell whose words they are reading. See paintWriteup in js/showtimes.js.
 */
.advicebar__writeup {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding-top: 8px;
  border-top: 1px dashed var(--border-strong);
}

/*
 * THE AGENT AT WORK, said twice over: the spark on its own button breathing, and the rule above
 * the block running with light.
 *
 * The press reads three or four tools and then writes a page of prose, which is tens of seconds
 * on a real week — and a page that does not move through a wait that long reads as broken rather
 * than as busy. The spark is the same mark, on the same cycle, as the AI Reviewer Agent's own box
 * (verify-spark, further up this file), so the two agents behave alike wherever a reader meets
 * them. The sweep is the second signal: the block being waited on says so itself, which the
 * button alone cannot do once somebody has scrolled to the prose.
 */
.advicebar__writeup--working {
  position: relative;
  border-top-color: transparent;
}

.advicebar__writeup--working::before {
  content: '';
  position: absolute;
  inset: 0 0 auto;
  height: 1px;
  background: linear-gradient(
    90deg,
    transparent 0%,
    color-mix(in oklab, var(--accent) 70%, transparent) 35%,
    var(--accent) 50%,
    color-mix(in oklab, var(--accent) 70%, transparent) 65%,
    transparent 100%
  );
  background-size: 45% 100%;
  background-repeat: no-repeat;
  animation: writeup-sweep 1.9s linear infinite;
}

@keyframes writeup-sweep {
  0% {
    background-position: -45% 0;
  }
  100% {
    background-position: 145% 0;
  }
}

/* The agent's own spark, breathing while it thinks — the reviewer's cycle, at a button's size. */
.agentrow__spark--working {
  animation: verify-spark 1.7s ease-in-out infinite;
}

/*
 * A button that cannot be pressed because it is BUSY is not the same as one that cannot be
 * pressed because there is nothing behind it, and the grey every other disabled control wears
 * says the second thing. While the agent is out, its button keeps the accent it had and only the
 * spark moves — the thing under the reader's finger is the thing that is working, and it should
 * look like it.
 */
.advicebar__writeup--working .agentrow__btn[disabled] {
  opacity: 1;
  background: color-mix(in oklab, var(--accent) 13%, var(--surface));
  color: var(--accent);
  border-color: color-mix(in oklab, var(--accent) 32%, transparent);
  cursor: progress;
}

/*
 * A reader who asked for no motion is told the same thing without it: the rule stays lit across
 * its whole width rather than running, and the spark holds still. The note beside them was always
 * the thing carrying the meaning.
 */
@media (prefers-reduced-motion: reduce) {
  .advicebar__writeup--working::before {
    animation: none;
    background: var(--accent);
    background-size: 100% 100%;
    opacity: 0.7;
  }

  .agentrow__spark--working {
    animation: none;
  }
}

.writeup__head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 12px;
}

/*
 * TELLING THE AGENT WHAT YOU WANT — folded, because most presses want nothing said.
 *
 * "Show another" asks the same question with the last answer ruled out, which is a different
 * angle chosen at random. This is the other half, and it is the SECOND press that usually has an
 * opinion: by then the reader has read a plan and knows what they want different. So it stays a
 * summary until somebody has something to say, and the band keeps its height.
 */
.writeup__steerfold {
  margin-top: 10px;
}

/*
 * IT HAS TO LOOK LIKE SOMETHING YOU CAN PRESS. It was dim grey text with the default marker
 * suppressed by the inline-flex, so it read as a caption — "you wouldn't even know it's a button
 * unless you knew what you were looking for", which is a control nobody finds.
 *
 * A bordered chip with its own chevron, at full text colour. Still quiet enough to sit under a
 * plan without competing with it, but unmistakably a thing rather than a label about a thing.
 */
.writeup__steersum {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  width: fit-content;
  padding: 6px 12px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  background: var(--surface);
  font-size: 0.85rem;
  font-weight: 600;
  color: var(--text);
  cursor: pointer;
  list-style: none;
}

.writeup__steersum::-webkit-details-marker {
  display: none;
}

/* The chevron, turned down when the box is open — the disclosure's own affordance, drawn rather
   than borrowed from the browser so it survives the list-style above. */
.writeup__steersum::after {
  content: '';
  width: 7px;
  height: 7px;
  border-right: 2px solid currentcolor;
  border-bottom: 2px solid currentcolor;
  transform: rotate(-45deg);
  margin-top: -3px;
  transition: transform 0.15s ease;
}

.writeup__steerfold[open] > .writeup__steersum::after {
  transform: rotate(45deg);
  margin-top: -5px;
}

.writeup__steersum:hover {
  border-color: var(--accent);
  color: var(--accent);
}

/* The mic on one side, the send button on the other — the box's own controls, on its own line. */
.writeup__steerrow {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 8px 10px;
  margin-top: 8px;
}

.writeup__steerrow > .writeup__steertools {
  margin: 0;
}

.writeup__steergo {
  margin-left: auto;
}

.writeup__steerhint {
  margin: 6px 0 0;
  color: var(--text-faint);
  font-size: 0.78rem;
}

.writeup__steer {
  margin-top: 8px;
}

.writeup__steerbox {
  width: 100%;
  resize: vertical;
  font: inherit;
  line-height: 1.45;
}

.writeup__steertools {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 10px;
  margin: 8px 0 0;
}

.writeup__steertools:empty {
  display: none;
}

/*
 * WHAT WAS ASKED FOR, kept with the plan it produced. Quoted rather than stated, because it is the
 * reader's own sentence coming back to them and a plain line would read as the app's words.
 */
.writeup__asked {
  margin: 10px 0 0;
  padding-left: 10px;
  border-left: 2px solid color-mix(in oklab, var(--accent) 40%, transparent);
  font-size: 0.85rem;
  color: var(--text-dim);
  font-style: italic;
}

.writeup__pager {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
}

.writeup__chip {
  font: inherit;
  font-size: 0.8rem;
  padding: 3px 10px;
  border-radius: 999px;
  border: 1px solid var(--border-strong);
  background: transparent;
  color: var(--text-dim);
  cursor: pointer;
}

.writeup__chip[aria-pressed='true'] {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-ink);
}

.writeup__chip:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.writeup__prose {
  max-width: 72ch;
  font-size: 0.9rem;
  line-height: 1.5;
}

.prose__h {
  margin: 12px 0 4px;
  font-size: 0.95rem;
  font-weight: 680;
  letter-spacing: -0.01em;
}

.prose__p {
  margin: 6px 0;
}

.prose__list {
  margin: 4px 0;
  padding-left: 18px;
}

.prose__scroll {
  overflow-x: auto;
}

.prose__table {
  border-collapse: collapse;
  font-size: 0.85rem;
  margin: 6px 0;
  font-variant-numeric: tabular-nums;
}

.prose__table th,
.prose__table td {
  padding: 4px 12px 4px 0;
  text-align: left;
  border-bottom: 1px solid var(--border);
  white-space: nowrap;
}

.prose__table th {
  color: var(--text-dim);
  font-weight: 600;
  font-size: 0.78rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

/*
 * The list under the grid: NOT ON THE SCREEN.
 *
 * It was a card, then a folded one, and now it is print-only. The grid says the same thing, and a
 * day laid out on a timeline does not need writing out underneath as well.
 *
 * Paper is the exception and the reason this still exists: the print rules hide the timeline —
 * nine hours across A4 halftones into a smear — and show this instead, because a booth typing into
 * the TMS wants one line per showing in the order they happen. `display: none` here, `block` in the
 * print block below.
 */
.sched__list {
  display: none;
  margin-bottom: 24px;
}

/*
 * The ticket sales panel, under the grid. It exists only for the eyes the server granted —
 * paintSales in js/schedule-grid.js leaves it hidden for everybody else, and their payload
 * carries no numbers to draw regardless. Quiet on purpose: a card of three facts, not a
 * dashboard, because the grid above is still the page.
 */
.sales {
  margin-bottom: 24px;
  padding: 14px 16px;
  background: var(--glass);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
}

.sales__title {
  margin: 0 0 10px;
  font-size: 0.78rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-faint);
}

.sales__facts {
  display: flex;
  flex-wrap: wrap;
  gap: 10px 36px;
}

.sales__fact {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.sales__label {
  font-size: 0.74rem;
  color: var(--text-dim);
}

.sales__figure {
  font-size: 0.95rem;
  font-variant-numeric: tabular-nums;
}

.sales__hint {
  font-size: 0.74rem;
  color: var(--text-faint);
  font-variant-numeric: tabular-nums;
}

/* ---------------------------------------------------------------- the Showtime Optimizer
 *
 * The way in and out, on the seam between what the week did and the week itself. A row rather
 * than a button on its own so the state of the plan can stand beside the control that reaches
 * it — "Saved, not pushed" is the thing somebody needs before they decide to press anything.
 */
.optbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 12px;
  margin: 0 0 12px;
}

.optbar__note {
  margin: 0;
  font-size: 0.78rem;
  color: var(--text-dim);
}

/* The band on a manager's board that says which week is under it. Above everything, because it
   changes what the rest of the page means — see paintPlan in js/schedule.js. */
.planbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 8px 16px;
  margin: 0 0 16px;
  padding: 10px 14px;
  background: var(--glass);
  border: 1px solid var(--border-strong);
  border-left: 3px solid var(--border-strong);
  border-radius: var(--radius);
}

/* Marked out only while the PLAN is the one showing. A booth copying times into a TMS is the
   reader this is for, and "this has not happened yet" is the one thing it must not have to
   work out. Back to the plain band once the running schedule is on screen. */
.planbar--plan {
  border-left-color: var(--accent);
  background: var(--accent-soft);
}

.planbar__text {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

/* ------------------------------------------------------------------------------ the dock
 *
 * The two boxes that follow you down a week being built: the seat count, and the save bar under
 * it. One sticky container rather than two, because two elements each pinned at `bottom: 16px`
 * are two elements in the same sixteen pixels — they would overlap, and which one won would be
 * whichever the stacking order happened to favour.
 *
 * The order is the order the work needs. Somebody placing shows looks at what it did to the week
 * far more often than they press Save, so the number is on top and the buttons are nearest the
 * thumb.
 */
.dock {
  position: sticky;
  /* Clear of the very bottom so it reads as floating over the page, not welded on. */
  bottom: 16px;
  z-index: 20;
  margin-top: 16px;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

/*
 * While a pill is in hand the dock stands aside: transparent to the pointer, and faded so
 * the lane underneath is readable through it. A drop aimed at the bottom lanes used to land
 * ON the dock — elementFromPoint answered with the panel, the lane was never found, and the
 * drag bounced. The figures come straight back the moment the pill is let go, which is also
 * when they change.
 */
body.is-dragging .dock {
  pointer-events: none;
  opacity: 0.25;
}

/* The bar keeps its floating LOOK and gives up its own position: the dock is what sticks now,
   and a sticky box inside a sticky box pins itself against the wrong thing. */
.dock .savebar--sticky {
  position: static;
  margin-top: 0;
}

/*
 * The panel borrows the bar's treatment, since the two are now one object on screen — and is
 * TIGHTER than it was standing under the grid, at every width rather than only on a phone.
 *
 * Measured stacked at 1280x720 the dock came to 352px: half the window, floating over the
 * Copy-day and Features row so neither could be pressed. A floating box is not a panel that
 * happens to move — it is spending screen the schedule needs, and it has to cost as little of it
 * as the numbers allow.
 */
.dock__panel {
  margin-bottom: 0;
  padding: 10px 14px;
  border-color: var(--border-strong);
  border-radius: var(--radius-lg);
  background: linear-gradient(180deg, var(--surface-3), var(--surface));
  box-shadow: var(--shadow-lift);
}

/* The figures and the week strip share one line, scrolling rather than wrapping — see the note
   in paintMaxes. Two rows of this is a third of a laptop window spent on a summary. */
.maxes__row {
  display: flex;
  align-items: center;
  gap: 8px 22px;
  overflow-x: auto;
  scrollbar-width: thin;
}

.dock__panel .sales__facts {
  flex: 0 0 auto;
  flex-wrap: nowrap;
  gap: 22px;
}

/* Tighter than the same figures standing under the grid: this is a floating box, and every line
   of it is a line of schedule somebody cannot see. */
.dock__panel .sales__figure {
  font-size: 0.9rem;
  line-height: 1.2;
}

/* The per-day chips lose their shows count: two lines, not three. The day's number is what the
   strip is for, and the working out behind it is already in the two figures beside it. */
.dock .maxes__shows {
  display: none;
}

.dock .maxes__days {
  margin-top: 0;
}

.dock .maxes__day {
  width: 66px;
  padding: 4px 8px;
}

.maxes__gap {
  margin: 6px 0 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/*
 * The bar in the dock is the phone's bar at every width, which is what was asked for: reported as
 * "huge, and covers a lot of what you're working on" from a desktop.
 *
 * Its explaining sentence is clamped to one line rather than dropped, with the whole of it on the
 * hover — what Save and Save-and-push each do is worth having the first few times, and two rows
 * of it is two rows of schedule nobody can see. Under 700px it goes entirely; there is no room to
 * clamp it into.
 */
.dock .savebar {
  padding: 12px 14px;
  gap: 10px;
}

.dock .savebar .field__hint {
  display: block;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.dock .savebar .btn {
  padding: 8px 12px;
  font-size: 0.84rem;
}

/*
 * Room to scroll the last lane clear of what is floating over it.
 *
 * A sticky element still occupies its place in the flow, so without this the bottom of a
 * ten-auditorium day can never be reached: the dock sits over it and the page has nowhere further
 * to go. Only while something in the dock is showing — holding the page open for a bar that
 * appears on the first change and a panel that only exists on a plan would be worse than either.
 */
.shell:has(#dock > :not([hidden])) {
  /* Measured on every paint rather than guessed at — see sizeDock in js/showtimes.js. A constant
     would be right for one screen: this bar is four buttons on a desktop and two rows of them on a
     phone, and the panel above it grows a line when a room has no seat count. */
  padding-bottom: var(--dock-room, 200px);
}

/*
 * On a phone the dock has to earn its space, because it is floating over the thing it describes.
 *
 * Measured at 390px it came to 481 of 700 — most of the screen given over to two boxes about a
 * schedule you could no longer see. The sentence under the save bar's heading goes (the heading
 * still says what is unsaved, and the buttons say what they do), and everything else tightens.
 */
@media (max-width: 700px) {
  .dock__panel,
  .dock .savebar {
    padding: 10px 12px;
  }

  .dock__panel .sales__facts {
    gap: 18px;
  }

  .maxes__row {
    gap: 6px 14px;
  }

  .dock .maxes__days {
    gap: 4px;
  }

  .dock .maxes__day {
    width: 54px;
    padding: 4px 6px;
  }

  /*
   * The sentence under the bar's heading goes. The heading still says what is unsaved and the
   * buttons say what they do — and what it costs is three lines of a box floating over the
   * schedule it is about.
   *
   * The panel's own hints STAY, phone or not: on a week copied from one that is running they read
   * "99 sold so far", which is the number the whole rebuild turns on.
   */
  .dock .savebar .field__hint {
    display: none;
  }

  /* Smaller buttons, so the four of them are one row rather than two. */
  .dock .savebar .btn {
    padding: 7px 10px;
    font-size: 0.8rem;
  }
}

/* Every day of the planned week at once. A weekly total says nothing about WHERE the week is
   short, and where is the only actionable half of the question. */
.maxes__days {
  display: flex;
  gap: 6px;
  margin-top: 12px;
  /* One row that scrolls rather than two that wrap: the dock is floating over the grid, and a
     panel that grows a second row on a phone takes the day it is describing off the screen. */
  overflow-x: auto;
  scrollbar-width: thin;
}

.maxes__day {
  display: flex;
  flex-direction: column;
  gap: 1px;
  /* Fixed rather than minimum: the row scrolls, so a chip that grew with its number would leave
     the seven days at seven different widths and the strip impossible to read across. */
  flex: 0 0 auto;
  width: 74px;
  padding: 6px 10px;
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
}

/* The day the grid above is showing. The panel and the grid must never leave a reader working
   out which day a number belongs to. */
.maxes__day--on {
  border-color: var(--accent);
}

.maxes__dayname {
  font-size: 0.7rem;
  color: var(--text-dim);
}

.maxes__figure {
  font-size: 0.92rem;
  font-variant-numeric: tabular-nums;
}

.maxes__shows {
  font-size: 0.68rem;
  color: var(--text-faint);
  font-variant-numeric: tabular-nums;
}

.schedule__film {
  font-weight: 600;
}

.schedule__at {
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.schedule__runtime,
.schedule__next {
  color: var(--text-dim);
  font-variant-numeric: tabular-nums;
}

.schedule__next {
  font-size: 0.78em;
}

/* ------------------------------------------------------------- the film panel
   Out from the right rather than above the grid: the list of every film the circuit holds is
   only wanted while something is being placed, and a film is DRAGGED from here onto a lane —
   which is why this is not a modal. There is no backdrop and the grid behind stays live. */
.library {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  width: min(400px, 92vw);
  /* Above the sticky top bar and its account menu, below the borrowed-view frame. */
  z-index: 45;
  display: flex;
  flex-direction: column;
  gap: 14px;
  /*
   * The insets, because this is a full-height overlay and on a phone the top and bottom of
   * the screen are not the top and bottom of the safe area.
   *
   * In the iOS home-screen app the page runs BEHIND the status bar — that is what
   * `apple-mobile-web-app-status-bar-style: black-translucent` buys, and it is why every
   * other fixed thing in this stylesheet already pads by `env(safe-area-inset-top)`. A panel
   * pinned to `top: 0` with a flat 18px put its header, and the only button that closes it,
   * underneath the clock. It could not be tapped and it could not be scrolled to, so the way
   * out of this panel was to force the app shut.
   */
  padding: calc(18px + env(safe-area-inset-top, 0px)) 18px
    calc(18px + env(safe-area-inset-bottom, 0px));
  /*
   * NOT the scroller any more — .library__scroll below is. The header has to stay on screen,
   * because it holds the way out.
   */
  overflow: hidden;
  background: var(--glass);
  border-left: 1px solid var(--border-strong);
  box-shadow: var(--shadow-lift);
  transform: translateX(100%);
  transition: transform 200ms var(--ease);
}

/*
 * The part that scrolls, and the part that keeps its gesture to itself.
 *
 * `overscroll-behavior: contain` is the whole of the second fix: without it a flick that
 * reaches the end of the features list is handed on to the page underneath, so on a phone
 * the schedule scrolled while the panel sat still — which reads as a panel that cannot
 * scroll at all. The same guard the modal bodies carry, for the same reason.
 *
 * `min-height: 0` because this is a flex child: without it a column flex item refuses to
 * shrink below its content, the box grows past the panel, and nothing scrolls anywhere.
 */
.library__scroll {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  gap: 14px;
  overflow-y: auto;
  overscroll-behavior: contain;
  /* Vertical panning only: the drag handles inside set their own touch-action. */
  touch-action: pan-y;
}

.library--open {
  transform: none;
}

/*
 * While the panel is out, the page shares the window with it instead of lying underneath:
 * the shell takes a right margin the panel's own width, the grid's scroll box narrows, and
 * fitDay redraws the whole day into what is left — nothing covered, nothing off screen.
 * Only where there is genuinely room to share; below it the panel overlays, as any sheet
 * does on a phone.
 */
@media (min-width: 1100px) {
  body.library-beside #main {
    margin-right: min(400px, 92vw);
  }
}

@media (prefers-reduced-motion: reduce) {
  .library {
    transition: none;
  }
}

.library__head {
  flex: none;
  display: flex;
  align-items: center;
  gap: 10px;
}

.library__title {
  margin: 0;
  font-size: 1.02rem;
  flex: 1 1 auto;
}

.library__close {
  font: inherit;
  font-size: 1.4rem;
  line-height: 1;
  width: 34px;
  height: 34px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  color: var(--text-dim);
  cursor: pointer;
}

.library__close:hover {
  color: var(--text);
  border-color: var(--border-strong);
}

.library__new {
  display: grid;
  gap: 10px;
  padding: 14px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
}

.library__new .field {
  margin: 0;
}

/* Search, with the count under it. Sticky, so scrolling a long library does not take away the
   box that shortens it. */
.library__find {
  position: sticky;
  /* Zero, not -18px: it sticks to .library__scroll now, and that box has no padding of its
     own to climb back over. */
  top: 0;
  z-index: 1;
  display: grid;
  gap: 4px;
  padding: 8px 0;
  background: var(--surface);
}

.library__count {
  margin: 0;
}

.library__list {
  display: grid;
  gap: 10px;
}

.feature {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  grid-template-areas:
    'grip main'
    'grip facts'
    'grip actions';
  gap: 6px 10px;
  padding: 10px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
}

.feature--retired {
  opacity: 0.72;
}

/* The grip, not the whole row: a row you can drag is a row you cannot select text in, and the
   name beside it is a text box. Hidden from assistive technology — the Place button next to it
   is the same journey without a gesture. */
.feature__grip {
  grid-area: grip;
  align-self: center;
  font-size: 1.1rem;
  line-height: 1;
  padding: 6px 2px;
  border: 0;
  background: none;
  color: var(--text-faint);
  cursor: grab;
  touch-action: none;
}

.feature__main {
  grid-area: main;
  display: flex;
  gap: 8px;
}

.feature__name {
  flex: 1 1 auto;
  min-width: 0;
}

.feature__runtime {
  flex: 0 0 104px;
  font-variant-numeric: tabular-nums;
}

.feature__facts {
  grid-area: facts;
  margin: 0;
}

.feature__actions {
  grid-area: actions;
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

/* A dialog's second line: which cinema and which day it is about. The title says what is being
   done, and on a page where both change under you, saying where is not optional. */
.modal__sub {
  margin: 4px 0 0;
  font-size: 0.86rem;
  color: var(--text-dim);
}

@media (max-width: 700px) {
  /* A narrower name column, which is the part fitDay cannot decide: it gives the day more of a
     390px screen to be drawn in, and "Auditorium 4" still fits. The scale itself is measured. */
  .grid {
    --room: 112px;
  }
}

/* =========================================================================
   Printed placards

   These go on paper beside an auditorium door, so they are deliberately black
   on white regardless of the theme — a dark placard would waste toner and scan
   no better.

   ## Why this card is designed rather than merely laid out

   It was a bordered white box with a code in it, which is what a placard looks
   like when nobody has decided what it should look like. The version below is
   meant to be printed, trimmed and mounted AS IT COMES: an amber cap across the
   top, the circuit's own mark or their name, the code inside a viewfinder, and a
   foot that answers the question somebody standing in front of it is actually
   asking — what is this going to want from me.

   ## Why the colours are forced to print

   Browsers drop backgrounds when printing, which is the right default nearly
   everywhere and wrong for exactly this card. The amber cap, the amber pupil in
   the wordmark's reticle and the reticle's own masked ring are all drawn as
   backgrounds — at the default a placard would come off the printer as a white
   box with a hole where the mark should be. `print-color-adjust` says this one is
   the document rather than decoration around it. Everything else here is drawn
   with borders and type, which print without being asked.
   ========================================================================= */
/*
 * The heading over a run of cards — "Issue reporting", "Sentiment" — which is also the handle
 * that folds it.
 *
 * Set as a label rather than a title: it is separating a stack of paper, not opening a chapter,
 * and a heavy heading between two grids would read as two pages. Kept ON the print, unlike the
 * admin strip beside each card, because the moment somebody is cutting eleven cards apart is
 * exactly when knowing which pile is which is worth a line of ink.
 */
.placards__group {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 28px 0 12px;
  padding: 4px 2px;
  font-size: 0.78rem;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-muted, var(--text));
  cursor: pointer;
  /* The UA's own triangle, replaced below by one that matches every other fold in the app. */
  list-style: none;
}

.placards__group::-webkit-details-marker { display: none; }

/* The chevron, turned by the open state — the same gesture the settings sections make. */
.placards__group::before {
  content: '';
  width: 7px;
  height: 7px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(-45deg);
  transition: transform 160ms var(--ease-out, cubic-bezier(0.32, 0.72, 0, 1));
}

.placards__section[open] > .placards__group::before { transform: rotate(45deg); }

.placards__group:hover { color: var(--text); }
.placards__group:focus-visible { outline: 2px solid var(--focus, var(--accent)); outline-offset: 2px; }

/* How many cards are inside, so a folded section still says what it is holding. */
.placards__group-count {
  padding: 1px 7px;
  border: 1px solid var(--border, rgba(128, 128, 128, 0.3));
  border-radius: 999px;
  font-size: 0.72rem;
  letter-spacing: 0.04em;
  font-variant-numeric: tabular-nums;
}

/* The first one sits directly under the toolbar and needs no gap above it. */
.placards__section:first-child > .placards__group { margin-top: 4px; }

@media (prefers-reduced-motion: reduce) {
  .placards__group::before { transition: none; }
}

.placards {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
  gap: 16px;
  /*
   * Every card the same height, so a sheet of them is a grid rather than a ragged
   * edge. `height: 100%` on the card cannot do this on its own — it equalises
   * within a row that is already tall enough, and "Auditorium 1" next to
   * "Concessions & bar" is precisely the case where it is not.
   */
  grid-auto-rows: 1fr;
}

/*
 * One printable card plus its admin controls. The controls are a sibling rather
 * than a child so the card stays a clean white object that can be trimmed out,
 * and so `.no-print` can drop them without leaving a gap inside the border.
 */
.placard-slot {
  display: flex;
  flex-direction: column;
  gap: 10px;
  break-inside: avoid;
}

/*
 * The strip under each card: the address the code points at, and the button that rotates it.
 *
 * Stacked rather than in a row, because the address wraps on a narrow column and a flex row
 * would put a two-line string beside a button and leave the button floating against nothing.
 * Dropped entirely when printing — see the `.no-print` block below — which is the whole reason
 * the address lives here rather than on the card.
 */
.placard__admin {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
}

.placard {
  position: relative;
  display: flex;
  flex-direction: column;
  /* Fills the slot, so the foot of every card on a sheet lands on the same line. */
  flex: 1;
  overflow: hidden;
  background: #ffffff;
  color: #11111b;
  border: 1.5px solid #11111b;
  border-radius: 14px;
  padding: 22px 18px 16px;
  text-align: center;
  break-inside: avoid;
  -webkit-print-color-adjust: exact;
  print-color-adjust: exact;
}

/*
 * The cap: the one band of colour on the card, and most of what makes it look made
 * rather than generated. A strip of amber across the top of a white card is what a
 * door badge or a hotel key card does, and it is five printed points of ink.
 *
 * A pseudo-element rather than a thicker `border-top`, because a border of a
 * different width on one side leaves a mitred wedge in each top corner once the
 * card has a radius. This is clipped by `overflow: hidden` instead, so it follows
 * the curve.
 */
.placard::before {
  content: '';
  position: absolute;
  inset: 0 0 auto;
  height: 5px;
  background: var(--brand-pupil);
}

/*
 * The circuit's own mark, when they have uploaded one — the same trade the guest
 * page makes. Bounded rather than sized: a logo is whatever shape that company's
 * logo is, and the card should accommodate it rather than stretch it.
 */
.placard__logo {
  display: block;
  max-height: 34px;
  max-width: 72%;
  width: auto;
  height: auto;
  object-fit: contain;
  margin: 0 auto 8px;
}

.placard__theatre {
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.14em;
  font-weight: 650;
  color: #6c6f85;
}

.placard__aud {
  font-size: 2.15rem;
  font-weight: 750;
  letter-spacing: -0.03em;
  margin: 3px 0 0;
  line-height: 1.05;
}

/*
 * The code, inside a viewfinder.
 *
 * Four corner brackets rather than a box: it is the shape a phone camera puts on
 * screen when it is hunting for something, so it reads as "point it here" without
 * spending a word on it, and it rhymes with the reticle now sitting in the middle
 * of the code — four ticks, four corners. That rhyme is what makes the card and
 * the code look like one designed object instead of a picture pasted onto paper.
 *
 * Borders rather than a background image, because a border is the one way of
 * drawing a line that no printer will decide is decoration — and these brackets
 * are the instruction. Four corners need four boxes, so the wrapper carries two
 * and an empty span carries the other two, which is how the wordmark's reticle is
 * drawn as well.
 */
/*
 * The padding is deliberately small, and it is the gap between the brackets and the code.
 *
 * The code brings its OWN four modules of quiet zone — the specification's, and the thing that
 * makes it scannable — so any padding here is a second margin stacked on the first. At 13px the
 * brackets floated in the middle of nowhere and read as a box the code happened to be inside
 * rather than a viewfinder on it.
 */
.placard__code {
  position: relative;
  /*
   * `fit-content` so the brackets hug the code rather than the column, and `max-width: 100%` so
   * a column narrower than the code shrinks it instead of letting it hang out over the edge.
   * Without the cap the bare sheet — which packs more cards to a row on purpose — clipped the
   * right-hand code off every row.
   */
  width: fit-content;
  max-width: 100%;
  margin: 14px auto 14px;
  padding: 5px;
}

.placard__code img {
  width: 100%;
  max-width: 210px;
  height: auto;
  display: block;
}

.placard__frame {
  position: absolute;
  inset: 0;
}

.placard__code::before,
.placard__code::after,
.placard__frame::before,
.placard__frame::after {
  content: '';
  position: absolute;
  width: 17px;
  height: 17px;
  border: 2.5px solid #11111b;
}

.placard__code::before {
  top: 0;
  left: 0;
  border-right: 0;
  border-bottom: 0;
  border-top-left-radius: 5px;
}

.placard__code::after {
  top: 0;
  right: 0;
  border-left: 0;
  border-bottom: 0;
  border-top-right-radius: 5px;
}

.placard__frame::before {
  bottom: 0;
  left: 0;
  border-right: 0;
  border-top: 0;
  border-bottom-left-radius: 5px;
}

.placard__frame::after {
  bottom: 0;
  right: 0;
  border-left: 0;
  border-top: 0;
  border-bottom-right-radius: 5px;
}

.placard__cta {
  font-size: 1.05rem;
  font-weight: 700;
  letter-spacing: -0.01em;
  margin: 0 0 5px;
}

.placard__sub {
  font-size: 0.78rem;
  color: #5c5f77;
  margin: 0;
}

/*
 * The foot, pushed to the bottom so a sheet of cards lines up along it.
 *
 * A border rather than a tinted strip, for the same reason as the brackets: this
 * has to survive a printer's opinion about backgrounds.
 */
.placard__foot {
  margin-top: auto;
  padding-top: 12px;
  border-top: 1px solid #dcdce4;
}

/*
 * What scanning costs them.
 *
 * This is the line that decides whether somebody standing in front of the card
 * actually takes their phone out. The unasked question is what it is going to want
 * — an app, an account, an email address — and the answer is none of it. It is the
 * same promise the form makes above its send button, moved to where the decision
 * is really taken, which is on the wall rather than on the page.
 */
.placard__promise {
  font-size: 0.7rem;
  font-weight: 600;
  color: #4c4f69;
  margin: 8px 0 0;
}

/*
 * And whose software it is. Ours, at the foot, never conditional on the circuit's
 * logo — their brand goes at the top and this stays, exactly as under the guest
 * form's send button.
 */
.placard__by {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 5px;
  margin: 5px 0 0;
  font-size: 0.6rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  /* Quiet, but not so quiet it halftones away: at #8c8fa1 this was 3.2:1 on the card, which is
     under the readable floor before a printer has had its say. */
  color: #6c6f85;
}

/*
 * Fixed colours, not the themed ones the mark carries everywhere else.
 *
 * `.wordmark` takes `var(--text)` and `.wordmark__cine` takes `var(--text-dim)`,
 * which are right on every page and wrong on this one: the card is a white sheet
 * whatever theme the app is wearing, so on a dark theme the mark would be drawn in
 * near-white on white and simply not be there. The same trap `.placard__url`
 * below fell into once already.
 */
.placard__by .wordmark {
  font-size: 0.63rem;
  letter-spacing: 0.1em;
  text-transform: none;
  color: #4c4f69;
}

.placard__by .wordmark__cine {
  color: #6c6f85;
}

/* ------------------------------------------------------------------ the bare print
 *
 * The same cards with everything but the identifier and the code taken off, for an operator
 * dropping these into a sign of their own. Wanted for TONER as much as for looks: the amber cap
 * is the most expensive square centimetre on the sheet, and printing a full placard in order to
 * cut the middle out of it wastes the rest of it too.
 *
 * A stamp on <html> rather than a second markup path — see BARE_KEY in public/js/qr.js. There is
 * one drawing of a placard, and the page is its own print preview, so what is ticked here is what
 * comes out of the printer.
 *
 * What survives is deliberately just the two things: the place, so whoever is assembling the
 * sheet knows which code is which, and the code. The cinema's name goes with the rest — the page
 * prints one cinema at a time, and the sign this is going into will be carrying their own name
 * already.
 */
html[data-placard='bare'] .placard {
  border: 0;
  border-radius: 0;
  padding: 4px 4px 10px;
}

html[data-placard='bare'] .placard::before,
html[data-placard='bare'] .placard__code::before,
html[data-placard='bare'] .placard__code::after,
html[data-placard='bare'] .placard__frame::before,
html[data-placard='bare'] .placard__frame::after {
  content: none;
}

html[data-placard='bare'] .placard__logo,
html[data-placard='bare'] .placard__theatre,
html[data-placard='bare'] .placard__cta,
html[data-placard='bare'] .placard__sub,
html[data-placard='bare'] .placard__foot {
  display: none;
}

/* The name shrinks with the card: at 2.15rem it was the placard's headline, and here it is a
   label on a code. Still the biggest thing on the card, because it is one of only two. */
html[data-placard='bare'] .placard__aud {
  font-size: 1.15rem;
  font-weight: 700;
  margin: 0;
}

html[data-placard='bare'] .placard__code {
  margin: 6px auto 0;
  padding: 0;
}

/*
 * More of them to a sheet, which is the other half of not wasting paper — and no narrower than
 * the code itself wants, because a column that squeezes it is a column that prints an unscannable
 * one. 210px of code, plus the padding either side.
 */
html[data-placard='bare'] .placards {
  grid-template-columns: repeat(auto-fill, minmax(222px, 1fr));
  gap: 10px;
}

/*
 * The address, now on the page rather than on the card.
 *
 * Themed tokens rather than the card's own fixed greys: the card is a white sheet whatever the
 * app's theme is, because it is printed, and these colours were chosen against that white. Read
 * on the dark page they were nearly invisible.
 */
.placard__url {
  font-family: var(--mono);
  font-size: 0.66rem;
  color: var(--text-faint);
  text-align: center;
  max-width: 260px;
  overflow-wrap: anywhere;
}

@media print {
  .topbar,
  /* The dock is the phone's chrome, and paper is not a phone. In the one block, because
     the placard pages' print rules are the last word on print and there is exactly one. */
  .dockbar,
  .dockbar__scrim,
  .dockbar__menu,
  .toolbar,
  .filters,
  .page-head p,
  .notice,
  .no-print,
  /* A borrowed view is a mode somebody is in, not part of the document they are
     printing — and the frame in particular would print as a box around every page. */
  .borrowed,
  .watched,
  html[data-borrowed]::after,
  /*
   * The schedule prints as the LIST, not as the timeline.
   *
   * Nineteen hours across A4 halftones into a smear, and the list is the half somebody in a
   * booth types from anyway — one line per showing, in the order they happen. The day tabs and
   * the panel go with it: both are ways of choosing what to look at, and paper has already
   * chosen.
   */
  .tabs,
  .sched__scroll,
  .sched__actions,
  .savebar,
  /* The printout is the booth's copy of the schedule. The takings are not part of it — a
     grant that walked out of the office on a sheet of A4 would not be much of a grant. */
  .sales,
  .library {
    display: none !important;
  }

  /* The half that IS printed. Hidden on screen — see .sched__list above. */
  .sched__list {
    display: block;
    border: 0;
    padding: 0;
  }

  /* Both, because the ground is on the root and the canvas is what a printer would otherwise fill
     with a page-sized block of Crust. */
  html,
  body {
    background: #fff;
    color: #11111b;
  }

  /* The dust goes with it: fourteen specks that a printer renders as grit on the glass, sitting
     over the white the line above just established.

     And the TINT with them, for exactly that reason. It used to be part of the root's background,
     so `background: #fff` above replaced it in one go; it is a fixed layer of its own now, which
     the whitening cannot reach — left alone it would print as a grey wash over the top of every
     page, and the printout is a booth's working copy. */
  .dust,
  html::before {
    display: none;
  }

  .page-head h1 {
    color: #11111b;
  }

  /* The placards page gets printed. A tinted tile beside the heading is toner spent on
     decoration, and the heading says what the page is without it. Same for the rule: a
     colour that fades to nothing on a screen halftones into a grey smear on paper. */
  .page-head__icon,
  .page-head::after {
    display: none;
  }

  .shell {
    --shell-pad: 0px;
    max-width: none;
    padding: 0;
  }

  .placards {
    grid-template-columns: repeat(2, 1fr);
    gap: 12px;
  }

  /* Black on white, and never orphaned at the foot of a sheet with its cards overleaf. The
     chevron goes: on paper there is nothing to fold, and an arrow beside a heading is a control
     somebody would try to press. */
  .placards__group {
    margin: 14px 0 8px;
    padding: 0;
    color: #000;
    break-after: avoid;
    page-break-after: avoid;
  }

  .placards__group::before { display: none; }

  .placards__group-count { border-color: #999; }

  /* The slot collapses to just the card once its controls are hidden. */
  .placard-slot,
  .placard {
    page-break-inside: avoid;
    gap: 0;
  }

  /*
   * On paper everything is present: a card still waiting to be scrolled to is a card
   * somebody printed, and a page of blanks is not a printout. Inside this block rather
   * than in one of its own — the stylesheet has exactly one print block by design, and
   * two tests hold it to that, counting the words that open one.
   */
  [data-caseview] .complaint--report.reveal {
    opacity: 1;
    transform: none;
  }
}

/* ------------------------------------------------------------- your password
   A narrow column on purpose. These inputs hold a dozen or so characters, and a
   field the width of the card reads as a text area rather than a password box.
   The button sits under them rather than beside: on a phone the row would wrap
   anyway, and a submit that moves depending on width is a submit people miss. */
.password-form {
  display: grid;
  gap: 16px;
  max-width: 380px;
}

.password-form .btn {
  justify-self: start;
}

/* ================================================================== sign-in
   The front door, and the only page with no top bar: everywhere else that bar
   carries a nav and an account menu, and here there is neither.

   One column, centred, with the mark doing the work a header would otherwise do.
   100svh rather than vh, so a phone's collapsing address bar cannot push the card
   under the fold and then leave a gap when it retracts. `place-items: center` with
   `align-content` so a short viewport scrolls instead of clipping the logo. */
.signin {
  display: grid;
  place-items: center;
  align-content: center;
  min-height: 100svh;
  padding: 40px 20px 56px;
  box-sizing: border-box;
}

.signin__panel {
  width: 100%;
  max-width: 400px;
  display: grid;
  gap: 26px;
}

/*
 * The claim page, wider than the sign-in cards it shares its dress with.
 *
 * Those are two fields and a button. This is the first screen of a product somebody has just
 * paid for: a heading, the plan, five fields and the address being made — and at 400px the
 * address wrapped to three lines and the whole card read like a login box that had grown.
 *
 * A MEDIA QUERY rather than a bigger max-width, because max-width alone would change nothing
 * on a phone (the screen is already narrower) and everything about how this reads at 1280.
 * Below the breakpoint it is the panel every other card is, which is what a phone wants.
 */
@media (min-width: 560px) {
  .signin__panel--claim {
    max-width: 520px;
  }

  .signin__panel--claim .signin__card {
    padding: 32px 34px 34px;
  }
}

.signin__brand {
  display: grid;
  justify-items: center;
  gap: 10px;
  text-align: center;
}

/*
 * The mark at display size.
 *
 * Everything inside the lockup is sized in em — including the reticle and its pupil —
 * so one font-size here scales the whole thing, and the markup stays character for
 * character identical to the copy in every top bar. clamp() rather than a media query:
 * the step from a 320px phone to a desktop is smooth, and there is no width at which
 * the wordmark wraps or overflows.
 *
 * There was a soft accent bloom behind this. It read as a smudge rather than as depth,
 * and the mark holds the page on its own — the reticle's amber pupil is the only colour
 * it needs. Its removal also took the only thing on either page that was wider than the
 * panel, which is what used to push a 320px phone into scrolling sideways.
 */
.signin__brand .wordmark {
  font-size: clamp(1.7rem, 8.5vw, 2.5rem);
  letter-spacing: 0.075em;
}

/* The apex picker: one button per circuit the proven account belongs to, stacked, each as
   pressable as the submit it replaces. Only ever seen by somebody whose password verified on
   more than one circuit — see public/js/sign-in.js. */
.signin__choice {
  display: block;
  width: 100%;
  margin: 0 0 8px;
}

/*
 * The way back to the circuit this device used last, above the form on the apex.
 *
 * The apex exists so nobody has to know their subdomain, and it does that job by asking for a
 * password first — which is right for somebody arriving for the first time and is a long way
 * round for the same person on the same phone every morning. So the second visit leads with
 * where they went, and the form stays underneath for everybody else.
 *
 * A rule under it rather than a card around it: this sits above the sign-in form and must read
 * as the top of that flow, not as a competing panel with its own edges.
 */
.signin__known {
  display: grid;
  gap: 10px;
  margin: 0 0 20px;
  padding: 0 0 20px;
  border-bottom: 1px solid var(--border);
}

/* The circuit's own name is the label, so it wraps rather than being clipped — some of these
   are long, and a name cut in half is the one word somebody needed to recognise. */
.signin__known .btn {
  white-space: normal;
}

.signin__known .linkish {
  justify-self: center;
  font-size: 0.9rem;
}

/*
 * The apex pages' forms — the sign-in and the claim page.
 *
 * The same grid-and-18px rhythm as .signin__group, and for a discovered reason rather than
 * taste: these forms hold their button DIRECTLY, where login.html's button sits inside a
 * group, so without this the button lands flush against the last field and the forgot
 * disclosure's -8px pull (meant to tuck it under a button 18px away) overlaps the button
 * itself — which is exactly how it shipped, and how it looked on a phone.
 */
.signin__form {
  display: grid;
  gap: 18px;
}

/* The apex's Forgot password? is a disclosure, not a control that goes somewhere: resets
   live on each circuit's own page, and this opens to the sentence that says so. The summary
   borrows .linkish so it reads like the circuits' own escape hatch, minus the marker. */
.signin__where summary.linkish {
  display: inline-block;
  list-style: none;
}

.signin__where summary.linkish::-webkit-details-marker {
  display: none;
}

.signin__where summary.linkish::marker {
  content: '';
}

.signin__where .field__hint {
  margin-top: 8px;
  text-align: left;
}

/* ------------------------------------------------------------- console: what is broken

   The attention strip. Warm rather than red: these are sales to chase this morning, not an
   outage — and a console that cries emergency at a spam-foldered email is one whose warnings
   get ignored. See renderAttention in public/js/console.js. The badge it puts on the rail is
   .rail__count, which lives with the rest of the rail further down. */

.attention {
  border-color: var(--warn);
  margin-bottom: 16px;
}

.attn__head {
  display: flex;
  align-items: baseline;
  gap: 12px;
  flex-wrap: wrap;
  margin: 0 0 4px;
}

.attn__head .field__hint {
  margin: 0;
  font-weight: 400;
}

/* One row: what kind, who and why, when, and the single button that ends it. */
.attn {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto auto;
  align-items: center;
  gap: 8px 14px;
  padding: 12px 0;
  border-top: 1px solid var(--border);
}

/* A fixed column, so the names line up: five rows whose text starts at five different
   places reads as a list nobody has looked at. */
.attn__tag {
  flex: none;
  width: 8.5rem;
  padding: 2px 9px;
  border-radius: 999px;
  background: var(--surface-3);
  font-size: 0.76rem;
  font-weight: 700;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  text-align: center;
}

/* The three that cost a customer something already — a licence they paid for and did not
   get, a sale that vanished, a link that never arrived — marked apart from the merely slow. */
.attn--suspended .attn__tag,
.attn--licence .attn__tag,
.attn--checkout .attn__tag,
.attn--email .attn__tag {
  background: var(--warn-soft);
  color: var(--warn);
}

/* ------------------------------------------------------------- the compliance register

   One row per obligation: where it stands, what it is for, which regimes ask for it, and the
   one button that records an answer. Same skeleton as .attn above — a fixed first column so
   twenty rows read as a list rather than as twenty different left margins. */

.oblig {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: start;
  gap: 8px 14px;
  padding: 14px 0;
  border-top: 1px solid var(--border);
}

.oblig__state {
  flex: none;
  width: 7rem;
  padding: 3px 9px;
  border-radius: 999px;
  background: var(--surface-3);
  font-size: 0.76rem;
  font-weight: 700;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  text-align: center;
}

.oblig__state--ok { background: var(--ok-soft); color: var(--ok); }
.oblig__state--warn { background: var(--warn-soft); color: var(--warn); }
.oblig__state--bad { background: var(--danger-soft); color: var(--danger); }
.oblig__state--quiet { background: var(--surface-3); color: var(--p-subtext0); }

.oblig__what {
  display: grid;
  gap: 4px;
  min-width: 0;
}

.oblig__what .field__hint { margin: 0; }

/* What the CHECK found, as opposed to what the obligation is — the one line that changes
   between two readings of this page, so it is the one that reads as data rather than prose. */
.oblig__detail {
  font-size: 0.86rem;
  color: var(--text);
  overflow-wrap: anywhere;
}

.oblig__tags {
  display: flex;
  flex-wrap: wrap;
  gap: 5px;
  margin-top: 2px;
}

.oblig__tag {
  padding: 1px 7px;
  border: 1px solid var(--border);
  border-radius: 999px;
  font-size: 0.72rem;
  color: var(--p-subtext0);
}

.oblig__evidence { font-size: 0.86rem; }

.oblig__do { display: flex; align-items: center; }

.compliance__actions { margin-top: 14px; }

@media (max-width: 640px) {
  /* The state chip keeps its own line rather than squeezing the title into two words. */
  .oblig { grid-template-columns: minmax(0, 1fr); }
  .oblig__state { width: max-content; }
}

/* A circuit that is not answering outranks all of them: nobody there can sign in and no
   guest can report, right now, which is not the same class of problem as a licence that has
   not landed yet. The one row on this page that should read as red. */
.attn--down .attn__tag {
  background: var(--danger-soft);
  color: var(--danger);
}

.attn__what {
  display: grid;
  gap: 2px;
  min-width: 0;
}

.attn__what .field__hint {
  margin: 0;
  overflow-wrap: anywhere;
}

.attn__when {
  margin: 0;
  white-space: nowrap;
}

.attn__do {
  display: flex;
  justify-content: flex-end;
}

@media (max-width: 720px) {
  .attn {
    grid-template-columns: 1fr auto;
  }

  .attn__what {
    grid-column: 1 / -1;
    order: 3;
  }

  .attn__do {
    grid-column: 1 / -1;
    order: 4;
    justify-content: flex-start;
  }
}

/* The counted numbering form: one question, one dropdown, sized like an answer rather
   than stretched like a form row. See openNumbering in public/js/starter.js. */
.starter__countRow {
  display: flex;
  align-items: center;
  gap: 12px;
  margin: 4px 0 8px;
}

.starter__countLabel {
  font-weight: 600;
}

.starter__countPick {
  width: auto;
  min-width: 96px;
}

/* ------------------------------------------------------------------- the claim page
   Where a licence becomes a circuit: the buyer's one form, in the sign-in's own card.
   See public/claim.html and src/routes/claim.js. */

/* What the link buys, said before anything is asked. The form's grid gap spaces it. */
/*
 * The claim card: the first screen of a product somebody has just paid for.
 *
 * The heading and the plan are CENTRED and everything below them is not, deliberately. A
 * centred masthead over a left-aligned form is how a welcome reads; centring the fields too
 * would make the labels wander and turn a form into a poster.
 */
.claim__title {
  margin: 0 0 6px;
  text-align: center;
}

.claim__plan {
  margin: 0 0 4px;
  text-align: center;
  color: var(--text-dim);
  /* The one line saying what was bought, so it carries a little more weight than a hint. */
  font-size: 0.95rem;
  letter-spacing: 0.01em;
}

/*
 * The address falling out of the name, under the field that makes it.
 *
 * A consequence rather than a control, so it is dimmed and small — and the Edit beside it is a
 * link rather than a button, because pressing it opens a field rather than doing anything.
 */
.claim__address {
  display: grid;
  gap: 3px;
  margin: 7px 0 0;
  font-size: 0.85rem;
}

/* The instruction — brighter than the line under it, because this is the address a whole
   team is meant to remember and the other one is a convenience. */
.claim__address-main {
  color: var(--text);
}

/*
 * The direct line, and Edit trailing it INSIDE the same flow.
 *
 * Not a flex row with the button as its own item: at 390px the address wraps to two lines and
 * a flex item would sit against the top one, leaving "Edit" pointing at nothing. As inline
 * text it follows the last word wherever that lands.
 *
 * `overflow-wrap` because a hostname has no spaces to break at — "grandpalacecinemas.
 * cinescope-app.com" is one word to a browser, and without this it pushes the card open on a
 * phone rather than wrapping inside it.
 */
.claim__address-direct {
  color: var(--text-dim);
  overflow-wrap: anywhere;
}

.claim__edit {
  margin-left: 6px;
  padding: 0;
  border: 0;
  background: none;
  color: var(--accent);
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  text-decoration: underline;
}

.claim__edit:hover,
.claim__edit:focus-visible {
  text-decoration: none;
}

/* The address: the typed label and the fixed suffix, reading as one address. */
.claim__domain {
  display: flex;
  align-items: center;
  gap: 8px;
}

.claim__domain input {
  flex: 1;
  min-width: 0;
}

.claim__suffix {
  color: var(--text-dim);
  font-family: var(--mono, ui-monospace, monospace);
  font-size: 0.92rem;
  white-space: nowrap;
}

/* Whose account this becomes — the check against a forwarded link, under the button. */
.claim__whose {
  margin: 0;
  text-align: center;
}

/* ------------------------------------------------------------- console: provisioning
   The mint form, the one-moment link box, and the book of claims. */

.provision {
  display: grid;
  gap: 4px;
  max-width: 560px;
}

/* Edition beside the location count: one decision, two dials. */
.provision__plan {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0 16px;
}

.provision > .btn {
  justify-self: start;
  margin-top: 4px;
}

/* The plaintext link's one moment on this side: boxed to copy, never stored. */
.provision__result {
  margin-top: 14px;
  padding: 12px 14px;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--surface-3);
}

.provision__url {
  margin: 0 0 10px;
}

.provision__url code {
  display: block;
  overflow-wrap: anywhere;
  font-size: 0.9rem;
}

.provision__actions {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
}

/* One claim: who, the plan, the state, the levers. */
.claimrow {
  display: grid;
  grid-template-columns: minmax(160px, 1.2fr) minmax(160px, 1fr) auto auto;
  align-items: center;
  gap: 8px 16px;
  padding: 12px 0;
  border-top: 1px solid var(--border);
}

.claimrow:first-child {
  border-top: 0;
  padding-top: 4px;
}

.claimrow__who,
.claimrow__plan {
  display: grid;
  gap: 2px;
  min-width: 0;
}

.claimrow__who .field__hint,
.claimrow__plan .field__hint {
  margin: 0;
  overflow-wrap: anywhere;
}

.claimrow__actions {
  display: flex;
  gap: 8px;
  justify-content: flex-end;
}

@media (max-width: 720px) {
  .provision__plan {
    grid-template-columns: 1fr;
  }

  .claimrow {
    grid-template-columns: 1fr auto;
  }

  .claimrow__plan {
    grid-column: 1 / -1;
    order: 3;
  }

  .claimrow__actions {
    grid-column: 1 / -1;
    order: 4;
    justify-content: flex-start;
  }
}

/* A touch more room than a card in a page full of cards: this one is the page. */
.signin__card {
  position: relative;
  padding: 26px 24px 28px;
  display: grid;
  gap: 18px;
  overflow: hidden;
}

/* A hairline of accent along the top edge. Inside the card's own rounded corners,
   which is what `overflow: hidden` above is for — a straight line laid over a 16px
   radius pokes out at both ends. */
.signin__card::before {
  content: '';
  position: absolute;
  inset: 0 0 auto;
  height: 2px;
  background: linear-gradient(
    90deg,
    transparent,
    color-mix(in oklab, var(--accent) 70%, transparent),
    transparent
  );
}

.signin__title {
  margin: 0;
  font-size: 1.12rem;
  font-weight: 600;
  letter-spacing: 0.01em;
}

/* The gap above is doing the spacing, so the fields lose their own bottom margin —
   otherwise the rhythm is two different sizes stacked. */
.signin__card .field {
  margin: 0;
}

/*
 * The password fields and their button, which are one box now rather than three rows.
 *
 * They were direct children of the card, spaced by its own grid gap; they are wrapped so the
 * page can hide them when this circuit does not take a password — see signInMethods in
 * src/auth.js. A wrapper is one grid item, so it has to carry the same gap inside itself or
 * the email, the password and the button close up against each other.
 */
.signin__group {
  display: grid;
  gap: 18px;
}

/*
 * Face ID, under the password button on a device that can use it.
 *
 * Its own grid so the divider and the button sit tighter to each other than the card's own 18px
 * rhythm — the pair is one offer, not two rows in the stack. Pulled up against the button above it
 * for the same reason the forgot-password line is: it belongs to that control.
 */
.signin__passkey {
  display: grid;
  gap: 10px;
  margin: -4px 0 0;
}

/*
 * "or" — a rule with a word sitting in it.
 *
 * The word is what makes the button under it a choice rather than a second step: a bare line
 * between two controls leaves a reader working out whether the one below is a fallback, the next
 * thing to do, or something else. The line is drawn with two flex-grown pseudo-elements, so it
 * fills whatever the text does not and needs no width known in advance.
 */
.signin__or {
  display: flex;
  align-items: center;
  gap: 12px;
  margin: 0;
  color: var(--text-dim);
  font-size: 0.85rem;
}

.signin__or::before,
.signin__or::after {
  content: '';
  flex: 1 1 auto;
  height: 1px;
  background: var(--border);
}

/*
 * A quieter line under the button: "Forgot password?", and the way back from it.
 *
 * Centred and small because it is an escape hatch rather than a step — somebody signing in
 * normally should read past it without deciding anything. Negative top margin pulls it against
 * the button it belongs to, out of the card's own 18px rhythm, so it reads as attached to that
 * control instead of as a third thing in the stack.
 */
.signin__alt {
  margin: -8px 0 0;
  text-align: center;
  font-size: 0.9rem;
}

/* The status-page line under the card: present, and not competing with the form. */
.signin__status {
  margin: 18px 0 0;
  text-align: center;
}

/*
 * A button that reads as a link.
 *
 * A <button> rather than an <a href>, because it goes nowhere: it swaps what this card is
 * asking. Anything that looked like a link and did not navigate would be a lie to anybody
 * middle-clicking it, and an <a> with no href is invisible to a keyboard. So the element is
 * honest and the appearance is borrowed.
 */
.linkish {
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  color: var(--accent);
  text-decoration: underline;
  text-underline-offset: 2px;
  cursor: pointer;
}

.linkish:hover {
  color: var(--text);
}

/* Every other way in, one button per row and tighter than the fields above: these are
   alternatives to each other, not steps in a form. */
.signin__methods {
  display: grid;
  gap: 10px;
}

/*
 * Empty on every circuit that takes only a password, which is all of them today.
 *
 * An empty grid item still takes the card's 18px gap, which would leave a hole under the
 * Sign in button that nobody could explain. Same reason `.person__state:empty` is hidden.
 */
.signin__methods:empty {
  display: none;
}

/*
 * There were two blocks of prose here — a lede inside the card and a footnote under it,
 * explaining who the page is for and who to ask for a password. Both are gone with the
 * paragraph they styled: a sign-in screen is read by people who already know why they
 * are on it, and the wordmark above the card says whose app it is.
 */

/* ---------------------------------------------------------- a borrowed view
 *
 * A dev looking at somebody else's dashboard, from View dashboard on Active users.
 *
 * Two pieces doing one job, and the second is the important one. The band carries the
 * detail and scrolls away like any other content; the frame is fixed, so forty reports
 * down a manager's queue the page is still visibly not yours. The failure this is built
 * against is quiet rather than loud — a manager's dashboard and a dev's own look broadly
 * alike, and by this point the nav and the account menu have legitimately become the other
 * person's — so the risk is not confusion in the moment but forgetting an hour later which
 * cinema's reports you were reading.
 *
 * Amber rather than red, matching `.alarm--due`: nothing is wrong, and a red page would say
 * something is. It is the same "you are in a mode" colour as a screen-sharing indicator,
 * which is exactly the relationship.
 */
.borrowed {
  background: color-mix(in oklab, var(--warn) 16%, var(--surface));
  border-bottom: 1px solid var(--warn);
  /* No stacking of its own, so the sticky top bar passes over it on the way up rather
     than under it. The frame is what keeps saying this after the band has gone. */
}

.borrowed__inner {
  max-width: 1180px;
  margin: 0 auto;
  padding: 9px 20px;
  display: flex;
  align-items: center;
  gap: 10px 14px;
  flex-wrap: wrap;
  font-size: 0.86rem;
}

.borrowed__tag {
  text-transform: uppercase;
  letter-spacing: 0.07em;
  font-size: 0.68rem;
  font-weight: 700;
  color: var(--warn);
}

.borrowed__who {
  color: var(--text);
  /* The one part that must survive a narrow screen intact, so it may take the width and
     push the rest onto a second line rather than being truncated itself. */
  min-width: 0;
}

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

/* Pushed to the end of the row, so the way out is always in the same place whether the
   band is one line or three. */
.borrowed__out {
  margin-left: auto;
  font-weight: 600;
  color: var(--warn);
  white-space: nowrap;
}

/*
 * The frame. Fixed, so it does not scroll; `pointer-events: none`, so it is decoration
 * over a page somebody is using rather than a sheet of glass in front of it.
 *
 * On `html` rather than `body` because body is the element the band was prepended to, and
 * a pseudo-element of the same box would sit inside the scroll rather than over it.
 */
html[data-borrowed]::after {
  content: '';
  position: fixed;
  inset: 0;
  border: 3px solid var(--warn);
  pointer-events: none;
  z-index: 60;
}

/* Neither of these is printed — both are hidden in the app's one print block, above. */





/* The logo preview in Settings: whatever shape the company's logo is, on a neutral tile so a
   white mark on a white background is still visible as a picture rather than as nothing. */
.logo-preview {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 72px;
  padding: 12px;
  margin: 10px 0;
  border: 1px dashed var(--border);
  border-radius: 10px;
  background: var(--surface-2, var(--surface));
}

.logo-preview img {
  max-height: 48px;
  max-width: 100%;
  width: auto;
  object-fit: contain;
}

.logo-preview__none {
  font-size: 0.84rem;
  color: var(--text-faint);
}

.logo-actions {
  display: flex;
  align-items: center;
  gap: 12px;
  flex-wrap: wrap;
}

/* What the upload said, under the control it is about rather than in the page banner. */
.logo-notice {
  margin-top: 10px;
}



/* ---------------------------------------------------------------------- units
 *
 * A hotel's rooms or a facility's storage units, in Location info. A wrapping row of small
 * chips rather than a list of rows: two hundred rooms as full-width rows is a page nobody can
 * scroll past, and the only things worth showing per unit are its name and the way to remove it.
 */
.units {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  padding: 12px 0 4px;
}

.unit {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 6px 8px 6px 12px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--surface-2, var(--surface));
  font-size: 0.86rem;
}

/* Switched off: still listed, because a room somebody turned off is a room they may turn back
   on, and a list that hides them is a list that cannot explain the count. */
.unit--off {
  opacity: 0.55;
  border-style: dashed;
}

.unit__name {
  font-weight: 600;
  color: var(--text);
}

.unit__meta {
  color: var(--text-faint);
  font-size: 0.78rem;
}

.unit__remove {
  padding: 2px 8px;
}

.unit__add {
  padding: 8px 0 4px;
}

/* -------------------------------------------------------------------- the licence
 *
 * How many locations are in use against how many were paid for, at the foot of the list it is
 * counting. Adding one used to be a developer's job because it crossed the licence meter and
 * the meter was an honour system; the meter is a number now, so the button that crosses it can
 * sit here, and the button that RAISES it takes its place at the ceiling.
 */
.licence {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 8px 14px;
  margin: 18px 0 6px;
  padding: 14px 16px;
  border: 1px solid var(--border);
  border-radius: 12px;
  background: var(--surface-2, var(--surface));
}

.licence__count {
  font-weight: 650;
  color: var(--text);
}

.licence__note {
  flex: 1 1 auto;
  font-size: 0.86rem;
  color: var(--text-muted, var(--text));
}

/* The last button on the row, whichever it is: Add while there is room, Upgrade once there
   is not. `margin-left: auto` on the row's end rather than on a specific control, so the two
   states line up in the same place instead of jumping. */
.licence .btn {
  margin-left: auto;
  flex: 0 0 auto;
}

/*
 * A way out to a page that explains, rather than a button that does something. Plain and
 * quiet on purpose: it sits on the same row as "Ask for more", and two things that looked
 * equally pressable would make the reading matter compete with the paying one.
 *
 * `flex-basis: 100%` puts it on its own line beneath the sentence it belongs to, so the row
 * above stays the count-note-button shape every other licence row has.
 */
.licence__link {
  flex: 0 0 100%;
  font-size: 0.86rem;
  color: var(--accent);
  text-decoration: none;
}

.licence__link:hover,
.licence__link:focus-visible {
  color: var(--accent-hover);
  text-decoration: underline;
}

/* At the ceiling the row says so with an edge rather than a colour, which would read as an
   error — being fully subscribed is not a fault. */
.licence--full {
  border-color: var(--accent);
}

@media (max-width: 560px) {
  .licence .btn {
    margin-left: 0;
    flex: 1 1 100%;
  }
}

/* ------------------------------------------------------------- quick-start presets
 *
 * The standalone licence's first question, riding the layout box's tile styles — same
 * furniture, same feel, one new piece: the detail block that says what a selected preset
 * actually sets up, so "Set up my circuit" is pressed on facts rather than on a label.
 */
/*
 * A GRID, overriding the layout box's single column.
 *
 * `.preset__choices` is a flex column, which is right for three choices and wrong for seven:
 * stacked, the tiles filled the whole scroll area and pushed the detail block — the thing that
 * justifies pressing the button — off the bottom behind the actions bar. Found by looking at a
 * screenshot, because every assertion about it passed: the element was in the DOM, Playwright
 * called it visible, and a person could not read it.
 *
 * `display: grid` is restated rather than assumed, since the rule it overrides sets `flex`.
 */
.starter__choices {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
  gap: 10px;
}

/* A name, centred, with nothing under it — so the tiles are a row of choices to scan rather
   than seven paragraphs to read. What each one contains is the detail block's job. */
.starter__tile {
  align-items: center;
  text-align: center;
  padding: 14px 12px;
}

.starter__tile.is-selected {
  border-color: var(--accent);
  outline: 2px solid var(--accent);
  outline-offset: -1px;
}

/*
 * Wider than a form dialog, because this one is a CHOICE between seven things shown side by
 * side. The class was named by the markup before it existed here — the dialog asked for
 * `modal--wide` and got the default 660px, which is the other half of why the tiles ran long.
 */
.modal.modal--wide {
  width: min(820px, calc(100vw - 32px));
}

.starter__detail {
  /* Room to breathe on both sides: the tiles above and the buttons below were both close
     enough to read as one crowded block rather than three steps. */
  margin: 14px 0 18px;
  padding: 12px 14px;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--surface-2, var(--surface));
}

/* The business the detail block is describing, so the box always says what is about to be
   applied even when the tile itself has scrolled away. */
.starter__chosen {
  margin: 0 0 8px;
  font-weight: 650;
  color: var(--text);
}

.starter__line {
  margin: 0 0 6px;
  font-size: 0.84rem;
  color: var(--text-muted, var(--text));
}

.starter__line:last-child {
  margin-bottom: 0;
}

.starter__line strong {
  color: var(--text);
}

/* --------------------------------------------- the two steps, and moving between them
 *
 * The tiles and the numbering form are the same box at the same size, so without a direction
 * of travel pressing Back is indistinguishable from pressing forward: the content simply
 * becomes different content. The slide is what says which way you went, and it is the only
 * thing that does — this is the first minute somebody spends in the app.
 *
 * Both directions are 220ms, matching STEP_MS in public/js/starter.js, which is what times the
 * class coming back off. Entry-length rather than exit-length in both directions because
 * neither of these is an exit: Back is a step in a form somebody is still filling in, not a
 * decision they have finished with.
 *
 * Everything here is zeroed by the sheet's blanket prefers-reduced-motion rule.
 */
.starter__step.is-forward {
  animation: starter-step-in 220ms var(--ease-arrive) both;
}

.starter__step.is-back {
  animation: starter-step-back 220ms var(--ease-arrive) both;
}

@keyframes starter-step-in {
  from {
    opacity: 0;
    transform: translateX(18px);
  }
}

@keyframes starter-step-back {
  from {
    opacity: 0;
    transform: translateX(-18px);
  }
}

/* The detail block redrawing under the tiles. Small on purpose — it sits a few pixels below
   the thing just clicked, and anything larger reads as the box itself having moved. */
.starter__detail.is-fresh {
  animation: starter-detail-in 180ms var(--ease-arrive) both;
}

@keyframes starter-detail-in {
  from {
    opacity: 0;
    transform: translateY(-4px);
  }
}

/* ------------------------------------------------------ the welcome, the plan, the finish
 *
 * Three screens that are not forms. The two at the front say what this is and what the next few
 * minutes hold; the one at the end says it worked. They share a shape — the mark, a line of
 * type, and a button — because they are the same kind of moment at either end of the same job.
 *
 * Centred and given a floor to stand on, so the box does not change height between the
 * invitation and the form that follows it.
 */
.starter__hero {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  /* Tall enough that the box does not jump between the invitation and the form after it, and
     no taller: the first version left a hand's width of nothing under the welcome line. */
  min-height: 300px;
  padding: 8px 0 4px;
}

/*
 * The mark, in the top quarter.
 *
 * `flex-basis: 25%` of the hero's height rather than a margin, so it holds that share of the
 * box at every size — which is the point of asking for a quarter rather than a gap.
 */
.starter__crown {
  display: flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 25%;
  min-height: 76px;
}

/* Four times the top bar's, which is em-based throughout, so the ring and its pupil scale
   with the letters rather than needing their own sizes here. */
.starter__mark {
  font-size: calc(2.1rem * var(--mark-scale));
}

/*
 * The words, centred in what the mark leaves rather than tucked under it.
 *
 * The first version put them directly below the logo with the rest of the box empty beneath,
 * so they read as a caption on the mark instead of as the thing the screen is saying. Taking
 * the remaining height and centring inside it gives them the separation and puts them on the
 * eye line — which is the whole difference between a welcome screen and a header.
 */
.starter__say {
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 10px;
  padding: 18px 0 8px;
}

.starter__welcome {
  margin: 6px 0 0;
  font-size: 1.35rem;
  font-weight: 650;
  line-height: 1.25;
  color: var(--text);
}

.starter__lede {
  max-width: 46ch;
  margin: 0 auto;
}

/* ------------------------------------------------------------------- what happens next */

.starter__plan {
  list-style: none;
  margin: 22px 0 4px;
  padding: 0;
  /* Left-aligned inside a centred column: three ticks down a ragged left edge read as three
     unrelated notes rather than as a list of three things. */
  text-align: left;
  width: min(340px, 100%);
}

.starter__planItem {
  display: grid;
  grid-template-columns: 26px 1fr;
  align-items: baseline;
  gap: 2px 10px;
  padding: 10px 0;
  border-bottom: 1px solid var(--border);
}

.starter__planItem:last-child {
  border-bottom: 0;
}

.starter__planTick {
  grid-row: span 2;
  align-self: center;
  justify-self: center;
  width: 22px;
  height: 22px;
  display: grid;
  place-items: center;
  font-size: 0.78rem;
  border-radius: 50%;
  color: var(--accent);
  border: 1px solid var(--accent);
}

/*
 * The same list on a step that is not a hero.
 *
 * `.starter__plan` is capped at 340px and left-aligned inside a CENTRED column, which is right
 * for the three two-word lines on the welcome screens and wrong everywhere else: the AI features
 * and the next steps carry a sentence each, and at 340px on the left of a 1080px box they read
 * as a narrow column somebody forgot to finish. Same rhythm, the width it is actually given.
 */
.starter__plan--full {
  width: 100%;
  margin: 14px 0 4px;
}

/* Tighter per row than the welcome screens' three, because these lists run to six — and six
   rows breathing like three is a list whose last item is below the fold on a laptop. The last
   item on the cinema walk is Connect POS Sync, which is exactly the one somebody would not
   think to go looking for. */
.starter__plan--full .starter__planItem {
  padding: 7px 0;
}

.starter__planName {
  font-weight: 650;
  color: var(--text);
}

.starter__planNote {
  grid-column: 2;
  font-size: 0.84rem;
  color: var(--text-muted, var(--text));
}

/* The last press of all, which is the only thing on its screen. */
.modal__foot--solo {
  justify-content: center;
}

.btn--lg {
  padding: 13px 34px;
  font-size: 1.02rem;
}

@media (max-width: 560px) {
  .starter__hero {
    min-height: 210px;
  }

  .starter__mark {
    font-size: calc(1.7rem * var(--mark-scale));
  }

  .starter__welcome {
    font-size: 1.16rem;
  }

  /* Full width, because a centred button with room either side of it on a phone reads as
     something optional. */
  .modal__foot--solo .btn {
    flex: 1 1 100%;
  }
}

/* ------------------------------------------------- the editable places and issue types
 *
 * Two screens of the same row: a name somebody can retype, and a way to take it out. They are
 * the point of the flow rather than decoration on it — by the time the last button is pressed
 * the owner has read their own vocabulary and changed a line of it, which is the difference
 * between an app that was configured for them and one they know is theirs.
 */
.starter__rows {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin: 10px 0 0;
}

.edit {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 8px;
  /* No box of its own. The input inside already draws one, and a bordered row around a
     bordered field is two rectangles saying the same thing — which is what a column of eleven
     of them looked like. */
  padding: 0;
}

/* The name takes the room. Everything beside it is a fixed-width answer about the name. */
.edit__label {
  flex: 1 1 160px;
  min-width: 0;
}

.edit__placard {
  display: flex;
  align-items: center;
  gap: 6px;
  flex: 0 0 auto;
  font-size: 0.82rem;
  color: var(--text-muted, var(--text));
  white-space: nowrap;
  cursor: pointer;
}

.edit__tick {
  margin: 0;
}

/* Why this row is about to be asked about floor by floor, said where the row is rather than
   two screens later where the question turns up. */
.edit__note {
  flex: 0 0 auto;
  font-size: 0.76rem;
  color: var(--text-muted, var(--text));
  white-space: nowrap;
}

/*
 * The quiet way out of a row.
 *
 * A bordered "Remove" repeated down eleven rows was the loudest thing on a screen whose whole
 * subject is the names beside it — a wall of buttons with the content squeezed between them.
 * This is a glyph that colours on hover and carries its full sentence as an accessible name,
 * so the list reads as a list and the removals are still exactly where they were.
 */
.edit__drop {
  flex: 0 0 auto;
  width: 30px;
  height: 30px;
  padding: 0;
  font: inherit;
  font-size: 1.15rem;
  line-height: 1;
  color: var(--text-muted, var(--text));
  background: none;
  border: 1px solid transparent;
  border-radius: var(--radius-sm, 8px);
  cursor: pointer;
}

.edit__drop:hover,
.edit__drop:focus-visible {
  color: var(--danger);
  border-color: var(--border);
}

/* The heading between the guest list and the staff list. */
.starter__group {
  margin: 20px 0 0;
  font-size: 0.84rem;
  color: var(--text-muted, var(--text));
}

.starter__group strong {
  color: var(--text);
}

/*
 * The one tickbox on this flow, and the shape is doing the reading for somebody.
 *
 * The box is pinned to the FIRST LINE rather than centred against the whole paragraph, because
 * the paragraph is three lines on a phone and a control floating beside its middle reads as
 * belonging to nothing. `align-items: start` plus a nudge down to sit on the text's baseline.
 */
.starter__check {
  display: grid;
  grid-template-columns: auto 1fr;
  align-items: start;
  gap: 10px;
  margin-top: 8px;
  font-size: 0.84rem;
  line-height: 1.5;
  color: var(--text-muted, var(--text));
  cursor: pointer;
}

.starter__check input {
  margin-top: 3px;
}

/*
 * The AI switch, given a box of its own.
 *
 * Every other tickbox in this flow is one option among a screenful; this one is the screen's
 * whole question, and the three features above it are the case for leaving it alone. Boxed and
 * tinted, it stops reading as a footnote under a list and starts reading as the thing being
 * decided — which is what it is, and the only decision here somebody could regret.
 */
/*
 * The screens step: one cinema per row, its name against a dropdown.
 *
 * A grid rather than a flex row, so every dropdown starts at the same x whatever the buildings
 * are called — "Alphaville 12" and "Riverside" beside each other with ragged selects would read
 * as a list of unrelated controls rather than as one question asked nine times.
 */
.starter__screens {
  display: grid;
  gap: 8px;
  margin: 14px 0 4px;
}

.starter__screenRow {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: center;
  gap: 12px;
  padding: 9px 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface-2, var(--surface));
}

.starter__screenName {
  font-weight: 600;
  color: var(--text);
  overflow: hidden;
  text-overflow: ellipsis;
}

@media (max-width: 560px) {
  /* Stacked, because a long cinema name and a dropdown do not share 320px. */
  .starter__screenRow {
    grid-template-columns: minmax(0, 1fr);
    gap: 6px;
  }
}

.starter__check--ai {
  margin-top: 16px;
  padding: 12px 14px;
  border: 1px solid color-mix(in oklab, var(--accent) 30%, transparent);
  border-radius: var(--radius-sm);
  background: color-mix(in oklab, var(--accent) 7%, var(--surface));
  color: var(--text);
}

/* The consequence, under the box that has it — sized like the hints it sits among rather than
   like the decision above it. */
.starter__aiNote {
  margin-top: 12px;
}

/* What the next screens hold, under the detail block that describes this one. */
.starter__next {
  margin-top: 10px;
  padding-top: 8px;
  border-top: 1px solid var(--border);
  color: var(--text-muted, var(--text));
}

/* Back is hidden on the first screen and "set up later" on every screen but it. The footer
   keeps its right-hand alignment because `modal__foot` pushes its last child over; an empty
   first slot changes nothing. */
.starter__foot [hidden] {
  display: none;
}

/*
 * The one way past a screen, and quieter than the way through it.
 *
 * It sits beside the primary rather than opposite it, because it is a variation on continuing
 * — the flow goes to the same next screen either way — rather than the opposite of it, which
 * is what Back is and where Back sits.
 */
.starter__later {
  margin-left: auto;
  font-size: 0.86rem;
  /* Quieter than Back, which is quieter than Continue. Three buttons on one bar have to rank
     themselves, and a bordered "set up later" beside a filled "Continue" read as two offers of
     equal weight — which is not what this screen means. */
  border-color: transparent;
  background: none;
  color: var(--text-muted, var(--text));
}

.starter__later:hover,
.starter__later:focus-visible {
  color: var(--text);
  border-color: var(--border);
}

/* What the licence leaves room for, under the Add button it governs. */
.starter__allowance {
  margin: 4px 0 0;
}

/*
 * The footer wraps, which is what lets a phone put the third button on its own line.
 *
 * Without this the `flex: 1 1 100%` below had nothing to wrap ONTO: the row is a plain flex
 * line, so a basis of 100% is simply shrunk back to fit and the three buttons stay jammed
 * together — which is exactly what a phone showed.
 */
.starter__foot {
  flex-wrap: wrap;
}

@media (max-width: 560px) {
  /*
   * On a phone: Back and Continue share the first line, and the sentence-long way past goes
   * underneath, quieter and smaller.
   *
   * Three buttons do not fit one phone line, and the one that should give way is the one least
   * likely to be pressed. Continue takes the rest of the line and sits on the RIGHT of Back,
   * which is where a primary belongs — it was on the left, before the wrap worked, because an
   * auto margin on the third button had shoved it there.
   */
  .starter__foot {
    gap: 10px;
  }

  .starter__foot .btn--cta {
    order: 2;
    flex: 1 1 auto;
  }

  .starter__later {
    order: 3;
    flex: 1 1 100%;
    margin-left: 0;
    text-align: center;
    /* Smaller than the two above it: it is an aside about this screen, not one of its two
       answers, and at the same size it read as a third equal choice. */
    font-size: 0.8rem;
    padding: 8px 10px;
  }
}

.edit.is-new {
  animation: floor-in 190ms var(--ease-arrive) both;
}

.edit.is-going {
  animation: floor-out 150ms var(--ease-out) both;
  pointer-events: none;
}

@media (max-width: 560px) {
  /* The name on its own line, and the answers about it underneath — the same rearrangement
     the floor rows make, and for the same reason: this row wraps at this width regardless,
     and wrapping mid-row leaves a tick box marooned beside a Remove button. */
  .edit__label {
    flex: 1 1 100%;
  }

  /*
   * Except where the row IS just a name. A location has no ticks, so stacking left the × alone
   * on a line under the field, which reads as a control belonging to nothing. Beside the name
   * is where it belongs when there is nothing else competing for the line.
   */
  .edit--plain .edit__label {
    flex: 1 1 0;
    min-width: 0;
  }

  .edit__drop {
    margin-left: auto;
  }

  /*
   * Which name the tick is about, settled by spacing.
   *
   * Stacked, every gap was the same size, so a row's "QR placard" sat exactly as close to the
   * name BELOW it as to its own — and read as belonging to the wrong one. Found in a
   * screenshot; nothing about it is visible at desktop width, where the row is a row.
   *
   * A row is now tight inside and loose outside, which is the only thing that says what
   * belongs to what when there is no line to follow.
   */
  .edit {
    row-gap: 2px;
  }

  .starter__rows {
    gap: 16px;
  }

  /* And indented under the name, so the second line reads as subordinate rather than as the
     start of the next thing. */
  .edit__placard {
    padding-left: 12px;
  }
}

/* ------------------------------------------------------- numbering, a floor at a time */

.starter__whereRow {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 14px 0 10px;
}

.starter__whereLabel {
  font-size: 0.84rem;
  color: var(--text-muted, var(--text));
}

.starter__whereOne {
  font-weight: 600;
}

/* Why there is a picker at all, beside the picker. A chain's second hotel is not numbered like
   its first, and nothing else on the screen says the two sets are separate. */
.starter__whereNote {
  flex: 1 1 100%;
  font-size: 0.82rem;
  color: var(--text-muted, var(--text));
}

.starter__floors {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

/*
 * One floor: its name, two numbers, what that comes to, and a way to drop it.
 *
 * Wraps rather than scrolls, because on a phone this is four controls in a 320px column and
 * the alternative to wrapping is a row nobody can reach the end of.
 */
.floor {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 8px;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--surface-2, var(--surface));
}

.floor__name {
  font-weight: 650;
  /* Fixed, so the inputs on every row start at the same x — a ragged left edge down a
     column of ten floors reads as ten unrelated controls. Bottom-aligned with them, since
     the captions above the boxes would otherwise leave it floating. */
  flex: 0 0 74px;
  align-self: flex-end;
  padding-bottom: 9px;
}

.floor__drop {
  align-self: flex-end;
}

/* A captioned box. The caption is what makes two fields read as "first" and "last" rather
   than as one range somebody has to know the syntax for. */
.floor__field {
  display: flex;
  flex-direction: column;
  gap: 3px;
  flex: 0 1 120px;
  min-width: 96px;
}

.floor__cap {
  font-size: 0.74rem;
  letter-spacing: 0.01em;
  color: var(--text-muted, var(--text));
}

.floor__num {
  width: 100%;
}

.floor__count {
  flex: 1 1 auto;
  font-size: 0.84rem;
  color: var(--text-muted, var(--text));
  /* The captions raise the boxes, so a count on the row's centre line sits above the text it
     is about. Nudged down to sit with the inputs instead. */
  align-self: flex-end;
  padding-bottom: 9px;
}

.floor__count.is-bad,
.starter__total.is-bad {
  color: var(--danger);
  font-weight: 600;
}

.floor__drop {
  margin-left: auto;
}

/* A floor arriving under the one above it, and leaving without the rows below it jumping up
   into the gap. Both are short: this is a row in a form, not a page. */
.floor.is-new {
  animation: floor-in 190ms var(--ease-arrive) both;
}

.floor.is-going {
  animation: floor-out 150ms var(--ease-out) both;
  pointer-events: none;
}

@keyframes floor-in {
  from {
    opacity: 0;
    transform: translateY(-6px);
  }
}

@keyframes floor-out {
  to {
    opacity: 0;
    transform: translateX(14px);
  }
}

/*
 * On a phone, a floor is a small block rather than a row.
 *
 * The row wraps at this width whatever it is told, and wrapping put "First room" beside the
 * floor's name and "Last room" underneath it against the left edge — two boxes that are a pair,
 * drawn as though they were not, with the name stranded between them. Found in a screenshot;
 * every assertion about this step passed at both widths.
 *
 * So the name takes the whole first line and the two boxes share the second, which is the
 * shape they have on a wide screen and the shape they mean.
 */
@media (max-width: 560px) {
  .floor__name {
    flex: 1 1 100%;
    align-self: flex-start;
    padding-bottom: 0;
  }

  .floor__field {
    /* Equal halves of one line. `min-width: 0` because a flex item will not shrink below its
       content otherwise, and an input's default size is wider than half a phone. */
    flex: 1 1 0;
    min-width: 0;
  }

  .floor__count {
    flex: 1 1 100%;
    align-self: flex-start;
    padding-bottom: 0;
  }

  /* Sized to its label, not to the line it landed on: a full-width "Remove" is the loudest
     control in a block whose subject is two numbers. */
  .floor__drop {
    margin-left: 0;
    flex: 0 0 auto;
    align-self: flex-start;
    width: auto;
  }
}

.starter__floorAdd {
  padding: 10px 0 2px;
}

.starter__total {
  margin: 6px 0 0;
  font-weight: 650;
}

/* ------------------------------------------------ the circuit's own mark, or ours
 *
 * Two marks ship in the guest page and exactly one is drawn. The server stamps `data-logo` on
 * <html> before first paint — see themeScript in src/app.js — so this is settled by the time the
 * page renders and a guest never sees one mark replaced by the other.
 *
 * Written as a pair of rules keyed off the stamp rather than a `hidden` attribute set by script,
 * for that reason alone: JS runs after paint, and a flash of somebody else's brand on the one
 * page the public sees is the thing this feature exists to remove.
 */
.brand-logo {
  display: none;
}

html[data-logo] .guest-hero .brand-logo {
  display: block;
  /* Bounded, not sized: a logo is whatever shape the company's logo is, and the page should
     accommodate it rather than stretch it. Height is what keeps the header consistent between
     a wide wordmark and a square badge. */
  max-height: 44px;
  max-width: min(260px, 70vw);
  width: auto;
  height: auto;
  object-fit: contain;
  margin-bottom: 4px;
}

html[data-logo] .guest-hero .wordmark {
  display: none;
}

/*
 * The credit, under the send button and under the anonymity note.
 *
 * It used to be a footer at the foot of the document — below the confirmation panel, which on a
 * form this short put it off the end of the page where nobody read it. Here it is out of the way
 * of the thing a guest came to do and still on screen while they do it.
 *
 * Quieter than the note above it: that note is a promise about their data and this is a byline.
 * Never conditional on the logo — a circuit's own mark goes at the top and this stays.
 */
.guest-send__by {
  margin: 6px 0 0;
  text-align: center;
  font-size: 0.72rem;
  letter-spacing: 0.02em;
  color: var(--text-faint);
}

.guest-send__by strong {
  font-weight: 600;
  color: var(--text-muted, var(--text-faint));
}


/* ------------------------------------------------- somebody else is in this circuit
 *
 * The band that says the deployment's support account is signed in right now.
 *
 * Deliberately the same shape and the same amber as `.borrowed` above, because they are the
 * same KIND of statement — "you are in a mode you did not choose to be reminded of" — and two
 * different-looking bands stacked on one page would read as two unrelated warnings rather than
 * as one page describing its situation.
 *
 * No fixed frame, though, and that is the difference. A borrowed view needs the frame because
 * the risk is forgetting whose dashboard you are reading and acting on it. Nothing here changes
 * what the reader is looking at or what their clicks do; it is information, not a mode, so it
 * scrolls away like the rest of the page.
 */
.watched {
  background: color-mix(in oklab, var(--warn) 12%, var(--surface));
  border-bottom: 1px solid color-mix(in oklab, var(--warn) 55%, transparent);
}

/* The suspended band, a shade firmer than the support one it sits beside: this is a state the
   reader has to do something about, where support being present is a thing they agreed to. Still
   amber rather than red — nothing is broken and nothing is lost. See markSuspended in util.js. */
.watched--suspended {
  background: color-mix(in oklab, var(--warn) 20%, var(--surface));
  border-bottom-color: color-mix(in oklab, var(--warn) 75%, transparent);
}

.watched__inner {
  max-width: 1180px;
  margin: 0 auto;
  padding: 9px 20px;
  display: flex;
  align-items: center;
  gap: 8px 14px;
  flex-wrap: wrap;
  font-size: 0.86rem;
}

.watched__tag {
  text-transform: uppercase;
  letter-spacing: 0.07em;
  font-size: 0.68rem;
  font-weight: 700;
  color: var(--warn);
  white-space: nowrap;
}

.watched__what {
  color: var(--text);
  /* Takes the width on a narrow screen and pushes the link to its own line, rather than
     being truncated — the sentence is the whole point of the band. */
  min-width: 0;
}

/* Pushed to the end, in the same place the borrowed band keeps its way out. */
.watched__out {
  margin-left: auto;
  font-weight: 600;
  color: var(--warn);
  white-space: nowrap;
}


/* ----------------------------------------------------------------- issue types
 *
 * One row per type in Settings: its name as an editable field, what it is called in the
 * database, what has been filed under it, and the buttons that apply to it.
 *
 * A row rather than a table because the actions belong to the row and a table cell full of
 * buttons wraps badly on a phone — this is a list somebody edits, not a grid they read across.
 */
.types {
  display: flex;
  flex-direction: column;
  gap: 10px;
  margin: 14px 0 18px;
}

.type {
  display: grid;
  /* Name and buttons on one line while there is room, stacked when there is not. The facts
     line always spans, so it reads as a caption under the name rather than a third column. */
  grid-template-columns: minmax(0, 1fr) auto;
  gap: 4px 12px;
  padding: 12px 14px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface-2);
}

.type__main {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  min-width: 0;
}

.type__name {
  flex: 1 1 200px;
  min-width: 0;
}

/* The notice window on an inspection row: a label, a narrow number, and "mo" so the unit is
   on screen rather than in a tooltip. Narrow because the value is 1–11 — a full-width number
   box would imply a bigger answer than the server will take. */
.type__months {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 0.84rem;
  color: var(--text-dim);
  white-space: nowrap;
}

.type__months-input {
  width: 72px;
}

/* The required-here checkbox on a location's view of the inspections — the name is a label
   for the box, so the whole name is clickable, like every other checkbox on this page. */
.type__required {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  flex: 1 1 200px;
  min-width: 0;
  cursor: pointer;
}

/* The permanent key, quiet and monospaced. On screen because it is what a report actually
   stores and what the API takes — and because it explains why renaming is safe. */
.type__key {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.78rem;
  color: var(--text-dim);
}

.type__facts {
  grid-column: 1 / -1;
  margin: 0;
}

.type__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  align-items: start;
}

/* --------------------------------------------- what kind of business one building is
 *
 * Sits in the row of things you DO to a location, beside Print QR placards, so it has to keep
 * that row's rhythm: inline, one line, the same height as the buttons either side of it. Left
 * to itself the label wrapped above a full-width select and the row stopped reading as a row.
 */
.preset-pick {
  display: inline-flex;
  align-items: center;
  gap: 8px;
}

/* What it is now, before the control that changes it — a statement, not a button. */
.preset-pick__now {
  font-size: 0.86rem;
  font-weight: 600;
  color: var(--text-dim);
  white-space: nowrap;
}

.preset-pick__select {
  /* Wide enough for the longest preset name and no wider; the row has other work to do. */
  max-width: 190px;
  padding: 6px 10px;
  font-size: 0.86rem;
}

/* ------------------------------------------- what a placard asks, inside the place itself
 *
 * SPANS BOTH COLUMNS, like the facts line above it, and that is load-bearing rather than
 * cosmetic: .type is a two-column grid — name on the left, buttons on the right — and a
 * fourth child left to auto-place lands back in the name's column, which squeezes the name
 * field into a strip and stacks "QR code" underneath it. Which is exactly what the first
 * draft of this did.
 */
.place__ask {
  grid-column: 1 / -1;
}

/* A place with no code asks nothing, and an empty block would still take a grid row's gap. */
.place__ask:empty {
  display: none;
}

/* Set in from the row's own edge, so the pills read as belonging to the place above them
   rather than as a second row of controls at the same level. */
.place__ask .setting__place {
  margin-top: 8px;
  padding-top: 10px;
  border-top: 1px solid var(--border);
}

/* Retired: still legible, visibly not in play. Dimmed rather than hidden, because the row is
   how somebody restores it — and its name is still what old reports read by. */
.type--retired {
  background: var(--surface);
  border-style: dashed;
}

.type--retired .type__name {
  color: var(--text-dim);
}

@media (max-width: 560px) {
  .type {
    grid-template-columns: minmax(0, 1fr);
  }
}

/*
 * A type with an unsaved edit on it.
 *
 * The accent down the left edge, which is what the app uses elsewhere to mean "this row is not
 * like its neighbours" — and a caption on the row says what changed in words, because a colour
 * alone cannot distinguish a rename from a retirement, and this page may well be read by
 * somebody who cannot tell the two hues apart.
 */
.type--dirty {
  border-color: color-mix(in oklab, var(--accent) 45%, var(--border));
  box-shadow: inset 3px 0 0 0 var(--accent);
}

/*
 * Show more, under a list the server has only sent one page of.
 *
 * Centred and quiet: it is the end of the list rather than an action anybody came for, and it
 * carries the honest count above it — see public/js/paging.js for why that sentence exists.
 */
.more {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
  padding: 28px 16px 8px;
  text-align: center;
}

.more__count {
  margin: 0;
  color: var(--text-dim);
  font-size: 0.9rem;
}

/* ------------------------------------------------------- the strength meter
 * Under every field where a password is chosen. The rule it draws is shared with the
 * server (public/js/password-strength.js), so the bar and the refusal cannot disagree.
 *
 * Hidden until something is typed: an empty box is not a weak password, and a red bar
 * on a field nobody has touched reads as a form that is already wrong.
 */
.strength {
  margin-top: 8px;
}

/* Four blocks rather than one growing bar. A percentage of a password means nothing;
   four segments are countable, and they are the same four rules listed underneath. */
.strength__bar {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 4px;
}

.strength__seg {
  height: 6px;
  border-radius: 999px;
  background: var(--surface-2);
  transition: background-color 180ms var(--ease);
}

/* Colour AND how many are lit move together, so the meter still reads for somebody who
   cannot tell red from green — and the words beside it say the same thing again, which is
   what actually carries it. */
.strength[data-score='1'] .strength__seg.is-on {
  background: var(--danger);
}

.strength[data-score='2'] .strength__seg.is-on {
  background: var(--warn);
}

.strength[data-score='3'] .strength__seg.is-on,
.strength[data-score='4'] .strength__seg.is-on {
  background: var(--ok);
}

.strength__says {
  margin: 8px 0 0;
  font-size: 0.82rem;
  color: var(--text-dim);
}

.strength__label {
  font-weight: 650;
  color: var(--text);
}

/* The rules, ticking as they are met. */
.strength__rules {
  list-style: none;
  margin: 8px 0 0;
  padding: 0;
  display: grid;
  gap: 4px;
}

.strength__rule {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 0.8rem;
  color: var(--text-faint);
  transition: color 180ms var(--ease);
}

.strength__rule.is-met {
  color: var(--text-dim);
}

/* An empty ring until the rule is met, then a filled tick. A shape change as well as a
   colour change, for the same reason the segments count as well as colour. */
.strength__mark {
  flex: none;
  width: 13px;
  height: 13px;
  border-radius: 50%;
  border: 1.5px solid var(--border-strong);
  position: relative;
  transition: background-color 180ms var(--ease), border-color 180ms var(--ease);
}

.strength__rule.is-met .strength__mark {
  background: var(--ok);
  border-color: var(--ok);
}

.strength__rule.is-met .strength__mark::after {
  content: '';
  position: absolute;
  left: 3.5px;
  top: 1px;
  width: 3px;
  height: 6px;
  border-right: 1.5px solid var(--bg);
  border-bottom: 1.5px solid var(--bg);
  transform: rotate(45deg);
}

/* Somebody who asked for no motion gets the state and none of the movement. */
@media (prefers-reduced-motion: reduce) {
  .strength__seg,
  .strength__rule,
  .strength__mark {
    transition: none;
  }
}

/* ------------------------------------------------- where you are signed in
 * Settings > Where you are signed in, every role. Rows rather than cards, like the log
 * below — but each row here carries an action, so it needs the room the log does not.
 *
 * The list is short by construction: a handful of browsers, and only the ones still live.
 */
.sessions {
  list-style: none;
  margin: 14px 0;
  padding: 0;
}

.session {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px 12px;
  padding: 12px 0;
  border-bottom: 1px solid var(--border);
}

.session:last-child {
  border-bottom: 0;
}

/* The one you are reading this on, marked with an edge as well as the badge. Scanning for
   "which of these is me" is the first thing anybody does here. */
.session--current {
  box-shadow: inset 3px 0 0 var(--ok, var(--accent));
  padding-left: 12px;
}

.session__top {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
  flex: 1 1 100%;
}

.session__what {
  font-weight: 650;
  color: var(--text);
}

.session__when {
  color: var(--text-faint);
  font-size: 0.82rem;
  margin-left: auto;
}

.session__facts {
  margin: 0;
  flex: 1 1 auto;
  font-size: 0.84rem;
  color: var(--text-dim);
  overflow-wrap: anywhere;
}

/* ------------------------------------------------------------- the sign-in log
 * Settings > Sign-in log, dev only. A run of rows rather than cards: it is read by
 * scanning down it for a pattern, and a stack of bordered boxes makes that harder.
 */
.audit__filters {
  /* The shared filter row brings its own padding for a bordered panel; here it sits under a
     paragraph of prose and needs the gap above instead. */
  padding: 0 0 12px;
  margin-top: 14px;
}

.audit__row {
  padding: 10px 0;
  border-bottom: 1px solid var(--border);
}

.audit__row:last-child {
  border-bottom: 0;
}

/* A refusal is the thing worth spotting while scrolling, so it gets an edge as well as
   the badge — the badge says it and the edge is what makes a run of them visible. */
.audit__row--refused {
  box-shadow: inset 3px 0 0 var(--danger);
  padding-left: 12px;
}

.audit__top {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}

.audit__action {
  font-weight: 650;
  color: var(--text);
}

.audit__when {
  color: var(--text-faint);
  font-size: 0.82rem;
  margin-left: auto;
}

.audit__who,
.audit__detail {
  margin: 2px 0 0;
  font-size: 0.84rem;
  color: var(--text-dim);
  overflow-wrap: anywhere;
}

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

/* The count line and the download sit on one row, so the button is beside what it
   would give you rather than under a list it has to be scrolled past. */
.audit__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap;
  margin-bottom: 4px;
}

.audit__head .field__hint {
  margin: 0;
}

/* --------------------------------------------------- two-step verification */
.totp__qr {
  display: block;
  /* On its own white ground, always. A QR code inverted by a dark theme is a QR code no
     camera will read, and the failure looks like a broken phone rather than a broken page. */
  background: #fff;
  padding: 8px;
  border-radius: var(--radius-sm);
  margin: 12px 0;
}

.totp__secret {
  font-family: var(--mono);
  letter-spacing: 0.08em;
  overflow-wrap: anywhere;
}

/* Read off a screen by somebody having a bad day, so: monospaced, spaced out, and
   selectable in one gesture. */
.totp__codes {
  font-family: var(--mono);
  font-size: 0.95rem;
  line-height: 1.9;
  letter-spacing: 0.06em;
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  padding: 12px 16px;
  margin: 12px 0;
  user-select: all;
}

/* ------------------------------------------------- withheld from the support account
 * The server took the text out — see src/privacy.js — and this is what stands where it was.
 *
 * A blur rather than a gap. An empty line reads as a report filed by somebody who typed
 * nothing, which is a bug worth chasing; a blurred bar reads as a decision somebody made.
 * There is nothing behind it to reveal: these characters are a placeholder the page invented,
 * not the guest's words with a filter over them.
 */
.complaint__body--hidden {
  user-select: none;
}

.redacted {
  display: inline-block;
  max-width: 42ch;
  border-radius: 3px;
  background: var(--surface-2, var(--surface));
  color: var(--text-faint);
  /* Enough to be unreadable at any zoom, and still obviously a line of text rather than a box. */
  filter: blur(4px);
  /* Not selectable and not read out: it is a placeholder, and copying it would paste a sentence
     nobody wrote. The paragraph carries an aria-label so a screen reader says "Hidden" instead. */
  pointer-events: none;
}

/* ----------------------------------------- the charts, withheld from the support account
 * Drawn instead of the real ones, not over them. The figures arrive zeroed from the server
 * — see FIGURE_KEYS in src/privacy.js — so there is nothing here to un-blur: these bars are
 * invented by the page at fixed heights and claim nothing about anybody's business.
 */
.chart--hidden__bars {
  display: flex;
  align-items: flex-end;
  gap: 10px;
  height: 120px;
  margin-top: 16px;
  filter: blur(5px);
  opacity: 0.55;
  pointer-events: none;
}

.chart--hidden__bar {
  flex: 1 1 0;
  border-radius: 4px 4px 0 0;
  background: var(--accent, var(--text-dim));
}

/* A download the support account may not open. Still visible, so the page reads the same for
   everybody and the reason is one click away rather than a missing button somebody hunts for. */
.btn--blocked {
  opacity: 0.55;
  cursor: not-allowed;
}

/* ============================================================= the deployment console
   One card per circuit, each a row of counts. The classes are `con*` rather than reusing the
   overview's stat tiles wholesale, because a circuit card packs thirteen small figures where a
   stat tile carries one hero number — different density, different type scale. */

.concard {
  margin-bottom: 16px;
}

/* An unreachable circuit is dimmed but present: on a fleet page, "this one did not answer" is
   information, and a card that vanished would read as a circuit that never existed. */
.concard--down {
  opacity: 0.75;
}

.conpanel__head {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 8px 12px;
  margin-bottom: 14px;
}

.concard__name {
  margin: 0;
  font-size: 1.05rem;
}

/* The durable name beside the human label, quiet and monospaced — it is what the routing and
   the registry key on, and what a support conversation needs spelled exactly. */
.concard__slug {
  font-family: var(--mono);
  font-size: 0.78rem;
  color: var(--text-faint);
}

.concard__grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(128px, 1fr));
  gap: 10px 14px;
  margin-bottom: 12px;
}

.confact {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 8px 10px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface-2);
}

.confact__value {
  font-size: 1.15rem;
  font-weight: 650;
  font-variant-numeric: tabular-nums;
}

.confact__label {
  font-size: 0.74rem;
  color: var(--text-dim);
  letter-spacing: 0.02em;
}

.concard__pulse {
  margin: 0;
}

/* The adoption line: which features this customer uses, as badges beside a quiet label. */
.concard__features {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px;
  margin: 0 0 10px;
}

/* --------------------------------------------------------------------- the section rail
   A vertical menu beside the content, in the manner of the admin dashboards the operator
   asked the console to feel like: a handful of sections, the active one a filled pill.
   Anchors, so the stylesheet keys the pill on aria-current — the state IS the style, set
   by one place.

   Shared by the deployment console and by Settings, which is why it is `rail` and not
   `conrail` as it was when only one page had one. Both pages have the same problem — more
   sections than fit on a screen anybody can scan — and a second, near-identical rail
   would have been exactly the sort of accretion Settings was reorganised to undo. */

.railed {
  display: grid;
  grid-template-columns: 200px minmax(0, 1fr);
  gap: 20px;
  align-items: start;
}

/*
 * Exactly two children, and the second holds everything.
 *
 * Not a style so much as a load-bearing rule: the rail is sticky, and a sticky element only
 * travels as far as its own containing block. Let the sections be grid items in their own
 * right and the rail's box is one row tall — it scrolls away with the first card, which on a
 * page you scroll is a menu that is never there when you want it.
 */
.railed__body {
  /* Long tables and wide code inside a grid item overflow the column without this. */
  min-width: 0;
}

.rail {
  /* Below the sticky topbar, so scrolling a long fleet keeps the menu in reach. */
  position: sticky;
  top: 76px;
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 10px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow);
}

.rail__item {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 9px 12px;
  border-radius: 9px;
  font-size: 0.92rem;
  font-weight: 600;
  color: var(--text-dim);
  text-decoration: none;
  transition:
    background-color 140ms var(--ease),
    color 140ms var(--ease);
}

.rail__item:hover {
  background: var(--surface-3);
  color: var(--text);
}

.rail__item[aria-current='true'] {
  background: var(--accent-fill);
  color: var(--accent-ink);
}

.rail__icon {
  flex: none;
  width: 17px;
  height: 17px;
}

/* What is waiting behind a section you are not looking at — sales to chase on the console,
   unsaved edits on Settings. Warm rather than red in both: neither is an outage, and a badge
   that cries emergency at a half-typed field is one that gets ignored when it matters. */
.rail__count {
  margin-left: auto;
  min-width: 20px;
  padding: 1px 7px;
  border-radius: 999px;
  background: var(--warn-soft);
  color: var(--warn);
  font-size: 0.78rem;
  font-weight: 700;
  text-align: center;
}

.rail__foot {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-top: 10px;
  padding-top: 12px;
  border-top: 1px solid var(--border);
}

.rail__note {
  margin: 0;
  font-size: 0.76rem;
  line-height: 1.45;
  color: var(--text-faint);
  overflow-wrap: anywhere;
}

/* The rail folds to a horizontal strip where there is no room for a column — the same
   breakpoint family the rest of the shell uses for one-column layouts. */
@media (max-width: 860px) {
  .railed {
    grid-template-columns: minmax(0, 1fr);
  }

  .rail {
    position: static;
    flex-direction: row;
    align-items: center;
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
  }

  .rail__foot {
    flex-direction: row;
    align-items: center;
    margin: 0 0 0 auto;
    padding: 0 0 0 12px;
    border-top: 0;
  }

  /* The observed line is detail, and a scrolling strip has no room for a sentence; the
     overview's own header still says what was read. */
  .rail__note {
    display: none;
  }
}

/* -------------------------------------------------------------- the headline figures
   The deployment as a row of stat cards: a tinted icon chip, the number, the label, and an
   honest delta where the payload carries the window before. The chip tint comes from the
   same soft tokens every badge uses, so the row recolours with all seven themes. */

.kpis {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 12px;
  margin-bottom: 20px;
}

.kpi {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 14px 16px;
  background: var(--glass);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow);
}

.kpi__chip {
  flex: none;
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  border-radius: 10px;
}

.kpi__chip svg {
  width: 20px;
  height: 20px;
}

.kpi__chip--accent {
  background: var(--accent-soft);
  color: var(--accent);
}

.kpi__chip--info {
  background: var(--info-soft);
  color: var(--info);
}

.kpi__chip--ok {
  background: var(--ok-soft);
  color: var(--ok);
}

.kpi__chip--warn {
  background: var(--warn-soft);
  color: var(--warn);
}

.kpi__chip--danger {
  background: var(--danger-soft);
  color: var(--danger);
}

.kpi__body {
  display: flex;
  flex-direction: column;
  min-width: 0;
}

.kpi__value {
  font-size: 1.55rem;
  font-weight: 780;
  letter-spacing: -0.035em;
  line-height: 1.15;
  font-variant-numeric: tabular-nums;
}

.kpi__label {
  font-size: 0.76rem;
  color: var(--text-dim);
  letter-spacing: 0.02em;
  white-space: nowrap;
}

.kpi__delta {
  align-self: flex-start;
  margin-left: auto;
  padding: 3px 8px;
  border-radius: 999px;
  font-size: 0.72rem;
  font-weight: 650;
  white-space: nowrap;
}

.kpi__delta--up {
  background: var(--ok-soft);
  color: var(--ok);
}

.kpi__delta--down {
  background: var(--danger-soft);
  color: var(--danger);
}

.kpi__delta--flat {
  background: var(--surface-3);
  color: var(--text-faint);
}

/* ------------------------------------------------------------------- the fleet table
   Every circuit as one sortable row. The headers are real buttons — sorting is keyboard
   work like any other — and the sorted column says so with aria-sort, which is also all
   the arrow is: the state, made visible. */

.fleet__scroll {
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
}

.fleet__table {
  width: 100%;
  min-width: 720px;
}

.fleet__sort {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  font-weight: 650;
  color: inherit;
  cursor: pointer;
  white-space: nowrap;
}

.fleet__sort:hover {
  color: var(--accent);
}

.fleet__arrow {
  /* Reserved even when empty, so the header does not shift as sorting moves between columns. */
  display: inline-block;
  min-width: 1ch;
  color: var(--accent);
}

.fleet__num {
  text-align: right;
  font-variant-numeric: tabular-nums;
}

th.fleet__num .fleet__sort {
  justify-content: flex-end;
  width: 100%;
}

.fleet__name strong {
  display: block;
}

.fleet__slug {
  font-family: var(--mono);
  font-size: 0.74rem;
  color: var(--text-faint);
}

.fleet__hosts {
  display: block;
  font-size: 0.78rem;
}

.fleet__hosts a {
  color: var(--accent);
  text-decoration: none;
}

.fleet__hosts a:hover {
  text-decoration: underline;
}

.fleet__hosts a + a::before {
  content: ' · ';
  color: var(--text-faint);
}

.fleet__row--down {
  opacity: 0.65;
}

/* ================================================================== the assistant
   A conversation, drawn the way chat is drawn everywhere: the asker on the right in the
   accent, the answers on the left on a card surface. The scroll region is the messages,
   not the page, so the composer never leaves reach. */

.chat {
  display: flex;
  flex-direction: column;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow);
  overflow: hidden;
}

.chat__scroll {
  overflow-y: auto;
  max-height: min(58vh, 640px);
  min-height: 260px;
  padding: 18px;
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.chat__msg {
  max-width: 82%;
  padding: 10px 14px;
  border-radius: 14px;
}

.chat__msg--user {
  align-self: flex-end;
  background: var(--accent-fill);
  color: var(--accent-ink);
  border-bottom-right-radius: 5px;
}

.chat__msg--assistant {
  align-self: flex-start;
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-bottom-left-radius: 5px;
}

.chat__text {
  margin: 0;
  /* The model answers in prose with deliberate line breaks; keep them, wrap the rest. */
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  font-size: 0.94rem;
  line-height: 1.55;
}

/* The "Looking at the data…" placeholder breathes so a long think reads as alive. */
.chat__msg--thinking .chat__text {
  color: var(--text-dim);
  animation: chat-breathe 1.6s ease-in-out infinite;
}

@keyframes chat-breathe {
  50% {
    opacity: 0.45;
  }
}

/* What the answer looked at — quiet provenance under the bubble's text. */
.chat__sources {
  margin: 8px 0 0;
  font-size: 0.74rem;
  color: var(--text-faint);
}

.chat__intro {
  margin: auto 0;
}

.chat__composer {
  display: flex;
  gap: 10px;
  align-items: flex-end;
  padding: 12px 14px;
  border-top: 1px solid var(--border);
  background: var(--surface-2);
}

.chat__composer textarea {
  flex: 1 1 auto;
  resize: none;
  min-height: 44px;
}

.chat__foot {
  margin: 0;
  padding: 8px 16px 12px;
  background: var(--surface-2);
}

/* ------------------------------------------------------- the corner launcher and panel
   The assistant lives in the bottom-right of every staff page: a round launcher, and a
   panel that opens over the page rather than instead of it — asking a question should
   never mean losing the screen the question is about. The inner parts are the chat
   classes above; this block is only the floating chrome. */

.aihub__launcher {
  position: fixed;
  right: 18px;
  bottom: 18px;
  z-index: 25; /* over the page (topbar is 20), under any open <dialog>'s top layer */
  display: grid;
  place-items: center;
  width: 52px;
  height: 52px;
  border: 1px solid var(--border-strong);
  /*
   * A rounded SQUARE of the same glass as the dock, not a filled circle: the reference
   * button is a dark tile whose identity lives in the ring inside it, and matching the
   * dock's corner radius is what makes the two read as one family on the phone.
   */
  border-radius: 18px;
  background: color-mix(in srgb, var(--surface-3) 70%, transparent);
  -webkit-backdrop-filter: blur(18px);
  backdrop-filter: blur(18px);
  color: var(--text);
  cursor: pointer;
  /* The slight glow: the accent breathing off the tile, under the ordinary lift. */
  box-shadow:
    var(--shadow-lift),
    var(--rim),
    0 0 16px color-mix(in srgb, var(--accent-fill) 40%, transparent);
  transition: transform 140ms var(--ease);
}

/*
 * The gradient RING around the logo — where the launcher's colour lives now, the way the
 * reference wears its identity as a ring around the W. Drawn as a 2px annulus: a gradient
 * fill masked so only the border of the circle survives. The spark sits inside it.
 */
.aihub__launcher::before {
  content: '';
  position: absolute;
  inset: 8px;
  border-radius: 50%;
  padding: 2px;
  background: linear-gradient(
    135deg,
    color-mix(in oklab, var(--accent-fill) 70%, white),
    var(--accent-fill) 45%,
    color-mix(in oklab, var(--accent-fill) 55%, var(--p-pink))
  );
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  mask-composite: exclude;
  pointer-events: none;
}

/*
 * The static copy the staff pages ship (see the dock's own comment in their markup):
 * hidden until the pre-paint stamp says this circuit has an assistant at all, so the
 * tile is on the first frame of every page turn instead of popping in when the widget
 * module lands. The widget takes the --static class off at adopt time, which is what
 * keeps a borrowed ?as= view — never stamped — showing the launcher its script built.
 */
html:not([data-assistant]) .aihub__launcher--static {
  display: none;
}

/* Guarded like every hover in this app that a tap must not stick: iOS holds :hover
   after a press, and an unguarded lift left the tile floating 2px high on a phone. */
@media (hover: hover) {
  .aihub__launcher:hover {
    transform: translateY(-2px);
  }
}

.aihub__panel {
  position: fixed;
  right: 18px;
  bottom: 82px;
  z-index: 25;
  display: flex;
  flex-direction: column;
  /* Closed state: just under its open position and slightly small, so opening reads as the
     panel coming UP out of the launcher rather than appearing on top of the page. The
     transform origin is the corner it grows from. */
  transform: translateY(12px) scale(0.98);
  transform-origin: 100% 100%;
  opacity: 0;
  transition:
    transform 200ms var(--ease),
    opacity 160ms var(--ease);
  width: min(390px, calc(100vw - 36px));
  /* A desktop window has no on-screen keyboard to make room for, so this is the window and
     nothing more. The phone surface below is where the keyboard is dealt with. */
  height: min(600px, calc(100dvh - 120px));
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-lift);
  overflow: hidden;
}

.aihub__panel--open {
  transform: translateY(0) scale(1);
  opacity: 1;
}

/* The launcher's spark turns as the panel opens — one movement, two parts of it. */
.aihub__launcher svg {
  transition: transform 200ms var(--ease);
}

.aihub__launcher[aria-expanded='true'] svg {
  transform: rotate(90deg);
}

.aihub__head {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px 14px;
  border-bottom: 1px solid var(--border);
  background: var(--surface-2);
}

.aihub__title {
  margin: 0;
  font-size: 0.98rem;
  /* One line. The close button gives up nothing, so without this the heading wrapped to
     "AI / Assistant" on a narrow phone and dragged the bar's height with it. */
  white-space: nowrap;
  /* The mark and the name as one unit, so they align on the same centre line and travel
     together. Flex rather than an inline SVG, which would sit on the text baseline and leave
     the star looking dropped. */
  display: flex;
  align-items: center;
  gap: 7px;
  min-width: 0;
}

/* The star keeps its size however narrow the bar gets — a flex item shrinks by default, and a
   squashed four-point spark reads as a smudge. The same declaration .spark carries. */
.aihub__title-spark {
  flex: none;
  color: var(--accent);
}

/*
 * The way out, hard against the right edge of the bar.
 *
 * `margin-left: auto` rather than a spacer element, and that is a repair rather than a tidy-up.
 * The header used to push the button across with a <span class="topbar__spacer">, borrowed from
 * the top nav — where a phone-width rule turns that class into `flex: 0 0 100%; height: 0` so
 * the nav can fold onto two lines. This bar has no second line to fold onto, so below that
 * breakpoint the spacer collapsed, stopped pushing, and the X came to rest against the heading
 * in the middle of the bar with empty space to its right.
 *
 * `flex: none` because the auto margin only works while the button keeps its width: as a
 * shrinkable item next to a nowrap heading it would be squeezed to nothing on a narrow phone.
 */
.aihub__close {
  display: grid;
  place-items: center;
  flex: none;
  margin-left: auto;
  width: 30px;
  height: 30px;
  border: 0;
  border-radius: 8px;
  background: none;
  color: var(--text-dim);
  cursor: pointer;
}

.aihub__close:hover {
  background: var(--surface-3);
  color: var(--text);
}

/* The scroll region grows to fill the panel; the page-sized cap above does not apply.
   `min-height: 0` is load-bearing rather than tidy: a flex item's default minimum is its
   content, so without it the transcript refuses to shrink, the panel overflows its own height,
   and the region has nothing left to scroll — which hands the gesture to the page behind. */
.aihub__scroll {
  flex: 1 1 auto;
  max-height: none;
  min-height: 0;
  /* A scroll that reaches the last message stops there, instead of continuing into the page. */
  overscroll-behavior: contain;
}

.aihub__error {
  margin: 0;
  padding: 8px 14px;
  font-size: 0.82rem;
  color: var(--danger);
  background: var(--danger-soft);
}

.aihub__composer textarea {
  min-height: 40px;
  max-height: 120px;
}

/* ---------------------------------------------------- the backup window picker
   One row per timeframe: the radio, the name, and the measured size beside it. An option
   over the restore limit stays visible but disabled — "too big" is information, and hiding
   it would make Most available look arbitrary. */

.backup-windows {
  display: grid;
  gap: 6px;
  margin: 10px 0 14px;
}

.backup-window {
  display: flex;
  align-items: baseline;
  gap: 10px;
  padding: 8px 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface-2);
  cursor: pointer;
}

.backup-window:has(input:checked) {
  border-color: var(--accent);
}

.backup-window__label {
  font-weight: 650;
  font-size: 0.9rem;
  white-space: nowrap;
}

.backup-window__detail {
  font-size: 0.8rem;
  color: var(--text-dim);
}

.backup-window--over {
  opacity: 0.55;
  cursor: not-allowed;
}

.aihub__foot {
  margin: 0;
  padding: 6px 14px 10px;
  background: var(--surface-2);
  font-size: 0.72rem;
}

/* ================================================================= the phone surface
   A DIFFERENT THING from the corner panel above, not a narrow version of it.

   The two want opposite geometry. The corner panel floats over a page that carries on working:
   it is small, it is anchored to a corner, and the page behind it still scrolls. A phone has no
   room for any of that, and it has a keyboard that takes half the screen — so on a phone the
   assistant is a full-screen sheet that owns the viewport while it is open.

   Trying to serve both from one set of rules is what produced a chat that floated in the middle
   of the screen with the page showing underneath it, could not be scrolled, and jumped about as
   the keyboard came and went. The class is put on by the widget from the same media query, so
   the JS and the stylesheet cannot disagree about which surface is in use.

   THE GEOMETRY IS THE VISIBLE STRIP AND NOTHING ELSE. --vtop is where the visible part of the
   window starts and --vh is how tall it is, both straight from window.visualViewport (see
   viewport.js). Because a `position: fixed` element is placed against the LAYOUT viewport —
   which on iOS neither shrinks for the keyboard nor moves when the visual viewport is scrolled —
   those two properties are the only way to cover exactly what a person can see. No vh, no
   percentage, and above all no innerHeight: the earlier version subtracted innerHeight from the
   visible height to guess a keyboard inset, and iOS changes innerHeight when it collapses its
   toolbars, so the guess outlived the keyboard. */
@media (max-width: 560px) {
  .aihub--phone .aihub__panel {
    inset-inline: 0;
    top: var(--vtop, 0px);
    /* dvh is the fallback for a browser with no visualViewport at all, which is also a browser
       with no on-screen keyboard worth measuring. */
    height: var(--vh, 100dvh);
    bottom: auto;
    width: 100%;
    max-height: none;
    border: 0;
    border-radius: 0;
    /* Closed is off the bottom of the screen: a sheet arrives from the edge it belongs to, and
       there is no scaling or fading, which on a full-screen surface reads as a flicker. */
    transform: translateY(100%);
    opacity: 1;
    transition: transform 240ms var(--ease);
  }

  /* After the rule above, and at the same specificity, so this is the one that wins when open. */
  .aihub--phone .aihub__panel--open {
    transform: translateY(0);
  }

  /* The transcript is the ONLY thing that scrolls, and it has to be genuinely scrollable for
     the gesture to land here rather than on the page. */
  .aihub--phone .aihub__scroll {
    padding: 14px;
    -webkit-overflow-scrolling: touch;
    touch-action: pan-y;
  }

  /* Bigger targets, and clear of the home indicator. env() collapses to 0 while the keyboard is
     up, which is exactly when the indicator is not there either. */

  /* THE HEADER CLEARS THE STATUS BAR, and this is the whole reason a full-screen sheet needs its
     own inset at the top.

     Installed on a home screen, the page starts at the PHYSICAL top of the screen — under the
     clock and the Dynamic Island — because the app asks for a translucent status bar and a
     cover-fit viewport (see the standalone block further down, which pays the same price for the
     top bar). A sheet placed at the top of the visible frame therefore puts its header behind the
     status bar, where a tap belongs to iOS and never reaches the page: the reported bug was a chat
     that opened with its close button unreachable, leaving force-quitting the app as the only way
     out of it.

     The padding is on the HEAD rather than on the panel so the strip behind the clock is the
     header's own colour and the sheet still covers the whole window — the same treatment the top
     bar gets, for the same reason. env() and not a media query because env() is already the answer
     to "what does this context require": zero in a browser tab, where the chrome occupies that
     strip and a sheet at the top of the frame is fully visible. */
  .aihub--phone .aihub__head {
    padding: calc(14px + env(safe-area-inset-top, 0px)) 16px 14px;
  }

  .aihub--phone .aihub__close {
    width: 40px;
    height: 40px;
  }

  .aihub--phone .aihub__composer {
    padding: 12px 14px;
  }

  .aihub--phone .aihub__foot {
    padding: 6px 14px calc(10px + env(safe-area-inset-bottom, 0px));
  }

  /* The launcher would sit under the sheet; while it is open the close control in the sheet's
     own header is the way out, so the launcher steps aside. */
  .aihub--phone .aihub__panel:not([hidden]) ~ .aihub__launcher {
    display: none;
  }
}

/* The page does not move while a full-screen sheet is over it. Set by the widget on the
   documentElement, and only for the phone surface — the corner panel deliberately leaves the
   page scrollable, because it covers a corner and not the work. */
html.aihub-locked,
html.aihub-locked body {
  overflow: hidden;
  overscroll-behavior: none;
}

/* Pinned, not merely clipped: iOS Safari scrolls a body whose overflow is hidden anyway, and
   scrolls the document on focus to "reveal" a composer that is already on screen — which is what
   threw the page halfway down itself when the chat opened. A fixed body has nothing left to
   scroll. --held-at is the offset the page stood at, applied negative so the reader keeps seeing
   the same pixels; the widget hands it back to scrollTo on unlock. */
html.aihub-locked body {
  position: fixed;
  top: var(--held-at, 0px);
  left: 0;
  width: 100%;
}

/* ============================================================ accessibility
 *
 * How one reader needs the app DRAWN, chosen per account in Settings → Accessibility and
 * applied to <html> before the first paint (see themeScript in src/app.js). Last in this
 * file on purpose: every rule here is somebody overriding a decision the stylesheet made
 * above, and the cascade should not need a specificity fight to let them.
 *
 * The registry in src/db.js says where each option comes from in WCAG. The rule these all
 * follow: a switch that changes only a stored value would be worse than no switch at all,
 * because it tells somebody the help is there when it is not.
 */

/* ------------------------------------------------- the option rows in Settings
 *
 * One row of buttons per accessibility option, with the current one marked. A dropdown was
 * the alternative and it is the wrong control here: what is being chosen is how the app is
 * drawn, the change lands on the page the instant it is pressed, and a list you have to open
 * hides the alternatives from somebody trying two to see which they can read.
 */
.choices {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

.choice {
  padding: 7px 13px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--surface-2);
  font: inherit;
  font-size: 0.85rem;
  font-weight: 550;
  color: var(--text-dim);
  cursor: pointer;
  transition:
    background-color 140ms var(--ease),
    border-color 140ms var(--ease),
    color 140ms var(--ease);
}

.choice:hover {
  border-color: color-mix(in oklab, var(--accent) 45%, var(--border));
  color: var(--text);
}

/* The chosen one is filled rather than only outlined: `aria-pressed` tells a screen reader
   which it is, and a border alone is not enough of a difference for everybody else — which
   would be an odd thing to get wrong in this particular section. */
.choice--on {
  border-color: var(--accent);
  background: var(--accent-fill);
  color: var(--accent-ink);
}

/* ---------------------------------------------------------- skip to content
 *
 * WCAG 2.4.1. Every page repeats the same top bar — brand, up to five nav links, the
 * account menu — so a keyboard or switch user met a dozen stops before the page's own
 * content on every single navigation. There was no way past it; now the first Tab of any
 * page offers one.
 *
 * CLIPPED, not moved above the page — and that correction came from the home-screen app.
 *
 * It was `transform: translateY(-200%)`, which parks the link in the space above the
 * document. That space is not nothing on iOS: rubber-band past the top and the browser
 * shows you what is up there, so the link appeared on every overscroll — constantly, in a
 * standalone app where the page fills the screen and there is no chrome to hide behind.
 * Hidden by being scrolled out of view is only hidden while nobody scrolls the other way.
 *
 * So it is clipped to nothing where it stands, which is the same technique as
 * `.visually-hidden` above and for the same reason: `display: none` cannot be focused, and
 * a skip link nobody can focus is not a skip link. On focus it becomes a real box, below
 * the status bar on a phone that has one.
 */
.skip {
  position: absolute;
  z-index: 100;
  top: 0;
  left: 0;
  width: 1px;
  height: 1px;
  padding: 0;
  /*
   * No negative margin, unlike .visually-hidden above. That -1px is there to keep a clipped
   * element from affecting the layout around it, which an absolutely positioned one cannot do
   * anyway — and it put this link one pixel above the top of the window, which is one pixel of
   * exactly the thing that made it visible on an overscroll. Zero.
   */
  margin: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
  background: var(--surface);
  color: var(--text);
  font-size: 0.9rem;
  font-weight: 600;
  text-decoration: none;
}

.skip:focus {
  /* Fixed rather than absolute, so it lands in the corner of the WINDOW wherever the reader
     had scrolled to — a link that appears eight pixels from the top of the document is a link
     nobody sees when they tab into a page halfway down it. */
  position: fixed;
  /* Clear of the clock on a phone running this from its home screen, where the page starts at
     the physical top of the screen. Zero everywhere else. */
  top: calc(8px + env(safe-area-inset-top, 0px));
  left: 8px;
  width: auto;
  height: auto;
  margin: 0;
  padding: 10px 16px;
  overflow: visible;
  clip: auto;
  clip-path: none;
  border: 1px solid var(--accent);
  border-radius: var(--radius-sm);
  box-shadow: var(--shadow-lift);
}

/*
 * And where the link lands. `#main` carries `tabindex="-1"` ONLY so this link has something
 * to move focus to — the two appear on exactly the same fourteen pages and on no others.
 *
 * The global :focus-visible ring cannot draw that box, and the way it fails is the bug this
 * fixes. Measured, on the review page:
 *
 *   393px   main spans 0..393 — the whole viewport — and starts at y=63, under a 63px sticky
 *           bar. With `outline-offset: 2px` the left edge draws at -2, the right at 395 and
 *           the top behind the bar, so THREE OF THE FOUR EDGES ARE OFF SCREEN. What is left
 *           is one lavender line lying across the page at 88% height, attached to nothing —
 *           reported, reasonably, as "a weird permanent line on the bottom of the screen".
 *   1280px  sides make it, top still does not: a three-sided box.
 *
 * So the ring is off by default and drawn INSIDE the box when the skip link is what sent the
 * reader here. Inset, all four edges are on screen at every width and it reads as what it is.
 *
 * Two things this deliberately does NOT do. It does not lean on :focus-visible, which matches
 * a programmatic focus here anyway (measured — so it cannot tell the skip link from anything
 * else). And it does not use `#main:target`, which looks free until you notice settings.js and
 * console.js already read the hash as their section name.
 *
 * The class is added by the link and removed when main blurs, so a focus that arrives any
 * OTHER way paints nothing — which is the Safari case behind the report, where closing a
 * dialog leaves focus on the landmark and Chromium hands it back to the button instead.
 */
#main:focus {
  outline: none;
}

#main.is-skip-target:focus {
  outline: 2px solid var(--focus);
  outline-offset: -2px;
}

/* ------------------------------------------------------------------ motion
 *
 * WCAG 2.2.2 and 2.3.3. The device preference is honoured throughout this file already;
 * this is the account's own switch, for the reader whose machine says nothing on their
 * behalf — a shared booth PC, a browser that never asked.
 *
 * Stronger than shortening: everything stops. `!important` because the rules being
 * overridden are component rules with class specificity, and 1ms rather than 0 because a
 * zero-duration animation never fires its `animationend`, which some panels wait for
 * before they finish opening. Smooth scrolling goes too — a page that glides to a heading
 * is motion nobody asked for.
 */
:root[data-a11y-motion='reduce'],
:root[data-a11y-motion='reduce'] *,
:root[data-a11y-motion='reduce'] *::before,
:root[data-a11y-motion='reduce'] *::after {
  animation-duration: 1ms !important;
  animation-iteration-count: 1 !important;
  transition-duration: 1ms !important;
  /*
   * The ROOT is in this list, and it has to be: `scroll-behavior: smooth` is declared on
   * <html> itself, so a rule that reached only its descendants left every in-page jump gliding
   * for somebody who had just asked for no movement. A browser test caught it, which is the
   * argument for testing what the page does rather than what the stylesheet says.
   */
  scroll-behavior: auto !important;
}

/* The dashboard's pulsing orb is the one thing here that moves for its own sake — it says
   "this number is live". Held still and left visible, exactly as the device preference
   already does it: the reading is the point, the pulse was the decoration. */
:root[data-a11y-motion='reduce'] .plot__orb-halo {
  animation: none !important;
  opacity: 0.3;
}

/* ------------------------------------------------------- colour vision
 *
 * WCAG 1.4.1. Status in this app always carries its words — a warning says what is wrong,
 * a resolved case says resolved — and these palettes are the other half of that promise.
 *
 * The palette SLOTS are overridden rather than the semantic tokens, which is what makes
 * this one short rule per mode instead of one per theme: every theme derives --ok, --warn
 * and --danger (and their soft fills, by color-mix) from these three, so replacing the
 * slots recolours all nine themes and anything added later.
 *
 * Colours are Okabe–Ito, the palette designed to stay distinct for every common type of
 * colour blindness, and they are chosen for LIGHTNESS separation as well as hue — two hues
 * a reader cannot tell apart are still two shades they can.
 */

/* Red–green (deuteranopia, protanopia): the good/bad pair becomes blue and vermillion,
   with caution a much lighter yellow so the three differ by lightness as well. */
:root[data-a11y-colour='red-green'] {
  --p-green: #0072b2;
  --p-yellow: #f0e442;
  --p-red: #d55e00;
}

/* Blue–yellow (tritanopia): blue and yellow are the confusable pair here, so good goes to
   a bluish green, caution to orange, and bad to a red that sits darker than both. */
:root[data-a11y-colour='blue-yellow'] {
  --p-green: #009e73;
  --p-yellow: #e69f00;
  --p-red: #d62728;
  --p-blue: #56b4e9;
}

/* --------------------------------------------------------------- text size
 *
 * WCAG 1.4.4. Browser zoom does this too and does it better, because it scales layout with
 * the text; this is for the reader on a machine whose zoom will not stay put — a shared
 * office desktop, a kiosk browser that resets. The root is the dial and every component
 * sizes in rem (see body above), so the whole app grows together.
 */
:root[data-a11y-text='large'] {
  font-size: 112.5%;
}

:root[data-a11y-text='larger'] {
  font-size: 125%;
}

/* --------------------------------------------------------------- contrast
 *
 * WCAG 1.4.3 and 1.4.11. The themes are built to pass, and passing is not the same as
 * readable in a bright lobby or a projector-lit booth. The dim and faint text tokens move
 * up a step each — which is where nearly all of this app's low contrast lives, in notes,
 * hints and table captions — and the hairline borders become the strong ones, so a card
 * has an edge rather than an implication.
 */
:root[data-a11y-contrast='high'] {
  --text-dim: var(--text);
  --text-faint: var(--p-subtext0);
  --border: var(--p-surface1);
  --border-strong: var(--p-overlay1);
  /* Glass is the other half of a low-contrast surface: whatever is behind it shows
     through and takes the contrast with it. Solidified at the token, so every panel
     that drinks from it goes opaque in one move. */
  --glass: var(--surface);
  --glass-strong: var(--surface);
}

/* Translucency and blur are the other half of a low-contrast surface: whatever is behind
   them shows through and takes the contrast with it. */
:root[data-a11y-contrast='high'] .topbar,
:root[data-a11y-contrast='high'] .modal,
:root[data-a11y-contrast='high'] .aihub__panel {
  backdrop-filter: none;
  background-color: var(--surface);
}

/* ------------------------------------------------------------------- links
 *
 * WCAG 1.4.1, in its most literal reading: a link told apart from its paragraph only by
 * colour is not told apart for everybody. Not on by default because most links in this app
 * are not in prose — they are nav items, buttons and whole cards, where an underline is
 * noise — so this underlines the ones that ARE in text and leaves the furniture alone.
 */
:root[data-a11y-links='underline'] a:not(.btn):not(.nav__link):not(.brand):not(.stat):not(.skip) {
  text-decoration: underline;
  text-underline-offset: 2px;
}

/* --------------------------------------------------------------------- focus
 *
 * WCAG 2.4.7 is already met for keyboard users — the app rings :focus-visible throughout.
 * This is for the reader who needs to see where they are after a pointer press too, which
 * :focus-visible deliberately does not show. Same ring, wider net.
 */
:root[data-a11y-focus='always'] :focus {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* --------------------------------------------------------------- the offline page
 *
 * public/offline.html, which the service worker answers a navigation with when there is no
 * network. Its own few rules rather than inline styles, because the app's CSP allows no inline
 * style — and because a page that only ever appears when something has gone wrong should look
 * like the app rather than like an unstyled document, which is what a reader reads as broken.
 */
.offline {
  margin-top: 12vh;
  text-align: center;
}

.offline__mark {
  border-radius: 18px;
}

.offline__title {
  margin: 18px 0 6px;
  font-size: 1.3rem;
}

.offline p {
  margin: 0 auto 14px;
  max-width: 34rem;
  color: var(--text-dim);
}

/* ====================================================== installed on a home screen
 *
 * The app run from an iPhone's home screen, which is the same pages with Safari's chrome
 * taken away — no address bar at the top, no toolbar at the bottom, and therefore nothing
 * between this page and the hardware. Everything here is about that gap.
 *
 * Scoped to `display-mode: standalone` rather than applied everywhere, because in a browser
 * tab the chrome already occupies these areas and padding for them would be a strip of empty
 * page under the address bar. `minimal-ui` is in the list for the same reason: it is what iOS
 * reports for a home-screen app in some versions, and being wrong about which one costs the
 * reader their top bar under the clock.
 */
@media (display-mode: standalone), (display-mode: minimal-ui), (display-mode: fullscreen) {
  /*
   * THE TOP BAR CLEARS THE STATUS BAR.
   *
   * With `apple-mobile-web-app-status-bar-style: black-translucent` the page starts at the
   * physical top of the screen — under the clock and the battery — which is what allows a dark
   * app to keep white status text instead of Apple's black-on-white. The cost is that the page
   * owns the inset, and this is where it pays it: the sticky bar keeps its own padding and
   * gains the status bar's height above it, so the nav sits where it looks like it should and
   * the blur runs all the way up behind the clock.
   */
  .topbar__inner {
    padding-top: calc(12px + env(safe-area-inset-top, 0px));
  }

  /* Landscape on a notched phone insets the sides as well, and a nav flush to the edge there
     is a nav with its first link under the rounded corner. */
  .topbar__inner,
  .shell {
    padding-left: max(var(--shell-pad, 20px), env(safe-area-inset-left, 0px));
    padding-right: max(var(--shell-pad, 20px), env(safe-area-inset-right, 0px));
  }

  /*
   * And the bottom, for the home indicator — the bar that replaced the button. The three
   * elements that sit against the bottom edge already handle it (the guest form's send bar, the
   * assistant's footer, the phone sheet), so this is the page's own end: without it the last
   * card of a long page stops underneath the indicator and reads as cut off.
   *
   * On the shell rather than on body, and ADDED to the 80px the shell already ends with — a bare
   * inset here would replace that and pull the last card up against the edge of the screen.
   * Body is also the wrong element for a second reason: it deliberately declares nothing about
   * its own box (the motes layer sits at a negative z-index behind it), and a test holds it to
   * that.
   */
  .shell {
    padding-bottom: calc(80px + env(safe-area-inset-bottom, 0px));
  }

  /*
   * The save bar floats at the foot of the window while a schedule is being built, so it is
   * the one element that has to clear the indicator by itself rather than relying on the
   * page's end.
   */
  .savebar--sticky {
    bottom: calc(16px + env(safe-area-inset-bottom, 0px));
  }
}

/* ================================================================== the lock
 *
 * The cover over a locked session. What is protecting the data is the server refusing every API
 * answer with a 423 (src/lock.js); this is what stops the last render being legible over
 * somebody's shoulder while it is locked, and it is where the way back in lives.
 *
 * Fixed and above everything, including the top bar and the assistant's sheet, because a lock
 * with something on top of it is not a lock.
 */
.lockscreen {
  position: fixed;
  inset: 0;
  /*
   * And out over the scrollbar gutter, which `inset: 0` does not reach.
   *
   * A fixed box is placed against the layout viewport, and on a wide screen that viewport now
   * stops short of the window by the width of the reserved gutter (see `scrollbar-gutter` on
   * html). Left at `inset: 0` this cover ended ten pixels early and the page showed through
   * beside it — on the one panel in the app whose whole job is that nothing shows through.
   * `100vw` is measured against the window rather than the viewport, which is exactly the
   * difference; a fixed element is out of flow, so it cannot put the page into a sideways
   * scroll by being that wide.
   */
  width: 100vw;
  z-index: 100;
  display: grid;
  place-items: center;
  padding: 24px;
  /* The blur is on the cover rather than on the page, so nothing under it has to be moved into a
     stacking context or filtered — a `filter` on the page would break the sticky top bar and the
     dialogs, both of which position against the viewport. */
  backdrop-filter: blur(22px) saturate(60%) brightness(0.55);
  -webkit-backdrop-filter: blur(22px) saturate(60%) brightness(0.55);
  /* And a ground colour for the browsers that have no backdrop filter, where the blur would
     otherwise be nothing at all and the page would sit there readable. */
  background: color-mix(in oklab, var(--p-crust) 82%, transparent);
}

.lockscreen__card {
  max-width: 26rem;
  padding: 26px 24px calc(22px + env(safe-area-inset-bottom, 0px));
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-lg);
  background: var(--glass);
  box-shadow: var(--shadow-lift);
  text-align: center;
}

.lockscreen__mark {
  border-radius: 16px;
}

.lockscreen__title {
  margin: 14px 0 8px;
  font-size: 1.2rem;
}

.lockscreen__note {
  margin: 0 0 18px;
  color: var(--text-dim);
}

.lockscreen__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  justify-content: center;
}

/* A dismissed prompt is not a fault, so this is a note rather than the app's error red. */
.lockscreen__trouble {
  margin: 16px 0 0;
  font-size: 0.85rem;
  color: var(--warn);
}

/*
 * While locked, the page behind does not scroll.
 *
 * Not for tidiness: scrolling a page you cannot read is how somebody discovers that the cover is
 * one element and the content is still there. It also stops a phone rubber-banding the cover away
 * from the top of the screen.
 */
.is-locked,
.is-locked body {
  overflow: hidden;
}

/* -------------------------------------------------------- the devices list
 * Settings → Face ID unlock. One row per device that can open this account.
 */
.devices {
  list-style: none;
  margin: 14px 0;
  padding: 0;
  display: grid;
  gap: 8px;
}

.device {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface-2);
}

.device__name {
  font-weight: 600;
}

.device__when {
  flex: 1 1 auto;
  font-size: 0.8rem;
  color: var(--text-dim);
}

/* ============================================================ what the POS says about itself
 *
 * The ticket price list, the POS version, and the raw concession list — folded under each cinema's
 * row in Settings → POS Scheduling. Reference rather than configuration: nothing in here is
 * editable, which is why it is a <details> and not another set of boxes.
 */
.pos-extra {
  margin: 10px 0 0;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface-2);
}

.pos-extra__summary {
  padding: 8px 12px;
  font-size: 0.82rem;
  font-weight: 600;
  color: var(--text-dim);
  cursor: pointer;
}

.pos-extra__summary:hover {
  color: var(--text);
}

.pos-extra__body {
  display: grid;
  gap: 10px;
  padding: 0 12px 12px;
}

.pos-extra__probe {
  display: grid;
  gap: 8px;
  padding-top: 10px;
  border-top: 1px solid var(--border);
}

/*
 * A grid item's min-width is its CONTENT by default, and the content here includes .pos-raw —
 * a third party's XML on one unbroken line. Without this, that line SIZES the track: the probe
 * panels measured 1272px inside an 840px card, the card clipped them, and the pre's own
 * overflow scrollbar never appeared because the pre was never actually too small. Zero hands
 * the width back to the card and the scrolling back to the pre, which is the design.
 */
.pos-extra__body > *,
.pos-extra__probe > * {
  min-width: 0;
}

/* One row per ticket type: the name, then the price, then what tax is inside it. */
.prices {
  display: grid;
  gap: 4px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.prices__row {
  display: flex;
  align-items: baseline;
  gap: 10px;
  font-size: 0.84rem;
}

.prices__name {
  flex: 1 1 auto;
}

/* Tabular figures so a column of prices lines up on the decimal point rather than on the glyph
   widths, which is the difference between a price list and a list of prices. */
.prices__price {
  font-weight: 650;
  font-variant-numeric: tabular-nums;
}

.prices__tax {
  color: var(--text-dim);
  font-size: 0.78rem;
}

/*
 * A third party's XML, shown as it arrived.
 *
 * Scrolls in both directions and wraps nothing: this is being read to work out a file's shape, and
 * re-wrapped XML hides exactly the nesting somebody is looking for. Capped in height so a long file
 * does not push the rest of the page out of reach.
 */
.pos-raw {
  max-height: 22rem;
  margin: 0;
  padding: 10px 12px;
  overflow: auto;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, monospace);
  font-size: 0.74rem;
  line-height: 1.5;
  white-space: pre;
  tab-size: 2;
}

/* The request half of a probe panel, folded — the answer is the headline, the packet is for
   whoever needs to quote it to RTS support. */
.pos-sent {
  margin: 4px 0;
}

.pos-sent summary {
  cursor: pointer;
  color: var(--text-dim);
  font-size: 0.78rem;
}

.pos-sent .pos-raw {
  margin-top: 4px;
  max-height: 8rem;
  white-space: pre-wrap;
  word-break: break-all;
}

/* The per-place issue-type rows on the settings page. */
.setting__place {
  padding: 0.6rem 0;
  border-top: 1px solid var(--line, rgba(128, 128, 128, 0.2));
}
.setting__place:first-child {
  border-top: 0;
  padding-top: 0;
}
.setting__place-head {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  margin-bottom: 0.35rem;
}
.setting__place-note {
  font-size: 0.85em;
  opacity: 0.7;
}

/*
 * The honeypot on the guest form: gone, without `display: none`.
 *
 * A field that is `display: none` or `hidden` is the one a well-written bot skips, because
 * that is the obvious tell. This is off the canvas instead — as far as the page is
 * concerned it has a position and a size, and only a human's eyes are spared it.
 */
.hp {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

/* A translated report: the English a reviewer acts on, the guest's own words behind it. */
.complaint__translated {
  margin: 0;
}
.complaint__original > summary {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  cursor: pointer;
  margin-top: 0.35rem;
  list-style: none;
}
.complaint__original-hint {
  font-size: 0.85em;
  opacity: 0.7;
}
.complaint__body-original {
  margin: 0.35rem 0 0;
  padding-left: 0.6rem;
  border-left: 2px solid var(--line, rgba(128, 128, 128, 0.3));
  opacity: 0.85;
}

/*
 * The guest form's language picker.
 *
 * Given room on both sides rather than just above. It sits between the heading and the place
 * chip, and at half a rem it read as crowding the chip — on a phone the two ran together into
 * one busy block where the chip is the thing worth reading. The picker is furniture; the chip
 * tells somebody we know where they are.
 */
.guest-lang {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin: 0.85rem 0 1rem;
}
.guest-lang__label {
  font-size: 0.9em;
  opacity: 0.8;
}

/* ------------------------------------------------------------ welcome tour
 *
 * The first-run walkthrough: a veil over the page, one element left lit, and a card
 * beside it saying what the thing is. Mounted by js/walkthrough.js on the pages of a
 * new account's own top bar, once per account ever.
 *
 * The dimming is not a backdrop element: it is the spotlight's own box-shadow, spread
 * further than any screen (200vmax). One element therefore owns the geometry — move the
 * hole and the darkness moves with it, and the glide between steps is one transition on
 * one node instead of four rects kept in agreement.
 *
 * z-index 80: over everything the pages stack (the topbar's 20, the assistant's 25, the
 * skip link's spotlighted moments), under nothing it needs to defer to — the tour never
 * runs while a <dialog> is open, and the top layer would beat it anyway.
 */
/*
 * Also the click shield. Transparent — the hole's shadow does the dimming — but this is
 * the element every stray click lands on, which is what makes the tour narration rather
 * than an obstacle course: the page underneath cannot be half-used mid-tour. The page
 * can still SCROLL (a shield blocks clicks, not wheels), and the spotlight follows;
 * only pressing things waits for the tour.
 */
.tour {
  position: fixed;
  inset: 0;
  z-index: 80;
}

.tour__hole {
  position: fixed;
  border-radius: 12px;
  pointer-events: none;
  /* Transparent until the tour says ready, so the dim fades in rather than banging on. */
  box-shadow: 0 0 0 200vmax rgba(9, 11, 19, 0);
  /* A thin ring in the app's own focus colour, tying "lit" to what focus already means. */
  outline: 1.5px solid color-mix(in oklab, var(--focus) 75%, transparent);
  outline-offset: 2px;
}

.tour--ready .tour__hole {
  box-shadow: 0 0 0 200vmax rgba(9, 11, 19, 0.62);
  transition:
    top 0.34s cubic-bezier(0.3, 0.8, 0.3, 1),
    left 0.34s cubic-bezier(0.3, 0.8, 0.3, 1),
    width 0.34s cubic-bezier(0.3, 0.8, 0.3, 1),
    height 0.34s cubic-bezier(0.3, 0.8, 0.3, 1),
    box-shadow 0.4s ease;
}

/*
 * While the page is scrolling under the tour, the spotlight is repositioned every frame
 * to stay glued to its element — and the glide transition above would turn that into a
 * spotlight chasing its target from behind. Pinned for the duration of the scroll,
 * glide again the moment it settles. js/walkthrough.js toggles this.
 */
.tour--pinned .tour__hole {
  transition: box-shadow 0.4s ease;
}

/* The farewell has nothing to point at: the spotlight closes to nothing, the dim stays. */
.tour__hole--none {
  width: 0;
  height: 0;
  top: 50vh;
  left: 50vw;
  outline: none;
}

.tour__card {
  position: fixed;
  z-index: 1;
  width: min(430px, calc(100vw - 24px));
  padding: 16px 20px 14px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow-lift);
  opacity: 0;
  transform: translateY(10px);
}

/* Re-armed per step by the module (class off, reflow, class on), so each step enters. */
.tour__card--in {
  opacity: 1;
  transform: translateY(0);
  transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.2, 0.8, 0.3, 1);
}

/* Focused programmatically each step so the reader's screen reader is told where they
   are. tabindex="-1" — nobody can Tab onto it — so a ring would mark nothing anyone did.
   Same reasoning as dialog:focus above. */
.tour__card:focus {
  outline: none;
}

/* The farewell card stands alone, centre stage. The module clears its inline position
   so these win. */
.tour__card--bare {
  inset: 0;
  margin: auto;
  height: fit-content;
}

.tour__kicker {
  margin: 0 0 6px;
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--accent);
}

.tour__title {
  margin: 0 0 6px;
  font-size: 1.06rem;
  line-height: 1.3;
}

.tour__text {
  margin: 0 0 14px;
  font-size: 0.92rem;
  line-height: 1.5;
  color: var(--text-dim);
}

.tour__row {
  display: flex;
  align-items: center;
  gap: 10px;
}

.tour__count {
  margin-left: auto;
  font-size: 0.8rem;
  font-variant-numeric: tabular-nums;
  color: var(--text-dim);
}

.tour__next {
  min-width: 40%;
}

/*
 * Phones: the card becomes a bottom sheet — full width, above the home indicator — and
 * the module stops positioning it per-target (it clears top/left, so these rules hold).
 * The spotlight scrolls its element into the top half instead, where the sheet is not.
 */
@media (max-width: 700px) {
  .tour__card {
    top: auto;
    left: 10px;
    right: 10px;
    bottom: calc(10px + env(safe-area-inset-bottom, 0px));
    width: auto;
  }

  .tour__card--bare {
    inset: auto 10px calc(10px + env(safe-area-inset-bottom, 0px));
    margin: 0;
  }

  .tour__next {
    min-width: 0;
    flex: 1;
  }
}

/*
 * The owner's email on the claim page: a field that is a FACT rather than a question.
 *
 * Readonly and dimmed so it does not invite typing, but a real input so it can be selected,
 * copied, and read out in the order the form is read. It was a hint under the button, which
 * is where a page puts something nobody needs — and this is the one line that catches a claim
 * link somebody forwarded before a circuit is created under the wrong account.
 */
.claim__owner {
  color: var(--text-dim);
  cursor: default;
}

.claim__owner:focus {
  outline: none;
}

/* ------------------------------------------------- the brand colour control (Settings)
 *
 * A colour well, its hex, and the two buttons that fill it. One row that wraps, because on a
 * phone "Match logo" and "Clear" belong under the colour rather than squeezed beside it.
 */
.brandpick__row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px;
}

/*
 * The native picker, dressed to look like the app.
 *
 * Browsers draw `input[type=color]` as a bevelled button with the swatch inset a few pixels;
 * stripping the chrome and zeroing the swatch's own padding is what turns it into a plain well
 * of colour. Both vendor spellings, because neither alone covers Chromium and Firefox.
 */
input.brandpick__well {
  flex: none;
  width: 46px;
  height: 34px;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: none;
  cursor: pointer;
}

.brandpick__well::-webkit-color-swatch-wrapper {
  padding: 2px;
}

.brandpick__well::-webkit-color-swatch,
.brandpick__well::-moz-color-swatch {
  border: 0;
  border-radius: 4px;
}

/* Monospace and tabular, because a hex is read character by character when somebody is
   checking it against a brand guide. */
input.brandpick__hex {
  /* Nine characters PLUS the shared rule's padding: the app is border-box everywhere, so a
     bare `9ch` is nine characters minus 26px of padding and "#c8102e" loses its last glyph. */
  width: calc(9ch + 26px);
  flex: none;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  text-transform: lowercase;
}

.brandpick__said {
  margin: 8px 0 0;
}

/* A row of named options — two or three buttons, not a dropdown. */
.choicerow {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

/* ------------------------------------------------- what is on in this room (guest form)
 *
 * The cinema's own schedule, stated in the header beside where the guest is standing.
 *
 * It began as a tinted card inside the form, in the space the "Film" and "Showtime" boxes used
 * to hold — which put it after the description box, and on a phone that is below the fold: the
 * one line the feature exists to show was the one line nobody saw until they scrolled down to
 * send. It is context, so it now sits with the other context, above the first question.
 *
 * No box, no tint, no icon. The chip beside it is the thing that has to be read; this is the
 * caption on it, and two boxed things side by side are two claims competing.
 */
.guest-hero__place {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  /* The whole of the responsive behaviour, and no breakpoint to guess at: the film rides
     beside the chip while both fit on the line and takes the next row when they do not. */
  gap: 7px 12px;
}

.onnow {
  margin: 0;
  /*
   * Nothing here shrinks to stay on the line. A title that does not fit takes the whole line
   * down to the next row rather than being squeezed into a column two words wide — which is
   * what a flex item does when its min-content is left alone.
   *
   * break-word rather than anywhere, deliberately: `anywhere` would make the min-content one
   * character wide, the block would always fit, and it would never wrap down at all.
   */
  max-width: 100%;
  overflow-wrap: break-word;
  font-size: 0.86rem;
  line-height: 1.4;
  color: var(--text-dim);
}

.onnow__label {
  font-size: 0.82em;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  opacity: 0.75;
}

.onnow__film {
  /* The part of the line that is the answer, and the only part at full strength: the label
     and the clock either side of it are there to be glanced at, not read. */
  color: var(--text);
  font-weight: 650;
}

.onnow__time {
  opacity: 0.85;
}

/* An escape hatch, sized and weighted like one: the guest whose report is about this film —
   nearly all of them — should read past it without pausing. */
.onnow__not {
  margin: 6px 0 0;
  padding: 0;
  font-size: 0.9em;
  opacity: 0.85;
}

/* ------------------------------------------------- the format choice, and the way out of it
 *
 * The detailed report's dialog offers two formats and one way to another report. Both used to
 * be things squeezed in beside something else — the formats were a second button in the foot,
 * which on a phone made three side by side and clipped Cancel off the edge; the other report was
 * an underlined phrase in a sentence, the only thing in the dialog shaped like nothing else in
 * the app.
 *
 * Both are now what the dialog already had a shape for: a full-width bordered control. The
 * format rides on .rangepick, the same control the period above it uses, and needs only the two
 * lines below because an option now carries a caption as well as a name.
 */
.rangepick__name {
  display: block;
}

/* What the option is FOR, under its name. Dimmer and lighter, so the name is still the thing
   being read and the caption is there for the reader who wants it. */
.rangepick__why {
  display: block;
  margin-top: 2px;
  font-size: 0.78rem;
  font-weight: 450;
  color: var(--text-dim);
  /* The chosen option tints its own text through `color`; the caption keeps its own so the
     tint marks the choice rather than shouting the sentence under it. */
  opacity: 0.85;
}

/*
 * A row that leaves: a name, a caption, and a chevron.
 *
 * Borrowed from .rangepick__option deliberately — same border, same radius, same padding — so it
 * sits in the column of controls above it rather than looking bolted on. What separates it is
 * the chevron: these choose, this one goes somewhere, and the arrow is the whole of that
 * distinction.
 */

/*
 * And a hairline above the field that holds one, because the chevron alone is not enough.
 *
 * Measured on a 390px phone: 6px between the two Format options, 14px before the jump row. At
 * that spacing three identical bordered rows read as one list of three, and the list is headed
 * "Format" — so "Closed cases" reads as a third thing to download. The rule is the same one
 * .modal__foot uses, and it means the same thing: what follows is a different region, not the
 * next item.
 *
 * No margin-top here on purpose. The space above the rule is the field's own margin, whatever
 * the surrounding block sets it to — `#report-dialog .field` carries an id and would win that
 * declaration anyway, and a property that loses everywhere it is used is worse than absent.
 */
.field--apart {
  padding-top: 16px;
  border-top: 1px solid var(--border);
}

.jumprow {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
  border: 1px solid var(--border);
  background: var(--surface-2);
  border-radius: 10px;
  padding: 10px 12px;
  font: inherit;
  color: var(--text-dim);
  text-align: left;
  cursor: pointer;
  transition:
    background-color 140ms var(--ease),
    border-color 140ms var(--ease),
    color 140ms var(--ease);
}

.jumprow:hover {
  background: var(--surface-3);
  border-color: color-mix(in oklab, var(--accent) 35%, var(--border));
  color: var(--text);
}

.jumprow__said {
  display: block;
  /* Takes the free space so the chevron is pushed to the far edge rather than sitting against
     the words, which is what makes the row read as a row. */
  flex: 1 1 auto;
  min-width: 0;
}

.jumprow__name {
  display: block;
  font-size: 0.88rem;
  font-weight: 550;
  color: var(--text);
}

.jumprow__note {
  display: block;
  margin-top: 2px;
  font-size: 0.78rem;
  color: var(--text-dim);
}

/* Sized in its own right rather than inheriting the caption's size: it is the affordance, and a
   chevron the size of small print does not read as one. */
.jumprow__arrow {
  flex: 0 0 auto;
  font-size: 1.25rem;
  line-height: 1;
  color: var(--text-dim);
}

.jumprow:hover .jumprow__arrow {
  color: var(--accent);
}

/* ------------------------------------------------------------ install page
   Four rules for /install.html, which is otherwise built entirely out of the app's own
   cards, hints and buttons. What it needed that nothing else has: numbered steps that read
   as instructions rather than as prose, and a row that holds the switch beside its proof. */
.install__step {
  margin: 0 0 10px;
}

/* The aside on a step — which button, where — a shade quieter than the instruction itself,
   so the thing to DO stays the thing you read first. */
.install__detail {
  color: var(--text-faint);
}

.install__done {
  margin: 0;
  color: var(--ok);
  font-weight: 600;
}

.install__row {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-bottom: 12px;
}

@media (max-width: 560px) {
  /* One tap target per line at phone width: these two buttons are "turn it on" and "prove it
     worked", and a wrapped pair with one of them hanging is exactly the arrangement the
     operator asked this app not to have. */
  .install__row .btn {
    flex: 1 1 100%;
  }
}

/* ================================================================== the dock

   Phone navigation as a bar under the thumb, drawn on the FIRST PAINT: the dock is static
   markup on every staff page (see the block each page carries), and which items show is
   decided by the same pre-paint stamps the top bar's links use — data-roles, data-grants and
   data-edition, written by theme.js before any script runs. js/dockbar.js only wires it:
   Home's true href, the current tab, the More menu. That is what stops the dock vanishing on
   every navigation and reappearing when /api/me answered, which is how the first version
   behaved and exactly what an app pinned to a home screen must not do.

   Desktop never sees any of it, and the media query is scoped to SCREEN: this section sits
   after the file's one print block, so an unqualified match would out-cascade print's
   display:none on narrow paper and put the phone's chrome onto a placard. */

.dockbar,
.dockbar__scrim,
.dockbar__menu {
  display: none;
}

@media screen and (max-width: 700px) {
  /* The strip, its fold button and Home all live inside .nav; the dock replaces the lot —
     from the first paint, since both the dock and this rule key on markup, not on script. */
  html:has(.dockbar) .nav {
    display: none;
  }

  /*
   * The bar itself steps aside on the way down and returns on the way up — the class is
   * toggled by wireBarFade in js/dockbar.js from scroll DIRECTION. Sticky layout is kept
   * (no reflow, only paint), and the pointer cannot press what the eye cannot see.
   */
  /*
   * THE CHROME RIDES THE SCROLL ITSELF. One number — `--chrome-away`, written per frame
   * by wireBarFade in js/dockbar.js: 0 at rest, 1 fully away, accumulating scroll the
   * way Safari's toolbar does — and everything below derives from it through calc().
   * The bar's fade and the dock and tile's shrink therefore move WITH the finger, both
   * directions, mid-gesture; nothing here flips a state and then animates on its own
   * clock. That was the operator's third note on this chrome: the quick timed version
   * read as the bar popping between sizes.
   *
   * ONE scale (6% at full away) and ONE bottom anchor for dock and tile both, per the
   * note before that: unequal scales dropped the tile's top out of level with the
   * dock's and the pair stopped reading as one bar.
   */
  html:has(.dockbar) .topbar {
    opacity: calc(1 - var(--chrome-away, 0));
    transform: translateY(calc(var(--chrome-away, 0) * -24px));
  }

  /* The one thing a number cannot carry: a bar that is visually gone must not swallow
     taps. Toggled by the same wirer, at nearly-gone. */
  html:has(.dockbar) .topbar--away {
    pointer-events: none;
  }

  /* Room at the page's end, so the last card scrolls clear of the bar. */
  html:has(.dockbar) body {
    padding-bottom: calc(79px + env(safe-area-inset-bottom, 0px));
  }

  /*
   * The two sticky bottom bars — the settings savebar and the schedule's tray — used to
   * float 16px off the window's edge, which is now inside the dock. Lifted to float the
   * same 16px above it instead, so saving and navigating never fight for the same pixels.
   */
  html:has(.dockbar) .savebar--sticky,
  html:has(.dockbar) .dock {
    bottom: calc(75px + env(safe-area-inset-bottom, 0px));
  }

  /*
   * The assistant's orb joins the dock's line: same height, same baseline, a matched 10px
   * gap on each side — one bar, two shapes. Its panel is unaffected; on a phone that is a
   * full-screen sheet (see .aihub--phone), and its higher z-index means an open chat covers
   * the dock rather than being poked through by it.
   */
  html:has(.dockbar) .aihub__launcher {
    width: 52px;
    height: 52px;
    right: 10px;
    bottom: calc(7px + env(safe-area-inset-bottom, 0px));
    /* The same scrubbed shrink as the dock, one scale off one bottom anchor — and no
       transition: the base rule's 140ms is a desktop hover snap, and here the value
       rides the scroll per frame (the settle animates on html instead). */
    transition: none;
    transform-origin: 50% 100%;
    transform: scale(calc(1 - 0.06 * var(--chrome-away, 0)));
  }

  /*
   * THE VEIL: the page dims and softens as it slides under the dock, so the bar reads as
   * floating over the content rather than pasted on it. A backdrop blur ramped in by a mask
   * plus a tint mixed from the theme's own ground — dark pages dim, light pages mist. On the
   * body's own pseudo so no page carries an element for it, inert to the pointer, and UNDER
   * the dock and its menu in the stack.
   */
  /*
   * And the same veil at the TOP, mirrored: content dims and softens on its way UNDER the
   * bar, not just at the moment it arrives there. The bar itself is already glass, so this
   * sits BEHIND it in the stack (19 to its 20) — the slice hidden by the bar costs nothing,
   * the slice below it is the fade the reference shows, and the bar's own glass deepens a
   * touch where the two overlap.
   */
  html:has(.dockbar) body::before {
    content: '';
    position: fixed;
    inset-inline: 0;
    top: 0;
    /*
     * Tall, and ending in NOTHING LEFT: frame-stepping the reference recording, content is
     * fully swallowed by the ground colour before it reaches the bar, and the ramp spans a
     * quarter of the screen — a long gentle fade-to-background, not a short blur. So the
     * gradient holds solid var(--bg) through the bar's zone, then lets go over ~150px. The
     * light blur is seasoning; the opacity ramp is the dish.
     */
    /* Shorter than it once was, on purpose: this ramp was sized for a bar that never left,
       and now the bar steps aside on the way down — so the fade only needs to swallow
       content at the screen's own edge, not reserve a bar's worth of depth below it. */
    height: 150px;
    z-index: 19;
    pointer-events: none;
    -webkit-backdrop-filter: blur(6px);
    backdrop-filter: blur(6px);
    -webkit-mask-image: linear-gradient(to bottom, black 25%, transparent 80%);
    mask-image: linear-gradient(to bottom, black 25%, transparent 80%);
    background: linear-gradient(
      to bottom,
      var(--bg) 0%,
      var(--bg) 15%,
      color-mix(in srgb, var(--bg) 50%, transparent) 48%,
      transparent 95%
    );
  }

  html:has(.dockbar) body::after {
    content: '';
    position: fixed;
    inset-inline: 0;
    bottom: 0;
    height: 160px;
    z-index: 21;
    pointer-events: none;
    /* Shade only — no backdrop blur. This veil once carried one, and the operator's
       phone showed why it was wrong: the action buttons scrolling through the bottom of
       the screen read as SMEARED, not shaded, because unlike the top veil this one sits
       over content a reader is actively aiming for. The dock's own glass keeps the blur;
       the floor under it is darkness alone. */
    /*
     * The stops are cut around the DOCK'S OWN GEOMETRY, not spread evenly, and that is
     * the second lesson from the operator's phone. The first deepening put its darkest
     * paint at the viewport's bottom edge — which the dock covers — so the strength went
     * where nobody could see it and the visible band above the bar barely moved. The bar
     * tops out 59px up (7px inset + 52px of bar); the 37% stop below is that edge, still
     * carrying most of the floor's weight, so the shadow is at full strength exactly
     * where content and dock meet and the separation holds AT ALL TIMES — this is a
     * fixed pseudo, there whether or not anything scrolls. Above that it thins over
     * ninety-odd pixels to nothing. Translucent black over whatever is beneath rather
     * than a painted colour, so it darkens a light theme the way a shadow would instead
     * of striping it with soot. No mask: it was feathering the blur, and the gradient
     * fades itself.
     */
    background: linear-gradient(
      to top,
      color-mix(in srgb, black 80%, transparent) 0%,
      color-mix(in srgb, black 62%, transparent) 37%,
      color-mix(in srgb, black 26%, transparent) 70%,
      transparent 100%
    );
  }

  .dockbar {
    position: fixed;
    left: 10px;
    right: 10px;
    /* 7px — the midpoint of two notes: "lower than 10" and then "not so close as 4,
       the iPhone's corners curve". The safe-area inset still rides on top, so a real
       home-indicator phone lifts the whole line clear of the curve regardless. */
    bottom: calc(7px + env(safe-area-inset-bottom, 0px));
    /* Over the page, its sticky bars (20) and the veil (21); under the assistant (25) — an
       open chat sheet owns the screen, dock included. */
    z-index: 22;
    display: flex;
    justify-content: space-around;
    align-items: center;
    /* 52px, down from 58 — the same trim as the bottom inset above: a slimmer bar,
       nearer the floor, ceding a little more of the screen to the content. */
    height: 52px;
    /* The ends breathe a little more than they did (4px), which draws the items a
       touch closer together — the operator asked for "ever so slightly", and a
       shrink-wrapped pill turned out to be far more than that. */
    padding: 0 12px;
    /* The scrubbed shrink, anchored to the bar's own bottom edge so it never opens a
       gap against the floor. NO transition: the value changes per frame with the
       scroll, and the settle animates the property itself on html instead. */
    transform-origin: 50% 100%;
    transform: scale(calc(1 - 0.06 * var(--chrome-away, 0)));
    /* Glass, not paint: enough of the theme's surface to read on, little enough that the
       page glows through — the blur underneath does the legibility work. Clipped, so the
       active tab's bloom below rises from the pill's own edge instead of leaking under it. */
    background: color-mix(in srgb, var(--surface-3) 58%, transparent);
    -webkit-backdrop-filter: blur(18px);
    backdrop-filter: blur(18px);
    border: 1px solid var(--border-strong);
    /* A rounded rectangle, not a capsule: the reference bar's corners turn at about a third
       of its height, which is what keeps it reading as a surface rather than a button. */
    border-radius: 18px;
    box-shadow: var(--shadow-lift), var(--rim);
    overflow: hidden;
  }

  /* With an orb to share the line with, the bar stops short of it: 10px edge + 52px orb
     + 10px gap. Stamped pre-paint by theme.js, so the pill never draws wide and shrinks. */
  html[data-assistant] .dockbar {
    right: 72px;
  }

  /*
   * WHO SEES WHICH ITEM — the same three-layer gate the top bar's links pass, verbatim in
   * spirit (see .nav__link[data-role] above): hidden until the pre-paint role stamp claims
   * one, grants narrowing roles, the edition trimming the schedule pages, and the data-nav
   * flag as the JS-reconciled fallback for accounts the stamps cannot describe.
   */
  .dockbar__item[data-role] {
    display: none;
  }

  html[data-roles~='admin'] .dockbar__item[data-role~='admin'],
  html[data-roles~='gm'] .dockbar__item[data-role~='gm'],
  html[data-roles~='maintenance'] .dockbar__item[data-role~='maintenance'],
  html[data-nav='ready'] .dockbar__item[data-role] {
    display: flex;
  }

  html:not([data-grants~='review']) .dockbar__item[data-grant='review'] {
    display: none !important;
  }

  html[data-grants~='review'] .dockbar__item[data-nogrant='review'] {
    display: none !important;
  }

  html[data-edition='standalone'] .dockbar__item[data-edition='cinema'] {
    display: none !important;
  }

  /*
   * THE OPTIONAL TABS, which belong to nobody by default.
   *
   * The bar seats four and a button, so a page cannot simply be added to it — the fifth would
   * push More off. The pages worth adding are the ones some people live in and others never
   * open, so they ship in the markup and are drawn only for an account whose own dock names
   * them. That answer is a preference rather than a rung, so it arrives as its own pre-paint
   * stamp (see the dock stamp in src/app.js) and the default here is no.
   *
   * Without this the tab was drawn on every first paint by the role rules above and taken away
   * again when wireDock answered — a fifth tab that appeared and vanished on every navigation.
   * `!important` because the rule that draws it is a stamp plus two attributes and would
   * otherwise win on specificity, which is the same reason the two rules above carry it.
   */
  .dockbar__item[data-optional] {
    display: none !important;
  }

  html[data-dock~='/market.html'] .dockbar__item[data-optional][href='/market.html'] {
    display: flex !important;
  }

  /* An engineer's two pages both earn tabs, leaving nothing for More to hold — and a
     button over an empty menu is worse than no button. Pre-paint, like everything above;
     the JS removes it too, for the accounts the stamps cannot see. */
  html[data-roles~='maintenance'] .dockbar__more {
    display: none;
  }

  .dockbar__item {
    position: relative;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 3px;
    min-width: 52px;
    padding: 2px 5px;
    border: 0;
    background: none;
    color: var(--text-dim);
    text-decoration: none;
    font: inherit;
    font-size: 10.5px;
    font-weight: 600;
    letter-spacing: 0.01em;
    cursor: pointer;
    /* No system flash on tap either — the item's own ink change is the whole feedback. */
    -webkit-tap-highlight-color: transparent;
  }

  /*
   * The dock refuses the page's link hover. Items are <a>, and the global `a:hover`
   * paints the accent — which iOS asserts on TAP and leaves asserted, so pressing a tab
   * flashed it blue and left it blue until the next touch (the operator saw exactly
   * that). On this bar brightness is the signal and ink is the only colour: resting
   * items stay dim under a finger, and the lit one stays at full ink.
   */
  .dockbar__item:hover {
    color: var(--text-dim);
  }

  .dockbar__item--on:hover {
    color: var(--text);
  }

  .dockbar__item svg {
    transition: transform 160ms var(--ease);
  }

  /*
   * The lit tab: the icon steps up and goes to full ink — var(--text), which is the
   * prominent white on the dark theme and stays legible on the light one — with a soft
   * bloom rising from the pill's bottom edge beneath it. Ink rather than accent, because
   * on a glass bar the accent read as one more tinted thing; brightness is the signal.
   */
  .dockbar__item--on {
    color: var(--text);
  }

  .dockbar__item--on svg {
    color: var(--text);
    transform: scale(1.18);
  }

  /*
   * WHOOP's light, MEASURED this time rather than eyeballed: sampling their pill's glass
   * top-to-bottom, the inactive zones darken toward the bottom edge while the active tab's
   * zone brightens toward it — light rising from underneath, broad and edgeless, strongest
   * at the rim and gone by icon height. Built the same way here: a wide soft ellipse whose
   * CENTRE sits below the pill's bottom edge, clipped by the pill's own overflow — so what
   * shows is only the upper slice of something big and gentle, never the outline of a shape.
   */
  .dockbar__item--on::after {
    content: '';
    position: absolute;
    left: 50%;
    bottom: -36px;
    transform: translateX(-50%);
    width: 100px;
    height: 62px;
    z-index: -1;
    border-radius: 50%;
    background: radial-gradient(
      closest-side,
      color-mix(in srgb, var(--text) 15%, transparent),
      transparent
    );
    pointer-events: none;
  }

  /*
   * THE TABLET'S DOCK IS BIGGER, like everything else on that page: read at arm's length
   * and tapped with a thumb while holding something else. The rest of the bar is the app's
   * and left exactly alone — the glass, the bloom under the lit tab, the scrubbed shrink.
   *
   * Three tabs and no More button, so nothing is ever pushed off it.
   */
  .dockbar--floor {
    height: 60px;
  }

  .dockbar--floor .dockbar__item {
    min-width: 64px;
    font-size: 11.5px;
  }

  /*
   * THE CHROME ARRIVES WITH THE UNLOCK, and so must the space it reserves.
   *
   * Every rule in this block keys on :has(.dockbar), which is true from the first paint —
   * but this page's dock is hidden until somebody has typed four digits. Without this the
   * PIN screen padded its foot for a bar nobody could see, and cast both of the bar's veils
   * over a screen with nothing to scroll under them.
   */
  body.shopfloor:not(.shopfloor--open) {
    padding-bottom: 0;
  }

  body.shopfloor:not(.shopfloor--open)::before,
  body.shopfloor:not(.shopfloor--open)::after {
    content: none;
  }

  .dockbar__scrim {
    position: fixed;
    inset: 0;
    z-index: 21;
    display: block;
    background: rgba(0, 0, 0, 0.35);
  }

  .dockbar__menu {
    position: fixed;
    right: 10px;
    bottom: calc(69px + env(safe-area-inset-bottom, 0px));
    z-index: 23;
    display: flex;
    flex-direction: column;
    min-width: 210px;
    padding: 6px;
    background: var(--glass-strong);
    -webkit-backdrop-filter: blur(14px);
    backdrop-filter: blur(14px);
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-lift);
  }

  /* The classes above set display, which would otherwise outrank the hidden attribute the
     JS toggles — and a menu that ignores [hidden] is open forever. */
  .dockbar__scrim[hidden],
  .dockbar__menu[hidden] {
    display: none;
  }

  .dockbar__menu-link {
    display: flex;
    align-items: center;
    gap: 11px;
    padding: 11px 12px;
    border-radius: 11px;
    color: var(--text);
    text-decoration: none;
    font-size: 14.5px;
    font-weight: 600;
  }

  .dockbar__menu-link svg {
    color: var(--text-dim);
    flex: 0 0 auto;
  }

  .dockbar__menu-link:active {
    background: color-mix(in srgb, var(--text) 7%, transparent);
  }

  .dockbar__menu-link[aria-current='page'] {
    color: var(--accent);
  }
}

/*
 * The scrub's number, registered so .chrome-settle can ANIMATE it: when scrolling goes
 * quiet mid-way the wirer sets the nearer end and this transition carries every calc()
 * that reads the property there smoothly. See wireBarFade in js/dockbar.js.
 */
@property --chrome-away {
  syntax: '<number>';
  inherits: true;
  initial-value: 0;
}

html.chrome-settle {
  transition: --chrome-away 320ms var(--ease);
}

/*
 * Premium pass: first-arrival choreography: the page's boxes rise into place,
 * top to bottom, ONCE per visit. The `arriving` class is put on <html> as the page's
 * modules boot and taken off shortly after load (see util.js), so anything drawn in
 * that window — the first data paint — performs its entrance, and anything drawn later
 * (the 20-second poll, a range switch, a tab change) simply appears where it belongs:
 * data changing must never bounce furniture. Backwards fill holds each box invisible
 * through its own delay, which is what makes the stagger read as one curtain rising
 * rather than boxes popping in a row.
 */
@keyframes arrive {
  from {
    opacity: 0;
    transform: translateY(var(--arrive-rise, 14px)) scale(var(--arrive-scale, 1));
  }
}

html.arriving :is(.stat, .card, .kpi, .empty, [data-caseview] .complaint--report) {
  animation: arrive 480ms var(--ease) backwards;
}

html.arriving :is(.stat, .card, .kpi, .empty, [data-caseview] .complaint--report):nth-child(2) {
  animation-delay: 45ms;
}

html.arriving :is(.stat, .card, .kpi, .empty, [data-caseview] .complaint--report):nth-child(3) {
  animation-delay: 90ms;
}

html.arriving :is(.stat, .card, .kpi, .empty, [data-caseview] .complaint--report):nth-child(4) {
  animation-delay: 135ms;
}

html.arriving :is(.stat, .card, .kpi, .empty, [data-caseview] .complaint--report):nth-child(5) {
  animation-delay: 180ms;
}

html.arriving :is(.stat, .card, .kpi, .empty, [data-caseview] .complaint--report):nth-child(6) {
  animation-delay: 225ms;
}

html.arriving :is(.stat, .card, .kpi, .empty, [data-caseview] .complaint--report):nth-child(n + 7) {
  animation-delay: 260ms;
}

@media (prefers-reduced-motion: reduce) {
  html.arriving :is(.stat, .card, .kpi, .empty, [data-caseview] .complaint--report) {
    animation: none;
  }
}

/*
 * THE BACK-OFFICE TABLET JOINS THE CHOREOGRAPHY.
 *
 * Putting the dock on floor.html is what makes it a "staff page" to util.js, which is where
 * the window is opened and closed — so its rows needed only a selector. Same keyframe and
 * the same 45ms stagger as the dashboard's cards, because the point of giving that screen
 * the app's chrome is that it IS the app and should not move differently from it.
 *
 * On a tab press the window is re-armed by replayArrival() and opened inside the render by
 * arrivalPaint(): the documented path for a deliberate change of subject, which a tab is
 * and a twenty-second poll is not.
 */
html.arriving .shopfloor__rise {
  animation: arrive 480ms var(--ease) backwards;
}

html.arriving .shopfloor__rise:nth-child(2) {
  animation-delay: 45ms;
}

html.arriving .shopfloor__rise:nth-child(3) {
  animation-delay: 90ms;
}

html.arriving .shopfloor__rise:nth-child(4) {
  animation-delay: 135ms;
}

html.arriving .shopfloor__rise:nth-child(5) {
  animation-delay: 180ms;
}

html.arriving .shopfloor__rise:nth-child(6) {
  animation-delay: 225ms;
}

html.arriving .shopfloor__rise:nth-child(n + 7) {
  animation-delay: 260ms;
}

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

/* Premium pass: the press, felt everywhere — the same dip the stat tiles take. */
.btn:active:not(:disabled) {
  transform: scale(0.97);
}

/* ------------------------------------------------- pull down to refresh
 *
 * The installed app's copy of the gesture every browser tab already has — built and
 * armed by js/pull-refresh.js, and ONLY when the app runs standalone on a touch device,
 * which is exactly where the native gesture is missing. The disc is the house glass at
 * the house radius, riding above everything (the top bar fades on scroll; this appears
 * at scroll top, so the two never actually meet, but a pull mid-fade must still draw
 * over the bar the way the native spinner draws over a page's own chrome).
 */

/* The whole reason the gesture pays for itself: with a pull standing in for the button,
   Refresh stops spending a controls row on every phone screen. Gated on the data flag
   the module stamps only after every arming check has passed — in a browser tab, or on
   a mouse, the button stands exactly as before. */
html[data-ptr] #refresh {
  display: none;
}

.ptr {
  /*
   * ABSOLUTE, not fixed, and that is the operator's phone talking. iOS rubber-bands the
   * whole page during an overscroll and carries fixed elements along with it, so the
   * disc rode the bounce instead of appearing in it — pulled down hard, the indicator
   * was somewhere off past the top edge. Parked above the DOCUMENT instead, it sits in
   * exactly the space the rubber band reveals, which is where the native spinner lives;
   * the translate below is for Android, whose standalone overscroll moves nothing.
   */
  position: absolute;
  top: -60px;
  left: 0;
  right: 0;
  display: flex;
  justify-content: center;
  z-index: 30;
  pointer-events: none;
  /* Parked invisible above the document edge; the module reveals it for the length of a
     pull — which is also what keeps it out of print. */
  visibility: hidden;
  transition: transform 220ms var(--ease);
}

.ptr--out {
  visibility: visible;
}

/* While a finger holds it the disc is the finger's, not the transition's. */
.ptr--dragging {
  transition: none;
}

.ptr__disc {
  display: flex;
  align-items: center;
  justify-content: center;
  /* Sized up from 44 after the operator could barely find it mid-pull. */
  width: 48px;
  height: 48px;
  border-radius: 50%;
  background: var(--glass-strong);
  -webkit-backdrop-filter: blur(14px);
  backdrop-filter: blur(14px);
  border: 1px solid var(--border-strong);
  box-shadow: var(--shadow-lift);
  color: var(--text-dim);
}

/* Past the threshold the release will act, and the accent says so — the same ink every
   pressed control in the app uses for "this is live". */
.ptr--armed .ptr__disc {
  color: var(--accent);
  border-color: color-mix(in srgb, var(--accent) 45%, var(--border-strong));
}

.ptr--busy .ptr__disc {
  animation: ptr-spin 800ms linear infinite;
}

@keyframes ptr-spin {
  to {
    transform: rotate(360deg);
  }
}

@media (prefers-reduced-motion: reduce) {
  .dockbar__item svg,
  .dockbar,
  .aihub__launcher,
  .topbar,
  .ptr {
    transition: none;
  }

  /* The settle snaps, and the wirer skips the scrub itself under this preference —
     the chrome changes state without performing motion. */
  html.chrome-settle {
    transition: none;
  }

  /* The pull is acknowledged by the disc appearing and the accent arming — not by spin. */
  .ptr--busy .ptr__disc {
    animation: none;
  }
}

/* ==================================================== Motion pass
 *
 * The round the operator chose from recordings, whole: pages that hand over instead of
 * cutting, popups that grow from the press that asked for them, a queue that says
 * goodbye to finished work and hello to new, a pull that resolves into a tick, menus
 * that arrive from their triggers — and skeletons rebuilt to the measured size of what
 * replaces them, so every page's first paint lands IN PLACE. Each movement is scoped,
 * runs once per cause, and dies under reduced motion — the house rules.
 */

/* 3 · The card that was acted on takes a bow: a small lift and fade, then the list
 *     closes the gap it leaves. Driven by bowOut() in review.js. */
.complaint--bow {
  overflow: hidden;
  transition:
    opacity 190ms var(--ease),
    transform 190ms var(--ease),
    height 300ms var(--ease) 130ms,
    margin 300ms var(--ease) 130ms,
    padding 300ms var(--ease) 130ms;
}
.complaint--bowed {
  opacity: 0;
  transform: translateY(-6px) scale(0.985);
  margin-top: 0 !important;
  margin-bottom: 0 !important;
  padding-top: 0 !important;
  padding-bottom: 0 !important;
}

/* 4 · The chart tooltip steps in rather than teleporting. Replayed only when it moves
 *     to a different mark — see tooltips() in charts.js. */
.chart__tip--in {
  animation: tip-in 130ms var(--ease);
}
@keyframes tip-in {
  from {
    opacity: 0;
    transform: translateY(3px);
  }
}

/* 5 · A report that arrived while the page was open says so once, when the reader asks
 *     to see it: a short rise, and the severity rail breathes a single glow. */
.complaint--fresh {
  animation:
    fresh-in 380ms var(--ease) backwards,
    fresh-glow 1.5s var(--ease) 380ms backwards;
}
@keyframes fresh-in {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
}
@keyframes fresh-glow {
  0% {
    box-shadow: -10px 0 26px -10px var(--card-edge, var(--border-strong));
  }
  100% {
    box-shadow: -10px 0 26px -10px transparent;
  }
}

/* 6 · The photo viewer ZOOMS from the press instead of rising like a form — same
 *     mechanism as every dialog (dialogs.js sets the origin from the pointer), with a
 *     deeper starting scale that reads as "the photograph grew from its thumbnail". */
dialog.viewer.is-arming {
  opacity: 0;
  transform: scale(0.8);
}
dialog.viewer.is-opening {
  animation-name: viewer-in;
}
@keyframes viewer-in {
  from {
    opacity: 0;
    transform: scale(0.8);
  }
}

/* 7 · The pull-to-refresh disc closes with a tick: spin ends, a check draws itself,
 *     then the disc leaves. States driven by pull-refresh.js. */
.ptr__check {
  position: absolute;
  inset: 0;
  margin: auto;
  opacity: 0;
}
.ptr__check path {
  stroke-dasharray: 22;
  stroke-dashoffset: 22;
}
.ptr--done .ptr__arrow {
  opacity: 0;
  transform: scale(0.6);
  transition:
    opacity 110ms var(--ease),
    transform 110ms var(--ease);
}
.ptr--done .ptr__disc {
  color: var(--accent);
  border-color: color-mix(in srgb, var(--accent) 45%, var(--border-strong));
}
.ptr--done .ptr__check {
  opacity: 1;
  transition: opacity 80ms var(--ease);
}
.ptr--done .ptr__check path {
  stroke-dashoffset: 0;
  transition: stroke-dashoffset 240ms var(--ease) 70ms;
}

/* 8 · Menus arrive from their trigger, and dock icons take the press. */
.account__menu--in {
  transform-origin: top right;
  animation: menu-down 150ms var(--ease);
}
.dockbar__menu--in {
  transform-origin: 50% 100%;
  animation: menu-up 160ms var(--ease);
}
@keyframes menu-down {
  from {
    opacity: 0;
    transform: scale(0.94);
  }
}
@keyframes menu-up {
  from {
    opacity: 0;
    transform: translateY(6px) scale(0.94);
  }
}
.dockbar__item {
  transition: transform 260ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.dockbar__item:active {
  transform: scale(0.88);
  transition-duration: 90ms;
}

@media (prefers-reduced-motion: reduce) {
  .complaint--bow,
  .complaint--bowed,
  .dockbar__item {
    transition: none;
  }
  .complaint--fresh,
  .chart__tip--in,
  .account__menu--in,
  .dockbar__menu--in,
  dialog.viewer.is-opening {
    animation: none;
  }
  .ptr--done .ptr__check path {
    transition: none;
    stroke-dashoffset: 0;
  }
}

/* 9 · Motion pass: skeletons that hold the page's real silhouette.
 *
 * The queue's ghosts were three short cards and the overview had none at all, so the
 * moment data landed the page GREW — content did not appear in place, the layout
 * unrolled top to bottom, and the entrance choreography rode on top of the reflow.
 * These ghosts are built to the measured size of what replaces them (tiles ~120px in
 * the tile grid's own columns, charts ~340px, queue cards ~270px), so the silhouette
 * is stable from the first paint and the real cards materialise where a ghost stood.
 */
.skelwrap {
  display: contents;
}
.skelcard--stat {
  height: 120px;
  align-content: center;
}
.skel--stat-value {
  height: 26px;
  width: 56%;
}
.skelcard--chart {
  height: 340px;
  grid-template-rows: auto 1fr;
}
.skel--plot {
  height: auto;
  border-radius: var(--radius-lg);
}
.skel--band {
  height: 54px;
  border-radius: var(--radius-lg);
}
.skelcard--case {
  height: 258px;
  grid-template-rows: auto auto auto 1fr;
}
.skel--act {
  height: 44px;
  align-self: end;
  border-radius: 12px;
}

/* 10 · Motion pass: the queue's arrival, made unmissable.
 *
 * A case card is ~280px of near-black on near-black: the shared 14px rise reads on the
 * overview's small tiles and vanishes on these. So the queue's cards travel further,
 * grow slightly into place, take longer, and are spaced twice as far apart — a
 * procession you can watch, not a shimmer you can miss. Values only; the animation,
 * its window, and the reduced-motion kill all stay the shared ones.
 */
html.arriving [data-caseview] .complaint--report {
  --arrive-rise: 26px;
  --arrive-scale: 0.965;
  animation-duration: 620ms;
  animation-delay: 130ms;
}
html.arriving [data-caseview] .complaint--report:nth-child(2) {
  animation-delay: 220ms;
}
html.arriving [data-caseview] .complaint--report:nth-child(3) {
  animation-delay: 310ms;
}
html.arriving [data-caseview] .complaint--report:nth-child(4) {
  animation-delay: 400ms;
}
html.arriving [data-caseview] .complaint--report:nth-child(5) {
  animation-delay: 490ms;
}
html.arriving [data-caseview] .complaint--report:nth-child(n + 6) {
  animation-delay: 560ms;
}

/* The LIST view's rows are forty-pixel lines, not cards: the full 26px rise read as
   the table falling apart. They keep the procession at a line's own scale. */
html.arriving [data-caseview] .complaint__row {
  --arrive-rise: 12px;
  --arrive-scale: 1;
  animation-duration: 420ms;
}

/* The furniture leads the procession: the cinema band first, its room row next, the
   cards after — four or five moving parts per screen instead of two. */
html.arriving [data-caseview] :is(.theatre__head, .auditorium__head) {
  --arrive-rise: 18px;
  animation: arrive 520ms var(--ease) backwards;
}
html.arriving [data-caseview] .auditorium__head {
  animation-delay: 70ms;
}
@media (prefers-reduced-motion: reduce) {
  html.arriving [data-caseview] :is(.theatre__head, .auditorium__head) {
    animation: none;
  }
}

/* 11 · Motion pass: the scheduler and the accounts page join the procession.
 *
 * The week grid arrives one LANE at a time — the ruler first, then each auditorium's
 * row with its pills — and the accounts page arrives one person at a time. Same window,
 * same keyframe, same kill switch; only the cast is new.
 */
html.arriving :is(.grid__ruler, .grid__lane, .person) {
  --arrive-rise: 20px;
  animation: arrive 560ms var(--ease) backwards;
}
html.arriving :is(.grid__lane, .person):nth-child(2) {
  animation-delay: 80ms;
}
html.arriving :is(.grid__lane, .person):nth-child(3) {
  animation-delay: 160ms;
}
html.arriving :is(.grid__lane, .person):nth-child(4) {
  animation-delay: 240ms;
}
html.arriving :is(.grid__lane, .person):nth-child(5) {
  animation-delay: 320ms;
}
html.arriving :is(.grid__lane, .person):nth-child(n + 6) {
  animation-delay: 400ms;
}
@media (prefers-reduced-motion: reduce) {
  html.arriving :is(.grid__ruler, .grid__lane, .person) {
    animation: none;
  }
}

/* 12 · Motion pass: the cards a reader has not reached yet.
 *
 * The arrival window greets what is on screen; a queue is forty cards long and a phone
 * holds two, so the rest waited armed and arrive as they are scrolled to (js/reveal.js).
 * `.reveal` is the armed state and is only ever ON a card the observer is holding —
 * every way out of that module takes it off again, so a card cannot be left invisible.
 */
[data-caseview] .complaint--report.reveal {
  opacity: 0;
  /* TRANSLATE ONLY, deliberately: a scale moves every child sideways by a fraction of a
     percent, and the list view's whole promise is that each row's columns sit at the
     same x as the row above (test/browser/case-view.browser.js measures exactly that). */
  transform: translateY(16px);
}

/* Arriving IS the class coming off: the properties are transitioned rather than
   animated, so a card that is revealed mid-scroll moves from wherever it actually is
   rather than restarting from a keyframe's first frame. */
[data-caseview] .complaint--report:not(.complaint--bow) {
  transition:
    opacity 420ms var(--ease),
    transform 420ms var(--ease);
}

@media (prefers-reduced-motion: reduce) {
  [data-caseview] .complaint--report:not(.complaint--bow) {
    transition: none;
  }
  [data-caseview] .complaint--report.reveal {
    opacity: 1;
    transform: none;
  }
}

/* 13 · Motion pass: the location pages arrive a building at a time.
 *
 * Location info and My cinemas draw one .theatre section per building — the estate, not a
 * queue — and they were the last work pages where everything simply appeared. Scoped by
 * `#results:not([data-caseview])`, which is exactly what tells these pages apart from the
 * case monitor: the queue puts its own cinema groups under that attribute, and those are
 * deliberately NOT the unit that animates there (one full-screen block fading dark on dark
 * reads as nothing — see the cast above, which moves the cards instead).
 */
html.arriving #results:not([data-caseview]) > .theatre {
  --arrive-rise: 20px;
  animation: arrive 560ms var(--ease) backwards;
}
html.arriving #results:not([data-caseview]) > .theatre:nth-child(2) {
  animation-delay: 80ms;
}
html.arriving #results:not([data-caseview]) > .theatre:nth-child(3) {
  animation-delay: 160ms;
}
html.arriving #results:not([data-caseview]) > .theatre:nth-child(4) {
  animation-delay: 240ms;
}
html.arriving #results:not([data-caseview]) > .theatre:nth-child(n + 5) {
  animation-delay: 300ms;
}

@media (prefers-reduced-motion: reduce) {
  html.arriving #results:not([data-caseview]) > .theatre {
    animation: none;
  }
}

/* ==================================================== PROTOTYPE · programming page
 *
 * Five lists that read the till: films under and over their building's usual, runs short
 * of screenings and runs wasting them, and shows still to play that are filling fast.
 * Marked as a prototype because it is being shown before it is agreed; the marker goes
 * when it does. Everything here leans on tokens the app already has — no new colours.
 *
 * Each list is a .section — the settings page's disclosure, heading and count on the
 * summary — so the page folds to a table of contents. The rules for the fold itself live
 * with the settings page; only what the lists add is here.
 */
.panel__head {
  display: grid;
  gap: 4px;
  margin-bottom: 14px;
}

/*
 * Collapse all / Expand all, on a slim line between the strip and the first list. A
 * text button rather than a boxed one: it is a convenience beside six controls that
 * each do the same thing for one list, and a box would make it read as the page's
 * main action. Kept off the range bar, whose phone layout is fitted to the controls
 * it has.
 */
.market__folds {
  display: flex;
  justify-content: flex-end;
  margin: -4px 0 10px;
}

.market__foldall {
  padding: 4px 8px;
  border: 0;
  border-radius: var(--radius-sm);
  background: none;
  font: inherit;
  font-size: 0.8rem;
  font-weight: 550;
  color: var(--text-dim);
  cursor: pointer;
}

.market__foldall:hover {
  color: var(--accent);
}

.market__foldall:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}

.panel__note {
  margin: 0;
  max-width: 68ch;
  color: var(--text-dim);
  font-size: 0.86rem;
  line-height: 1.45;
}

/* A table is the right shape for a ranked list of pairs, and the wrong shape for a phone:
   it scrolls in its own box rather than pushing the page sideways. */
.table__scroll {
  overflow-x: auto;
  margin: 0 -4px;
  padding: 0 4px;
}

.market__table {
  width: 100%;
  min-width: 620px;
}

.market__film {
  font-weight: 650;
}

.market__code {
  display: block;
  color: var(--text-faint);
  font-size: 0.78rem;
}

.market__num {
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.market__num--warn {
  color: var(--p-peach, var(--text));
}

.market__num--go {
  color: var(--accent);
}

/* A run with nothing left to give: already down to its last screening a day. Said in the
   page's quietest ink, because "no move" is an answer and not an alarm. */
.market__num--none {
  color: var(--text-faint);
}

/*
 * The one cell on the programming page that holds a control: a room swap's "Draft into next
 * week's plan". Right-aligned and never wrapped, so a table of findings does not grow a ragged
 * left edge of buttons — and `white-space: nowrap` because the label is a sentence and a button
 * broken across two lines reads as two.
 *
 * On a phone the table becomes stacked blocks (see the data-label rule above), where a right
 * edge means nothing: the button goes full width there, like every other action in the app.
 */
.market__act {
  text-align: right;
  white-space: nowrap;
}

@media (max-width: 560px) {
  .market__act .btn {
    width: 100%;
  }
}

/* A row of actions under a setting — the digest's "email me one" beside its "preview it". */
.setting__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 6px;
}

/* A figure and, under it, what it is a share of — "4 shows" over "40% of this cinema's
   screenings". One element on purpose: on a phone the cell is a flex row of label and
   value, and two loose spans would sit side by side as two values. */
.market__stack {
  display: grid;
  gap: 1px;
}

.market__sub {
  color: var(--text-faint);
  font-size: 0.78rem;
  font-weight: 400;
  white-space: normal;
}

/* The count of what a list was chosen from, quieter than the sentence saying what it measures. */
.panel__note--faint {
  color: var(--text-faint);
  font-size: 0.8rem;
}

/* Whether the advice on this panel has been worth taking — see advice-outcomes.js. Set at full
   text colour against a tinted rule, because it is the one line here a booker should read
   BEFORE the list rather than after it, and because for the first month it is the line that
   says "there is not enough of a record to trust this yet". */
.panel__worked {
  margin: 0;
  max-width: 68ch;
  padding: 8px 0 8px 12px;
  border-left: 3px solid var(--accent);
  color: var(--text);
  font-size: 0.86rem;
  line-height: 1.5;
}

/* The index, as a track with PAR marked on it: the eye ranks the rows without reading a
   single number, and the line says where "doing what this building normally does" is. */
.idx {
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 132px;
}

.idx__track {
  position: relative;
  flex: 1;
  height: 8px;
  border-radius: 999px;
  background: color-mix(in oklab, var(--text) 10%, var(--surface-2));
  overflow: hidden;
}

.idx__fill {
  height: 100%;
  border-radius: 999px;
  background: var(--accent);
}

.idx__fill--cold {
  background: color-mix(in oklab, var(--p-peach, var(--accent)) 78%, var(--accent));
}

/* Par sits at 1.00, which is halfway along a track that runs to 2.00. */
.idx__par {
  position: absolute;
  inset: 0 auto 0 50%;
  width: 2px;
  background: color-mix(in oklab, var(--text) 42%, transparent);
}

.idx__value {
  font-variant-numeric: tabular-nums;
  font-weight: 650;
  font-size: 0.85rem;
}

/* The coverage strip, above the lists it qualifies. */
.cover {
  display: flex;
  gap: 10px;
  align-items: flex-start;
  padding: 12px 14px;
  margin-bottom: 16px;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  background: var(--surface);
  color: var(--text-dim);
  font-size: 0.86rem;
  line-height: 1.5;
}

.cover__dot {
  width: 8px;
  height: 8px;
  margin-top: 6px;
  border-radius: 999px;
  flex: none;
  background: var(--accent);
}

/*
 * The two states of the strip, which are a colour apart and nothing else — the sentence
 * beside the dot does the telling. Green for a circuit whose POS feeds are all current,
 * amber for one where a location's numbers are understated and every list below it has to
 * be read as a question.
 */
.cover--ok .cover__dot {
  background: var(--ok);
}

.cover--quiet .cover__dot {
  background: var(--p-peach, var(--accent));
}

.cover__lead {
  margin: 0 0 4px;
}

/* The holiday line: the one caveat in the strip that is about the calendar, not the feed. */
.cover__lead--holiday {
  color: var(--text);
  font-weight: 560;
}

.cover__list {
  margin: 0;
  padding-left: 18px;
}

.market__quiet {
  display: inline-block;
  margin-top: 3px;
  padding: 1px 6px;
  border-radius: 999px;
  border: 1px solid color-mix(in oklab, var(--p-peach, var(--accent)) 45%, transparent);
  color: var(--p-peach, var(--text-dim));
  font-size: 0.72rem;
}

/*
 * WHY a film ranks where it does — the one word that turns the rank lists from a puzzle into
 * an instruction. Two hues rather than good-and-bad, because both reasons are neutral facts
 * and only the lever differs: a BOOK reason is answered by the schedule, a DRAW reason is
 * answered by the audience and cannot be booked away. Kept the shape of the badge above so
 * the two read as one family of marks rather than two systems.
 */
.market__why {
  display: inline-block;
  padding: 1px 7px;
  border-radius: 999px;
  border: 1px solid currentcolor;
  font-size: 0.72rem;
  white-space: nowrap;
}

.market__why--book {
  color: var(--p-blue, var(--accent));
}

.market__why--draw {
  color: var(--text-dim);
}

/*
 * The per-cinema table that leads the circuit view: one row a building, its name a link
 * into that building's own view of the page. The counts are figures, not sentences, so the
 * columns are narrow and the cinema takes the width.
 */
.market__site {
  font-weight: 650;
}

.market__site a {
  color: inherit;
  text-decoration: none;
  border-bottom: 1px solid color-mix(in oklab, var(--accent) 40%, transparent);
}

.market__site a:hover {
  color: var(--accent);
  border-bottom-color: var(--accent);
}

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

/* The one figure on a row that is a count of something, drawn so the eye finds it. */
.market__count {
  font-weight: 650;
}

.market__ok {
  color: var(--text-faint);
  font-size: 0.8rem;
}

/*
 * The rest of a long list, behind a button. A circuit of twenty sites can put a hundred
 * rows on one list, and the first twenty are the ones that matter — the sort is by size —
 * so the page shows those and says how many more there are, rather than either scrolling
 * the reader through all of them or quietly cutting the list short.
 */
.market__more {
  display: block;
  width: 100%;
  margin-top: 8px;
  padding: 9px 12px;
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius-sm);
  background: none;
  font: inherit;
  font-size: 0.84rem;
  font-weight: 550;
  color: var(--text-dim);
  cursor: pointer;
}

.market__more:hover {
  color: var(--accent);
  border-color: var(--accent);
}

/* A card that lists rather than plots — the programming answers on the overview. */
.chart__plot--board {
  padding: 0;
}

.board {
  display: grid;
  gap: 2px;
}

.board__row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 14px;
  padding: 9px 2px;
  border-bottom: 1px solid var(--border);
}

.board__row:last-child {
  border-bottom: 0;
}

.board__what {
  display: grid;
  gap: 1px;
  min-width: 0;
}

.board__title {
  font-weight: 620;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.board__where {
  color: var(--text-faint);
  font-size: 0.79rem;
}

.board__verdict {
  display: grid;
  justify-items: end;
  gap: 1px;
  flex: none;
}

.board__value {
  font-variant-numeric: tabular-nums;
  font-weight: 680;
}

.board__value--go {
  color: var(--accent);
}

.board__value--warn {
  color: var(--p-peach, var(--text));
}

.board__note {
  color: var(--text-faint);
  font-size: 0.78rem;
  white-space: nowrap;
}

.board__more {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  margin-top: 10px;
  color: var(--text-dim);
  font-size: 0.84rem;
  text-decoration: none;
}

.board__more:hover {
  color: var(--accent);
}

/* The programming page's own arrival: the lists rise like every other card, and their rows
   follow one after another — a ranked list reads better arriving in its order. */
html.arriving .market__table tbody tr {
  --arrive-rise: 10px;
  animation: arrive 420ms var(--ease) backwards;
}
html.arriving .market__table tbody tr:nth-child(2) {
  animation-delay: 60ms;
}
html.arriving .market__table tbody tr:nth-child(3) {
  animation-delay: 120ms;
}
html.arriving .market__table tbody tr:nth-child(4) {
  animation-delay: 180ms;
}
html.arriving .market__table tbody tr:nth-child(n + 5) {
  animation-delay: 230ms;
}

/* And the board cards' rows on the overview, on the same footing. */
html.arriving .board__row {
  --arrive-rise: 10px;
  animation: arrive 400ms var(--ease) backwards;
}
html.arriving .board__row:nth-child(2) {
  animation-delay: 55ms;
}
html.arriving .board__row:nth-child(3) {
  animation-delay: 110ms;
}
html.arriving .board__row:nth-child(4) {
  animation-delay: 165ms;
}
html.arriving .board__row:nth-child(n + 5) {
  animation-delay: 210ms;
}

@media (prefers-reduced-motion: reduce) {
  html.arriving .market__table tbody tr,
  html.arriving .board__row {
    animation: none;
  }
}

/*
 * The programming lists on a phone: stacked blocks, not a table dragged sideways.
 *
 * Six columns will not fit 390px and never will, and the scroll box that let them try was
 * the compromise: a reader saw the film and the cinema, and everything the page exists to
 * say — the index, the move, the pace — lived off the right-hand edge behind a drag most
 * people never make. So the table stops being a table here. Each row becomes a block with
 * the film as its heading and every figure printed against the name of its own column,
 * which is the one thing a dropped heading row has to be replaced by.
 */
@media (max-width: 560px) {
  .table__scroll {
    overflow-x: visible;
    margin: 0;
    padding: 0;
  }

  .market__table {
    min-width: 0;
    display: block;
  }

  /* Kept for screen readers, taken off the layout: the labels below say it in place. */
  .market__table thead {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }

  .market__table tbody,
  .market__table tr,
  .market__table td {
    display: block;
  }

  .market__table tr {
    padding: 12px 0;
    border-bottom: 1px solid var(--border);
  }

  .market__table tr:last-child {
    border-bottom: 0;
  }

  .market__table td {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 12px;
    padding: 3px 0;
    border: 0;
  }

  /* The column's name, printed where the head used to be. */
  .market__table td::before {
    content: attr(data-label);
    flex: none;
    color: var(--text-faint);
    font-size: 0.78rem;
    letter-spacing: 0.02em;
    text-transform: uppercase;
  }

  /* The film leads the block as its heading, so the first line is what it is about — and on
     the per-cinema table it is the cinema that leads. */
  .market__table td.market__film,
  .market__table td.market__site {
    display: block;
    padding-bottom: 6px;
    font-size: 1.02rem;
    font-weight: 700;
  }

  .market__table td.market__film::before,
  .market__table td.market__site::before {
    content: none;
  }

  /* The cinema sits under it as a subtitle rather than as another labelled figure. By class,
     not by column position: the column is dropped altogether once one cinema is chosen, and
     a positional rule would then have restyled whichever figure moved into second place. */
  .market__table td.market__where::before {
    content: none;
  }

  .market__table td.market__where {
    display: block;
    margin-top: -4px;
    padding-bottom: 6px;
    color: var(--text-dim);
  }

  .market__table td.market__where .market__code,
  .market__table td.market__site .market__code {
    display: inline;
    margin-left: 6px;
  }

  /* A count of nothing is a dash on a wide screen and no line at all on a phone: twenty
     cinemas at six labelled lines each is a scroll, and most of those lines would read "—". */
  .market__table td.market__none {
    display: none;
  }

  /* The index bar keeps its track — it is the fastest read on the card. */
  .idx {
    min-width: 0;
    flex: 1;
    max-width: 60%;
  }

  /* The figure and its share stack against the right edge, under the label they answer. */
  .market__stack {
    text-align: right;
  }
}

/* The dock picker: the places this account can keep, and the order they are kept in. */
.dockpick {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

.dockpick__item {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 7px 12px;
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  background: var(--surface-2);
  color: var(--text-dim);
  font: inherit;
  font-size: 0.87rem;
  cursor: pointer;
}

.dockpick__item:disabled {
  opacity: 0.45;
  cursor: default;
}

.dockpick__item--on {
  border-color: color-mix(in srgb, var(--accent) 55%, var(--border-strong));
  background: color-mix(in srgb, var(--accent) 14%, var(--surface-2));
  color: var(--text);
}

/* The number is the order the dock will hold them in, which is the order they were
   chosen — the one piece of state a row of chips cannot show any other way. */
.dockpick__rank {
  display: none;
  min-width: 17px;
  height: 17px;
  border-radius: 999px;
  background: var(--accent);
  color: var(--on-accent, #0b0b10);
  font-size: 0.72rem;
  font-weight: 700;
  line-height: 17px;
  text-align: center;
}

.dockpick__item--on .dockpick__rank {
  display: inline-block;
}

/* ======================================================================
   Strips that scroll sideways say so at their edges.

   The case tabs, the settings rail and the scheduler's day strip scroll rather than wrap on a
   phone, with the scrollbar hidden — and a strip cut clean at the edge looked finished: the
   fifth tab and the fourth shelf were there, and nothing said so. js/util.js (watchOverflow)
   marks which side has more, and the mask fades that edge into the page. A strip that fits
   carries neither class and no fade, which is every desktop.
   ====================================================================== */
.is-more-right {
  -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 36px), transparent);
  mask-image: linear-gradient(to right, #000 calc(100% - 36px), transparent);
}

.is-more-left {
  -webkit-mask-image: linear-gradient(to right, transparent, #000 36px);
  mask-image: linear-gradient(to right, transparent, #000 36px);
}

.is-more-left.is-more-right {
  -webkit-mask-image: linear-gradient(to right, transparent, #000 36px, #000 calc(100% - 36px), transparent);
  mask-image: linear-gradient(to right, transparent, #000 36px, #000 calc(100% - 36px), transparent);
}

/* ------------------------------------------------ the programming page's method, folded */

/*
 * "How this is judged", one line above each list, with the paragraph behind it. The method is
 * the same on every visit and a booker reads it once; after that it was four lines between
 * every heading and its numbers, and ten screens of them on a phone.
 */
.panel__how {
  margin: 0 0 6px;
}

.panel__how-summary {
  list-style: none;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  cursor: pointer;
  user-select: none;
  font-size: 0.8rem;
  font-weight: 600;
  color: var(--text-dim);
}

.panel__how-summary::-webkit-details-marker {
  display: none;
}

.panel__how-summary::before {
  content: '';
  width: 6px;
  height: 6px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(-45deg);
  transition: transform 160ms var(--ease);
}

.panel__how[open] > .panel__how-summary::before {
  transform: rotate(45deg);
}

.panel__how-summary:hover {
  color: var(--text);
}

.panel__how-summary:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
  border-radius: 4px;
}

.panel__how > .panel__note {
  margin-top: 8px;
}

/* --------------------------------------------- the scheduler as a view, on a phone */

/*
 * Pills that scroll with the page. `.showing` takes the touch for the drag; on a cinema the
 * POS owns, and on a phone that has not pressed Edit, there is no drag to take it for — and a
 * pill that swallowed the swipe was the reason the page could only be scrolled from the gaps
 * between rooms. See canEdit in js/showtimes.js.
 */
.grid--view .showing,
.grid--pos .showing {
  touch-action: auto;
  cursor: pointer;
}

/* Both phone-only: the Edit button, and the line that stands in for the cinema and week boxes. */
.sched__edit,
.sched__pick {
  display: none;
}

@media (max-width: 700px) {
  .sched__edit {
    display: inline-flex;
  }

  .sched__edit--on {
    border-color: var(--accent);
    color: var(--accent);
  }

  /*
   * The cinema and the week, folded behind one line. Somebody checking tonight's schedule on
   * their phone is not changing either, and the two boxes were a third of the screen above the
   * thing they came to read. The chevron is the way in; the boxes come back below it.
   */
  .sched__pick {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 10px;
    width: 100%;
    margin: 0 0 12px;
    padding: 12px 14px;
    background: var(--glass);
    border: 1px solid var(--border-strong);
    border-radius: var(--radius);
    color: var(--text);
    font: inherit;
    font-size: 0.92rem;
    font-weight: 600;
    text-align: left;
    cursor: pointer;
  }

  .sched__pick-text {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .sched__pick-chevron {
    flex: none;
    color: var(--text-dim);
    transition: transform 160ms var(--ease);
  }

  .sched__pick[aria-expanded='true'] .sched__pick-chevron {
    transform: rotate(180deg);
  }

  .sched__toolbar:not(.sched__toolbar--open) {
    display: none;
  }

  /*
   * The case monitor's toolbar as one row: "Filters" rather than "Advanced filters", and the
   * two buttons pushed to the right, so Refresh stops sitting alone on a second line.
   */
  .filters__label-long {
    display: none;
  }

  /* What is left reads "filters"; the capital comes back with this. */
  .filters__label {
    text-transform: capitalize;
  }

  .filters--advanced > .filters__summary {
    flex-wrap: wrap;
  }

  .filters--advanced > .filters__summary > #raise {
    margin-left: auto;
  }
}

/* ============================================================ the consent screen

   The connector's one page for a person: drawn by a route rather than being a file in public/,
   because it has to name the assistant that is asking. It borrows nothing from the app shell —
   no top bar, no dock — since it is reached from outside the app and the reader has one
   question to answer. See src/consent.js. */

.consent {
  display: grid;
  place-items: center;
  min-height: 100vh;
  padding: 24px 16px;
  background: var(--bg);
}

.consent__card {
  width: 100%;
  max-width: 30rem;
  display: flex;
  flex-direction: column;
  gap: 18px;
  padding: 28px;
  border: 1px solid var(--border);
  border-radius: 14px;
  background: var(--surface);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 18px 40px -28px rgba(0, 0, 0, 0.5);
}

.consent__title {
  margin: 0;
  font-size: 1.35rem;
  font-weight: 650;
  line-height: 1.3;
  text-wrap: balance;
}

/* The stranger's text, which is the one thing on this page nobody here wrote. Given weight
   rather than colour: it has to be READ, and colour would make it look like a link. */
.consent__client {
  font-weight: 750;
}

.consent__who,
.consent__note {
  margin: 0;
  color: var(--text-dim);
  font-size: 0.92rem;
  line-height: 1.55;
}

.consent__list {
  margin: 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 9px;
  font-size: 0.94rem;
  line-height: 1.5;
}

.consent__list li {
  padding-left: 2px;
}

.consent__yes,
.consent__no {
  font-weight: 650;
}

.consent__yes {
  color: var(--accent);
}

/* The line that makes it safe to say yes, so it is the one that gets the warm colour. */
.consent__no {
  color: var(--p-peach, var(--text));
}

.consent__actions {
  display: flex;
  gap: 10px;
  margin: 2px 0 0;
}

.consent__button {
  flex: 1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 11px 16px;
  border-radius: 9px;
  border: 1px solid transparent;
  font: inherit;
  font-weight: 600;
  text-decoration: none;
  cursor: pointer;
}

.consent__button--go {
  background: var(--accent);
  color: var(--accent-ink);
}

.consent__button--quiet {
  background: transparent;
  border-color: var(--border);
  color: var(--text);
}

.consent__button:disabled {
  opacity: 0.55;
  cursor: default;
}

.consent__error {
  margin: 0;
  font-size: 0.9rem;
  color: var(--p-peach, var(--text));
}

/* ------------------------------------------------------------------ the connector's address */

/*
 * The address an assistant is pointed at, beside the button that copies it.
 *
 * A read-only <input> rather than a <code> block, because the job here is "get this into the
 * clipboard or into a text box somewhere else" and an input is the one element every browser
 * already lets you select, drag and copy out of with no help from us. It wraps on a phone,
 * where the button below the field beats a field too narrow to read.
 */
.connect-how {
  display: grid;
  gap: 0.5rem;
  margin-bottom: 1rem;
}

.connect-how__row {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  align-items: center;
}

.connect-how__row .input {
  flex: 1 1 18rem;
  min-width: 0;
  font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, monospace);
  font-size: 0.85rem;
}

/* ------------------------------------------------------------------ the film rating card */

/*
 * GOLD, and deliberately the gold everybody already knows.
 *
 * A five-star row is one of the few controls a phone needs no instructions for, and that is
 * worth more here than a palette that matches the rest of the app: the guest has seconds and no
 * patience, and a rating in the circuit's own accent colour would be a rating they have to stop
 * and interpret. Its own variable rather than a literal, so a theme that genuinely cannot carry
 * it has somewhere to say so.
 */
.rate,
.verdict {
  --star-gold: #F2B01E;
  --star-warm: #FFD166;
  --star-off: color-mix(in srgb, var(--text) 20%, transparent);
}

/*
 * CENTRED IN WHAT IS LEFT of the screen rather than stacked at the top.
 *
 * The first draft sat under the header with two thirds of a phone empty beneath it, which reads
 * as a page that failed to finish loading. min-height is the viewport less the header, so the
 * card holds the middle on a tall phone and simply flows on a short one.
 */
.rate,
.verdict,
.rate-nope {
  display: grid;
  justify-items: center;
  align-content: center;
  min-height: min(60vh, 32rem);
  padding: 2rem 1.25rem 3rem;
  text-align: center;
}

/* --------------------------------------------------------- the arrival */

/*
 * One movement, once, and it is the whole animation budget for the page.
 *
 * The card rises as it appears and its parts follow a beat apart, so the eye is walked down to
 * the stars rather than dropped in front of a finished screen. Everything is at its resting
 * state by ~600ms — this is a rating card in a corridor, not a title sequence, and the second
 * viewing of anything clever here is worse than the first.
 */
@keyframes rate-rise {
  from { opacity: 0; transform: translateY(0.75rem); }
  to   { opacity: 1; transform: none; }
}

.rate > *,
.verdict > * {
  animation: rate-rise 420ms var(--ease-out, cubic-bezier(0.2, 0.8, 0.3, 1)) backwards;
}
.rate > :nth-child(1), .verdict > :nth-child(1) { animation-delay: 40ms; }
.rate > :nth-child(2), .verdict > :nth-child(2) { animation-delay: 90ms; }
.rate > :nth-child(3), .verdict > :nth-child(3) { animation-delay: 140ms; }
.rate > :nth-child(4), .verdict > :nth-child(4) { animation-delay: 190ms; }
.rate > :nth-child(5), .verdict > :nth-child(5) { animation-delay: 240ms; }
.rate > :nth-child(6) { animation-delay: 290ms; }
.rate > :nth-child(7) { animation-delay: 340ms; }

/* --------------------------------------------------------- the words */

.rate__label {
  margin: 0;
  font-size: 0.78rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-muted, var(--text));
}

/* The subject of the card, set big: the film on /film.html, the cinema on /venue.html. Two
   selectors rather than one shared name, because a class called rate__film on a page rating a
   building is the kind of thing somebody later "corrects" by changing the CSS. */
.rate__film,
.rate__venue {
  margin: 0.25rem 0 0;
  font-size: clamp(1.5rem, 7vw, 2.25rem);
  line-height: 1.1;
  text-wrap: balance;
}

.rate__showtime {
  margin: 0.35rem 0 0;
  font-size: 0.9rem;
  color: var(--text-muted, var(--text));
}

/* The question, given room and weight — it is what the stars are for. */
.rate__ask {
  margin: 2rem 0 0;
  font-size: 1.15rem;
  font-weight: 600;
  text-wrap: balance;
}

/* --------------------------------------------------------- the stars */

.stars {
  display: flex;
  gap: 0.15rem;
  padding: 0.75rem 0 0;
}

/*
 * Big targets. 44px is the smallest a thumb reliably hits, and this is used one-handed, in a
 * corridor, by somebody walking — so the glyph is larger than that and the button larger again.
 */
.star {
  appearance: none;
  background: none;
  border: 0;
  padding: 0.35rem;
  cursor: pointer;
  line-height: 0;
  border-radius: 10px;
  color: var(--star-off);
  transition: color 180ms ease, transform 180ms var(--ease-out, ease);
}

.star__glyph {
  width: 3rem;
  height: 3rem;
  fill: currentColor;
  /* A hairline of the same gold under the outline state, so an unfilled star still reads as a
     star rather than as a grey smudge on a dark theme. */
  stroke: color-mix(in srgb, var(--star-gold) 40%, transparent);
  stroke-width: 0.75;
  transition: filter 220ms ease;
}

.star--on {
  color: var(--star-gold);
}

/*
 * THE CASCADE. Choosing four lights four, and they light left to right a beat apart rather than
 * all at once — which is the difference between a control that responds and one that simply is
 * in a new state. The delay is per-star and set inline-free by nth-child, so it works whichever
 * star was pressed.
 */
.star--on:nth-child(1) { transition-delay: 0ms; }
.star--on:nth-child(2) { transition-delay: 45ms; }
.star--on:nth-child(3) { transition-delay: 90ms; }
.star--on:nth-child(4) { transition-delay: 135ms; }
.star--on:nth-child(5) { transition-delay: 180ms; }

/* The one that was actually pressed gets the pop, so a tap has a target and not just a result. */
@keyframes star-pop {
  0%   { transform: scale(1); }
  45%  { transform: scale(1.28); }
  100% { transform: scale(1); }
}
.star--hit { animation: star-pop 340ms var(--ease-out, cubic-bezier(0.2, 0.8, 0.3, 1)); }
.star--on .star__glyph { filter: drop-shadow(0 0 6px color-mix(in srgb, var(--star-warm) 45%, transparent)); }

.star:active { transform: scale(0.92); }
.star:focus-visible { outline: 2px solid var(--focus, var(--accent)); outline-offset: 3px; }

/* The word for the rating, swapped in rather than replaced, so it reads as an answer. */
@keyframes hint-in {
  from { opacity: 0; transform: translateY(0.25rem); }
  to   { opacity: 1; transform: none; }
}

.rate__hint {
  margin: 0.9rem 0 0;
  min-height: 1.5rem;
  font-size: 1.05rem;
  font-weight: 600;
  color: var(--text-muted, var(--text));
}
.rate__hint--said {
  color: var(--star-gold);
  animation: hint-in 260ms var(--ease-out, ease);
}

.rate__send {
  margin-top: 1.5rem;
  min-width: 11rem;
  transition: opacity 220ms ease, transform 220ms var(--ease-out, ease);
}
.rate__send[disabled] { opacity: 0.45; transform: scale(0.97); }

.rate__quiet {
  margin: 1.25rem 0 0;
  font-size: 0.8rem;
  color: var(--text-muted, var(--text));
  opacity: 0.75;
}

/* --------------------------------------------------------- after the tap */

/* text-wrap: balance because this is a sentence now rather than a word, and a phone breaks it
   over two lines — balanced, those two lines are even; unbalanced, the second is one word. */
.verdict__title { margin: 0; font-size: 1.6rem; line-height: 1.2; text-wrap: balance; }
.verdict__film,
.verdict__venue {
  margin: 0.3rem 0 1.75rem;
  color: var(--text-muted, var(--text));
  letter-spacing: 0.04em;
}

.verdict__pair {
  display: flex;
  gap: 2.75rem;
  align-items: flex-start;
  justify-content: center;
  flex-wrap: wrap;
}

.verdict__side { display: grid; gap: 0.2rem; }

.verdict__cap {
  margin: 0;
  font-size: 0.72rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-muted, var(--text));
}

.verdict__score {
  margin: 0;
  font-size: 1.9rem;
  font-weight: 700;
  color: var(--star-gold);
  font-variant-numeric: tabular-nums;
  line-height: 1.2;
}

.verdict__count { margin: 0; font-size: 0.78rem; color: var(--text-muted, var(--text)); }
.verdict__note { margin: 1.75rem 0 0; font-size: 1rem; text-wrap: balance; }

.rate-nope__text { margin: 0 auto; max-width: 28rem; color: var(--text-muted, var(--text)); }

/*
 * Everything above is decoration on a page whose whole job is one tap, so all of it goes when
 * somebody has asked for less movement — and what is left is the page at its resting state,
 * which is the state every one of these animations ends in anyway.
 */
@media (prefers-reduced-motion: reduce) {
  .rate > *,
  .verdict > *,
  .star,
  .star--hit,
  .rate__hint--said,
  .rate__send {
    animation: none;
    transition: none;
  }
}

/* ------------------------------------------------------------------ the recommend row */

/*
 * Nought to ten, as eleven targets.
 *
 * A slider was the obvious alternative and is worse in every way that matters on a phone held
 * one-handed in a corridor: it needs a drag rather than a tap, it needs aiming, and it leaves
 * the reader unsure which number they landed on. Eleven buttons wrap onto two rows on a narrow
 * screen, each is one tap, and the one chosen is unmistakable.
 */
.nps {
  display: grid;
  justify-items: center;
  gap: 0.6rem;
  width: 100%;
  max-width: 26rem;
  margin-top: 2rem;
}

/* A separating rule when it follows the stars, so the card reads as two questions and not one
   long one. Drawn on the block rather than between them, so a card asking only this has none. */
.asked:not([hidden]) + .nps {
  padding-top: 1.75rem;
  border-top: 1px solid var(--border, rgba(128, 128, 128, 0.25));
}

.rate__ask--second { margin-top: 0; }

/* And when the recommend row is the ONLY question — the visit card — the block's own top margin
   is the whole of the gap. Without this the ask carries both and sits four rems below the name it
   is asking about, which reads as two unrelated halves of a page rather than one question. */
.nps > .rate__ask:first-child { margin-top: 0; }

/*
 * ELEVEN ACROSS, ONE ROW, whatever the screen.
 *
 * They share the width rather than each claiming a minimum: `flex: 1 1 0` with `min-width: 0`
 * makes every button exactly one eleventh of what is there, so the scale reads as a scale — a
 * line from "not at all" to "extremely" — instead of as seven buttons and then four more, which
 * is what wrapping made of it.
 *
 * THE COST, because it is real: eleven across a narrow phone is about 30px each, under the 44px
 * a thumb reliably hits, and on an eleven-point scale a mis-tap can cross the band between a
 * passive and a promoter. Two mitigations rather than a shrug — they stay a full 44px TALL, so
 * only the horizontal axis is tight, and the gap is kept visible so the boundaries are legible
 * rather than a single grey bar to aim into.
 */
.nps__row {
  display: flex;
  flex-wrap: nowrap;
  justify-content: center;
  gap: 0.25rem;
  width: 100%;
}

.nps__n {
  appearance: none;
  flex: 1 1 0;
  min-width: 0;
  min-height: 2.75rem;
  padding: 0 0.1rem;
  border-radius: 8px;
  border: 1px solid var(--border, rgba(128, 128, 128, 0.3));
  background: transparent;
  color: var(--text);
  font: 600 0.95rem/1 var(--font, inherit);
  font-variant-numeric: tabular-nums;
  cursor: pointer;
  transition: background 160ms ease, border-color 160ms ease, color 160ms ease, transform 160ms var(--ease-out, ease);
}

.nps__n:focus-visible { outline: 2px solid var(--focus, var(--accent)); outline-offset: 2px; }
.nps__n:active { transform: scale(0.94); }

/*
 * The chosen one in the same gold as the stars, because they are two answers to one card and
 * two accent colours would read as two unrelated controls.
 */
.nps__n--on {
  background: var(--star-gold, #F2B01E);
  border-color: var(--star-gold, #F2B01E);
  color: #1a1400;
}

@keyframes nps-pop {
  0%   { transform: scale(1); }
  45%  { transform: scale(1.18); }
  100% { transform: scale(1); }
}
.nps__n--hit { animation: nps-pop 300ms var(--ease-out, cubic-bezier(0.2, 0.8, 0.3, 1)); }

/* The anchors, because a bare 0..10 does not say which end is good. */
.nps__ends {
  display: flex;
  justify-content: space-between;
  width: 100%;
  font-size: 0.75rem;
  color: var(--text-muted, var(--text));
  opacity: 0.8;
}

@media (prefers-reduced-motion: reduce) {
  .nps__n,
  .nps__n--hit { animation: none; transition: none; }
}

/* =========================================================================
   GUEST SENTIMENT — what people said on the way out.

   Two cards: the building's own net promoter score, and a table of films.
   The visit sits first because it is the number an owner asks for by name,
   and because it is about what the reader can change this week.
   ========================================================================= */

.sentiment {
  display: grid;
  gap: 16px;
}

.sentiment__h {
  margin: 0 0 10px;
  font-size: 1rem;
  letter-spacing: 0.02em;
}

/* The visit's headline number, set at the size of a finding. */
.sentiment__big {
  margin: 0;
  font-size: clamp(2.4rem, 9vw, 3.4rem);
  font-weight: 700;
  line-height: 1;
  font-variant-numeric: tabular-nums;
  color: var(--text);
}

/* Tone is applied only where a threshold is defensible: +50 is the number the
   industry treats as strong, and below zero means more detractors than
   promoters, which is arithmetic rather than opinion. Everything between is
   left uncoloured rather than given a verdict nobody agreed to. */
.score--good { color: var(--ok, #3fb27f); }
.score--bad { color: var(--danger, #d9534f); }

.sentiment__cap {
  margin: 6px 0 0;
  font-size: 0.78rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-muted, var(--text));
}

.sentiment__count {
  margin: 2px 0 0;
  font-size: 0.82rem;
  color: var(--text-muted, var(--text));
}

/* The bands under the headline: the shape, not just the score. */
.sentiment__bands {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin: 12px 0 0;
}

.band {
  padding: 2px 9px;
  border: 1px solid var(--border, rgba(128, 128, 128, 0.3));
  border-radius: 999px;
  font-size: 0.75rem;
  font-variant-numeric: tabular-nums;
  color: var(--text-muted, var(--text));
}

.band--pro { border-color: color-mix(in srgb, var(--ok, #3fb27f) 55%, transparent); }
.band--det { border-color: color-mix(in srgb, var(--danger, #d9534f) 55%, transparent); }

/* Dashed, because a passive is the one band that is IN the sample and OUT of the score: 7s and
   8s are counted in the denominator and contribute nothing to the numerator. A solid outline
   like its neighbours would say it pulls the number one way or the other, and it does not. */
.band--pas { border-style: dashed; }

/* Why a number is missing, said where the number would have been. */
.sentiment__none {
  margin: 0;
  font-size: 0.9rem;
  color: var(--text-muted, var(--text));
  max-width: 56ch;
}

.sentiment__why { display: block; margin-top: 4px; font-size: 0.82rem; }

/* The standing caveat above the film table — that a score is not a sale. */
.sentiment__lead {
  margin: 0 0 14px;
  font-size: 0.88rem;
  color: var(--text-muted, var(--text));
  max-width: 70ch;
}

.sentiment__lead strong { color: var(--text); font-weight: 600; }

/* Wide content scrolls inside its own box; the page never scrolls sideways. */
.tablewrap { overflow-x: auto; }

.sentiment__table { margin-top: 2px; }
.sentiment__table th[scope='col'] {
  font-size: 0.72rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-muted, var(--text));
  white-space: nowrap;
}

.sentiment__film { font-weight: 600; }

/* A score and the count it rests on, always together: a number without its
   sample is the thing this whole feature exists to avoid printing. */
.score {
  font-size: 1.05rem;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}

.score--stars { color: var(--star-gold, var(--text)); }
.score--none { color: var(--text-muted, var(--text)); font-weight: 400; }

.sentiment__n {
  margin-left: 8px;
  font-size: 0.76rem;
  color: var(--text-muted, var(--text));
  font-variant-numeric: tabular-nums;
}

.sentiment__foot {
  margin: 12px 0 0;
  font-size: 0.8rem;
  color: var(--text-muted, var(--text));
  max-width: 70ch;
}

/* ------------------------------------------------------------------ invoices */

/*
 * THE ENTRY SCREEN, laid out around one fact: somebody is standing there holding a bill.
 *
 * The form is above the list on this page and below it on every other, which is deliberate —
 * see the note at the top of js/invoices.js. Everything here is spent on making the filing
 * quick, because a customer who asked for this feature and then stops using it has not decided
 * invoices are useless, they have decided the form is tiring.
 */
.invoice-add {
  margin-bottom: 18px;
}

/*
 * The document, first and biggest, as a label wrapping a hidden input — so the whole dashed
 * area is one tap target on a phone and one drop target on a desktop, without a second control
 * for the second gesture.
 */
.invoice-drop {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  padding: 26px 18px;
  border: 2px dashed var(--border-strong);
  border-radius: var(--radius-lg);
  background: var(--surface-2, transparent);
  cursor: pointer;
  text-align: center;
  transition: border-color 120ms ease, background 120ms ease;
}

.invoice-drop:hover,
.invoice-drop:focus-within,
.invoice-drop--over {
  border-color: var(--accent);
  background: var(--accent-soft, var(--glass));
}

/* Once a file is chosen the border stops asking for one — solid, and the accent goes to the name. */
.invoice-drop--has {
  border-style: solid;
  border-color: var(--accent);
}

.invoice-drop__icon {
  color: var(--text-dim);
  line-height: 0;
}

.invoice-drop--has .invoice-drop__icon {
  color: var(--accent);
}

.invoice-drop__text {
  font-weight: 600;
  /* A long filename wraps rather than pushing the card sideways on a phone. */
  overflow-wrap: anywhere;
  max-width: 100%;
}

.invoice-drop__hint {
  font-size: 0.82rem;
  color: var(--text-dim);
}

/*
 * Two columns where there is room and one where there is not. auto-fit rather than a media
 * query, so the same rule serves a phone, a split window and a desk monitor — and 15rem is
 * chosen as the width at which a date input stops truncating its own placeholder.
 */
.invoice-form__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
  gap: 0 18px;
  margin-top: 18px;
}

.invoice-form__grid .field {
  margin-bottom: 14px;
}

/* The currency, said once beside the amount rather than repeated down the list. */
.field__unit {
  font-weight: 400;
  color: var(--text-dim);
}

.field__optional {
  font-weight: 400;
  font-size: 0.82rem;
  color: var(--text-dim);
}

.invoice-form__acts {
  display: flex;
  justify-content: flex-end;
}

.invoice-form__note {
  color: var(--danger);
  font-weight: 600;
  margin: 0 0 12px;
}

/*
 * The duplicate answer. Warned rather than refused, and it carries two buttons because only a
 * person can tell a second visit from the same contractor from the same document twice.
 */
.invoice-dup {
  border: 1px solid var(--warn, var(--border-strong));
  border-radius: var(--radius-md, 10px);
  padding: 14px 16px;
  margin-bottom: 14px;
}

.invoice-dup__text {
  margin: 0 0 12px;
}

.invoice-dup__acts {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
  justify-content: flex-end;
}

/* ------------------------------------------------------------- the list side */

.invoice-filters__row {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
  align-items: center;
  margin: 12px 0 4px;
}

.invoice-filters__row .input {
  width: auto;
  flex: 1 1 9rem;
  min-width: 0;
}

/*
 * The downloads sit under the filters they carry rather than beside them, so the row of controls
 * somebody is adjusting stays one row of controls. Pushed right, because they are the end of that
 * sentence: narrow it, then take it away.
 */
.invoice-filters__row--exports {
  justify-content: flex-end;
  margin: 0 0 4px;
}

/*
 * What the model read off the document.
 *
 * Quiet by default and deliberately so: it is a note about how the boxes came to be filled, not
 * a result. The warn state is louder because it is only ever used for something that would
 * otherwise be silently wrong — a currency that is not the one this circuit files in.
 */
.invoice-read {
  grid-column: 1 / -1;
  margin: 0;
  font-size: 0.85rem;
  line-height: 1.5;
  color: var(--text-dim);
}

.invoice-read--busy {
  font-style: italic;
}

.invoice-read--warn {
  color: var(--warn);
  font-weight: 600;
}

/*
 * The total, in the same tabular figures the rows use so the digits line up when somebody
 * scrolls past a column of them.
 */
.invoice-total {
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  margin: 10px 0 14px;
}

.invoice-list {
  display: flex;
  flex-direction: column;
}

.invoice-row {
  display: flex;
  align-items: baseline;
  gap: 14px;
  padding: 11px 0;
  border-top: 1px solid var(--border);
}

.invoice-row__main {
  display: flex;
  flex-direction: column;
  gap: 2px;
  /* Takes the slack so the money and the link stay put down the column. */
  flex: 1 1 auto;
  min-width: 0;
}

.invoice-row__supplier {
  font-weight: 600;
  overflow-wrap: anywhere;
}

.invoice-row__meta {
  font-size: 0.84rem;
  color: var(--text-dim);
}

/* tabular-nums so a column of amounts lines up on the decimal point rather than drifting. */
.invoice-row__money {
  font-variant-numeric: tabular-nums;
  font-weight: 600;
  white-space: nowrap;
}

.invoice-row__open {
  white-space: nowrap;
}

/* A row whose document has gone says so, rather than offering a link that answers 404. */
.invoice-row__gone {
  font-size: 0.84rem;
  color: var(--text-dim);
  white-space: nowrap;
}

.invoice-empty {
  color: var(--text-dim);
  margin: 14px 0 0;
}

/*
 * The line that has to be on the page rather than only in the terms: a backup carries these
 * figures and not these documents, so the originals are the customer's to keep.
 */
.invoice-keep {
  margin: 22px 0 0;
  font-size: 0.84rem;
  color: var(--text-dim);
  max-width: 70ch;
}

@media (max-width: 40rem) {
  /*
   * The location gets its own row and the two dates share the next one. Left to wrap on their
   * own the three controls stranded one date beside Clear, which reads as though the second
   * date belongs to the button.
   */
  .invoice-filters__row select.input {
    flex: 1 1 100%;
  }

  .invoice-filters__row input.input {
    flex: 1 1 8rem;
  }

  /* On a phone the money drops under the supplier rather than squeezing the name to nothing. */
  .invoice-row {
    flex-wrap: wrap;
    gap: 4px 14px;
  }

  .invoice-row__main {
    flex: 1 0 100%;
  }
}

/*
 * ============================================================ THE ROTA
 *
 * Seven day columns that scroll sideways rather than reflowing, and that is the deliberate
 * part. A week is a shape a manager reads across — Friday is heavy BECAUSE Thursday is not —
 * and a grid that stacks to a list on a phone throws the comparison away exactly where it is
 * most wanted, which is standing in a foyer. So the columns keep their width and the strip
 * scrolls, inside its own container so the page body never moves.
 */
.rota__bar {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 12px;
  margin-bottom: 12px;
}

.rota__field {
  margin: 0;
  min-width: 0;
  flex: 1 1 180px;
}

.rota__weeknav {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-inline-end: auto;
  /*
   * A flex item will not shrink below its content by default, and three unbreakable things in
   * a row — two buttons and a date span — held this bar wider than a phone, which pushed the
   * whole PAGE sideways. The grid below is allowed to scroll in its own container; the page
   * body is not.
   */
  min-width: 0;
}

.rota__weekname {
  font-weight: 600;
}

.rota__status:empty {
  display: none;
}

.rota__scroll {
  overflow-x: auto;
  /* Room for the focus ring on the buttons at the edges, which a tight box clips. */
  padding: 3px;
  margin: -3px;
}

.rota__head {
  padding: 8px;
  border-radius: 8px;
  background: var(--p-mantle, var(--surface-2));
  border: 1px solid var(--p-hairline, var(--border));
}

.rota__head p {
  margin: 0;
  line-height: 1.35;
}

.rota__date {
  font-weight: 600;
}

.rota__crowd {
  font-size: 0.78rem;
  font-variant-numeric: tabular-nums;
}

.rota__crowd--figure {
  color: var(--p-text, var(--text));
  font-weight: 600;
}

.rota__crowd--peak,
.rota__crowd--quiet {
  color: var(--text-dim);
}

.rota__crowd--up {
  color: var(--p-green, var(--text-dim));
}

.rota__crowd--down {
  color: var(--p-peach, var(--text-dim));
}

/*
 * THE COVER LINE. Given its own rule and its own weight because it is the sentence the whole
 * strip above it exists to produce — the crowd is only worth the space because this sits
 * under it. A peak with nobody on it is the loudest thing on the page on purpose: it is the
 * one state that is still cheap to fix while the week is a draft.
 */
.rota__cover {
  margin-top: 4px !important;
  font-size: 0.8rem;
  font-weight: 600;
  color: var(--p-text, var(--text));
}

.rota__cover--hole {
  color: var(--p-red, var(--danger));
}

/*
 * NOBODY IN CHARGE, said in the colour that means act on it.
 *
 * Amber and not red, and the distinction is the fix rather than the severity. A hole in cover
 * is red because the shift cannot run; this one CAN run — it is staffed — and what it needs is
 * one specific phone call. Red for both would teach a manager to read past the colour on the
 * days that are merely awkward, which is exactly when they would miss one that is not.
 *
 * Its OWN class rather than a modifier of .rota__cover, which is what it started as. Reusing
 * that class to inherit four lines of type made every `.rota__cover` selector on the page
 * ambiguous — a test asking a column for its cover line got two elements and failed. Sharing
 * a class is sharing an identity, and these are two different findings about two different
 * windows.
 */
.rota__unled {
  margin-top: 4px !important;
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  font-size: 0.8rem;
  font-weight: 600;
  text-align: start;
  cursor: pointer;
  color: var(--p-yellow, var(--accent));
}

/*
 * Claimed. Quiet rather than green, and that is the point of the whole mechanism: a manager
 * covering a night is the ordinary state of a small building, not an achievement to be
 * congratulated on. It reads as settled so that the ones still amber stand out — which they
 * cannot do if every day in the row is shouting something.
 */
.rota__unled--covered {
  color: var(--text-dim);
  font-weight: 500;
}

/*
 * BY HOW MANY HOURS, on the shift that took them past it. Red where the draft flag beside it
 * is grey, because a draft is a proposal and this is a number with a law behind it.
 */
.rota__chipover {
  display: block;
  margin-top: 2px;
  font-size: 0.7rem;
  font-weight: 700;
  color: var(--p-red, var(--danger));
  font-variant-numeric: tabular-nums;
}

/* The person's week against their ceiling, under their name in the row head. */
.rota__whoname {
  display: block;
}

.rota__whohours {
  display: block;
  margin-top: 2px;
  font-size: 0.72rem;
  font-weight: 500;
  color: var(--text-dim);
  font-variant-numeric: tabular-nums;
}

.rota__whohours--over {
  color: var(--p-red, var(--danger));
  font-weight: 700;
}

/*
 * "(15h elsewhere)". Dim and lighter than the total it hangs off, including inside an OVER
 * badge — the red is about the ceiling, and where the hours were worked is context rather
 * than a second alarm.
 */
.rota__whoaway {
  color: var(--text-dim);
  font-weight: 400;
}

.rota__tally {
  font-size: 0.75rem;
  color: var(--text-dim);
}

.rota__flag,
.rota__why {
  font-size: 0.72rem;
  color: var(--text-dim);
}

/* A draft is dashed rather than greyed: greyed reads as disabled, and a draft is the one
   thing on this grid the manager can still freely change. */
.rota__times {
  display: flex;
  gap: 12px;
}

.rota__times .field {
  flex: 1;
}

.rota__despite {
  padding: 8px;
  border-radius: 8px;
  border: 1px solid var(--p-yellow, var(--accent));
}

.rota__refusal {
  margin: 0 0 6px;
  font-size: 0.85rem;
}

.rota__seen {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: 8px;
  margin: 0;
  padding: 6px 0;
  border-bottom: 1px solid var(--p-hairline, var(--border));
}

.rota__seen:last-child {
  border-bottom: 0;
}

.rota__seenwho {
  font-weight: 600;
}

.rota__seenstate {
  font-size: 0.85rem;
  color: var(--text-dim);
}

/* Not-seen is the actionable state, so it is the one that is coloured. Seen is quiet on
   purpose — a page where everything is highlighted highlights nothing. */
.rota__seen--stale .rota__seenstate,
.rota__seen--never .rota__seenstate {
  color: var(--p-peach, var(--text-dim));
}

/* ============================================================ the crew's own page */

.myrota__title {
  margin-bottom: 2px;
}

.myrota__span,
.myrota__keep {
  color: var(--text-dim);
  font-size: 0.85rem;
}

.myrota__shift {
  padding: 10px 0;
  border-bottom: 1px solid var(--p-hairline, var(--border));
}

.myrota__shift p {
  margin: 0;
}

.myrota__day {
  font-weight: 600;
}

.myrota__time {
  font-variant-numeric: tabular-nums;
}

.myrota__note {
  font-size: 0.85rem;
  color: var(--text-dim);
}

.myrota__shift--dropped .myrota__day,
.myrota__shift--dropped .myrota__time {
  text-decoration: line-through;
  color: var(--text-dim);
}

.myrota__flag {
  font-size: 0.8rem;
  font-weight: 600;
  color: var(--p-peach, var(--text-dim));
}

.myrota__shift--dropped .myrota__flag {
  color: var(--p-red, var(--danger));
}

.myrota__gone,
.myrota__empty {
  color: var(--text-dim);
}

.myrota__cal {
  margin-top: 16px;
}

/* Getting back in. The page a spent link lands on, so it carries the weight the shift list
   normally does — a heading, not a footnote. */
.myrota__back {
  margin-top: 20px;
  padding: 14px 16px;
  border: 1px solid var(--p-hairline, var(--border));
  border-radius: 10px;
}

.myrota__backtitle {
  margin: 0 0 8px;
  font-size: 1.05rem;
}

.myrota__backsay {
  margin: 0 0 14px;
  font-size: 0.88rem;
  color: var(--text-dim);
}

.myrota__backform .field {
  margin: 0 0 12px;
}

.myrota__backerr {
  margin: 12px 0 0;
  padding: 8px 10px;
  border-radius: 8px;
  border: 1px solid var(--p-red, var(--danger));
  color: var(--p-red, var(--danger));
  font-size: 0.85rem;
}

/* The home-screen offer. A strip rather than a dialog: it is an offer, and a modal would
   stand between somebody and the shifts they opened the page to read. */
.myrota__install {
  margin-top: 18px;
  padding: 12px 14px;
  border: 1px solid var(--p-hairline, var(--border));
  border-radius: 10px;
  background: var(--p-mantle, var(--surface-2));
}

.myrota__installsay {
  margin: 0 0 10px;
  font-size: 0.88rem;
}

.myrota__installdo {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px;
  margin: 0;
}

/* Whose page this is, and the way off it. Quiet — it is reassurance on your own phone and
   only becomes the important line on a shared one. */
.myrota__whoami {
  margin-top: 18px;
  font-size: 0.85rem;
  color: var(--text-dim);
}

.myrota__calout {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 10px;
  margin-top: 10px;
}

.myrota__calnote {
  margin: 0;
  font-size: 0.85rem;
  color: var(--text-dim);
}

/*
 * THE WEEK AS A MATRIX — people down, days across.
 *
 * The stacked layout this replaces measured 1736px on a desktop and 3391px on a phone for one
 * real week (twenty crew, seventy-nine shifts). It grew with the busiest DAY, which nobody
 * controls. This grows with HEADCOUNT, which an operator does.
 *
 * A real table rather than a grid of divs, because it is a matrix and the semantics are free:
 * a screen reader announcing "Maya, Saturday, 5–1" is the whole page working, and no amount of
 * aria on divs is as reliable as a th with a scope on it.
 */
.rota__table {
  border-collapse: separate;
  border-spacing: 0;
  width: 100%;
  font-variant-numeric: tabular-nums;
}

/*
 * The name column stays put while the week scrolls under it. Without this a manager scrolled
 * to Sunday and lost track of whose row they were reading, which is the one thing this layout
 * exists to make easy.
 */
.rota__who,
.rota__corner {
  position: sticky;
  inset-inline-start: 0;
  z-index: 2;
  min-width: 88px;
  padding: 4px 8px 4px 0;
  background: var(--p-base, var(--bg));
  text-align: start;
  font-weight: 600;
  white-space: nowrap;
}

.rota__corner {
  z-index: 3;
  color: var(--text-dim);
  font-size: 0.78rem;
}

.rota__head {
  /*
   * Wide enough for "10:30am–12:00am", which is the longest a shift gets, and narrow enough
   * that seven of them plus the name column clear a desktop card. Both halves matter: a full
   * time that wraps is no clearer than a short one, and a week whose Sunday is cut off is a
   * week you cannot read in one go.
   */
  min-width: 144px;
  padding: 8px;
  border: 1px solid var(--p-hairline, var(--border));
  border-radius: 8px 8px 0 0;
  background: var(--p-mantle, var(--surface-2));
  text-align: start;
  vertical-align: top;
  font-weight: 400;
}

.rota__cell {
  padding: 2px;
  border-inline-end: 1px solid var(--p-hairline, var(--border));
  border-bottom: 1px solid var(--p-hairline, var(--border));
  vertical-align: top;
}

.rota__cell:first-of-type {
  border-inline-start: 1px solid var(--p-hairline, var(--border));
}

.rota__chip {
  display: flex;
  flex-direction: column;
  gap: 0;
  width: 100%;
  padding: 3px 6px;
  border: 0;
  border-inline-start: 3px solid transparent;
  border-radius: 5px;
  background: var(--p-surface1, var(--surface));
  color: inherit;
  font: inherit;
  font-size: 0.82rem;
  text-align: start;
  cursor: pointer;
}

.rota__chip + .rota__chip {
  margin-top: 2px;
}

/*
 * MORNING AND NIGHT, told apart by the edge rather than by reading the time. "Who is on
 * Saturday night" was eighteen cards to read in the stacked layout; here it is a colour down
 * one column. The boundary is a stated 3pm, not a clever guess from the schedule — a rule a
 * manager can predict beats one that is usually right.
 */
.rota__chip--morning {
  border-inline-start-color: var(--p-teal, var(--accent));
}

.rota__chip--night {
  border-inline-start-color: var(--p-lavender, var(--accent));
}

.rota__chip--draft {
  border-block: 1px dashed var(--p-hairline, var(--border));
  border-inline-end: 1px dashed var(--p-hairline, var(--border));
}

.rota__chip--dropped .rota__chiptime {
  text-decoration: line-through;
}

.rota__chip--despite {
  border-inline-start-color: var(--p-yellow, var(--accent));
}

.rota__chiptime {
  font-weight: 600;
}

.rota__chipnote,
.rota__chipflag {
  font-size: 0.7rem;
  color: var(--text-dim);
}

.rota__chip--dropped .rota__chipflag {
  color: var(--p-red, var(--danger));
}

/*
 * The empty cell IS the add button — the place you would point at is the place you press.
 * Nearly invisible until the row is hovered or the button is focused, because a hundred and
 * forty plus signs would be the loudest thing on a page whose subject is the shifts.
 */
.rota__put {
  width: 100%;
  padding: 3px 6px;
  border: 0;
  border-radius: 5px;
  background: none;
  color: transparent;
  font: inherit;
  font-size: 0.82rem;
  cursor: pointer;
}

.rota__row:hover .rota__put,
.rota__put:focus-visible {
  color: var(--text-dim);
  background: var(--p-mantle, var(--surface-2));
}

.rota__divide th {
  padding: 12px 0 4px;
  color: var(--text-dim);
  font-size: 0.78rem;
  font-weight: 400;
}

.rota__row:hover .rota__who {
  color: var(--p-blue, var(--accent));
}

/*
 * ON TOUCH THERE IS NO HOVER, so the plus that reveals itself on a desktop row would simply
 * never appear on the phone this is most often held on — an invisible button is the same as
 * no button. Coarse pointers get it faintly but always.
 */
@media (hover: none) {
  .rota__put {
    color: var(--text-dim);
    opacity: 0.5;
  }
}

/* ============================================================ the crew list */

.crew__row {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 4px 12px;
  width: 100%;
  padding: 10px 0;
  border: 0;
  border-bottom: 1px solid var(--p-hairline, var(--border));
  background: none;
  color: inherit;
  font: inherit;
  text-align: start;
  cursor: pointer;
}

.crew__name {
  font-weight: 600;
  margin-inline-end: auto;
}

.crew__hours,
.crew__link,
.crew__review {
  font-size: 0.85rem;
  color: var(--text-dim);
}

/*
 * Only ever drawn on the people who can, so it is a mark rather than a column. A rank printed
 * on every row — "Crew" eleven times and "Shift manager" twice — spends the list's ink on the
 * answer nobody scans it for.
 */
/*
 * REGULARLY OVER, on the crew row. Two weights, and the difference is the one that matters:
 * a few weeks over is worth knowing and is grey; an AVERAGE week over the ceiling is the shape
 * an insurer's test actually takes, and is red. Marking both the same would put the ordinary
 * awkward month in the same colour as the classification problem.
 */
.crew__over {
  font-size: 0.78rem;
  color: var(--text-dim);
  font-variant-numeric: tabular-nums;
}

.crew__over--regular {
  color: var(--p-red, var(--danger));
  font-weight: 600;
}

/*
 * The week-by-week list in the dialog. A three-column row so the numbers line up down it.
 *
 * `pastweek` and not `week`, and that is not fussiness: `.crew__week` is already the
 * availability fieldset three inches up this same dialog, so reusing the name would have laid
 * a twelve-row grid over the seven weekday rows. The same mistake as sharing `.rota__cover`
 * earlier today — caught this time by checking what the name already meant before writing it.
 */
.crew__past {
  margin: 14px 0 0;
  padding: 10px 12px;
  border: 1px solid var(--p-hairline, var(--border));
  border-radius: 8px;
}

.crew__pastweek {
  display: grid;
  grid-template-columns: 1fr auto auto;
  gap: 12px;
  margin: 0;
  padding: 3px 0;
  font-size: 0.82rem;
  font-variant-numeric: tabular-nums;
}

.crew__pastwhen {
  color: var(--text-dim);
}

.crew__pastover {
  min-width: 64px;
  text-align: end;
}

.crew__pastweek--over .crew__pasthours,
.crew__pastweek--over .crew__pastover {
  color: var(--p-red, var(--danger));
  font-weight: 600;
}

.crew__runs {
  font-size: 0.78rem;
  font-weight: 600;
  padding: 1px 8px;
  border-radius: 999px;
  border: 1px solid var(--p-hairline, var(--border));
  color: var(--p-blue, var(--accent));
  white-space: nowrap;
}

/*
 * SOMEBODY WITH NO HOURS IS THE LOUD CASE. A record that looks complete and cannot be rostered
 * onto anything is the failure this list exists to make visible — mayWork refuses every day of
 * the week for them, and a manager filling in a week would otherwise just find them missing.
 */
.crew__hours--warn,
.crew__link--warn {
  color: var(--p-peach, var(--text-dim));
}

.crew__row--gone .crew__name {
  color: var(--text-dim);
}

.crew__hours--gone {
  font-style: italic;
}

.crew__review {
  color: var(--p-yellow, var(--accent));
}

.crew__actions {
  margin: 14px 0 0;
}

.crew__day {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  padding: 4px 0;
}

.crew__dayname {
  display: flex;
  align-items: center;
  gap: 6px;
  min-width: 130px;
}

/*
 * The two time boxes sit ON the day's row rather than under it. .input is full width by
 * default, which is right for a form and wrong for seven rows of it — unconstrained, one
 * person's week was three screens long and the dialog could not be read without scrolling
 * past the thing being edited.
 */
.crew__day .input {
  flex: 0 0 auto;
  width: 9rem;
}

/* The past-midnight box on an availability row. Small, because it is the exception. */
.crew__late {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  font-size: 0.75rem;
  color: var(--text-dim);
  white-space: nowrap;
  cursor: pointer;
}

.crew__dash {
  color: var(--text-dim);
}

.crew__week,
.crew__off,
.crew__sites,
.crew__linkbox {
  margin: 14px 0 0;
  padding: 10px 12px;
  border: 1px solid var(--p-hairline, var(--border));
  border-radius: 8px;
}

/* The cinemas somebody works at. Same shape as the time-off list under it — a row per
   thing with the button at the end, and one add control below. */
.crew__siterow {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin: 0 0 6px;
}

.crew__sitename {
  margin-inline-end: auto;
}

.crew__siteadd {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 8px;
}

.crew__siteadd .field {
  margin: 0;
  min-width: 0;
  flex: 1 1 160px;
}

.crew__offrow {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin: 0 0 6px;
}

.crew__offwhy {
  color: var(--text-dim);
  font-size: 0.85rem;
  margin-inline-end: auto;
}

.crew__offadd {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 8px;
}

.crew__offadd .field {
  margin: 0;
  min-width: 0;
  flex: 1 1 130px;
}

.crew__caution {
  padding: 8px 10px;
  border-radius: 8px;
  border: 1px solid var(--p-yellow, var(--accent));
  font-size: 0.85rem;
}

/* The link itself, shown once. Breaks anywhere, because it is long and gets read off a screen. */
.crew__linkout {
  padding: 8px 10px;
  border-radius: 8px;
  background: var(--p-mantle, var(--surface-2));
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.8rem;
  overflow-wrap: anywhere;
}

.crew__linkstate {
  font-size: 0.85rem;
  color: var(--text-dim);
}

.crew__linkactions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

/*
 * WHAT HAPPENED TO THE EMAIL, and the two answers must not look alike.
 *
 * The whole reason that route awaits its send and reports honestly is so a manager cannot
 * walk away believing somebody has their rota. A failure drawn in the same quiet grey as
 * "Emailed to maya@example.com" would hand back the silence the await was spent buying —
 * which is exactly what shipped until the stylesheet guard caught the missing rule.
 */
.crew__linksent {
  margin-top: 8px;
  font-size: 0.85rem;
  color: var(--p-green, var(--success, var(--text-dim)));
}

.crew__linksent--no {
  color: var(--p-red, var(--danger));
  font-weight: 600;
}

/*
 * WHAT NARROWING SOMEBODY'S HOURS BROKE, and it is a report rather than an alarm: the app has
 * changed nothing, and the manager is being told so they can decide.
 */
.crew__stranded {
  margin-top: 12px;
  padding: 8px 10px;
  border-radius: 8px;
  border: 1px solid var(--p-peach, var(--text-dim));
  font-size: 0.85rem;
}

/* The seen card's headline: three counts, only two of which are anybody's job. */
.rota__seencount {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 14px;
  margin: 0 0 10px;
  font-size: 0.9rem;
}

.rota__seenpart--done {
  color: var(--text-dim);
}

.rota__seenpart--stale,
.rota__seenpart--never {
  color: var(--p-peach, var(--text-dim));
  font-weight: 600;
}

.rota__seenall {
  margin: 0;
  color: var(--text-dim);
}

.rota__seenfold > summary {
  cursor: pointer;
  color: var(--text-dim);
  font-size: 0.85rem;
  padding: 4px 0;
}

/* ============================================================ speaking instead of typing

   The mic beside a box that feeds an agent — see js/dictate.js. Absent, not disabled, where the
   browser has no SpeechRecognition, so these rules simply never match on Firefox. */

.dictate {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  flex: none;
}

.dictate__glyph {
  flex: none;
}

/*
 * LISTENING. The accent plus a slow pulse, which is the only state in this app where something is
 * happening because of the person rather than to them — the pulse is the app saying "still here,
 * keep going", and a manager mid-sentence needs that more than they need a label.
 *
 * It is the button's own edge that breathes rather than a halo, for the reason the agent button's
 * glow is inset: a ring painted outside the border box makes the row it sits in read as misaligned.
 */
/*
 * WRITTEN AGAINST `.btn--secondary`, AND ITS HOVER, rather than on the modifier alone.
 *
 * `.btn--secondary:hover:not(:disabled)` is three classes deep and a bare `.dictate--live` is one,
 * so the plain modifier lost the whole lit state to the hover — and the moment it lost it is
 * exactly the moment it is most needed: you press the mic, your pointer is still on it, and the
 * button you just started goes grey under your own cursor. Caught by asking the page what it had
 * actually computed rather than by reading the rule and believing it.
 */
.dictate--live.btn--secondary,
.dictate--live.btn--secondary:hover:not(:disabled) {
  background: color-mix(in oklab, var(--accent) 16%, var(--surface));
  color: var(--accent);
  border-color: color-mix(in oklab, var(--accent) 55%, transparent);
  animation: dictate-live 1.8s ease-in-out infinite;
}

.dictate--live.btn--secondary:hover:not(:disabled) {
  background: color-mix(in oklab, var(--accent) 24%, var(--surface));
}

@keyframes dictate-live {
  0%,
  100% {
    box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent) 20%, transparent);
  }
  50% {
    box-shadow: inset 0 0 10px color-mix(in oklab, var(--accent) 38%, transparent);
  }
}

.dictate__hint {
  font-size: 0.8rem;
  color: var(--text-dim);
  line-height: 1.4;
}

.dictate__hint[hidden] {
  display: none;
}

/*
 * A reader who asked for no motion keeps the lit edge and loses the breathing. The words on the
 * button already say Listening; the pulse was only ever the faster way to read it.
 */
@media (prefers-reduced-motion: reduce) {
  .dictate--live {
    animation: none;
    box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent) 30%, transparent);
  }
}

/* ============================================================ three questions to start with

   The assistant's empty state, turned into an offer — see src/openers.js for which three, and
   why every one of them is gated against what the reading account may actually be told. */

.chat__openers {
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 0 14px 10px;
}

.chat__openers[hidden] {
  display: none;
}

/*
 * A ROW, not a chip. These are whole questions — "Which titles are under-performing this week,
 * and what would you change?" — and a chip that long is a chip that wraps into an unreadable
 * blob. Full width, left-aligned, one per line: the shape of a list somebody reads down.
 */
.chat__opener {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 10px;
  width: 100%;
  padding: 10px 12px;
  border: 1px solid var(--p-hairline, var(--border));
  border-radius: 10px;
  background: var(--surface);
  color: var(--text);
  font: inherit;
  text-align: start;
  line-height: 1.4;
  cursor: pointer;
}

.chat__opener:hover {
  border-color: color-mix(in oklab, var(--accent) 45%, transparent);
  background: color-mix(in oklab, var(--accent) 7%, var(--surface));
}

/*
 * The suggestion and its dismiss sit side by side rather than one inside the other: a button
 * within a button is invalid, and the browsers that tolerate it disagree about which one a tap
 * belongs to. The × is laid over the row's end so it costs no width.
 */
.chat__openerrow {
  position: relative;
  display: flex;
}

.chat__openerdrop {
  position: absolute;
  inset-block: 0;
  inset-inline-end: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 34px;
  border: 0;
  border-radius: 0 10px 10px 0;
  background: transparent;
  color: var(--text-faint);
  font-size: 1rem;
  line-height: 1;
  cursor: pointer;
}

/*
 * Quiet until the row is under the pointer, because it is the second thing anybody wants from a
 * suggestion and a row of × marks reads as a list of things to delete. It stays reachable by
 * keyboard whatever the pointer is doing — :focus-visible is not conditional on the hover.
 */
.chat__openerrow:hover .chat__openerdrop,
.chat__openerdrop:focus-visible {
  color: var(--text);
  background: color-mix(in oklab, var(--accent) 10%, transparent);
}

.chat__openerdrop:hover {
  color: var(--danger);
}

/* The arrow shifts aside so the two never overlap where the × actually sits. */
.chat__openerrow .chat__opener {
  padding-inline-end: 38px;
}

.chat__openertext {
  min-width: 0;
}

/* The mark that says a row goes somewhere. Accent, and only on hover does it come fully up —
   three of these at full strength would read as three buttons competing to be pressed. */
.chat__openergo {
  flex: none;
  color: var(--accent);
  opacity: 0.55;
}

.chat__opener:hover .chat__openergo {
  opacity: 1;
}

/* The assistant's own strip for it, above the composer: the composer is a tight flex line ending
   in Ask, and a sentence in there would push the send button onto a second row mid-press. */
.chat__tools {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 10px;
  padding: 0 14px;
}

.chat__tools:empty {
  display: none;
}

/* ============================================================ the AI staffing agent */

/*
 * The card wears the accent on its edge while the agent is out, which is the same sentence the
 * scheduler's write-up bar says with its running rule: something is happening and nothing is
 * broken. Inset, for the reason the live agent button's glow is inset — a halo outside the
 * border reads as the card's edge and puts this card visibly out of line with the ones above it.
 */
.staffagent--working {
  box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent) 45%, transparent);
}

/*
 * A button that cannot be pressed because it is BUSY is not the same as one that cannot be
 * pressed because there is nothing behind it, and the grey every other disabled control wears
 * says the second thing. The same rule the scheduler's agent button has, for the same reason:
 * while the agent is out, the thing under the reader's finger is the thing that is working.
 */
.staffagent--working .agentrow__btn[disabled] {
  opacity: 1;
  background: color-mix(in oklab, var(--accent) 13%, var(--surface));
  color: var(--accent);
  border-color: color-mix(in oklab, var(--accent) 32%, transparent);
  cursor: progress;
}

/* The hint above is about the whole card; the label below belongs to the box. Without this they
   set solid against each other and the label reads as the hint's last line. */
.staffagent__ask {
  margin-top: 16px;
}

.staffagent__box {
  width: 100%;
  /* A paragraph, not a line. The three-facts-in-one-sentence case is the one this is for, and a
     single-line box invites a single fact. */
  min-height: 88px;
  resize: vertical;
  font: inherit;
  line-height: 1.5;
}

.staffagent__actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 12px;
  margin: 10px 0 0;
}

.staffagent__count {
  font-size: 0.82rem;
  color: var(--text-dim);
  font-variant-numeric: tabular-nums;
}

.staffagent__out {
  margin-top: 16px;
  padding-top: 14px;
  border-top: 1px solid var(--p-hairline, var(--border));
}

/* What the agent understood, in its own words, above the rows it turned that into. */
.staffagent__narrative {
  margin: 0 0 12px;
  line-height: 1.55;
}

.staffagent__rows {
  display: flex;
  flex-direction: column;
}

/*
 * ONE PROPOSED ROW, and the whole label is the hit area.
 *
 * A tick beside a sentence is a control somebody has to aim at; a row that toggles anywhere on
 * it is one a thumb can use. These are read on a phone in an office as often as at a desk.
 */
.staffagent__row {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: 10px 2px;
  border-bottom: 1px solid var(--p-hairline, var(--border));
  cursor: pointer;
}

.staffagent__tick {
  flex: none;
  /* Nudged down onto the first line's baseline rather than floating at the box's top edge. */
  margin-top: 3px;
  width: 18px;
  height: 18px;
  accent-color: var(--accent);
}

.staffagent__what {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}

.staffagent__words {
  line-height: 1.45;
}

/* Their own sentence, kept beside the row it became — the check on whether the agent heard right. */
.staffagent__why {
  font-size: 0.82rem;
  color: var(--text-dim);
}

/*
 * WHAT THIS APP CANNOT HOLD. A panel rather than a line, because it is the half of the answer
 * the manager has to act on themselves: these are the rules that stay on the noticeboard.
 */
.staffagent__cant {
  margin-top: 14px;
  padding: 12px;
  border-radius: 8px;
  background: var(--p-mantle, var(--surface-2));
}

.staffagent__canthead {
  margin: 0 0 8px;
  font-size: 0.9rem;
  font-weight: 600;
}

.staffagent__cantrow {
  display: flex;
  flex-direction: column;
  gap: 2px;
  margin: 0 0 8px;
}

.staffagent__cantrow:last-child {
  margin-bottom: 0;
}

.staffagent__cantsaid {
  font-style: italic;
}

.staffagent__cantwhy {
  font-size: 0.82rem;
  color: var(--text-dim);
}

.staffagent__dropped {
  margin-top: 12px;
}

.staffagent__droprow {
  margin: 0 0 6px;
  font-size: 0.85rem;
  color: var(--text-dim);
}

.staffagent__apply {
  display: flex;
  flex-wrap: wrap;
  gap: 8px 10px;
  margin: 14px 0 0;
}

.staffagent__say {
  margin: 10px 0 0;
  font-size: 0.88rem;
  color: var(--text-dim);
}

/* ============================================================ the shift patterns */

/*
 * WHAT ACTUALLY APPLIES, on a chosen day. Given its own band above the rows because it is the
 * answer, and the rows below it are the workings — six patterns with overlapping scopes are
 * easy to write and hard to hold in your head.
 */
.pattern__try {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 14px;
  margin-bottom: 12px;
  padding: 10px 12px;
  border-radius: 8px;
  background: var(--p-mantle, var(--surface-2));
}

.pattern__try .field {
  margin: 0;
  min-width: 0;
}

.pattern__applies {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px;
  margin: 0;
  font-size: 0.85rem;
}

.pattern__appliesled {
  color: var(--text-dim);
}

.pattern__chip {
  padding: 2px 8px;
  border-radius: 999px;
  border: 1px solid var(--p-hairline, var(--border));
  font-variant-numeric: tabular-nums;
}

.pattern__row {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 4px 12px;
  width: 100%;
  padding: 10px 0;
  border: 0;
  border-bottom: 1px solid var(--p-hairline, var(--border));
  background: none;
  color: inherit;
  font: inherit;
  text-align: start;
  cursor: pointer;
}

.pattern__name {
  font-weight: 600;
  min-width: 72px;
}

.pattern__hours {
  font-variant-numeric: tabular-nums;
}

.pattern__on,
.pattern__when {
  font-size: 0.85rem;
  color: var(--text-dim);
}

.pattern__scope {
  margin-inline-start: auto;
  font-size: 0.78rem;
  color: var(--text-dim);
}

/* A row scoped to one building is the exception to the circuit's rule, so it is the one
   marked — the general case is the quiet one. */
.pattern__scope--mine {
  color: var(--p-blue, var(--accent));
}

.pattern__none {
  color: var(--text-dim);
}

.pattern__days,
.pattern__season {
  margin: 14px 0 0;
  padding: 10px 12px;
  border: 1px solid var(--p-hairline, var(--border));
  border-radius: 8px;
}

.pattern__day {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  margin-inline-end: 12px;
  white-space: nowrap;
}

/* ============================================================ the back-office tablet
 *
 * Sized for a screen on a counter, read at arm's length, tapped with a thumb while holding
 * something else. Everything here is bigger than the rest of the app on purpose — this is the
 * one surface nobody is sitting down at.
 */
.shopfloor {
  margin: 0;
  min-height: 100vh;
  background: var(--bg);
}

.shopfloor__panel {
  max-width: 560px;
  margin: 0 auto;
  padding: 24px 20px 40px;
}

.shopfloor__head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 24px;
}

.shopfloor__place {
  margin: 0;
  font-size: 0.9rem;
  color: var(--text-dim);
}

.shopfloor__title {
  margin: 0 0 18px;
  font-size: 1.5rem;
}

.shopfloor__names {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 12px;
}

/* A name is a target, so it is a tile rather than a row: 56px is a thumb on a busy night. */
.shopfloor__name {
  min-height: 56px;
  padding: 12px 14px;
  border: 1px solid var(--p-hairline, var(--border));
  border-radius: 10px;
  background: var(--p-mantle, var(--surface-2));
  color: var(--text);
  font: inherit;
  font-size: 1rem;
  font-weight: 600;
  cursor: pointer;
}

/* Shown and disabled, never left out. Missing from the list reads as "the app has lost me"
   and produces a phone call; greyed out with a line underneath reads as an instruction. */
.shopfloor__name--nopin {
  opacity: 0.45;
  cursor: not-allowed;
}

.shopfloor__pinsay {
  margin: 0 0 10px;
  color: var(--text-dim);
}

.shopfloor__pinbox {
  /* max-width as well as width: .input sets width:100% and a bare width here loses to it in
     the cascade, which draws a four-character field the width of the tablet. */
  width: 8ch;
  max-width: 8ch;
  font-size: 1.6rem;
  letter-spacing: 0.4em;
  text-align: center;
}

.shopfloor__pad {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 10px;
  margin: 18px 0;
  max-width: 320px;
}

.shopfloor__key {
  min-height: 64px;
  border: 1px solid var(--p-hairline, var(--border));
  border-radius: 10px;
  background: var(--p-mantle, var(--surface-2));
  color: var(--text);
  font: inherit;
  font-size: 1.3rem;
  cursor: pointer;
}

.shopfloor__key--wide {
  font-size: 0.95rem;
}

.shopfloor__err {
  margin: 0 0 14px;
  padding: 10px 12px;
  border-radius: 8px;
  border: 1px solid var(--p-red, var(--danger));
  color: var(--p-red, var(--danger));
}

.shopfloor__insay {
  margin: 0 0 20px;
  color: var(--text-dim);
}

/* The way off, and deliberately the largest control on the screen: the timer is the backstop,
   handing the tablet to the next person is the ordinary case. */
.shopfloor__done {
  min-height: 56px;
  width: 100%;
  margin-top: 24px;
}

.shopfloor__gone {
  padding: 16px;
  border-radius: 10px;
  border: 1px solid var(--p-hairline, var(--border));
  color: var(--text-dim);
}

/* What the crew asked for, and where it stands. */
.myrota__ask,
.myrota__spare {
  margin-top: 22px;
  padding: 14px 16px;
  border: 1px solid var(--p-hairline, var(--border));
  border-radius: 10px;
}

/*
 * ON SHIFT RIGHT NOW — the door to the shift view.
 *
 * The one box on this page that is not a request or a record, so it is the one that reads as
 * live: the accent edge rather than the hairline every other card wears. It is on screen for
 * the length of a shift and gone the rest of the week, which is the whole reason it can afford
 * to be the loudest thing here without becoming wallpaper.
 */
.myrota__onnow {
  margin-top: 22px;
  padding: 14px 16px;
  border: 1px solid color-mix(in oklab, var(--accent) 40%, transparent);
  border-radius: 10px;
  background: color-mix(in oklab, var(--accent) 8%, transparent);
}

.myrota__onnowsay {
  margin: 0 0 10px;
  font-weight: 600;
}

/* A button that is really a link, so it has to centre its own text and drop its underline. */
.myrota__onnowgo {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  min-height: 48px;
  text-decoration: none;
}

.myrota__asktitle {
  margin: 0 0 10px;
  font-size: 1.05rem;
}

.myrota__askrow {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-bottom: 12px;
}

.myrota__req,
.myrota__sparerow {
  padding: 10px 0;
  border-top: 1px solid var(--p-hairline, var(--border));
}

.myrota__reqwhat,
.myrota__spareday {
  margin: 0;
  font-weight: 600;
}

.myrota__reqstate,
.myrota__reqwhy {
  margin: 2px 0 0;
  font-size: 0.85rem;
  color: var(--text-dim);
}

/* A decision reads differently from a wait. Not approved is not an error — somebody said no
   for a reason, and the reason is on the line underneath. */
.myrota__req--approved .myrota__reqstate {
  color: var(--p-green, var(--ok, var(--accent)));
  font-weight: 600;
}

.myrota__req--declined .myrota__reqstate {
  color: var(--p-yellow, var(--accent));
  font-weight: 600;
}

/* "Offer this shift up" sits inside a shift row, so it is quiet: the shift is the thing on
   the page and this is something you might do to it. */
.myrota__offer {
  padding-inline: 0;
  font-size: 0.85rem;
}

/* One ask in the manager's queue. */
.ask {
  padding: 12px 0;
  border-top: 1px solid var(--p-hairline, var(--border));
}

.ask:first-child {
  border-top: 0;
  padding-top: 0;
}

.ask__said {
  margin: 0;
}

.ask__note,
.ask__claim {
  margin: 3px 0 0;
  font-size: 0.85rem;
  color: var(--text-dim);
}

/* An unclaimed giveaway is the manager's cue to go and ask somebody, not to wait: approving
   one is nothing, and the shift is still uncovered on the night. */
.ask__claim--none {
  color: var(--p-yellow, var(--accent));
}

.ask__do {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin: 10px 0 0;
}

/* The reason an approval was refused. On the row and not in a toast: the manager has a
   decision to make and needs the sentence still there while they make it. */
.ask__trouble {
  margin: 10px 0 0;
  padding: 8px 10px;
  border-radius: 8px;
  border: 1px solid var(--p-yellow, var(--accent));
  font-size: 0.85rem;
}

/* A day's proposed hours: the tick, the two times, and the late box. Wraps on a phone,
   because five controls do not fit a 390px row and a squeezed time field is unusable. */
.myrota__hoursrow {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  padding: 8px 0;
  border-top: 1px solid var(--p-hairline, var(--border));
}

.myrota__hoursrow .input[type='time'] {
  width: auto;
  flex: 0 0 auto;
}

/* What the page says back — a refusal, or what a manager will see. On the page rather than
   in window.alert, which a browser can switch off. */
.myrota__say {
  margin-top: 16px;
  padding: 10px 12px;
  border-radius: 8px;
  border: 1px solid var(--p-yellow, var(--accent));
  font-size: 0.88rem;
}

/*
 * The routine, on the manager's side: the checklists, the lines inside them, and who owns
 * which. Two levels on screen because there are two levels in the data — a boxed list with
 * its lines indented under it, rather than one flat run of rows that would make a line and a
 * checklist look like the same kind of thing.
 */
.routine__list {
  padding: 12px 14px;
  margin-bottom: 12px;
  border: 1px solid var(--p-hairline, var(--border));
  border-radius: 10px;
}

.routine__head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 4px 12px;
  padding-bottom: 8px;
  border-bottom: 1px solid var(--p-hairline, var(--border));
}

.routine__name {
  flex: 1 1 10rem;
  font-weight: 700;
}

.routine__lines {
  padding-top: 4px;
}

.routine__line {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 4px 12px;
  padding: 6px 0 6px 12px;
}

.routine__label {
  flex: 1 1 12rem;
}

.routine__when,
.routine__where,
.routine__theirs {
  font-size: 0.85rem;
  color: var(--text-dim);
}

/* The circuit's lists, which this manager may read but not change. Quieter rather than
   hidden: they are part of what the building does every morning. */
.routine__list--theirs {
  border-style: dashed;
}

.routine__list--theirs .routine__name {
  font-weight: 600;
}

.routine__addline {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  padding: 8px 0 0 12px;
}

.routine__addline .input {
  flex: 1 1 10rem;
}

.routine__add {
  display: flex;
  flex-wrap: wrap;
  align-items: end;
  gap: 12px;
  margin-top: 16px;
}

.routine__add .field {
  flex: 1 1 12rem;
  margin: 0;
}

.routine__say {
  margin-top: 12px;
  padding: 10px 12px;
  border-radius: 8px;
  border: 1px solid var(--p-red, var(--danger));
  font-size: 0.88rem;
}

/*
 * THE ROUTINE. Tickboxes sized for a thumb on a greasy screen, like the PIN pad — 52px rows,
 * full width, so tapping the wrong line takes effort rather than being the default.
 */
/*
 * A PANEL. Exactly one is on screen and the switcher owns which — see showPanel in
 * js/floor.js. Siblings on one page rather than three documents, because the session, its
 * half-hour timer and the sign-out that ends it all belong to this page.
 */
.shopfloor__view {
  margin-top: 28px;
}

.shopfloor__group {
  margin-bottom: 18px;
}

/* The first screen: which checklist, and how much is left in it. Sized like the name tiles
   on the PIN screen, because it is the same kind of choice made in the same conditions. */
.shopfloor__listrow {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  width: 100%;
  min-height: 60px;
  padding: 10px 16px;
  margin-bottom: 10px;
  text-align: start;
  font: inherit;
  color: var(--text);
  border: 1px solid var(--p-hairline, var(--border));
  border-radius: 10px;
  background: var(--p-mantle, var(--surface-2));
  cursor: pointer;
}

.shopfloor__listname {
  font-size: 1.05rem;
  font-weight: 650;
}

/* The count is the reason the extra level is affordable, so it is the second-loudest thing
   on the row rather than a footnote. */
.shopfloor__left {
  flex: 0 0 auto;
  font-size: 0.9rem;
  font-weight: 650;
  color: var(--p-yellow, var(--accent));
}

/* Said in words, not as a zero: "All done" reads across a room and "0 to do" does not. */
.shopfloor__left--done {
  color: var(--p-green, var(--success, var(--text-dim)));
}

.shopfloor__listrow--done .shopfloor__listname {
  color: var(--text-dim);
  font-weight: 600;
}

.shopfloor__back {
  margin-top: 16px;
  min-height: 44px;
}

.shopfloor__grouptitle {
  margin: 0 0 8px;
  font-size: 0.78rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-dim);
}

.shopfloor__jobs {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.shopfloor__tick {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
  min-height: 52px;
  padding: 8px 14px;
  text-align: start;
  font: inherit;
  font-size: 1rem;
  color: var(--text);
  border: 1px solid var(--p-hairline, var(--border));
  border-radius: 10px;
  background: var(--p-mantle, var(--surface-2));
  cursor: pointer;
}

.shopfloor__tickbox {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 28px;
  width: 28px;
  height: 28px;
  border: 2px solid var(--p-hairline, var(--border));
  border-radius: 7px;
  font-weight: 700;
}

/* Done stays on the list rather than disappearing: a finished routine should read as finished,
   not as empty. Quieter, ticked, and carrying the name of whoever did it. */
.shopfloor__tick--done {
  color: var(--text-dim);
}

.shopfloor__tick--done .shopfloor__tickbox {
  color: var(--p-green, var(--success, var(--accent)));
  border-color: var(--p-green, var(--success, var(--accent)));
}

.shopfloor__tick[disabled] {
  cursor: default;
}

.shopfloor__tickby {
  color: var(--text-dim);
  font-size: 0.85rem;
}

/* The floor's worklist: what a guest reported, still open, worst first. */
.shopfloor__work {
  margin-top: 28px;
}

.shopfloor__worktitle {
  margin: 0 0 12px;
  font-size: 1.1rem;
}

/*
 * A severity stripe rather than a coloured card. The whole list is things that are wrong, so
 * painting every row red says nothing — the stripe is what makes "this one first" readable
 * from arm's length without reading a word.
 */
.shopfloor__job {
  padding: 12px 14px;
  margin-bottom: 10px;
  border: 1px solid var(--p-hairline, var(--border));
  border-inline-start: 4px solid var(--p-hairline, var(--border));
  border-radius: 10px;
  background: var(--p-mantle, var(--surface-2));
}

/* A room nobody can watch a film in. Loudest stripe, and the widest, because it is the one
   row on this list that is not "when you get a minute". It had no rule at all and wore the
   default hairline — the same edge as a sticky floor. */
.shopfloor__job--emergency {
  border-inline-start-width: 7px;
  border-inline-start-color: var(--p-red, var(--danger));
}

.shopfloor__job--high {
  border-inline-start-color: var(--p-red, var(--danger));
}

.shopfloor__job--medium {
  border-inline-start-color: var(--p-yellow, var(--accent));
}

.shopfloor__jobwhere {
  margin: 0 0 4px;
  font-weight: 650;
}

.shopfloor__jobseat,
.shopfloor__jobago {
  font-weight: 400;
  color: var(--text-dim);
}

.shopfloor__jobsaid {
  margin: 0;
}

.shopfloor__jobfilm,
.shopfloor__jobnotes {
  margin: 4px 0 0;
  font-size: 0.85rem;
  color: var(--text-dim);
}

/*
 * THE FIX CLAIM at the foot of a job.
 *
 * Sized for a thumb on a greasy screen, like the PIN pad above it — 44px minimum on
 * everything tappable, and the sentence box full width rather than sharing a row with its
 * button, because at tablet width a side-by-side pair leaves a field nobody can type a
 * sentence into.
 */
.shopfloor__fixwrap,
.shopfloor__fixed {
  margin-top: 10px;
  padding-top: 10px;
  border-top: 1px solid var(--p-hairline, var(--border));
}

.shopfloor__fix,
.shopfloor__fixgo {
  min-height: 44px;
}

.shopfloor__fixform {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

.shopfloor__fixbox {
  flex: 1 1 220px;
  min-height: 44px;
}

/* Claimed: quieter than the job above it. The work is done and this row is now a note to
   whoever reads the list next, not a thing to act on. */
.shopfloor__fixedsay {
  margin: 0;
  font-weight: 650;
  color: var(--p-green, var(--success, var(--text-dim)));
}

.shopfloor__fixednote {
  margin: 2px 0 0;
  font-size: 0.85rem;
  color: var(--text-dim);
}

.shopfloor__undo {
  margin-top: 4px;
  min-height: 44px;
}

.shopfloor__clear {
  margin-top: 28px;
  color: var(--text-dim);
}

/* A rostered shift. The stripe is green where the guest reports' is red: this is the one
   list on the tablet that is not a list of things that are wrong. */
.shopfloor__shift {
  padding: 12px 14px;
  margin-bottom: 10px;
  border: 1px solid var(--p-hairline, var(--border));
  border-inline-start: 4px solid var(--p-green, var(--success, var(--border)));
  border-radius: 10px;
  background: var(--p-mantle, var(--surface-2));
}

/* Taken off the rota. Still listed, because somebody told in passing needs to see the app
   agree — greyed, with the line underneath saying it in words. */
.shopfloor__shift--off {
  border-inline-start-color: var(--p-hairline, var(--border));
  opacity: 0.6;
}

.shopfloor__shiftday {
  margin: 0 0 4px;
  font-weight: 650;
}

.shopfloor__shifttime {
  margin: 0;
  font-size: 1.05rem;
}

.shopfloor__shiftnote {
  margin: 6px 0 0;
  font-size: 0.85rem;
  color: var(--text-dim);
}

.shopfloor__shiftflag {
  margin: 6px 0 0;
  font-size: 0.85rem;
  font-weight: 600;
  color: var(--p-yellow, var(--accent));
}

/* "The next fortnight", beside the heading rather than under it. */
.shopfloor__span {
  font-size: 0.85rem;
  font-weight: 400;
  color: var(--text-dim);
}

/* Where the things this panel deliberately does not do actually live. */
.shopfloor__minesay {
  margin-top: 20px;
  font-size: 0.85rem;
  color: var(--text-dim);
}

/* The crew's front door at staff.<base>: two boxes, one sentence, nothing else. */
.staff__say {
  margin: 0 0 18px;
  color: var(--text-dim);
}

.staff__other {
  margin-top: 24px;
  font-size: 0.85rem;
  color: var(--text-dim);
}

.staff__otherlink {
  color: inherit;
}

/* ------------------------------------------------------------------ the building's tablets
 *
 * A list, a form, and a code shown once. The list is the part that has to read well in a
 * hurry: the morning after a tablet goes missing, somebody is looking for the row that says
 * which one it was and the button that takes it away.
 */
.tablet__row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 12px;
  padding: 10px 0;
  border-bottom: 1px solid var(--p-hairline, var(--border));
}

.tablet__which {
  flex: 1 1 12rem;
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.tablet__name {
  font-weight: 700;
}

.tablet__seen {
  font-size: 0.85rem;
  color: var(--text-dim);
}

.tablet__add {
  display: flex;
  flex-wrap: wrap;
  align-items: end;
  gap: 12px;
  margin-top: 16px;
}

.tablet__add .field {
  flex: 1 1 12rem;
  margin: 0;
}

/*
 * The link, as a picture and as words. Bordered rather than bare because it is the one thing
 * on this page that stops being true when it is dismissed, and it should not read as part of
 * the list above it.
 */
.tablet__out {
  margin-top: 16px;
  padding: 16px;
  border: 1px solid var(--p-hairline, var(--border));
  border-radius: 12px;
  text-align: center;
}

.tablet__outsay {
  margin-bottom: 12px;
  font-size: 0.9rem;
}

/*
 * Capped well below the code's own 512px so it never fills a phone, and square so a scanner
 * meets the quiet zone the library drew rather than a stretched one. See QR_PIXELS.
 */
.tablet__qr {
  width: min(240px, 70vw);
  height: auto;
  aspect-ratio: 1;
  background: #fff;
  border-radius: 8px;
}

/* The address in full, for a tablet whose camera does not work — which is an ordinary thing
   in a back office. Wrapped, because it is long and must not push the card sideways. */
.tablet__link {
  margin: 12px 0;
  font-family: var(--font-mono, ui-monospace, monospace);
  font-size: 0.8rem;
  word-break: break-all;
  color: var(--text-dim);
}

.tablet__say {
  margin-top: 12px;
  font-size: 0.85rem;
  color: var(--text-dim);
}

/* ------------------------------------------------------- the bar, once the nav grew
 *
 * Eight links fitted; the ninth did not, and what fell off was the ACCOUNT — the avatar
 * dropped to a second row on its own while the links stayed on the first, which reads as the
 * bar being broken rather than as the bar being full. That happens because the row wraps and
 * the account is last in it, so the one control that must always be findable is the first
 * thing width takes away.
 *
 * ## The bar is allowed to be wider than the page
 *
 * It was capped at the same 1180px as the shell, so the wordmark lined up with the left edge
 * of the first card — tidy, and the reason the bar ran out of room on a 1920px screen with
 * 700px of it unused. The alignment was never load-bearing: the bar has its own background and
 * its own border, so it reads as its own surface rather than as the top of the column. Wider,
 * it holds nine links at the padding they were designed with instead of nine links squeezed.
 *
 * The shell is untouched at 1180 — the CONTENT's measure is a reading decision and has nothing
 * to do with how many pages there are.
 *
 * ## And the account still cannot be the thing that drops
 *
 * The row no longer wraps, so the account cannot be pushed off it; the nav becomes the flexible
 * item, so if a deployment ever does run out of width it is the LINKS that wrap onto a second
 * line, inside the nav, where a second row of links looks deliberate. `min-width: 0` is what
 * lets the nav shrink below its content at all — a flex item's default minimum is its content,
 * so without it the nav refuses to narrow and pushes the account off exactly as before.
 *
 * Measured: the nine links want 985px, and at 1400 the nav has 1056 — seventy pixels spare,
 * which is most of one more page. Below 700px the mobile block takes over, where Home rides the
 * wordmark's row and the rest fold behind a button; that arrangement is left alone.
 */
@media (min-width: 701px) {
  .topbar__inner {
    max-width: 1400px;
    flex-wrap: nowrap;
  }

  .nav {
    flex: 1 1 auto;
    min-width: 0;
  }
}

/* ------------------------------------------------------------------- the app's own tooltip
 *
 * The box that replaced the browser's. See tips() in js/util.js for why it exists; this is
 * only what it looks like — the app's surface, the app's border, the app's type, so an
 * explanation reads as part of the page rather than as the operating system interrupting it.
 *
 * Fixed, because it explains things inside tables that scroll sideways in their own boxes.
 * A very high stacking order for the same reason: it sits above the sticky bar, the dock and
 * any open card, all of which it may need to overlap to point at what it is explaining.
 */
.tip {
  position: fixed;
  z-index: 1200;
  max-width: min(320px, calc(100vw - 24px));
  padding: 8px 11px;
  border-radius: 10px;
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text);
  font-size: 0.82rem;
  line-height: 1.45;
  /* The measure is short and the box is narrow, so the words break rather than the layout. */
  overflow-wrap: anywhere;
  box-shadow: 0 10px 28px rgb(0 0 0 / 34%);
  /* It is drawn where the pointer already is, so it must never be the thing under it. */
  pointer-events: none;
}

/* Anything carrying an explanation says so with the cursor, which is the convention the
   badges here already used while their tooltip was the browser's. */
[data-tip] {
  cursor: help;
}

/*
 * Except the ones that are already something you do. A button with a hint is still a button,
 * and a help cursor on it would promise the wrong thing.
 */
button[data-tip],
a[data-tip],
label[data-tip],
summary[data-tip] {
  cursor: revert;
}
