/* Neutral visual identity, on purpose.
   No character, artwork, logo, trade dress or brand palette appears anywhere in
   this portal. That is an IP constraint, not a style preference. The palette
   below is the "Nocturne" design system's own — a violet-leaning blurple accent
   over cool indigo neutrals, chosen because it resembles nothing.

   WHERE THESE VALUES CAME FROM
   Ported 2026-09-05 from `docs/design/design_handoff_cafe_portal_redesign/`
   (`README.md` "Design tokens", and `_ds/nocturne-…/styles.css`, which is the
   source of truth for the numbers). Nocturne is DARK-FIRST; this file is
   LIGHT-first by the contract below, so the ramps are MIRRORED here — step 100
   is the darkest ink on `:root` and the lightest tint in the dark blocks — and
   the handoff's own "Light (proposed)" set is what `:root` carries.

   THE ONE DELIBERATE DEVIATION FROM THE HANDOFF: THE FONT.
   Nocturne specifies Inter and pulls it from a font CDN. This tier ships no
   external asset (see the rule at the foot of this comment), so the port keeps
   SYSTEM FONTS and takes the colours, the radii, the shadows and the spacing
   and type scales instead. Self-hosting Inter under `web/static` is a separate
   change with its own licence question and is not made here. Decided in
   `docs/superpowers/plans/2026-09-05-web-redesign.md` §2.2.

   TWO VALUES THE HANDOFF PROPOSED WERE MEASURED AND MOVED. It asked for the
   light set to be contrast-checked before shipping, and the accent-on-its-own-
   tint pair failed in BOTH themes. See the note above `--accent` below.

   THEMING CONTRACT
   ----------------
   Every colour in this file is a custom property, and every one of them is
   given its value on bare `:root` — the LIGHT palette is the complete palette.
   The dark blocks below REDEFINE tokens; they never introduce one. A colour
   whose only definition lived in a dark-only block would render as nothing at
   all for a light or system-light viewer, so that is forbidden here and pinned
   by `tests/test_web_theme.py::test_no_colour_is_defined_only_in_a_dark_block`.

   Three states, one attribute. `<html data-theme>` is written by the SERVER
   from the `portal_theme` cookie and is one of `light`, `dark`, `system`:

     data-theme="light"   → the :root palette, always.
     data-theme="dark"    → the dark palette, always.
     data-theme="system"  → the :root palette, swapped by prefers-color-scheme.
                            This is the default when no cookie has been set.

   The two dark blocks carry identical declarations on purpose (plain CSS has no
   way to share them) and a test asserts they have not drifted apart.

   No @import, no @font-face, no external host. This tier ships no external
   asset and must not start: system fonts only. */

:root {
  /* --- the two tonal ramps ------------------------------------------------
     Nocturne generates both in OKLCH on one shared lightness scale, so the
     same step of either role matches the other in visual value. They are
     MIRRORED for this file: 100 is the darkest step on a light ground and the
     dark blocks flip them back to the way Nocturne wrote them. Nothing in the
     stylesheet paints with a ramp step directly — the semantic tokens below
     are what the templates name, and the ramp is where their values come
     from, so a later screen can reach for an in-between step without
     inventing a colour. */
  --neutral-100: #292b31;
  --neutral-200: #3f424d;
  --neutral-300: #595d6c;
  --neutral-400: #75798c;
  --neutral-500: #9397ab;
  --neutral-600: #b2b6ca;
  --neutral-700: #cfd3e5;
  --neutral-800: #e4e7f5;
  --neutral-900: #eceef7;

  --accent-100: #2b2741;
  --accent-200: #423a6a;
  --accent-300: #5d5294;
  --accent-400: #5a4db3;
  --accent-500: #968ae0;
  --accent-600: #b5abfc;
  --accent-700: #d2cefd;
  --accent-800: #e7e5fe;
  --accent-900: #f5f4ff;

  /* --- ink ------------------------------------------------------------- */
  --ink: #1c1d2a;
  --ink-soft: #3f424d;
  --ink-faint: #595d6c;

  /* --- surfaces -------------------------------------------------------- */
  --page: #f5f5f9;
  --card: #ffffff;
  --sunken: #eceef7;
  --input-bg: #ffffff;
  --btn-bg: #ffffff;
  --btn-hover: #eceef7;
  --skip-bg: #ffffff;

  /* --- lines ------------------------------------------------------------
     Nocturne's divider is `color-mix(in srgb, <text> 14%, transparent)`. It is
     written here as the solid the mix resolves to over each ground, because a
     translucent divider over a tinted pill or a striped calendar square is a
     different colour in every place it is used, and these two tokens are read
     by tests that expect a value they can measure. */
  --line: #dcdfea;
  --field-line: #75798c;

  /* --- accent -------------------------------------------------------------
     MEASURED AND MOVED. The handoff proposes `#6a5cc4` as the light accent and
     `#9184d9` as the dark one, over an `accent-800` tint. Both fail
     `--accent` on `--accent-soft` at WCAG AA: 4.32:1 light and 4.43:1 dark
     against the 4.5 this portal's `current nav on its tint` pair requires.
     Neither is a hue problem, so the fix is one step along Nocturne's own
     ramp rather than a colour of our own invention: the light accent takes
     `accent-400` (#5a4db3, 5.41:1 on the tint, 6.67:1 on a card) and the dark
     accent takes what `accent-400` mirrors to (#b5abfc, 4.99:1 on the tint,
     7.38:1 on a card). Nothing else in the proposed set had to move. */
  --accent: #5a4db3;
  --accent-strong: #423a6a;
  --accent-soft: #e7e5fe;
  --accent-line: #796cbf;
  --on-accent: #ffffff;

  /* --- semantic tones ---------------------------------------------------
     Nocturne has no good/warn/bad role — it is a product system, not a status
     system — so these keep the values they had. They already meet AA on the
     new grounds and re-hueing them to sit closer to the blurple would trade a
     measured pass for a taste. The DARK ones did have to be retuned: they were
     mixed against a near-black page and Nocturne's dark ground is two stops
     lighter, so a tint darker than the card it sits on read as a hole. */
  --good: #176440;
  --good-soft: #e3f2ea;
  --good-line: #3f8d67;
  --warn: #6f4a0d;
  --warn-soft: #fbf1dd;
  --warn-line: #9a7326;
  --bad: #8c2f1c;
  --bad-soft: #fbe9e5;
  --bad-line: #b25a45;

  /* --- calendar: observed and sold out --------------------------------- */
  --full-bg: #e4e7f5;
  --full-line: #75798c;
  --full-ink: #595d6c;

  /* --- section grounds ----------------------------------------------------
     Nocturne's saturated deep-indigo divider ground, its bloom and its ghost
     tint. Deck-scale FILLS only — never interface colour, never behind body
     text — so they are the same in both themes: a band of this ground is dark
     in a light page on purpose. Nothing paints with them yet; the public home
     and the shared chrome are where they land. */
  --section: #262a60;
  --section-glow: #353b80;
  --section-ghost: #4c5397;

  /* --- depth --------------------------------------------------------------
     Two families. `--shadow` / `--shadow-lift` are what this portal's cards,
     buttons and popovers already use, retuned to the new grounds and kept
     RINGLESS because every one of those components draws its own 1px border —
     a ring shadow under a border is a two-pixel edge. `--shadow-sm/md/lg` are
     Nocturne's own elevation set, ring included, for the borderless surfaces
     the later phases introduce. */
  --shadow: 0 1px 2px rgba(20, 22, 40, .05), 0 10px 26px -18px rgba(20, 22, 40, .22);
  --shadow-lift: 0 1px 3px rgba(20, 22, 40, .07), 0 18px 40px -24px rgba(20, 22, 40, .32);
  --shadow-sm: 0 0 0 1px #dcdfea;
  --shadow-md: 0 0 0 1px #dcdfea, 0 6px 18px rgba(20, 22, 40, .10);
  --shadow-lg: 0 0 0 1px #cfd3e5, 0 16px 40px rgba(20, 22, 40, .16);
  --inset-top: none;
  --edge: linear-gradient(90deg, transparent, var(--accent-line), transparent);

  /* --- non-colour ---------------------------------------------------------
     Nocturne's radii (4 / 8 / 14), its 0.7×-density spacing scale and its type
     scale. `--radius` keeps its name and its 8px because every existing rule
     names it; the other two steps are new. The spacing and type steps are
     declared here as the scale this tier now measures in — the screens are
     restyled to them phase by phase, not in this one. */
  --radius-sm: 4px;
  --radius: 8px;
  --radius-lg: 14px;

  --space-1: 2.8px;
  --space-2: 5.6px;
  --space-3: 8.4px;
  --space-4: 11.2px;
  --space-6: 16.8px;
  --space-8: 22.4px;

  /* The calendar square's edge. One number, because the square and the blank
     that pads a week out to seven must always be the same height — two numbers
     is how a grid ends up with a ragged last row. The size variants override
     this on `.cal`, never on `.day`. */
  --day-size: 64px;

  --text-h1: 30px;
  --text-h2: 22px;
  --text-body: 15px;
  --text-ui: 14px;
  --text-small: 13px;
  --text-caption: 11px;
  --text-kicker: 10px;

  --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  --tick: 140ms;

  color-scheme: light;
}

/* Follow the operating system when the viewer has not chosen — the default.
   Guarded so that an explicit `light` choice still wins on a dark desktop. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --neutral-100: #f3f5fe;
    --neutral-200: #e4e7f5;
    --neutral-300: #cfd3e5;
    --neutral-400: #b2b6ca;
    --neutral-500: #9397ab;
    --neutral-600: #75798c;
    --neutral-700: #595d6c;
    --neutral-800: #3f424d;
    --neutral-900: #292b31;
    --accent-100: #f5f4ff;
    --accent-200: #e7e5fe;
    --accent-300: #d2cefd;
    --accent-400: #b5abfc;
    --accent-500: #968ae0;
    --accent-600: #796cbf;
    --accent-700: #5d5294;
    --accent-800: #423a6a;
    --accent-900: #2b2741;
    --ink: #e9e9ed;
    --ink-soft: #b2b6ca;
    --ink-faint: #9397ab;
    --page: #161826;
    --card: #232532;
    --sunken: #1c1e2c;
    --input-bg: #1b1d2a;
    --btn-bg: #292b31;
    --btn-hover: #3f424d;
    --skip-bg: #292b31;
    --line: #3f424d;
    --field-line: #75798c;
    --accent: #b5abfc;
    --accent-strong: #d2cefd;
    --accent-soft: #423a6a;
    --accent-line: #968ae0;
    --on-accent: #161826;
    --good: #63d6a0;
    --good-soft: #24453a;
    --good-line: #57a883;
    --warn: #e5b566;
    --warn-soft: #453a24;
    --warn-line: #a88748;
    --bad: #ff9384;
    --bad-soft: #4a2f2c;
    --bad-line: #bf7066;
    --full-bg: #191b26;
    --full-line: #75798c;
    --full-ink: #b2b6ca;
    --shadow: 0 1px 0 rgba(255, 255, 255, .03) inset, 0 10px 28px -20px rgba(0, 0, 0, .95);
    --shadow-lift: 0 1px 0 rgba(255, 255, 255, .05) inset, 0 20px 44px -26px rgba(0, 0, 0, 1);
    --shadow-sm: 0 0 0 1px #3f424d;
    --shadow-md: 0 0 0 1px #595d6c, 0 6px 18px rgba(0, 0, 0, .55);
    --shadow-lg: 0 0 0 1px #9397ab, 0 16px 40px rgba(0, 0, 0, .65);
    --inset-top: inset 0 1px 0 rgba(255, 255, 255, .04);
    color-scheme: dark;
  }
}

/* The explicit choice. Identical declarations to the block above — the only
   difference is what triggers them. */
:root[data-theme="dark"] {
  --neutral-100: #f3f5fe;
  --neutral-200: #e4e7f5;
  --neutral-300: #cfd3e5;
  --neutral-400: #b2b6ca;
  --neutral-500: #9397ab;
  --neutral-600: #75798c;
  --neutral-700: #595d6c;
  --neutral-800: #3f424d;
  --neutral-900: #292b31;
  --accent-100: #f5f4ff;
  --accent-200: #e7e5fe;
  --accent-300: #d2cefd;
  --accent-400: #b5abfc;
  --accent-500: #968ae0;
  --accent-600: #796cbf;
  --accent-700: #5d5294;
  --accent-800: #423a6a;
  --accent-900: #2b2741;
  --ink: #e9e9ed;
  --ink-soft: #b2b6ca;
  --ink-faint: #9397ab;
  --page: #161826;
  --card: #232532;
  --sunken: #1c1e2c;
  --input-bg: #1b1d2a;
  --btn-bg: #292b31;
  --btn-hover: #3f424d;
  --skip-bg: #292b31;
  --line: #3f424d;
  --field-line: #75798c;
  --accent: #b5abfc;
  --accent-strong: #d2cefd;
  --accent-soft: #423a6a;
  --accent-line: #968ae0;
  --on-accent: #161826;
  --good: #63d6a0;
  --good-soft: #24453a;
  --good-line: #57a883;
  --warn: #e5b566;
  --warn-soft: #453a24;
  --warn-line: #a88748;
  --bad: #ff9384;
  --bad-soft: #4a2f2c;
  --bad-line: #bf7066;
  --full-bg: #191b26;
  --full-line: #75798c;
  --full-ink: #b2b6ca;
  --shadow: 0 1px 0 rgba(255, 255, 255, .03) inset, 0 10px 28px -20px rgba(0, 0, 0, .95);
  --shadow-lift: 0 1px 0 rgba(255, 255, 255, .05) inset, 0 20px 44px -26px rgba(0, 0, 0, 1);
  --shadow-sm: 0 0 0 1px #3f424d;
  --shadow-md: 0 0 0 1px #595d6c, 0 6px 18px rgba(0, 0, 0, .55);
  --shadow-lg: 0 0 0 1px #9397ab, 0 16px 40px rgba(0, 0, 0, .65);
  --inset-top: inset 0 1px 0 rgba(255, 255, 255, .04);
  color-scheme: dark;
}

:root[data-theme="system"] { color-scheme: light dark; }

* { box-sizing: border-box; }

body {
  margin: 0;
  font: 16px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
  color: var(--ink);
  background: var(--page);
  -webkit-font-smoothing: antialiased;
}

.skip { position: absolute; left: -9999px; }
.skip:focus {
  left: 8px; top: 8px; background: var(--skip-bg); color: var(--ink);
  padding: 8px; z-index: 10; border-radius: 6px; box-shadow: var(--shadow-lift);
}

/* --- rules ---------------------------------------------------------------
   Nocturne's signature: a freestanding rule fades to transparent at both ends
   instead of stopping dead, over 48px an end. It is the divider token painted
   as a gradient, so it costs no new colour and both palettes drive it. Only
   freestanding rules fade — a box outline, a separator inside a control and a
   short accent mark all stay solid, which is why this is its own class and not
   a restyle of every `border-top` in the file. */
.hr {
  height: 1px; border: 0; margin: var(--space-4) 0;
  background: linear-gradient(to right,
    transparent, var(--line) 48px,
    var(--line) calc(100% - 48px), transparent);
}

/* Motion is decoration here: a 140ms tint change on hover and focus, nothing
   more. Turned off entirely when the viewer has asked for less. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
    scroll-behavior: auto !important;
  }
}

/* --- chrome ------------------------------------------------------------ */
.masthead {
  background: var(--card);
  border-bottom: 1px solid var(--line);
  box-shadow: var(--inset-top);
  position: relative;
}
/* One hairline of accent under the masthead. Static, 1px, no animation. */
.masthead::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 1px;
  background: var(--edge); opacity: .9;
}
/* 60px tall, and the height is the point: the header is a place-marker, not a
   surface. `min-height` rather than `height` so a narrow screen may wrap the
   row without clipping it. */
.masthead__inner {
  max-width: 1100px; margin: 0 auto; padding: 12px 36px; min-height: 60px;
  display: flex; gap: 22px; align-items: center; flex-wrap: wrap;
}

/* --- the brand mark ------------------------------------------------------
   ONE mark, drawn in CSS, on every surface. It used to be two different
   strings — "Booking portal" in the portal and "pokecafe.shop" on the public
   pages — and no mark at all, so the two halves of one site did not look like
   one site.

   It is a ring and a dot and nothing else. No image, no SVG, no artwork: this
   tier ships no external asset AND no character, logo or trade dress, and both
   of those are constraints rather than preferences (see the head of this
   file). A border and a pseudo-element are the whole drawing, so there is no
   file that could later be swapped for something with a rights-holder. */
.brand { display: flex; align-items: center; gap: 10px; margin-right: auto; }
.brand__mark {
  width: 22px; height: 22px; border-radius: 6px; flex: none;
  border: 1.5px solid var(--accent);
  display: inline-flex; align-items: center; justify-content: center;
}
.brand__mark::after {
  content: ""; width: 8px; height: 8px; border-radius: 999px;
  background: var(--accent);
}
/* 500, never bolder: Nocturne's headings top out at medium and the wordmark is
   the largest thing in the header, so 700 here would shout over every page
   title beneath it. */
.brand__word { font-size: 16px; font-weight: 500; letter-spacing: -.01em; color: var(--ink); }

/* A quiet label, not a badge. It says which surface you are on; it is not a
   status and must not read like one, so it takes the neutral tint and never an
   accent or a tone colour. */
.tag {
  display: inline-block; font-size: 11px; line-height: 1.45; font-weight: 600;
  padding: 1px 7px; border-radius: 999px; border: 1px solid var(--line);
}
.tag--neutral { background: var(--sunken); color: var(--ink-faint); }

/* Nav links are TEXT. The pill-with-a-tint treatment moved to the admin rail,
   where a vertical list needs a filled row to show which item is current; in a
   horizontal header the accent alone is enough, and four filled pills read as
   four buttons rather than as a place in a site. Colour is not the only cue —
   `aria-current="page"` is on the active item and is what a screen reader
   announces. */
.nav { display: flex; gap: 2px; flex-wrap: wrap; }
.nav a {
  padding: 6px 8px; border-radius: 6px; text-decoration: none; color: var(--ink);
  font-size: 14px; transition: background-color var(--tick), color var(--tick);
}
.nav a:hover { background: var(--btn-hover); color: var(--accent); }
.nav a[aria-current] { color: var(--accent); font-weight: 500; }

.signout { display: flex; align-items: center; gap: 10px; }
.who { font-size: 13px; color: var(--ink-faint); }

/* --- the theme selector -------------------------------------------------
   Three submit buttons in one ordinary form. No script anywhere in the path:
   the server reads the cookie and writes `data-theme` before the first byte
   leaves, so there is no flash, and a viewer with JavaScript disabled has
   exactly the same control everyone else has. */
.theme { display: flex; align-items: center; gap: 8px; margin: 0; }

/* THE LABEL IS READ, NOT SEEN. The design review's finding 10: the selector
   took about 180px of every header — a word, then three pills — and a control
   that wide reads as one of the page's primary actions. It is not; it is a
   preference somebody sets once. So the word "Theme" is now announced and not
   drawn, and the group keeps `role="group"` with `aria-labelledby` pointing at
   it, which is what a screen reader needs to hear before the first button.
   Nothing about the mechanism changed. */
.theme__label {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* --- the segmented control ----------------------------------------------
   Nocturne's `.seg`: one bordered strip, hairline separators, no gaps and no
   pills. Written as a general component because the request screens want the
   same control for venue and party size; the theme form is simply its first
   user, and it stays THREE SUBMIT BUTTONS IN ONE PLAIN FORM — no script, no
   client state, `aria-pressed` for which one is in force. */
.seg {
  display: inline-flex; margin: 0; padding: 0; overflow: hidden;
  border: 1px solid var(--line); border-radius: var(--radius-sm);
  background: var(--sunken);
}
.seg-opt {
  font: inherit; font-size: 12px; font-weight: 500; line-height: 1;
  padding: 5px 10px; border: 0; border-radius: 0; background: transparent;
  color: var(--ink-soft); cursor: pointer; white-space: nowrap;
  transition: background-color var(--tick), color var(--tick);
}
.seg-opt + .seg-opt { border-left: 1px solid var(--line); }
.seg-opt:hover { color: var(--ink); background: var(--btn-hover); }
/* The chosen one. Colour is not the only cue: it also lifts off the strip's
   sunken ground onto the card colour, which survives greyscale. */
.seg-opt[aria-pressed="true"] { background: var(--card); color: var(--accent); font-weight: 600; }

.wrap { max-width: 1100px; margin: 0 auto; padding: 24px 20px 48px; }
.foot { max-width: 1100px; margin: 0 auto; padding: 0 20px 40px; color: var(--ink-faint); font-size: 13px; }

/* --- the public footer ---------------------------------------------------
   Two columns: what this site is not, and where the machine-readable and legal
   pages are. The disclaimer is the widest thing on it on purpose — it is the
   one piece of text a reader is owed before they believe anything above it —
   and it is RENDERED from `public_content.DISCLAIMER` rather than retyped, so
   there is exactly one copy of that sentence in the repository. */
.foot--public {
  padding: 28px 36px 36px; border-top: 1px solid var(--line);
  display: grid; grid-template-columns: 2fr 1fr; gap: 24px 36px;
  font-size: 12px; color: var(--ink-faint);
}
.foot--public p { margin: 0 0 6px; max-width: 78ch; }
.foot--public p:last-child { margin-bottom: 0; }
.foot__links { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
.foot__links a { color: var(--ink-faint); text-decoration: none; }
.foot__links a:hover { color: var(--accent); text-decoration: underline; }

/* --- the admin left rail -------------------------------------------------
   REPLACES A FLAT ROW OF TEN PILLS (design review, finding 6). Ten equally
   weighted controls in one line is a list with no shape: an operator looking
   for the thing that needs doing had to read all ten every time. The rail
   groups them by WHAT THEY ARE FOR — a worklist you act on, records you look
   things up in, and the machinery — so the eye lands in the right third first.

   THE LAYOUT IS DONE HERE AND NOT IN THE TEMPLATES on purpose. `_nav.html` is
   an include at the top of every admin page's content block, so it has no way
   to wrap what follows it. `:has()` lets the stylesheet notice a rail is
   present and turn `<main>` into two columns without touching sixteen
   templates. Where `:has()` is unsupported the rail simply renders as a
   grouped block above the page — every item still reachable, still labelled,
   still marked `aria-current`. */
main.wrap:has(> .rail) {
  display: grid; grid-template-columns: 220px minmax(0, 1fr);
  column-gap: 28px; row-gap: 0; align-items: start;
  max-width: 1400px; padding-left: 0;
}
/* Everything that is not the rail is the page, and the page is column two.
   `:not(.rail)` is load-bearing and is NOT belt-and-braces. `:has()` takes the
   specificity of its argument, so `main.wrap:has(> .rail) > *` weighs 0-2-1
   while `.rail`'s own `grid-column: 1` weighs 0-1-0 — the rail lost to a rule
   meant for its siblings, landed in column two, and stacked ABOVE the page
   with a 220px gutter empty beside it. Seen in a browser on 2026-09-05; no
   test in this tier renders CSS, so nothing else could have caught it. */
main.wrap:has(> .rail) > *:not(.rail) { grid-column: 2; min-width: 0; }
/* The header widens with the page beneath it, so the wordmark stays over the
   rail rather than floating in from the left. */
body:has(main.wrap > .rail) .masthead__inner { max-width: 1400px; }

.rail {
  grid-column: 1; align-self: stretch;
  /* Spanning far more rows than any admin page has, because the divider is
     what separates the rail from the page and it has to run the whole height.
     A grid with no explicit rows has no `-1` line to reach for; the empty
     implicit tracks this creates are zero-height and `row-gap` is 0, so they
     add nothing. */
  grid-row: 1 / span 100;
  border-right: 1px solid var(--line); padding: 18px 14px;
}
.rail__inner { position: sticky; top: 16px; display: grid; gap: 18px; }
.rail__label {
  margin: 0 0 6px; font-size: var(--text-kicker); font-weight: 700;
  text-transform: uppercase; letter-spacing: .1em; color: var(--ink-faint);
}
.rail__items { list-style: none; margin: 0; padding: 0; display: grid; gap: 1px; }
.rail__items a {
  display: block; padding: 7px 10px; border-radius: 6px; font-size: 13px;
  text-decoration: none; color: var(--ink);
  transition: background-color var(--tick), color var(--tick);
}
.rail__items a:hover { background: var(--btn-hover); color: var(--accent); }
/* WHERE YOU ARE. Accent text on the accent tint — the one pair
   `tests/test_web_theme.py` measures as "current nav on its tint", so the
   contrast is computed rather than eyeballed and holds in both palettes. The
   fill is not the only cue: `aria-current="page"` carries it for anyone who
   cannot see the tint. */
.rail__items a[aria-current] {
  color: var(--accent); background: var(--accent-soft); font-weight: 600;
}
.rail__foot { border-top: 1px solid var(--line); padding-top: 14px; }

/* Narrow screens have no room for a 220px column, so the rail becomes what it
   is without the grid: a grouped block above the page. */
@media (max-width: 900px) {
  main.wrap:has(> .rail) {
    display: block; max-width: 1100px; padding-left: 20px;
  }
  body:has(main.wrap > .rail) .masthead__inner { max-width: 1100px; }
  .rail {
    border-right: 0; border-bottom: 1px solid var(--line);
    padding: 0 0 16px; margin-bottom: 20px;
  }
  .rail__inner { position: static; }
}

h1 { font-size: 24px; margin: 0 0 6px; letter-spacing: -.02em; }
h2 { font-size: 17px; margin: 0 0 10px; letter-spacing: -.01em; }
.lede { color: var(--ink-soft); margin: 0 0 20px; max-width: 70ch; }
.muted { color: var(--ink-faint); }
.small { font-size: 13px; }
.mono { font-family: var(--mono); font-size: 13px; font-variant-numeric: tabular-nums; }
.right { text-align: right; }
a { color: var(--accent); }

/* --- cards & tables ---------------------------------------------------- */
.card {
  background: var(--card); border: 1px solid var(--line); border-radius: var(--radius);
  padding: 18px; margin: 0 0 20px; box-shadow: var(--shadow);
}
.card--centred { max-width: 520px; margin: 40px auto; }
.card--warn { border-color: var(--warn-line); background: var(--warn-soft); color: var(--ink); }
.card__head { display: flex; align-items: baseline; gap: 16px; justify-content: space-between; flex-wrap: wrap; }

.table { width: 100%; border-collapse: collapse; font-size: 14px; }
table.card { padding: 0; overflow: hidden; }
.table th, .table td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--line); vertical-align: top; }
.table th {
  font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--ink-faint);
  background: var(--sunken);
}
.table td { font-variant-numeric: tabular-nums; }
.table tr:last-child td { border-bottom: 0; }
.table tbody tr:hover td { background: var(--sunken); }
.detail { max-width: 34ch; word-break: break-word; }

/* THE SAME TEXT, ON A PAGE INSTEAD OF IN A CELL (2026-09-02).
   `run_log.detail` is a whole stack trace and `/admin/runs/{id}` prints it verbatim; the
   34ch cap above exists to stop one cell stretching a table and is exactly wrong here.
   It wraps rather than scrolling sideways — a traceback read one horizontal scroll at a
   time is a traceback nobody reads — and it keeps the monospace the worker wrote in. */
pre.detail {
  max-width: none;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  padding: 12px 14px;
  margin: 0 0 14px;
  font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
}

.pairs { margin: 0; }
.pairs > div { display: flex; gap: 12px; padding: 4px 0; }
.pairs dt { width: 110px; color: var(--ink-faint); font-size: 14px; margin: 0; }
.pairs dd { margin: 0; }
/* THE SUBJECT OF A DETAIL PAGE, AS A HEADER RATHER THAN A CARD.
   `/admin/wishes/{id}` used to state the request in a bordered card of its own, which read
   as one more comparable thing beside the tables that might serve it. It is not one: it is
   what the page is about. So it wraps into a strip under the title and takes no border, no
   background and no heading — the same facts, one glance, and the eye reaches the table
   without stepping over a box first. Falls back to the plain stacked `.pairs` rows on a
   narrow screen because `flex-wrap` is all that is doing the work. */
.pairs--tight { display: flex; flex-wrap: wrap; gap: 4px 28px; margin: 0 0 18px; }
.pairs--tight > div { padding: 0; gap: 8px; }
.pairs--tight dt { width: auto; }

/* --- pills ------------------------------------------------------------- */
.pill {
  display: inline-block; padding: 2px 8px; border-radius: 999px;
  font-size: 12px; font-weight: 600; background: var(--sunken); color: var(--ink-soft);
  border: 1px solid var(--line);
}
.pill--open, .pill--good, .pill--booked, .pill--ok, .pill--active { background: var(--good-soft); color: var(--good); border-color: var(--good-line); }
.pill--busy, .pill--claiming, .pill--held, .pill--confirming, .pill--running, .pill--invited { background: var(--accent-soft); color: var(--accent); border-color: var(--accent-line); }
.pill--unknown, .pill--expired { background: var(--warn-soft); color: var(--warn); border-color: var(--warn-line); }
.pill--bad, .pill--failed, .pill--error, .pill--revoked { background: var(--bad-soft); color: var(--bad); border-color: var(--bad-line); }
.pill--idle, .pill--cancelled, .pill--lapsed { background: var(--sunken); color: var(--ink-faint); border-color: var(--line); }
/* `lapsed` (018) reads as SPENT, like `cancelled`, and deliberately not as `failed`:
   red would say the attempt went wrong, and what actually happened is that the
   cafe's temporary hold expired. It is a finished row, not a fault. */
/* Booking-state TONES (`presenters.BOOKING_STATES`). These are named after what the row
   wants from a human, not after a database word, so a renamed state keeps its colour.
   `act` is the café's fifteen minutes actually running — the only genuinely live thing on
   the page — and `off` is spent: nothing to do, nothing at risk. */
.pill--act { background: var(--accent-soft); color: var(--accent); border-color: var(--accent-line); font-weight: 700; }
.pill--warn { background: var(--warn-soft); color: var(--warn); border-color: var(--warn-line); }
.pill--off { background: var(--sunken); color: var(--ink-faint); border-color: var(--line); }
.pill--site { font-weight: 700; }

/* --- a missing reservation id -------------------------------------------
   THE ABSENCE IS THE FACT. A bare em-dash in this column is how twelve unnamed claims
   looked ordinary for a whole day, so `missing` is a red badge that reads at a glance
   across a long table. `pending` and `none` deliberately do NOT shout: too early to have
   an id, and nothing was ever booked, are both correct answers. */
.noid {
  display: inline-block; padding: 2px 8px; border-radius: var(--radius);
  font-size: 12px; font-weight: 600; letter-spacing: .02em;
  border: 1px solid var(--line); background: var(--sunken); color: var(--ink-faint);
}
.noid--missing {
  background: var(--bad-soft); color: var(--bad); border-color: var(--bad-line);
  font-weight: 800; text-transform: uppercase; letter-spacing: .04em;
}
.noid--pending { background: var(--accent-soft); color: var(--accent); border-color: var(--accent-line); }

/* --- grouped booking list ------------------------------------------------
   The groups are the ordering, and the ordering is the fix: a row that may be an
   unnameable table must never be fourteenth in a list sorted by id. */
.group { margin: 0 0 32px; }
.group h2 { margin-bottom: 4px; }
.group .lede { margin-top: 0; max-width: 80ch; }
.state { max-width: 46ch; }
.state p { margin: 6px 0 0; }
.resid { max-width: 22ch; word-break: break-word; }

/* --- state legend -------------------------------------------------------- */
.legend { margin: 0 0 24px; }
.legend summary { cursor: pointer; font-weight: 600; }
.legend__list > div { display: flex; gap: 14px; padding: 8px 0; border-bottom: 1px solid var(--line); }
.legend__list dt { width: 200px; flex: none; margin: 0; }
.legend__list dd { margin: 0; font-size: 14px; max-width: 80ch; }
.legend__list > div:last-child { border-bottom: 0; }
/* booking_intent statuses. `done` is green because the intent succeeded — but a done
   intent means a REAL TABLE now exists, so the green belongs to the queue row and never
   to the hold it produced. `pending` and `claimed` are deliberately quiet: nothing
   irreversible has happened yet and the queue is not an alarm. */
.pill--pending { background: var(--sunken); color: var(--ink-soft); border-color: var(--line); }
.pill--claimed { background: var(--accent-soft); color: var(--accent); border-color: var(--accent-line); }
.pill--done { background: var(--good-soft); color: var(--good); border-color: var(--good-line); }

/* --- what the café's form was filled with (D12) --------------------------
   A definition list rather than a table: the four rows are one label each and the label
   is the point — "email on the café's form" is a DIFFERENT fact from "forward the
   confirmation to", and a table of two anonymous columns is exactly how those two get
   read as the same address. The phone row is prose and never a number; see
   `_form_identity.html`. */
.identity { margin: 8px 0 0; font-size: 14px; }
.identity > div { display: flex; gap: 12px; padding: 5px 0; border-bottom: 1px solid var(--line); }
.identity > div:last-child { border-bottom: 0; }
.identity dt { width: 190px; flex: none; margin: 0; color: var(--ink-soft); }
.identity dd { margin: 0; max-width: 70ch; }
.identity-details summary { cursor: pointer; font-weight: 600; }
.identity-details, .identity-open { margin-top: 8px; }

/* --- held, unassigned --------------------------------------------------- */
.hold__actions { display: flex; flex-direction: column; gap: 12px; }
.hold__actions .inline-form { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.hold__actions label { margin-bottom: 0; }
.hold__cancel { border-top: 1px solid var(--line); padding-top: 10px; }
.hold__cancel summary { cursor: pointer; font-weight: 600; font-size: 14px; }
.hold__cancel p { margin: 8px 0 0; }
/* A command line is read character by character before it is run, so it wraps rather
   than scrolling out of sight — a reservation id half off the edge is a mis-typed
   cancellation. */
.hold__cancel .mono { word-break: break-all; }
/* The Detach control sits inside a table cell and next to prose elsewhere, so it needs to
   stop being a block that pushes the row apart. */
.inline-form { display: inline-flex; gap: 8px; align-items: center; flex-wrap: wrap; }

/* --- run log paging ----------------------------------------------------- */
/* Forward-only, because the browser's Back button is the other direction and is already
   correct. See the comment in `admin/runs.html`. */
.pager { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 0 0 24px; }

/* --- the numbered page row ------------------------------------------------ */
/* An ordered list, because that is what it is: pages in order. The markers are turned off
   because the numbers ARE the markers, and a screen reader still announces "list, 5
   items", which is the useful half. */
.pager__pages { display: flex; gap: 4px; align-items: center; list-style: none; margin: 0; padding: 0; }
.pager__pages li { display: flex; }
/* Sized by min-width rather than padding so a two-digit page and a three-digit page are
   the same width: a row of numbers that reflows as the reader walks it is a row where the
   Next arrow moves out from under the cursor between clicks. */
.pager__page {
  min-width: 34px; padding: 5px 6px; text-align: center; border-radius: var(--radius);
  border: 1px solid var(--line); font-variant-numeric: tabular-nums; font-size: 14px;
  text-decoration: none; color: inherit;
}
a.pager__page:hover { background: var(--sunken); }
/* THE PAGE YOU ARE ON, and it must be legible as such without colour alone — this is the
   one control in the row that is not a link, so somebody who cannot see the fill needs
   the weight and the border to tell them. `aria-current="page"` carries it for the rest. */
.pager__page--here { background: var(--sunken); border-color: var(--ink); font-weight: 600; }
/* Pages that exist and were skipped. Not a control, so it is not sized like one. */
.pager__gap { padding: 5px 2px; color: var(--ink-faint); }

/* --- the one-time API key secret ---------------------------------------- */
/* Big, monospaced, and wrapping rather than scrolling: this value is selected by hand and
   pasted once, and a character hidden past the right edge is a key that does not work
   with no way left to find out why — the server kept only a hash of it. */
.secret {
  font-size: 16px; word-break: break-all; user-select: all;
  background: var(--sunken); border: 1px solid var(--line); border-radius: var(--radius);
  padding: 12px 14px; margin: 12px 0;
}
.minted h2 { margin-top: 0; }

.notice { padding: 10px 14px; border-radius: var(--radius); margin: 0 0 20px; font-size: 14px; }
.notice--good { background: var(--good-soft); color: var(--good); border: 1px solid var(--good-line); }
.notice--bad { background: var(--bad-soft); color: var(--bad); border: 1px solid var(--bad-line); }
/* Not an error: a caveat that has to be read. Used for the sold-out waitlist
   warning, which is the one thing on the request page a user must not
   misread as a confirmed seat. */
.notice--warn { background: var(--warn-soft); color: var(--warn); border: 1px solid var(--warn-line); }
.notice a { color: inherit; }

/* --- forms ------------------------------------------------------------- */
.form .field { margin-bottom: 18px; }
.form--inline { display: flex; gap: 18px; align-items: flex-start; flex-wrap: wrap; }
.form--inline .field { margin-bottom: 0; }
.row { display: flex; gap: 18px; flex-wrap: wrap; }
label, legend { display: block; font-weight: 600; font-size: 14px; margin-bottom: 4px; }
fieldset { border: 0; padding: 0; margin: 0 0 18px; }
input[type=text], input[type=email], input[type=date], input[type=time], input:not([type]), select {
  font: inherit; padding: 8px 10px; border: 1px solid var(--field-line); border-radius: 6px;
  background: var(--input-bg); color: var(--ink); min-width: 200px;
}
input[type=checkbox] { accent-color: var(--accent); }
input:focus, select:focus, button:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
input:focus-visible, select:focus-visible, button:focus-visible, a:focus-visible {
  outline: 2px solid var(--accent); outline-offset: 2px;
}
.help { font-size: 13px; color: var(--ink-faint); margin: 6px 0 0; max-width: 62ch; }
.err { font-size: 13px; color: var(--bad); margin: 6px 0 0; font-weight: 600; }
.field--bad input, .field--bad select { border-color: var(--bad); }
.checks { display: flex; gap: 14px; flex-wrap: wrap; }
.check { font-weight: 400; display: flex; gap: 6px; align-items: center; }
.times { display: flex; gap: 10px; flex-wrap: wrap; }
.times input { min-width: 120px; }
.actions { display: flex; gap: 10px; align-items: center; }
.filters { display: flex; gap: 14px; align-items: flex-end; margin-bottom: 16px; flex-wrap: wrap; }
.filters label { font-size: 13px; }
.inline { display: flex; gap: 6px; }
.inline select { min-width: 100px; }

.btn {
  display: inline-block; font: inherit; font-size: 14px; font-weight: 600;
  padding: 8px 14px; border-radius: 6px; border: 1px solid var(--field-line);
  background: var(--btn-bg); color: var(--ink); cursor: pointer; text-decoration: none;
  transition: background-color var(--tick), border-color var(--tick), color var(--tick);
}
.btn:hover { background: var(--btn-hover); }
.btn--primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.btn--primary:hover { background: var(--accent-strong); border-color: var(--accent-strong); color: var(--on-accent); }
.btn--quiet { color: var(--ink-soft); font-weight: 500; }
/* A row action inside a table: small enough not to compete with the row it belongs to,
   and deliberately NOT --primary — clearing a worker registration is a repair, not the
   thing the page invites you to do. */
.btn--small { font-size: 12px; font-weight: 500; padding: 4px 9px; }
/* Deliberately NOT --primary and deliberately not filled: recording a hold as cancelled
   is irreversible and is never the thing the page invites you to do. It is outlined in the
   bad tone so it reads as the serious control on the card while the Assign button stays
   the accented one. Colour is not the only signal — the button says what it does, and the
   text beside it says what it does not. */
.btn--danger { color: var(--bad); border-color: var(--bad-line); }
.btn--danger:hover { background: var(--bad-soft); border-color: var(--bad); }
.btn[disabled] { opacity: 0.6; cursor: default; }

/* --- calendar ----------------------------------------------------------
   Five states, five renderings, in BOTH themes. UNKNOWN must never look like
   FULL: a day nobody has observed yet may well be free, and a full sweep takes
   six minutes, so unobserved is an ordinary state.

   The three rules this component is held to, and which no restyle may weaken:

     (a) every state is legible with the colour ignored entirely;
     (b) "not checked yet" and "sold out" differ in MORE THAN ONE way, because
         confusing them sends somebody at a seat that is gone — or, worse,
         away from one that is still there;
     (c) no square ever shows a seat count. Whether, not how many.

   2026-09-05, phase 3 of the Nocturne redesign, REASSIGNED the non-colour cues.
   Until then `open` and `limited` were both solid-bordered and differed only by
   tint, which failed (a) outright — the one state pair the old comment never
   defended. The edges now read:

     open      solid edge, DOUBLED by a 1px inner ring   (a thick, closed edge)
     limited   DASHED edge                               (a broken edge)
     full      solid edge + the number STRUCK THROUGH
     unknown   DOTTED edge
     out       NO edge at all, and the only `disabled` square

   Five signatures, none of them a tint, none of them shared. `unknown` against
   `full` — the pair (b) is about — differs in the edge style AND in the strike
   AND in the words, three ways, and it is only the tint that is gone: no state
   carries a fill any more, so the accent tint on a picked day is now the one
   filled thing on the grid and cannot be mistaken for an availability tone.
   Every cue survives the dark palette, greyscale, and colour being ignored. */
.legend { list-style: none; padding: 0; margin: 0 0 12px; display: grid; gap: 6px; font-size: 13px; color: var(--ink-soft); }
.legend--states { gap: 8px; }
.swatch {
  display: inline-block; width: 14px; height: 14px; border-radius: 4px;
  vertical-align: -2px; margin-right: 6px; border: 1px solid var(--line);
}
/* The legend is the legend FOR THESE SQUARES, so every swatch carries the same
   edge its square does. A swatch that kept a cue the square had dropped would
   be a caption for a picture nobody is looking at. `--out` therefore has no
   edge and no fill — which is exactly what an out-of-window square has; the
   word beside it is doing the work, as it does on the grid. */
.swatch--open { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
.swatch--limited { border-color: var(--accent); border-style: dashed; }
.swatch--full {
  border-color: var(--full-line);
  background: linear-gradient(to bottom,
    transparent 5px, var(--full-ink) 5px, var(--full-ink) 7px, transparent 7px);
}
.swatch--unknown { border-color: var(--field-line); border-style: dotted; }
.swatch--out { border-color: transparent; background: transparent; }

/* --- month navigation ---------------------------------------------------
   Three controls in a row: back, the month being shown, forward. Back and
   forward are ordinary links so they work without JavaScript; at either end of
   the bookable range the link becomes a `span.is-disabled` carrying the reason
   in its title, because a dead link that silently does nothing is worse than a
   control that says why it is off. */
.calnav { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 16px; }
.calnav__month { margin: 0; font-size: 17px; }
.calnav__step { white-space: nowrap; }
.calnav__why { margin: 6px 0 0; }
.btn.is-disabled {
  opacity: 0.45; cursor: not-allowed; background: var(--sunken); color: var(--ink-faint);
}

.cal { margin-top: 14px; }
.cal__head, .cal__week { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; }
.cal__head { margin-bottom: 6px; font-size: 12px; color: var(--ink-faint); text-transform: uppercase; letter-spacing: .04em; }
.cal__head span { text-align: center; }
.cal__week { margin-bottom: 6px; }

/* The size variants. They set `--day-size` on the GRID, so the squares and the
   blanks that pad a week move together and nothing here has to know which it
   is. Only the default and `--compact` (the phone breakpoint, below) are wired
   to anything today; `--sidebar` and `--mini` are the sizes the handoff's
   sidebar and hero cards ask for, and are here so that the screen which
   eventually wants one does not invent a sixth square. */
.cal--sidebar { --day-size: 58px; }
.cal--compact { --day-size: 44px; }
.cal--mini { --day-size: 34px; }
/* At 34px there is no room for a word, and the hero card's calendar is a
   picture of a month rather than a control — so the number stands alone and the
   legend beneath it carries the states. */
.cal--mini .day { padding: 4px 5px; }
.cal--mini .day__num { font-size: 12px; }
.cal--mini .day__label, .cal--mini .day__note { display: none; }

.day {
  font: inherit; text-align: left; cursor: pointer;
  min-height: var(--day-size); padding: 7px 8px; border-radius: var(--radius);
  border: 1px solid var(--line); background: var(--card); color: var(--ink);
  display: flex; flex-direction: column; gap: 2px;
  transition: border-color var(--tick), background-color var(--tick), box-shadow var(--tick);
}
/* Hover says "this one responds", and only where that is true: the one square
   the server marks `disabled` does not respond and must not claim to. */
.day:not([disabled]):hover { border-color: var(--accent); }
.day__num { font-weight: 500; font-size: 15px; font-variant-numeric: tabular-nums; }
.day__label { font-size: 11px; line-height: 1.25; color: var(--ink-soft); }
.day--blank { border: 0; background: transparent; min-height: var(--day-size); box-shadow: none; }

/* Observed and free: a solid edge in the accent, DOUBLED by a 1px inner ring.
   The ring is the non-colour half — it makes the edge read as thick and closed
   next to the broken edge of `limited` — and it is drawn with `box-shadow`
   rather than a wider border so the square does not change size between
   states. */
.day--open { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
.day--open .day__label { color: var(--accent); font-weight: 600; }

/* Observed and full: no fill, greyed, the number STRUCK THROUGH — and
   SELECTABLE, because a released seat is exactly how a full day becomes
   bookable. Its edge is the plain solid one, so the strike and the second line
   of words ("Waitlist only") are what stop a picked sold-out square reading as
   a seat; the cursor is an ordinary pointer because it IS clickable, and lying
   about that would be the interaction bug. */
.day--full {
  background: transparent; border-color: var(--full-line); border-style: solid;
  color: var(--full-ink); box-shadow: none;
}
.day--full .day__num { text-decoration: line-through; }
.day--full .day__label { color: var(--full-ink); }
.day__note { font-size: 10px; line-height: 1.2; font-weight: 700; text-transform: uppercase; letter-spacing: .03em; }
.day--full .day__note { color: var(--full-ink); }

/* Never observed: DOTTED edge, neutral ink, still clickable. A dotted edge is
   an edge that has not been drawn in yet, which is the whole of what this state
   says. It differs from `full` in the edge style AND in carrying no strike AND
   in its words — three ways, none of them a tint — because those two are the
   pair a reader must never confuse. */
.day--unknown { border-color: var(--field-line); border-style: dotted; color: var(--ink-soft); }
.day--unknown .day__label { color: var(--ink-soft); }

/* Outside the café's sale window, or already past: NO edge at all, faint ink,
   and the only square on the grid that is genuinely `disabled`. It is not sold
   out and it is not unobserved — the café has simply not published it, so there
   is nothing to request and nothing anyone could have looked at. Having no
   border is the strongest way to say "not part of the grid" without a tint, and
   the border is kept at `transparent` rather than removed so the square keeps
   its size and the weeks stay square. */
.day--out {
  background: transparent; border-color: transparent;
  color: var(--ink-faint); cursor: not-allowed; box-shadow: none;
}
.day--out .day__label { color: var(--ink-faint); }

/* Observed, with space, and the café's own figure for the day was low. It is a
   FIFTH rendering and not a shade of the fourth, because "some" and "plenty"
   are different answers — and since 2026-09-05 the difference is a BROKEN edge
   rather than a second tint. Until then `open` and `limited` were both solid
   and differed only by colour, which is the one thing this component is not
   allowed to rely on.

   It carries NO number, and there is nowhere here for one to appear. The café
   publishes a figure per day whose unit this project has never established;
   `presenters.LIMITED_AT_OR_BELOW` turns it into one of two words and throws it
   away. Written after `.day--open` on purpose, and it must stay there: the
   template puts BOTH classes on a limited day, so this is the override that
   says which — including dropping `open`'s inner ring, or the square would
   wear two edges at once. */
.day--limited { border-color: var(--accent); border-style: dashed; box-shadow: none; }
.day--limited .day__label { color: var(--accent); font-weight: 600; }

/* The picked range is the ONLY filled thing on the grid, which is why no state
   above carries a tint any more: a fill here means "you asked for this day" and
   can mean nothing else. Both classes are two-class selectors on purpose, so
   they beat every one-class state rule regardless of source order. */
.day.is-inrange { background: var(--accent-soft); }
.day[aria-pressed="true"], .day.is-picked {
  background: var(--accent-soft); box-shadow: inset 0 0 0 2px var(--accent);
}
/* …except on a day the café has not published, which a range can SPAN but can
   never include: there is nothing there to ask for. Painting it as picked would
   claim a day is part of the request when the server will never treat it as
   one — and, in dark, would put faint ink on the accent tint at 3.55:1, under
   AA. Same specificity as the two rules above, so it must stay after them. */
.day--out.is-inrange, .day--out.is-picked, .day--out[aria-pressed="true"] {
  background: transparent; box-shadow: none;
}

/* --- the time grid for one day ------------------------------------------
   Four things the reader must be able to tell apart at a glance: a grid that
   was read (and WHEN), a check that is queued, a day with nothing to check,
   and an answer that could not be got. Tone is a tint and a left edge; the
   headline says which state it is in words, and `.timegrid__read` is never
   optional on a rendered grid — the presenter refuses to hand over sittings
   without a read-at moment, so a dated grid is the only kind that exists. */
.timegrid { margin-top: 14px; padding: 12px 14px; border-radius: var(--radius);
  border: 1px solid var(--line); border-left-width: 4px; background: var(--sunken); }
.timegrid__head { margin: 0 0 6px; font-size: 15px; }
.timegrid__read { margin: 0 0 8px; }
.timegrid__detail { margin: 0; color: var(--ink-soft); font-size: 13px; }
.timegrid--good { border-left-color: var(--good); background: var(--good-soft); }
.timegrid--warn { border-left-color: var(--warn); background: var(--warn-soft); }
.timegrid--bad { border-left-color: var(--bad); background: var(--bad-soft); }
.timegrid--idle { border-left-color: var(--ink-faint); }

/* Being read. The card still holds the LAST answer — a real answer about a real
   day — so it is dimmed rather than blanked: swapping a schedule for a spinner
   throws away information in order to announce that information is coming. The
   attribute is set by the one script and by nothing else, so with no script
   there is no such state and no rule to apply. */
#times[aria-busy="true"] { opacity: 0.55; transition: opacity var(--tick); }

/* The ask control: a POST, because it can put a real café request in a queue.
   Laid out inline with its own caveat so the cost is beside the button and not
   in a footnote somewhere below it. */
.form--times { display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
  margin-top: 10px; }
.form--times .field { margin-bottom: 0; }

.sittings { list-style: none; padding: 0; margin: 10px 0 8px;
  display: flex; flex-wrap: wrap; gap: 8px; }
.sitting { display: flex; flex-direction: column; gap: 1px; min-width: 84px;
  padding: 6px 8px; border-radius: 6px; border: 1px solid var(--line);
  background: var(--card); }
.sitting__time { font-weight: 700; font-variant-numeric: tabular-nums; }
.sitting__seat { font-size: 11px; color: var(--ink-faint); }
.sitting__state { font-size: 10px; font-weight: 700; text-transform: uppercase;
  letter-spacing: .03em; }
/* Open and taken differ in colour, in the word underneath, and — for the taken
   ones — in a struck-through time, the same non-colour cue a sold-out day uses. */
.sitting--open { border-color: var(--good); background: var(--good-soft); }
.sitting--open .sitting__state { color: var(--good); }
.sitting--taken { border-color: var(--full-line); background: var(--full-bg); color: var(--full-ink); }
.sitting--taken .sitting__time { text-decoration: line-through; }
.sitting--taken .sitting__state { color: var(--full-ink); }

/* --- freshness --------------------------------------------------------- */
.age { font-size: 13px; font-variant-numeric: tabular-nums; }
.age--fresh { color: var(--good); }
.age--aging { color: var(--warn); }
.age--stale { color: var(--bad); font-weight: 600; }
.age--never { color: var(--ink-faint); }

.feed--released td { background: var(--good-soft); }

@media (max-width: 720px) {
  /* Seven squares across a 370px screen. The handoff's phone frames use the
     compact size, so the breakpoint sets the variant's number rather than a
     sixth one of its own. */
  .cal { --day-size: 44px; }
  .day__label { font-size: 10px; }
  /* The header's 36px gutter is a desktop measure. On a phone it costs a fifth
     of the screen, so it falls back to the page's own 20px. */
  .masthead__inner { gap: 12px; padding: 12px 20px; }
  .foot--public { grid-template-columns: 1fr; padding: 24px 20px 32px; }
}

/* --- terms and conditions ---------------------------------------------- */
/* One column of prose held to a comfortable measure. No new components: the
   page is ordinary `.card`s with headings, paragraphs and lists inside them,
   and every colour is an existing token so both themes come for free. */
.terms { max-width: 76ch; }
.terms h2 { margin-top: 0; }
.terms p { color: var(--ink-soft); margin: 0 0 12px; }
.terms p:last-child { margin-bottom: 0; }
.terms ul { margin: 0 0 12px; padding-left: 20px; }
.terms li { color: var(--ink-soft); margin: 0 0 8px; }
.terms li:last-child { margin-bottom: 0; }

/* --- paged, filtered admin worklists ------------------------------------ */
/* Every colour below is an existing token, so both themes come for free and
   there is no new visual identity: this is the same card, table and pill
   vocabulary the rest of the portal already speaks. What is new is the SHAPE
   of an operational list — a tally of what needs a human, the filters that are
   currently applied, a summary line, and the detail behind a disclosure.
   State is encoded in FORM as well as colour: a left stripe on an urgent row,
   a rule between records, a triangle on what opens. */

/* The tally: how many rows are in each urgency group, over the whole set. */
.tally { list-style: none; display: flex; flex-wrap: wrap; gap: 8px;
         padding: 0; margin: 0 0 8px; }
.tally__item { flex: 1 1 150px; }
.tally__item a {
  display: flex; flex-direction: column; gap: 2px; height: 100%;
  padding: 10px 12px; border: 1px solid var(--line); border-left-width: 4px;
  border-radius: var(--radius); background: var(--card);
  text-decoration: none; color: var(--ink);
}
.tally__item a:hover { background: var(--btn-hover); }
.tally__n { font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums; }
.tally__what { font-size: 12px; color: var(--ink-faint); line-height: 1.3; }
.tally__item--bad a { border-left-color: var(--bad); }
.tally__item--bad .tally__n { color: var(--bad); }
.tally__item--act a { border-left-color: var(--accent); }
.tally__item--act .tally__n { color: var(--accent); }
.tally__item--warn a { border-left-color: var(--warn); }
.tally__item--warn .tally__n { color: var(--warn); }
.tally__item--off a { border-left-color: var(--line); }
.tally__item--off .tally__n { color: var(--ink-faint); }
.tally__item.is-current a { box-shadow: inset 0 0 0 2px var(--accent-line); }

/* Applied filters, stated and individually removable. */
.chips { display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
         margin: 0 0 16px; }
.chip {
  display: inline-flex; gap: 6px; align-items: center; font-size: 12px;
  padding: 3px 9px; border: 1px solid var(--accent-line); border-radius: 999px;
  background: var(--accent-soft); color: var(--accent); text-decoration: none;
}
.chip:hover { background: var(--btn-hover); }
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* URGENCY IN FORM AS WELL AS COLOUR, so it survives a monochrome screen and does not
   depend on distinguishing two greens.

   Keyed off the ROW's own `data-rank` since 2026-09-02. A booking row used to be a
   `<tbody class="brow">` of two `<tr>`s — a summary line and a `<details>` disclosure —
   and the rank lived on the wrapper. The disclosure is gone (it is `/admin/bookings/{id}`
   now, a real page), so each row is a single `<tr>` carrying its own rank, and the
   selectors follow it rather than a wrapper that no longer exists. */
.brow__summary--urgent th:first-child, .brow__summary--urgent td:first-child {
  box-shadow: inset 3px 0 0 var(--warn);
}
.brow__summary[data-rank="-1"].brow__summary--urgent th:first-child {
  box-shadow: inset 3px 0 0 var(--bad);
}
.brow__summary[data-rank="0"].brow__summary--urgent th:first-child {
  box-shadow: inset 3px 0 0 var(--accent);
}

/* The blocks a detail PAGE is built from — /admin/bookings/{id}, /admin/holds/{id} and
   /admin/intents/{id} share them. They were written for a disclosure and the disclosure's
   own rules went with it; the grid did not change, because what it lays out did not. */
.rowdetail__grid {
  display: grid; gap: 16px 24px; margin-bottom: 12px;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.rowdetail__block { min-width: 0; }
.rowdetail__h {
  margin: 0 0 6px; font-size: 11px; font-weight: 700; letter-spacing: .06em;
  text-transform: uppercase; color: var(--ink-faint);
}
.rowdetail__block p { margin: 0 0 6px; max-width: 60ch; }
.rowactions {
  display: flex; flex-direction: column; gap: 10px;
  border-top: 1px solid var(--line); padding-top: 10px;
}
.rowactions .inline-form { align-items: baseline; }
.rowactions .muted { max-width: 60ch; }

/* A long worklist loses its headings on the way down. Scoped to the bookings table by
   its own hook rather than to `.table--linked`, which /admin/holds and /admin/wishes/{id}
   also use and which are short, unpaged lists that never scroll far enough to need it. */
@media (min-height: 640px) {
  .table[data-bookings-table] thead th {
    position: sticky; top: 0; z-index: 1;
    background: var(--card); box-shadow: 0 1px 0 var(--line);
  }
}

/* Wide content scrolls inside its own container; the page body never does. */
.group { overflow-x: auto; }
.tablewrap { overflow-x: auto; }

/* The way back from a detail page. It is a real link and not a back-button hint: the
   page has an address, so it can be arrived at from a bookmark or somebody else's
   message, where there is no history to go back through. */
.crumbs { margin: 0 0 12px; font-size: 13px; }
.crumbs a { color: var(--ink-soft); }
.crumbs a:hover { color: var(--ink); }

/* A whole row is a link target on /admin/holds, but only the id cell carries the anchor:
   a row-wide click handler would need script, and a nested anchor per cell would give a
   screen reader seven links saying the same thing. */
.table--linked tbody tr:hover td, .table--linked tbody tr:hover th { background: var(--sunken); }
.table--linked th[scope="row"] a { font-weight: 600; }

/* THE ID CELL IS A ROW HEADER, because "which row is this" is what names every other
   cell on the line — a screen reader announcing "booking 93" before each value is the
   whole reason it is a `<th scope="row">` and not a `<td>`. It must not therefore inherit
   the COLUMN header's uppercase treatment and sunken background, which would read as a
   heading in the middle of the body. */
.table--linked tbody th[scope="row"] {
  font-size: 14px; text-transform: none; letter-spacing: 0;
  color: var(--ink); background: transparent; font-weight: 600; white-space: nowrap;
}

/* The status legend on /admin/holds. Not links — there is nothing to filter to on an
   unpaged page — so it needs the padding and the rule the tally's anchors carry. */
.tally--legend .tally__item {
  flex: 1 1 220px; border-left: 3px solid var(--line);
  background: var(--sunken); border-radius: 6px; padding: 8px 10px;
}
.tally--legend .tally__n { font-size: 14px; margin-left: 6px; }
.tally--legend .tally__what { display: block; margin-top: 4px; }

/* --- the search bar and the state checklist on /admin/bookings ----------- */
/* Sean, 2026-09-03: the search was "the sixth control in a row of seven" and had to be
   made obvious. It is the same input it was — one GET form, one query parameter — given
   the width and the position that say what it is for. Every colour is an existing token;
   nothing here is a new visual identity. */
.filters--booking { display: block; }
.filters__row {
  display: flex; gap: 14px; align-items: flex-end; flex-wrap: wrap;
}
.searchbar {
  display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
  padding: 12px 14px; margin: 0 0 14px;
  border: 1px solid var(--line); border-left: 3px solid var(--accent);
  border-radius: var(--radius); background: var(--card);
}
.searchbar__label { font-weight: 600; font-size: 14px; }
.searchbar__input {
  flex: 1 1 320px; min-width: 220px; font-size: 15px; padding: 8px 12px;
}
.searchbar__hint { flex: 1 1 100%; margin: 0; }

/* The checklist that replaced the single-select State dropdown. It is a fieldset because
   it is one question with eight answers, and a legend because a screen reader has to hear
   what these eight boxes are before it hears the first of them. */
.states { border: 1px solid var(--line); border-radius: var(--radius);
          padding: 8px 12px 10px; margin: 0; min-width: 0; }
.states legend { font-size: 13px; padding: 0 4px; }
.states__boxes { display: flex; flex-wrap: wrap; gap: 4px 16px; }
.states .check { align-items: baseline; font-size: 13px; }
.check__word { font-weight: 600; }
.check__meaning { max-width: 22ch; }
.states__note { margin: 6px 0 0; }
