/* shared/base_colors.css */
/* base_colors.css — the colour library: descriptive names mapped to values,
   and nothing else. Nothing outside colors.css should reference these; a
   component asks for a semantic token (--accent, --line), never a hue.

   The cobalt system, from the July 2026 design workshop (docs/design-system.md)
   and re-hued off Tailwind's stock blue on 2026-08-27 — see the ramp below.

   NEUTRALS ARE WARM IN LIGHT AND COOL IN DARK, which reverses half of the
   workshop's "cool-biased, never flat grey" (Vicky, 2026-08-27: warm light mode
   a little, and avoid greys). The point of the original rule survives — these
   have never been flat grey and still aren't — but light mode now leans to
   paper rather than to blue.

   The re-hue was done in OKLab with LIGHTNESS HELD, so every contrast
   measurement recorded in colors.css still holds: relative luminance moved by
   at most 0.0035 and the worst text ratio in light mode is 5.15:1. Chroma is
   CAPPED at 0.013 rather than carried over, because the cool ramp's chroma grew
   as it darkened — rotating that to warm unchanged turned the mid-tones tan.
   Capping keeps the warmth even down the ramp (+13 to +15 on red-minus-blue,
   against the -3 to -14 it was) and keeps it subtle, which was the brief.

   THE THREE LIGHT SURFACES CARRY LESS THAN THAT — +1, not +13. --paper-25,
   --paper-50 and --paper-75 came down twice on review (Vicky, 2026-08-27: the
   sidebar fill a notch, then the canvas with it), and they are now barely off
   neutral while the lines and inks below them keep the full warm bias. That
   split is intentional: the warmth reads on the large flat areas long before
   it reads on a hairline, so the big surfaces need the least of it.

   It is also the floor. At +1 there is one unit of warm left before these are
   plain grey, which is the thing the brief ruled out — so a further "notch
   down" on the surfaces is not available, and the answer to a page that still
   feels too warm is the LINES, which have room to spare.

   TWO RAMPS, ONE PER THEME, AND THE NAMES SAY WHICH. --paper-* is light mode's
   and warm; --slate-* is dark mode's and cool. Nothing is shared, so no step
   has to serve two theme's worth of intent.

   It was one `--gray-*` ramp until 2026-08-27 and that name had gone from
   loose to false: the steps were never flat grey (cool-biased was the original
   rule), and once light mode warmed they were paper. Worse, three of them —
   --gray-300, -350 and -400 — were dark mode's TEXT colours sitting in the
   middle of the light ramp, cool values with warm names, which is why this
   comment used to have to describe the ramp as "mixed" and warn people off
   warming three of its members. They are --slate-250, -300 and -350 now, in
   the cool ramp where they always belonged.

   Both ramps keep the same convention: a HIGHER number is DARKER. */
:root {
  --white: #ffffff;
  --paper-25: #fcfbfb;
  --paper-50: #faf9f9;
  /* Between the canvas and the first line colour: the ground the event
     shell's floating pane sits ON, so it has to be deeper than --bg and
     still well clear of --line, or the hairlines inside the sidebar would
     land on a value close to their own. See --shell-ground.

     THIS ONE CARRIES LESS CHROMA THAN THE REST OF THE WARM RAMP, and the
     reason is worth keeping. The first warm cut put it at #f4f0ea, which read
     as too dark in the event sidebar (Vicky, 2026-08-27). Measured, it was
     not darker at all — OKLab lightness went UP a hair, from .9553 to .9566,
     and its step below the canvas was unchanged. What changed was chroma:
     3.2x the canvas's, in a WARM direction, where the old cool tint was 2.9x
     in a direction that recedes. A warm tint at equal lightness advances, so
     it reads heavier. Dropping chroma to 1.6x the canvas fixed what was
     actually wrong and left the structural step intact.

     Then down again to CHROMA PARITY WITH THE CANVAS (Vicky, 2026-08-27: take
     the saturation of the whole sidebar fill down a notch). That is the rule
     now, and it is a better rule than a multiplier: the sidebar differs from
     the content pane in LIGHTNESS ONLY and carries no extra colour of its own.

     Worth knowing if this comes up again, because it bounds what tuning can
     do here: at this lightness the entire warm range available in 8-bit is
     about five units of red-over-blue. The canvas sits at +3 and this sat at
     +5, so "a notch" is genuinely a two-unit move — real, but small. If the
     sidebar still reads too warm, the lever is LIGHTNESS (closer to --bg, at
     the cost of the panel separation), not saturation; there is almost no
     saturation left to spend. */
  --paper-75: #f1f0f0;
  --paper-100: #ebe6df;
  --paper-150: #e4ddd5;
  --paper-200: #ded7cf;
  --paper-450: #807b73;
  --paper-500: #5a554e;
  --mist: #e2e6f0;
  /* The dark neutrals sit off the floor on purpose. A canvas at #0b0c0f left
     no room beneath the card, so surface-vs-canvas measured 1.09:1 and the
     "subtler" surface-2 1.04:1 — steps the eye cannot find. Lifting the whole
     ramp buys that room back and drops body text from 15.42:1 to 13.32:1,
     which stops near-white on near-black blooming for astigmatic readers.
     The scale is ordered: a higher number is always darker — which is what
     --slate-600 is doing at a new value: --line-strong's lifted colour sits
     between --field-line and --line, and the old #4d5366 was left with no
     consumers when --field-line moved up to --slate-550. */
  /* The three lightest slates carry dark mode's TEXT rather than its edges:
     -250 is --text-muted and -350 is --text-faint. They were named --gray-300,
     --gray-350 and --gray-400 and sat up in the light ramp, which was the whole
     reason that ramp had to be described as "mixed" — they are cool values with
     warm names in a warm block. Renumbered into this ramp on 2026-08-27, in
     lightness order so the scale stays monotonic: .3979, .3392, .3242, then
     -400's .2669. */
  --slate-250: #a5a9b7;
  --slate-300: #9a9dab;
  --slate-350: #949aad;
  /* The two below sit off the top of the ramp and are for
     `prefers-contrast: more` only, where every border is pushed well past the
     WCAG non-text floor — at rest nothing reaches up here. */
  --slate-400: #868da1;
  --slate-450: #767d90;
  --slate-550: #646a78;
  --slate-600: #545a6c;
  --slate-700: #383c4a;
  --slate-800: #1b1e27;
  --slate-850: #15171e;
  --slate-900: #101219;
  /* The one value BELOW the canvas, and the only place the ramp is allowed
     to go back down toward the floor the lift moved it off: it is the ground
     OUTSIDE the event pane, never a surface under body text. The pane keeps
     --bg, so nothing that carries reading text moves. See --shell-ground. */
  --slate-950: #0a0b10;
  --ink: #201c16;
  --midnight: #0a1022;

  /* The cobalt ramp, which replaced cornflower on 2026-08-27 (Vicky). The old
     values were #2563eb and its neighbours — the stock Tailwind blue-600 ramp,
     and about the most-used accent on the web, which is what made the app read
     as assembled from a starter kit rather than designed. Cobalt is the same
     family a few steps deeper and slightly off the primary.

     It is also measurably better, which was not the reason but is the reason
     it survived review: every accent contrast in the system improved, and the
     one documented FAILURE went away. --accent on a tint cleared AA in exactly
     one of six light placements under cornflower; under cobalt it clears all
     six (worst 5.69). The rule above --accent-ink in colors.css still stands
     anyway — ink remains the stronger pick and a rule that churns with the
     palette is worse than one that holds.

     Lower number is lighter, as everywhere else in this file. */
  --cobalt-700: #16389e;
  --cobalt-600: #1a45c4;
  --cobalt-400: #8fb3ff;
  --cobalt-300: #9cbeff;
  --cobalt-250: #a9c6ff;

  /* EVERY status hue is split in two, not just amber (which was the first to
     be caught). The mid value paints SURFACES — tints, borders, dots — and in
     light mode is far too pale to carry a word; the -800 stop is the ink for
     words and icons, and clears AA on every tint its own hue makes. The
     measured table lives beside --ok-ink in colors.css.

     Dark mode needs no second value for green or amber: the bright stop is
     comfortable on its own tint (5.6:1 and 5.9:1), so --ok/--warn and their
     inks coincide there. Rose is the exception both ways round — it is the
     tightest pair in the set, and --rose-400 on its own 14% tint measures
     4.54:1, which passes by 0.04. --rose-350 is that headroom. */
  --emerald-600: #1f9d63;
  --emerald-400: #43c489;
  --emerald-800: #146c43;
  /* The two outer stops exist for ONE pair: the arrived pill, which has to
     out-rank the all-clear pill without leaving the green family (see
     --ok-strong in colors.css). Each is the ink for a deeper tint of its own
     theme's --ok, so light goes darker and dark goes lighter. */
  --emerald-900: #0f5533;
  --emerald-300: #8fe9bb;

  --amber-500: #eab308;
  --amber-400: #f2a33c;
  --amber-800: #8a5200;

  --rose-600: #d5484f;
  --rose-400: #ef6a71;
  --rose-350: #f4868c;
  --rose-800: #b3262c;

  --shade-soft: rgb(20 20 30 / 5%);
  --shade: rgb(20 20 30 / 7%);
  --shade-pop: rgb(20 20 30 / 8%);
  --shade-pop-far: rgb(20 20 30 / 14%);
  --night-soft: rgb(0 0 0 / 40%);
  --night: rgb(0 0 0 / 45%);
  --night-pop: rgb(0 0 0 / 50%);
  --night-pop-far: rgb(0 0 0 / 55%);

  /* Flat black veils; the suffix is the alpha percent. These carry the
     elevation levels that were written as literals at their call sites long
     enough to hide a real bug — the same value in both themes, which reads
     in light and contributes nothing at all against a #0b0c0f canvas. They
     are kept (light mode is tuned around them) and every level that uses one
     now opens with --rim, which is the layer dark mode can actually see. */
  --soot-3: rgb(0 0 0 / 3%);
  --soot-6: rgb(0 0 0 / 6%);
  --soot-8: rgb(0 0 0 / 8%);
  --soot-12: rgb(0 0 0 / 12%);
  --soot-15: rgb(0 0 0 / 15%);
  --soot-25: rgb(0 0 0 / 25%);
  --soot-30: rgb(0 0 0 / 30%);
  --soot-35: rgb(0 0 0 / 35%);
  /* The rim's own light: a hairline of the page's brightest value, laid on
     the outer edge of anything raised. Dark mode only — see --rim. */
  --sheen-8: rgb(255 255 255 / 8%);
}

/* shared/colors.css */
/* colors.css — semantic tokens mapped to base_colors.css, and the one place
   light and dark are decided. `color-scheme: light dark` plus `light-dark()`
   means one declaration per token instead of a light block and a duplicate
   dark block: change a colour here and both themes follow.

   The theme toggle stamps data-theme on <html>; pinning color-scheme there
   is what makes light-dark() (and bare native widgets — the .mx matrix
   checkboxes) follow the choice rather than the OS. Because those two rules
   only set color-scheme, every token below is authored exactly once. */
:root {
  color-scheme: light dark;

  --bg: light-dark(var(--paper-50), var(--slate-900));
  --surface: light-dark(var(--white), var(--slate-800));
  --surface-2: light-dark(var(--paper-25), var(--slate-850));

  --line: light-dark(var(--paper-100), var(--slate-700));
  --line-strong: light-dark(var(--paper-200), var(--slate-600));
  /* An input's outline is the ONLY thing that says "this is a control", so it
     is a non-text contrast target (WCAG 2.2 SC 1.4.11) and owes 3:1 — not the
     decorative hairline budget --line spends. Dark was #4d5366, which
     measured 2.34:1 against --surface and failed; --slate-550 clears it.
     Note the margin is thin now: --slate-550 was picked against the old
     #16171c surface, where it measured 3.30:1. Lifting --surface to #1b1e27
     takes it to 3.07:1 — still over the 3:1 floor, but anything that lifts
     --surface again has to lift --field-line with it. */
  --field-line: light-dark(var(--paper-150), var(--slate-550));
  /* Shell-level edges ONLY — the sidebar against the canvas, and the same
     edge turned horizontal in the ≤800px top bar. Not for card dividers or
     table rows, and the token stays even though it now RESOLVES to --line's
     value in both themes: it names a different job, `prefers-contrast: more`
     at the foot of this file moves it on its own, and an edge that wants to
     part company with the hairline again should have somewhere to go that
     isn't every divider in the admin.

     What it was for, and why that argument lost. --surface-2 on --bg is a
     1.05:1 fill difference, so no fill can separate the sidebar from the
     canvas — the edge has to carry the boundary alone, and dark ran it at
     --slate-600 (2.72:1) to be sure it did. Measured against the screen
     rather than the reasoning, that was a bright full-height seam splitting
     the window, twice as loud as anything else on the page (Vicky, twice, on
     2026-08-11: take it down, then still too bright at --slate-650's
     2.15:1). At --line's 1.70:1 the boundary still reads — a 1px rule does
     not need contrast to be FOUND, only to be followed — and it reads as an
     edge instead of a seam. The floor beneath this is roughly 1.5:1, where
     the line starts disappearing into the canvas on a dim screen.

     It sits far BELOW --field-line, and the ordering is worth stating
     because it looks wrong at a glance: --field-line is HIGHER (3.45:1) not
     because a form input outranks the app's frame, but because an input's
     border is a control boundary owing 3:1 under WCAG 1.4.11. That is a
     compliance floor, not a statement of visual weight, and 1.4.11 does not
     govern a structural edge — only boundaries needed to IDENTIFY a control.
     So this sits deliberately below it, and `prefers-contrast: more` is
     where that trade is taken the other way: the shell edge stays a step
     clear of the borders inside it THERE, which is where a reader who needs
     the separation drawn loudly actually gets it.

     Light was never the problem and has not moved: it has been --paper-100
     all along, which is also --line. */
  --line-structural: light-dark(var(--paper-100), var(--slate-700));
  /* The ground the EVENT shell floats its content pane on — the account
     shell keeps the rule above and never paints this. It sits one step
     BELOW --bg in both themes, which is what lets the pane keep --bg as its
     own fill: every card inside is levelled against exactly the surface it
     was levelled against before, so the whole change is outside the pane.
     Dark leans on --rim as much as on the fill step (the pane over this
     ground is 1.06:1, and no darker ground would help much — see
     --slate-950), which is the same argument --rim was introduced for. */
  --shell-ground: light-dark(var(--paper-75), var(--slate-950));

  --text: light-dark(var(--ink), var(--mist));
  --text-muted: light-dark(var(--paper-500), var(--slate-250));
  /* NOT FOR TEXT — svg icons, border-colors and background tints only, where
     the floor is WCAG 1.4.11's 3:1 and this clears it (4.00:1 on --bg at its
     worst). It cannot carry words: in LIGHT it measured 4.00:1 on --bg and
     4.21:1 on --surface, both under the 4.5:1 AA needs, and every text use in
     the admin was moved to --text-muted on 2026-08-06. (Dark was always fine —
     5.93:1 to 6.67:1.)

     The reason this is a rule and not a darker value: there is no room on this
     palette for two text greys. Clearing 4.5:1 on --bg needs about #6d7285,
     which lands 1.13:1 from --text-muted — invisible. The old pair was only
     1.28:1 apart to begin with, so the "two tiers" the token seemed to give
     were never being read as two: the quiet look of a stat label or a column
     head comes from its size and weight (label_recipe.css), not from the grey.
     Darkening the token instead would also have dragged the ten non-text
     consumers below with it, none of which had a problem.

     Two of the moved uses could not have been fixed by any faint grey anyway:
     .lg-row.pinned .lsub and a checked .nc-pick's .nc-cap sit on --accent-weak,
     where the best a faint value reaches is 4.35:1 and --text-muted gave 4.77:1.

     --text-muted GOT DARKER IN LIGHT MODE on 2026-08-27, from #666a76 (and
     then briefly #6f6962, its warm twin) to #5a554e. Vicky: "in general this
     text just isn't contrasty enough now in light mode", looking at the
     sidebar nav — and she was right about the feel even though the old value
     passed AA. What the measurement showed is that light mode had always been
     the weaker theme here, not that the warm-up broke it: the cool value was
     5.22:1 on --surface-2 and the warm one 5.24:1, while DARK mode's
     --text-muted sits at 7.10:1. Light was living just over the floor and dark
     was comfortable.

     #5a554e brings light to 7.38:1 on --surface, 7.02:1 on --bg and 7.14:1 on
     --surface-2 — level with dark, which is the point.

     AND IT RETIRES A LANDMINE. The note that used to end this comment said
     --text-muted was 4.31:1 on --accent-weak-2 and that the tint "must never
     carry muted text". That was true, and the warm re-hue had quietly made it
     worse (4.15:1). At #5a554e the pair measures 5.65:1, so the prohibition is
     gone: muted text on either accent tint is fine now. Do not lighten this
     token back without re-measuring both tints, because that rule comes back
     with it. */
  --text-faint: light-dark(var(--paper-450), var(--slate-350));

  /* --on-accent flips to dark ink in dark mode so primary buttons clear AA
     in both themes. */
  --accent: light-dark(var(--cobalt-600), var(--cobalt-400));
  --accent-strong: light-dark(var(--cobalt-700), var(--cobalt-300));
  /* TEXT ON A TINT IS ALWAYS --accent-ink, NEVER --accent. --accent is a text
     colour for --bg / --surface and a fill colour for solid accent objects;
     the moment it sits on --accent-weak or --accent-weak-2 it is too close to
     its own tint to be worth the argument.

     THE MEASUREMENTS MOVED WITH THE COBALT RE-HUE (2026-08-27) AND THE RULE
     DID NOT. Under cornflower, --accent on a tint passed in exactly one of six
     real light placements and only by 0.06 — that is what this rule was
     written for. Cobalt is dark enough that all six now clear:

       over --bg        weak 6.42  weak-2 5.69     ink 8.26 / 7.32
       over --surface   weak 6.73  weak-2 5.97     ink 8.65 / 7.68
       over --surface-2 weak 6.54  weak-2 5.80     ink 8.41 / 7.46

     The rule stays anyway, for two reasons. Ink is still the stronger pick by
     ~2 points in both themes (dark: 6.70-8.45 against --accent's 5.52-6.97),
     and a rule that relaxes every time the palette gets darker has to be
     re-litigated every time it gets lighter — the next hue would silently put
     a dozen labels back under AA with nothing red. Which surface a tint lands
     on is not something a rule can know, so there is no case-by-case version.
     TestAccentOnTintIsInk (styles_test.go) holds the line for same-rule
     pairings; a background and a colour set in two different rules is on the
     reviewer. */
  --accent-ink: light-dark(var(--cobalt-700), var(--cobalt-250));
  --on-accent: light-dark(var(--white), var(--midnight));
  /* The weak tints mix their own hue into transparent, and dark mode needs a
     HIGHER percentage to read against a dark surface. light-dark() substitutes
     colours, not <percentage>s (same limit the shadow geometry hits below), so
     the pick has to wrap the whole color-mix() — `var(--accent) light-dark(9%,
     14%)` parses as nothing, which invalidates the token and silently drops
     every consumer to its property's initial value (transparent for a
     background, BLACK for an SVG fill — how this last broke, #322). */
  --accent-weak: light-dark(
    color-mix(in srgb, var(--accent) 9%, transparent),
    color-mix(in srgb, var(--accent) 14%, transparent));
  /* Dark sits at 18%, not the 24% you'd expect from the --accent-weak
     pattern above. It was capped by a text contrast: under cornflower,
     --accent on a 24% tint over --surface measured 4.44:1, under AA
     (a.lbadge:hover was the live case), and 18% brought it to 5.03:1. The
     palette lift did not fix it — it moved --accent to #7aa8ff and the
     surface with it, 4.41:1 before and 4.44:1 after.

     THE COBALT RE-HUE DID fix it: 24% now measures 4.81:1 and 18% measures
     5.52:1, so the cap this comment records is no longer load-bearing. 18%
     stays because that is what the selected fill has looked like for a month
     and there is no reason to make it louder — but a future change that wants
     24% back can now have it, and the escape hatch beneath that is still
     --accent-ink for the text (5.83:1 at 24%), never a brighter tint. See
     --accent-ink above; that is the standing rule for both themes. */
  /* LIGHT IS 12%, DOWN FROM 16% on 2026-08-27 (Vicky: take the saturation of
     the menu background down a notch, looking at the current row in the event
     sidebar). Cobalt is a more saturated blue than the cornflower it replaced,
     so the same 16% read as more colour than it used to — the number did not
     change, the hue underneath it did.

     Everything that sits on this tint got better, measured on the sidebar
     ground where the nav's current row actually lives: --text 11.55 -> 12.28,
     --text-muted 5.03 -> 5.35, --accent-ink 6.84 -> 7.27.

     Dark stays at 18%. Its own cap is recorded below and the re-hue already
     bought that one room; there was no complaint about dark and no reason to
     spend it. */
  --accent-weak-2: light-dark(
    color-mix(in srgb, var(--accent) 12%, transparent),
    color-mix(in srgb, var(--accent) 18%, transparent));

  /* A STATUS-COLOURED WORD IS ALWAYS THE INK, NEVER THE BASE — and unlike
     --accent-ink above, that holds on a plain page as well as on a tint,
     because these three hues are not text colours in light mode at all. They
     paint surfaces: tints, borders, dots, the toggle track. Measured live in
     the styleguide (composited backgrounds, browser-level scheme emulation),
     light mode, worst of the three real bases (--bg / --surface / --surface-2)
     against the strongest tint each hue makes and against bare page:

                     on its own tint     on a plain surface
       --ok           2.76 - 3.06         3.29 - 3.46
       --warn-ink     4.24 - 4.52         4.67 - 4.92     (the old #a16207)
       --danger       3.32 - 3.79         4.09 - 4.31
       --ok-ink       5.13 - 5.69         6.12 - 6.45
       --warn-ink     5.51 - 5.86         6.06 - 6.39     (the new #8a5200)
       --danger-ink   5.01 - 5.71         6.17 - 6.50

     So green fails everywhere, red fails everywhere, and amber — already
     split once, which is how the pattern got its name — was still short on
     every tint it paints. The inks clear AA with room in all of it.

     Dark mode is the mirror image: the bright stops sit ON dark tints and are
     comfortable (--ok 5.57-6.72, --warn 5.91-7.09), so green and amber ink is
     the same value as its base, exactly as amber has always done. Only rose
     gets a second dark value, because --danger on its own 14% tint measures
     4.54:1 — a pass by 0.04, and the palette lift has eaten a margin that
     size before (see --danger-weak below). --rose-350 takes it to 5.62.

     Which surface a word lands on is not something a rule can know, so this is
     flat like the accent rule: every status-coloured word asks for the ink.
     TestStatusWordsTakeTheInk holds the line — and because the base hue is
     never text anywhere, that guard needs no pairing and so has none of
     TestAccentOnTintIsInk's blind spot. */
  --ok: light-dark(var(--emerald-600), var(--emerald-400));
  --ok-ink: light-dark(var(--emerald-800), var(--emerald-400));
  --ok-weak: light-dark(
    color-mix(in srgb, var(--ok) 11%, transparent),
    color-mix(in srgb, var(--ok) 16%, transparent));
  /* --ok-strong is the SECOND green, and it exists because two good states
     now sit in the same column: an attendee whose details are all in
     ("complete") and one who has walked through the door ("checked in").
     Both are good news, so both are green, and one has to read as more than
     the other or the arrived pill disappears into a page of them. A deeper
     tint plus a deeper ink is that step — measured 6.76:1 in light and
     6.32:1 in dark, either side of AA with room. Deliberately NOT a solid
     fill: half a list wears this by the end of a door shift, and a block of
     solid colour down the status column shouts over everything that actually
     needs attention. */
  --ok-strong: light-dark(
    color-mix(in srgb, var(--ok) 24%, transparent),
    color-mix(in srgb, var(--ok) 30%, transparent));
  --ok-strong-ink: light-dark(var(--emerald-900), var(--emerald-300));
  --warn: light-dark(var(--amber-500), var(--amber-400));
  --warn-ink: light-dark(var(--amber-800), var(--amber-400));
  --warn-weak: light-dark(
    color-mix(in srgb, var(--warn) 13%, transparent),
    color-mix(in srgb, var(--warn) 16%, transparent));
  --danger: light-dark(var(--rose-600), var(--rose-400));
  --danger-ink: light-dark(var(--rose-800), var(--rose-350));
  /* Rose on its own tint is the tightest pair in the status set, so the
     lifted dark surface is what forces 16% down to 14%: at 16% the pill's
     own text measured 4.41:1, under AA. At 14% it is 4.56:1. That cap is
     belt-and-braces now — the words on it take --danger-ink, which clears
     AA at 16% too — but it stays, because the tint also has to keep its
     distance from the surface it sits on. */
  --danger-weak: light-dark(
    color-mix(in srgb, var(--danger) 10%, transparent),
    color-mix(in srgb, var(--danger) 14%, transparent));
  /* The badges paint a stronger tint than the --*-weak fills and .badge-soldout
     spells its own out, so it does NOT inherit --danger-weak above and needs
     the same dark-mode step down here. Light mode keeps 16% in both places.
     --ok and --warn badges stay at 16% (5.60:1 and 5.88:1 after the lift —
     both DARK-mode numbers; in light these badges were the worst text in the
     product until their words moved to the inks above). */
  --danger-badge: light-dark(
    color-mix(in srgb, var(--danger) 16%, transparent),
    color-mix(in srgb, var(--danger) 14%, transparent));
  --neutral-pill: light-dark(var(--paper-500), var(--slate-300));

  /* --rim is the separation channel dark mode never had. In light, a raised
     thing is set apart from the canvas TWICE: by a lightness step and by a
     drop shadow. In dark the shadow does nothing — a drop shadow works by
     darkening, and --slate-900 has nothing left to darken, so every elevation
     shadow in the product measures about 1.03:1 against it — which left dark
     mode running structure on the lightness step alone. So the raised edge
     catches a hairline of light instead, and light mode takes a transparent
     ring and changes in no way at all. (Lifting the canvas off #0b0c0f to
     #101219 did not give the shadow its job back; it only gave the rim room.)
     (8%, down from the 10% it landed at: the two extra points were there to
     carry the rim against the old #16171c surface. That surface is #1b1e27
     now, so the rim reads at 8% and 10% starts to look like a drawn border
     rather than a caught edge.) */
  --rim: light-dark(transparent, var(--sheen-8));

  /* The elevation ladder. Every level opens with --rim, and no level's own
     layers changed: they are the values each surface was tuned to in light,
     lifted out of the eleven components that had inlined them — which is
     exactly how a whole theme's worth of no-op shadows went unnoticed, since
     a literal in a component is a value nobody is looking at. Levels stay
     distinct rather than collapsing to three, because collapsing them would
     retune light. TestElevationShadowsUseTokens keeps them here. */
  --shadow: 0 0 0 1px var(--rim),
    0 1px 2px light-dark(var(--shade-soft), var(--night-soft)),
    0 8px 24px light-dark(var(--shade), var(--night));
  /* The one token light-dark() can't fold on its own: the far layer's blur
     and offset differ between themes, and light-dark() substitutes colours,
     not lengths. So the geometry — and only the geometry — is picked per
     scheme, and the two theme hooks below re-pick it. */
  --shadow-pop-far: 0 12px 32px var(--shade-pop-far);
  --shadow-pop: 0 0 0 1px var(--rim),
    0 4px 10px light-dark(var(--shade-pop), var(--night-pop)), var(--shadow-pop-far);
  /* A segmented control's selected pill (seg-tabs, the export panel's). */
  --shadow-pill: 0 0 0 1px var(--rim), 0 1px 2px var(--soot-8);
  /* The buyer journey's weightless cards, at rest and lifted on hover. */
  --shadow-hairline: 0 0 0 1px var(--rim), 0 1px 2px var(--soot-3);
  --shadow-hairline-lift: 0 0 0 1px var(--rim), 0 2px 10px var(--soot-6);
  /* Menus that hang off a control: the admin's, then the buyer journey's
     quieter one. */
  --shadow-menu: 0 0 0 1px var(--rim), 0 6px 20px var(--soot-30);
  --shadow-menu-soft: 0 0 0 1px var(--rim), 0 6px 20px var(--soot-25);
  /* The buyer journey's page card where nothing else has said — a Classic
     (root-domain) journey page, and any page rendered without a Design.
     A Pro journey page overrides it per request with the organizer's own
     Card shadow (layout.html), so this is the floor rather than the usual
     case, and it should look like what an untouched account gets.

     It floated on 0 20px 35px until 2026-08-21 — the exact shadow Vicky named
     as what dated these pages, and the one the refit had already replaced
     everywhere else. Drawn now rather than floated: the card takes a real
     hairline border (journey_shell.css) and this is a whisper of lift under
     it. */
  --shadow-journey: 0 0 0 1px var(--rim), 0 1px 2px var(--soot-3),
    0 12px 32px -24px var(--soot-15);
  /* The one level with NO --rim, and the exception proves the rule. The rim is
     for a raised edge that has nothing else drawing it; modal-panel and
     .ic-dialog both carry a real 1px --line border, so in dark the ring landed
     just OUTSIDE that border as a second, fainter edge — a #1d1e21 halo around
     a #383c4a line — and two stacked hairlines on a 14px radius read as a
     blurred outline rather than a drawn one. The border alone measures 1.80:1
     against the dimmed backdrop, which is a followable edge. */
  --shadow-modal: 0 20px 60px var(--soot-35);

  /* Legacy aliases (buttons were restyled to the accent system; these keep
     stray rules working until the sweep finishes). */
  --accent-btn: var(--accent);
  --btn-shadow: none;
  --btn-primary-bg: var(--accent);
  --btn-primary-fg: var(--on-accent);
  --fg: var(--text);
  --muted: var(--text-muted);
  --accent-fg: var(--on-accent);
  --nav-text: var(--text-muted);
}
/* Font smoothing is the other thing that is a per-scheme decision rather than
   a colour, so it is picked here beside the geometry. A light stroke on a
   dark ground blooms (irradiation), so the type renders about half a weight
   step heavier than authored — and this UI is weight-heavy (64 rules at 600,
   31 at 550, a tail up to 900), which is a large part of why a dense dark
   screen reads as muddy. Antialiased strokes give that half-step back. It is
   DARK ONLY: in light mode the same declaration thins text and makes it
   worse, so the light hook spends a declaration putting `auto` back rather
   than leaving the media query to win. */
@media (prefers-color-scheme: dark) {
  :root {
    --shadow-pop-far: 0 16px 40px var(--night-pop-far);
    -moz-osx-font-smoothing: grayscale;
    -webkit-font-smoothing: antialiased;
  }
}
:root[data-theme="light"] {
  color-scheme: light;
  --shadow-pop-far: 0 12px 32px var(--shade-pop-far);
  -moz-osx-font-smoothing: auto;
  -webkit-font-smoothing: auto;
}
:root[data-theme="dark"] {
  color-scheme: dark;
  --shadow-pop-far: 0 16px 40px var(--night-pop-far);
  -moz-osx-font-smoothing: grayscale;
  -webkit-font-smoothing: antialiased;
}

/* ---- The floor: browsers with no light-dark() ----
   Every token above is one light-dark() call, and a browser that doesn't know
   the function doesn't fall back per-scheme — it fails to parse, invalidating
   the custom property and dropping every consumer to its property's initial
   value. That is not a degraded theme, it is no theme: surfaces go
   transparent, borders vanish, and the admin sidebar's switcher panel lands
   unreadably on top of the nav behind it. light-dark() is Safari 17.5+ /
   Chrome 123+, so anyone older got exactly that (reported 2026-08-06).

   This is DELIBERATELY NOT a second palette. Duplicating all 41 tokens would
   reintroduce the light/dark block pair this file exists to abolish, and the
   copy would drift. It restores only what decides whether the admin can be
   READ and NAVIGATED — surfaces, edges, text, accent, and the status colours,
   because "this button is destructive" is meaning and not decoration. Anything
   omitted degrades to plain rather than to broken: shadows resolve to none
   (every level opens with var(--rim), so they are invalid here whatever we do)
   and the elevation is carried by the borders instead.

   Light values only — an old browser gets the reference theme, not the OS's
   choice, which is the honest trade for a fallback nobody will be looking at.
   TestLightDarkFallbackTracksTokens keeps the names in step with the block
   above. Everything here is plain colour or color-mix() (Safari 16.2+), so the
   effective floor this buys back is roughly Safari 16.2. */
@supports not (color: light-dark(#fff, #000)) {
  :root {
    --bg: var(--paper-50);
    --surface: var(--white);
    --surface-2: var(--paper-25);
    --shell-ground: var(--paper-75);

    --line: var(--paper-100);
    --line-strong: var(--paper-200);
    --field-line: var(--paper-150);
    --line-structural: var(--paper-100);
    /* Light's --rim is transparent, and it has to be DECLARED rather than left
       out: it is the first layer of every --shadow*, so an invalid --rim takes
       the whole ladder with it even in a browser that could paint the rest. */
    --rim: transparent;

    --text: var(--ink);
    --text-muted: var(--paper-500);
    --text-faint: var(--paper-450);

    --accent: var(--cobalt-600);
    --accent-strong: var(--cobalt-700);
    --accent-ink: var(--cobalt-700);
    --on-accent: var(--white);
    --accent-weak: color-mix(in srgb, var(--accent) 9%, transparent);
    --accent-weak-2: color-mix(in srgb, var(--accent) 12%, transparent);

    /* Each hue's INK ships beside its base, and that is the whole point of
       restoring the status colours at all. A word takes the ink, never the
       base ("A STATUS-COLOURED WORD IS ALWAYS THE INK" above, held by
       TestStatusWordsTakeTheInk) — so a fallback that restores only the base
       restores the DECORATION and drops the MEANING: the tint still paints
       and the word inside it goes back to plain body text. That is what an
       omitted ink does here, not a duller green (an unresolvable var() is
       invalid at computed-value time, and `color` then inherits). Measured
       2026-08-29. --ok-ink and --danger-ink were missing outright; --warn-ink
       pointed at --amber-700, a stop deleted when the ink moved to #8a5200
       for contrast, so it was just as invalid as the two that were absent —
       all three status words lost their colour here, which is the one thing
       this block exists to prevent. TestLightDarkFallbackResolvesEveryVar and
       TestLightDarkFallbackKeepsInksWithBases hold both shapes. */
    --ok: var(--emerald-600);
    --ok-ink: var(--emerald-800);
    --ok-weak: color-mix(in srgb, var(--ok) 11%, transparent);
    --ok-strong: color-mix(in srgb, var(--ok) 24%, transparent);
    --ok-strong-ink: var(--emerald-900);
    --warn: var(--amber-500);
    --warn-ink: var(--amber-800);
    --warn-weak: color-mix(in srgb, var(--warn) 13%, transparent);
    --danger: var(--rose-600);
    --danger-ink: var(--rose-800);
    --danger-weak: color-mix(in srgb, var(--danger) 10%, transparent);
    --danger-badge: color-mix(in srgb, var(--danger) 16%, transparent);
    --neutral-pill: var(--paper-500);
  }
}

/* The escape hatch for anyone who finds our borders too quiet — and dark
   mode is where it earns its keep, because that is where every one of these
   sits below the 3:1 non-text floor at rest.
   Re-pointed at the TOKENS, so no component needs a rule of its own; the
   values clear 3:1 against every surface they can land on (--bg, --surface,
   --surface-2) in both themes. The hierarchy above survives: the shell edge
   stays a step clear of the borders inside it.
   These are re-declared with light-dark() rather than as a dark-only block
   because color-scheme is already pinned on :root, so one declaration still
   serves both themes — the same reason every token above is authored once.
   Component-level contrast rules (qty.css) still stand on their own. */
@media (prefers-contrast: more) {
  :root {
    --line: light-dark(var(--paper-450), var(--slate-450));
    --line-strong: light-dark(var(--paper-450), var(--slate-450));
    --field-line: light-dark(var(--paper-450), var(--slate-450));
    --line-structural: light-dark(var(--paper-500), var(--slate-400));
  }
}

/* shared/type_scale.css */
/* type_scale.css — the one type scale (rem): .75 .8125 .875 1 1.125 1.375 2
   2.75. Steps are named by weight, never by use, so a component asks for a
   step rather than inventing a number.

   --font-mono is here for the same reason the steps are: the stack was written
   out in ten components in three different spellings, and eight of those had
   already started asking for a token — `--mono` in the admin, `--font-mono` on
   the journey — that no file anywhere declared. All but one of the eight got
   the face anyway, off a fallback beside the var(). message_vars.css named no
   fallback, twice, so both declarations were dropped whole and the variable
   chooser's examples and the plain-text mail preview rendered in the page's
   body face — under a comment that calls them monospaced.

   The name is --font-mono rather than --mono on two counts: it is the second of
   a pair with the admin's --font-chrome, and `.mono` in this codebase is a
   BADGE — the accent-tinted chip a code wears — not a typeface. A token one
   word off a class that means something else is the next trap.
   ui-monospace leads the stack, so whatever the platform calls its own code
   face is what a code face is here. */
:root {
  --font-mono: ui-monospace, "SF Mono", Menlo, monospace;

  --fs-3xs: .75rem;
  --fs-2xs: .8125rem;
  --fs-xs: .875rem;
  --fs-s: 1rem;
  --fs-m: 1.125rem;
  --fs-l: 1.375rem;
  --fs-xl: 2rem;
  --fs-2xl: 2.75rem;
}

/* shared/radius_scale.css */
/* radius_scale.css — the one corner scale, alongside type_scale.css and for
   the same reason: a component asks for a step rather than inventing a number.

   Before this file the admin carried EIGHTEEN distinct corner radii across
   250 declarations — 10, 9, 8, 7, 6, 5, 4, 3, 2px and up, plus pills written
   both 99px and 999px. Individually each was defensible; together they are
   what makes a screen read as assembled rather than designed, because two
   things sitting side by side round differently for no reason a reader can
   find. Five steps, named by weight and never by use.

   The steps are the four bands the old values already clustered into, taken
   at their most-used member, plus the pill:

     xl  overlays that float over the page (modal, dialog, confirm card)
     lg  cards, panels, bands — anything holding other things
     md  controls: buttons, inputs, selects, nav items
     sm  small marks: swatches, code chips, tight badges
     pill  fully-round: status pills, meter bars, preset buttons

   TWO THINGS ARE DELIBERATELY OUTSIDE THIS SCALE and must stay literal:

   - pass_mockup.css. Those corners replicate a real Apple Wallet and Google
     Wallet pass (14px and 16px), so they answer to Apple and Google, not to
     us. Rounding them to our scale would make the mockup wrong.
   - The buyer journey (styles/public/, styles/event/). Its radii are a
     documented public contract themes style against (docs/themes.md) and it
     already has its own tokens (--bj-radius, --journey-radius,
     --button-radius). Moving those is a separate decision with an audience.

   Circles (50%) and square corners (0) are not steps and take no token. */
:root {
  --r-sm: 4px;
  --r-md: 7px;
  --r-lg: 10px;
  --r-xl: 14px;
  --r-pill: 999px;
}

/* shared/reset.css */
* { box-sizing: border-box; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Dragging over text fell to the browser default, which is a heavy opaque
   blue that swallows dark-mode text. Ours is the accent at a tint that
   reads as a highlight over both themes, with the ink left alone so text
   keeps whatever colour it already had (and whatever contrast it was
   authored for) instead of being forced to one selection foreground.
   Authored once, in both bundles — the standalone event page is its own
   bundle on the account's --custom-* palette and has no --accent to spend. */
::selection { background: color-mix(in srgb, var(--accent) 30%, transparent); }

/* shared/page.css */
body { background: var(--bg); color: var(--fg); font: 16px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
  margin: 0; }
main { margin: 0 auto; max-width: 620px; padding: 2.5rem 1.25rem 4rem; }

/* shared/typography.css */
h1 { font-size: 1.6rem; line-height: 1.25; margin: 0 0 .35rem; }
h2 { font-size: 1.1rem; margin: 2rem 0 .75rem; }
p { margin: .5rem 0; }
a { color: var(--accent); }
.muted { color: var(--muted); }

/* shared/meta_icon.css */
.meta-icon { fill: currentColor; height: 1em; vertical-align: -.125em; width: 1.125em; }

/* shared/row.css */
.rows { border-top: 1px solid var(--line); margin-top: 1.25rem; }
.row { align-items: center; border-bottom: 1px solid var(--line); display: flex;
  gap: 1rem; padding: .8rem 0; }
.row .grow { flex: 1; }
/* A button in a row sits BESIDE its fields, not under them, so it drops the
   stacked-form top margin every button carries by default. Left on, that
   margin does not just add space: align-items centres the MARGIN box, so the
   button's border box lands half the margin low — 10px below the input it is
   meant to line up with, which reads as a broken control rather than as
   spacing. Same correction .form-actions and .box-head already make. */
.row button, .row a.button { margin-top: 0; }
/* A label used as a row's main text (the order-edit quantity rows) reads as
   body text, not as the small muted form label. */
.row > label.grow { color: inherit; font-size: inherit; margin: 0; }

/* shared/page_header.css */
.page-header { align-items: baseline; display: flex; flex-wrap: wrap; gap: 1rem; justify-content: space-between; margin-bottom: .35rem; }
.page-header h1 { margin-bottom: 0; }
.page-sub { margin-top: 0; }

/* shared/price.css */
.price { font-variant-numeric: tabular-nums; }

/* shared/input_number.css */
/* A number box is deliberately SMALL — 4.5rem says "a few digits go here"
   where a full-width box would promise a sentence. form_fields.css excludes
   [type=number] from the house frame for exactly that reason, so this file
   is the only thing that sizes one.

   What was NOT part of that decision is the padding. A number QUESTION (a
   custom field, rendered `class="full"`) stands in the same column as the
   text, select and choose-several questions around it, and .4rem/.5rem put
   its answer 9px in and its box 35px tall where every neighbour is 10.6px
   in and 36.6px tall — a near-miss rather than a difference (Vicky,
   2026-08-22). It takes the column's own frame below; the WIDTH is left
   alone, because the small box is the decision and the inset was the
   byproduct.

   Scoped to .full — a price box (money_input.css) and a quantity stepper
   (qty.css) are composites with their own measure, not fields in a column,
   and the stepper sets `padding: 0 .3rem` over this anyway. */
input[type=number] { background: transparent; border: 1px solid var(--line); border-radius: var(--r-md);
  color: inherit; font: inherit; padding: .4rem .5rem; width: 4.5rem; }
input[type=number].full { padding: .45rem .6rem; }

/* shared/button.css */
/* Buttons (workshop reference): quiet neutral default — surface fill,
   line-strong hairline, no shadow; the primary is the one cobalt fill,
   with --on-accent text so it clears AA in both themes. */
button, a.button { align-items: center; background: var(--surface); border: 1px solid var(--line-strong); border-radius: var(--r-md); color: var(--text);
  cursor: pointer; display: inline-flex; font: inherit; font-size: .8125rem;
  font-weight: 550; gap: .35rem; line-height: 1.4; margin-top: 1.25rem;
  padding: .38rem .8rem; text-decoration: none; transition: border-color .12s, background .1s, transform .05s; }
button:hover, a.button:hover { border-color: var(--text-faint); }
button:active, a.button:active { transform: translateY(.5px); }
button:disabled { cursor: default; opacity: .5; }

/* shared/ref_mono.css */
.ticket, body.buyer-journey .share-link a { font-family: var(--font-mono); font-size: .95rem; }

/* shared/status_text.css */
.ok { color: var(--ok-ink); font-weight: 600; }

/* shared/pill.css */
.pill { border: 1px solid var(--line); border-radius: var(--r-pill); color: var(--muted);
  display: inline-block; font-size: .8rem; padding: .1rem .55rem; text-decoration: none;
  white-space: nowrap; }

/* shared/qty.css */
.qty { align-items: center; display: flex; gap: .35rem; }
/* The input matches the step buttons' 2rem height — a taller box between
   two smaller squares reads as broken, not as a control. The selector
   out-specifies the journey-wide input padding (0,1,2): WebKit refuses to
   shrink an input below its padding box, so the padding must actually be
   0 here, not merely overridden on height. */
.qty input[type=number],
body.buyer-journey form:not([data-checkout]) .qty input[type=number] { box-sizing: border-box; height: 2rem; margin: 0; padding: 0 .3rem; text-align: center; }
/* touch-action: manipulation — rapid +/- taps must never read as a
   double-tap-to-zoom gesture; manipulation keeps pinch-zoom intact. */
button.step { align-items: center; border-radius: var(--r-md); box-sizing: border-box; display: inline-flex; flex: none;
  height: 2rem; justify-content: center; line-height: 1; font-size: 1.1rem;
  margin: 0; padding: 0; touch-action: manipulation; width: 2rem; }
button.step:disabled { cursor: default; filter: none; opacity: .35; }
/* A quiet round wash, matching the event page's — it was wearing the accent
   fill and competing with the page's actual primary action. No outline: the
   fill is the affordance, and a ring around every ± made the row read as a
   toolbar. Same bargain as the fields — the boundary appears only when the
   reader has asked their OS for more contrast. */
/* The corner is the card's corner, capped like every other button on a buyer
   page (journey_shell.css). Fully round until 2026-08-21, which read as a
   pair of pills beside a squared-off card the moment the default radius came
   down to 5 — the rounded secondary buttons Vicky kept catching. */
body.buyer-journey button.step { background: color-mix(in srgb, var(--fg) 6%, var(--journey-card));
  border: 0; border-radius: var(--bj-radius-sm); color: var(--fg); font-weight: 400; }
@media (prefers-contrast: more) {
  body.buyer-journey button.step { border: 1px solid var(--control-line); }
}
input.stepped { appearance: textfield; -moz-appearance: textfield; text-align: center; }
input.stepped::-webkit-outer-spin-button, input.stepped::-webkit-inner-spin-button {
  -webkit-appearance: none; margin: 0; }

/* shared/button_busy.css */
/* The busy state hides the label so the ring can sit where the words were.
   That works wherever nothing else paints the button's `color` — which is
   the admin, and is why this stayed unnoticed for so long. The buyer journey
   paints its buttons in two places with more specificity than a single
   class can reach, so each of those owns its own busy override, right
   beside the rule that causes the problem (journey_shell.css,
   journey_form.css). Reported on the checkout details step (Paul,
   2026-08-15); it was every buyer-journey button.

   Worth knowing why the tests missed it: they assert the `busy` CLASS,
   which was always applied correctly. Nothing asserted that a pixel
   changed, so a spinner drawn underneath fully-visible text passed. */
button.busy { color: transparent; pointer-events: none; position: relative; }
button.busy::after { animation: spin .6s linear infinite; border: 2px solid var(--text-muted); border-top-color: transparent; border-radius: 50%;
  content: ""; height: 1em; inset: 0;
  margin: auto; position: absolute;
  width: 1em; }
@keyframes spin { to { transform: rotate(360deg); } }

/* shared/button_variants.css */
button.primary, a.button.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
button.primary:hover, a.button.primary:hover { border-color: var(--accent); filter: brightness(1.07); }
/* Destructive buttons darken the danger token so white text clears AA in both
   themes (the light --danger is a text tone, too light for a solid fill). */
button.danger, a.button.danger { background: color-mix(in srgb, var(--danger), #000 28%); border-color: transparent; color: #fff; }
button.danger:hover, a.button.danger:hover { background: color-mix(in srgb, var(--danger), #000 34%); }
button.subtle, a.button.subtle { background: transparent; border-color: var(--line-strong); color: var(--text); }
button.subtle:hover, a.button.subtle:hover { background: var(--surface-2); color: var(--text); }
button.ghost, a.button.ghost { background: transparent; border-color: transparent; color: var(--text-muted); }
button.ghost:hover, a.button.ghost:hover { background: var(--accent-weak); color: var(--text); }
/* A quiet destructive link — ghost's shape in danger ink — is the way into a
   confirm step from inside an editor (remove-link). It has to be written
   after BOTH single rules: ghost and danger sit at equal specificity and
   ghost came later, so "ghost danger" rendered as a plain grey link and one
   screen worked around it with .pill.danger (#1190). */
button.ghost.danger, a.button.ghost.danger { background: transparent; color: var(--danger-ink); }
button.ghost.danger:hover, a.button.ghost.danger:hover { background: color-mix(in srgb, var(--danger) 12%, transparent); color: var(--danger-ink); }
.pill.danger, a.danger { color: var(--danger-ink); }
.pill.primary { background: var(--accent-btn); border-color: var(--accent-btn); color: var(--on-accent); }

/* shared/form_fields.css */
/* Every text-shaped field wears the house frame BY DEFAULT, and the ones that
   must look different opt out by name. It was the other way round until
   2026-08-12 — an allowlist of `type=` values — and the failure that shape
   produces is silent and had happened more than once (Vicky): a field written
   without a `type` attribute at all matches nothing here, because the selector
   tests the ATTRIBUTE and not the effective type, so it renders as a raw
   browser input on a page where nothing else does. `type="color"` was in the
   same position, unstyled by omission rather than by decision.

   So the list below is exclusions, and every one of them is a real decision:
   checkbox/radio are drawn by check_label.css and the matrix, file has its own
   rule beneath this, number is sized by input_number.css (which loads BEFORE
   this file, so matching it here would silently override its width), and
   submit/button/reset/image are buttons. Anything not on that list — a new
   input type, or one written with no type at all — is picked up with no
   further work, which is the whole point.

   TestFormFieldsAreStyledByDefault is the fence: it fails if this ever goes
   back to naming the types it styles. */
input:not([type=checkbox], [type=radio], [type=file], [type=number], [type=color],
  [type=submit], [type=button], [type=reset], [type=image], [type=range], [type=hidden]),
textarea, select { background: transparent;
  border: 1px solid var(--line); border-radius: var(--r-md); color: inherit;
  font: inherit; padding: .45rem .6rem; }
/* A textarea is as tall as the template asked for. The floor below used to be
   unconditional at 9rem — six rows — so every `rows="2"` description on every
   admin form rendered as a paragraph-sized hole, and three surfaces had
   already written their own override to claw the height back (new event,
   event details, translations). The rule now applies only where nothing said
   how tall the box should be, which is the buyer's long-answer fields: those
   carry no `rows`, and a paragraph box is the right size for them.

   TestTextareaRowsDecideHeight is the fence. */
textarea { resize: vertical; width: 100%; }
textarea:not([rows]) { min-height: 9rem; }
/* File pickers wear the same frame as every other field, with the native
   browse control restyled as a small ghost button. */
input[type=file] { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-md);
  color: inherit; font: inherit; padding: .45rem .6rem; width: 100%; }
input[type=file]::file-selector-button { background: transparent; border: 1px solid var(--line-strong);
  border-radius: var(--r-md); color: inherit; cursor: pointer; font: inherit; margin-right: .7rem; padding: .2rem .7rem; }
input[type=file]::file-selector-button:hover { border-color: var(--accent); }

/* shared/select.css */
/* Native <select> matches our inputs: no OS double-arrow, a --text-muted
   chevron, a --surface fill so it never reads transparent-on-canvas. Focus
   ring comes from the shared :focus-visible. This styling alone (zero JS)
   fixes the ugly native arrow; searchable-select.js layers a typeahead on
   top. The :not([multiple]) is now a statement about the past rather than a
   carve-out: a "choose several" question renders as a group of checkboxes
   (check_group.css), so nothing in the product draws a native list box any
   more. Leave the scope on — an old override or a hand-written theme could
   still produce one, and the chevron and padding below are wrong for it. The
   chevron ships as two data-URIs (light/dark), swapped on the theme so it
   stays legible in both; the combobox input (.ss-input) borrows the same
   look. */
select:not([multiple]), .ss-combo > .ss-input {
  background-color: var(--surface); background-repeat: no-repeat;
  background-position: right .6rem center; background-size: .8rem;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2398a2b5' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
  padding-right: 2rem; }
@media (prefers-color-scheme: light) { select:not([multiple]), .ss-combo > .ss-input {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%235b6579' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); } }
:root[data-theme="dark"] select:not([multiple]), :root[data-theme="dark"] .ss-combo > .ss-input {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2398a2b5' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); }
:root[data-theme="light"] select:not([multiple]), :root[data-theme="light"] .ss-combo > .ss-input {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%235b6579' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); }
select:not([multiple]) { appearance: none; -webkit-appearance: none; -moz-appearance: none; cursor: pointer; }

/* shared/label.css */
label { color: var(--muted); display: block; font-size: .85rem; margin: .8rem 0 .2rem; }

/* shared/summary_disclosure.css */
details[data-summary] { margin-top: .25rem; }
details[data-summary] summary { align-items: center; border-bottom: 1px solid var(--line); cursor: pointer;
  display: flex; font-weight: 600; gap: .6rem; list-style: none;
  padding: .8rem 0; }
details[data-summary] summary::-webkit-details-marker { display: none; }
details[data-summary] summary::before { color: var(--muted); content: "▸";
  font-size: .8em; transition: transform .15s; }
details[data-summary][open] summary::before { transform: rotate(90deg); }
details[data-summary] [data-breakdown] .row { color: var(--muted); }

/* shared/caret.css */
/* caret — the shared FA disclosure marker (replaces the old "▾"/"▸" glyphs
   everywhere, Vicky 2026-07-23). Rotates with its details.

   Lifted out of admin/list_dropdown.css and into shared/ on 2026-08-03: the
   name field is a buyer-journey component that needs the same marker the
   admin menus use, and a component in shared/ cannot depend on a rule that
   only ships in the admin bundle. Nothing about the rule changed. */
.caret { align-items: center; color: var(--text-faint); display: inline-flex; font-size: .8em; margin-left: .3rem; transition: transform .15s; }
.caret svg, .caret i { display: block; }
details[open] > summary > .caret { transform: rotate(180deg); }
@media (prefers-reduced-motion: reduce) {
  .caret { transition: none; }
}

/* shared/name_field.css */
.name-row { display: grid; gap: .8rem; grid-template-columns: 1fr 1fr; }
/* The ultimate name field. The input is the field; the reading beneath it is
   an EXTENSION OF THE BOX — one thin strip, flush under the input, same
   border, bottom corners rounded — that gains a labelled pair per part as
   the name is typed (Paul, 2026-08-29). It replaced a hairline-ruled block
   of one row per part, which put the same words on three lines and pushed
   the rest of the checkout down for every name with a middle one. Where the
   split was a guess the doubted value carries the runner-up inline, one tap
   away. The correction itself lives one step down behind the Edit control.

   Being this small is also why the strip shows from the first word again,
   where the block waited for a guess to justify itself — see name-field.js
   on what that trade did and did not settle. */
/* A row, so the control can sit at the TRAILING EDGE of the strip (Vicky,
   2026-08-19) rather than under it. The pairs reflow inside the dl while
   the control keeps its corner, so a long reading costs a second line of
   words and never a line of its own. */
.namefield-read {
  align-items: baseline;
  background: color-mix(in srgb, var(--fg, currentColor) 3%, transparent);
  border: 1px solid var(--control-line, var(--line));
  /* The input's own corner, so the two boxes are one shape — the buyer
     field wears the event design's radius, and the house step covers
     anywhere else this partial is rendered. */
  border-radius: 0 0 var(--bj-radius-sm, var(--r-md)) var(--bj-radius-sm, var(--r-md));
  border-top: 0;
  box-sizing: border-box;
  display: flex;
  font-size: var(--bj-support, .85rem);
  gap: .5rem;
  /* Over the input's own bottom border, so the two read as one control
     rather than as a box with a box under it. */
  margin: -1px 0 0;
  padding: .3rem .65rem;
  width: 100%;
}
/* [hidden] must still beat display:flex — it does on specificity (0,2,0
   over 0,1,0), which is why the restatement below is not enough on its own
   and this rule must never gain a second class. */
.namefield-read > dl { flex: 1; min-width: 0; }
/* Author display rules outrank the UA's [hidden] — restate it. */
.namefield-read[hidden], .namefield-details > summary[hidden], .namefield-words[hidden] { display: none; }
.namefield-strip { display: flex; flex-wrap: wrap; gap: .1rem .9rem; margin: 0; }
/* Label and value are one item, so the strip wraps between parts and never
   between a label and the word it labels. */
.namefield-pair { align-items: baseline; display: flex; gap: .35rem; min-width: 0; }
/* Muted, and then a step back toward the text: the strip's own tint moves
   the local background 3% toward the foreground, which takes plain --muted
   on this surface close enough to the AA floor that a palette rounding the
   other way could cross it. The label pays the tint back and still reads as
   the quiet half of the pair. */
.namefield-read dt { color: color-mix(in srgb, var(--muted, var(--text-muted)) 88%, var(--fg, currentColor)); font-weight: 400; }
.namefield-read dd { margin: 0; }

/* The one-tap alternative: quiet enough to ignore, plainly an offer. Dashed
   underline so it reads as "or this?" rather than as the current value. */
/* Dashed UNDERLINE, not a border: a border-bottom gives the button a taller
   box, which made the one row carrying an offer sit lower than its
   neighbours in the reading. text-decoration costs no height. */
button.namefield-alt, body.buyer-journey button.namefield-alt {
  background: none; border: 0; border-radius: var(--r-sm); box-shadow: none;
  color: var(--journey-link, var(--accent)); cursor: pointer; display: inline; font: inherit; line-height: inherit;
  margin: 0 0 0 .5rem; min-height: 0; padding: 0; text-decoration: underline dashed;
  text-decoration-color: color-mix(in srgb, var(--journey-link, var(--accent)) 55%, transparent);
  text-underline-offset: .25em; width: auto; }
button.namefield-alt:hover, body.buyer-journey button.namefield-alt:hover {
  background: var(--accent-weak, rgba(37,99,235,.09)); text-decoration-color: var(--journey-link, var(--accent)); }
.namefield-applied { align-items: center; color: var(--ok-ink); display: inline-flex; gap: .25rem; margin-inline-start: .5rem; }

/* A pencil and the word "Edit", top right of the block it changes, and
   only ever beside a reading we have already said we are unsure about
   (Vicky, 2026-08-19). The 2026-08-03 objection this still answers was an
   unlabelled pencil floating off to the right of rows it never claimed —
   the fix was a label, not a position, so the label is what stayed.

   Not underlined: it is an action on the block beside it, not a link out of
   the page, and the underline was what made it read as one. flex: none so
   the label never wraps into two lines against a long reading.

   WCAG 2.2 2.5.8 (target size, minimum): the 24px floor is on min-height,
   with the padding doing the width — a min-width would strand the tint away
   from the words on a locale whose "Edit" is one short word. */
button.namefield-edit, body.buyer-journey button.namefield-edit {
  align-items: center; background: none; border: 0; border-radius: var(--r-md); box-shadow: none;
  color: var(--journey-link, var(--accent)); cursor: pointer; display: inline-flex;
  flex: none; font: inherit; font-size: var(--bj-support, .85rem); gap: .3rem;
  line-height: 1.4; margin: 0; min-height: 24px; padding: .15rem .35rem;
  text-decoration: none; white-space: nowrap; width: auto; }
button.namefield-edit:hover, body.buyer-journey button.namefield-edit:hover {
  background: var(--accent-weak, rgba(37,99,235,.09)); }
.namefield-edit svg { flex: none; }
/* Visually hidden, still read out: the live region, and the sentence that
   describes the Edit control (see name-field.js — it is a description
   rather than the accessible name so WCAG 2.5.3 holds in every language). */
.namefield-live, .namefield-vh { block-size: 1px; clip-path: inset(50%); inline-size: 1px; overflow: hidden; position: absolute; }

/* namefield-more is the ADMIN fold — title, middle name, suffix and goes
   by, folded because almost nobody fills them in. Its own class rather
   than namefield-details: that one names the buyer field's reading
   machinery, and TestAdminAttendeeNameFields exists to keep the buyer
   field out of admin. Same look, so it rides the same rules below. */
.namefield-details, .namefield-more { margin: .45rem 0 0; }
/* WCAG 2.2 2.5.8 (target size, minimum): a disclosure summary IS a pointer
   target, and at .85rem this row measured 140×20px on a 390px screen — the
   admin screen audit caught it the day it shipped. min-height rather than
   padding, so the row grows to the floor without adding a gap above the
   fields it opens. Admin only: the buyer field's own summary sits in the
   journey layout and is audited separately. */
.namefield-more > summary { min-height: 24px; }
/* The disclosure marker is the house .caret (shared/caret.css) — the same
   one the admin menus use — not a text glyph (Vicky, 2026-08-03). */
.namefield-details > summary, .namefield-more > summary, .namefield-all > summary { align-items: center; color: var(--muted, var(--text-muted)); cursor: pointer; display: flex; font-size: var(--bj-support, .85rem); list-style: none; width: fit-content; }
.namefield-details > summary::-webkit-details-marker, .namefield-more > summary::-webkit-details-marker, .namefield-all > summary::-webkit-details-marker { display: none; }
.namefield-details[open] > summary, .namefield-more[open] > summary, .namefield-all[open] > summary { margin-bottom: .35rem; }
/* "View all name fields" is the way further down, not the next thing to
   read: it sits at the right edge, out of the reading order the words and
   the reading above it establish (Vicky, 2026-08-03). */
.namefield-all > summary { margin-left: auto; }
/* A tighter, deliberate rhythm inside the BUYER field. The page-wide label
   margin is set for standalone form rows; stacked two-up rows inside a
   disclosure read as drifting apart at that spacing (Vicky, 2026-08-03).
   The admin variant keeps the house rhythm — it IS a standalone form. */
.namefield-details .name-row label, .namefield-more .name-row label { margin-top: 0; }
.namefield-details .name-row + .name-row, .namefield-more .name-row + .name-row { margin-top: .55rem; }

/* Admin: six fields in a form, so they breathe like the rest of one. The
   pairs sit closer to each other than to the field above and below, so a
   row reads as one thing rather than two loose columns. */
.namefield-plain .name-row { gap: 1.1rem 1rem; }
.namefield-plain .name-row + .name-row { margin-top: 1.1rem; }
.namefield-plain .name-row label { margin-top: 0; margin-bottom: .3rem; }
.namefield-plain { margin-bottom: 1.4rem; }
.namefield-all { border-top: 1px solid var(--hairline, var(--line)); margin-top: .8rem; padding-top: .7rem; }
/* Only once the fields are actually showing: a rule between them and
   whatever the form asks for next, so the name stops being one continuous
   run of inputs with the email. */
.namefield-all[open] { border-bottom: 1px solid var(--hairline, var(--line)); padding-bottom: 1rem; }

/* Tap the words that make up the last name. Word chips are deliberately
   NOT buttons-sized — hairline outline, supporting-text weight — because a
   row of big buttons reads as the primary action of the page rather than a
   quiet correction. Still ≥1.5rem tall, for WCAG 2.2 target size. */
.namefield-words { margin: .1rem 0 .2rem; }
.namefield-taphint { color: var(--muted, var(--text-muted)); font-size: var(--bj-support, .85rem); margin: 0 0 .45rem; }
.namefield-wordrow { display: flex; flex-wrap: wrap; gap: .35rem; }
button.namefield-word, body.buyer-journey button.namefield-word {
  align-items: center; background: transparent; border: 1px solid var(--hairline, var(--line));
  border-radius: var(--r-md); box-shadow: none; color: var(--fg, var(--text)); cursor: pointer; display: inline-flex;
  font: inherit; font-size: var(--bj-support, .85rem); gap: .3rem; margin: 0; min-height: 1.75rem;
  padding: .15rem .55rem .15rem .4rem; transition: background .13s, border-color .13s, color .13s; width: auto; }
button.namefield-word:hover, body.buyer-journey button.namefield-word:hover {
  border-color: var(--control-line, var(--line-strong)); }
button.namefield-word[aria-pressed="true"], body.buyer-journey button.namefield-word[aria-pressed="true"] {
  background: var(--accent-weak, rgba(37,99,235,.09)); border-color: color-mix(in srgb, var(--journey-link, var(--accent)) 32%, transparent);
  color: var(--accent-ink, var(--journey-link, var(--accent))); font-weight: 550; }
/* Every chip carries a mark: a faint cross when the word is not part of the
   last name, the accent tick when it is. Blank space on one side read as an
   alignment bug rather than a state (Vicky, 2026-08-03), and rendering both
   at the same size keeps the row from shifting under the finger.  */
.namefield-tick { display: inline-flex; line-height: 0; transition: color .12s; }
.namefield-tick.is-off { color: var(--muted, var(--text-muted)); opacity: .5; }
.namefield-tick.is-on { color: var(--journey-link, var(--accent)); opacity: 1; }
@media (prefers-reduced-motion: reduce) {
  .namefield-tick, button.namefield-word { transition: none; }
}

/* shared/phone_field.css */
/* A phone answer is two controls, one question: the country (its calling
   code) and the national number share a row. The select stays as narrow as
   its chosen option; the number input takes the room — it's the part being
   typed. min-width:0 lets both shrink inside narrow columns instead of
   overflowing them. */
/* The PAIR takes the form's measure, not each half of it: capping the two
   controls individually made one question twice as wide as every other field
   in the column (2026-08-21). The fallback keeps this component whole on the
   surfaces that have no --bj-field, where it stretches as it always did. */
.field-phone { display: flex; gap: .5rem; max-width: var(--bj-field, none); }
.field-phone select { flex: 0 1 auto; min-width: 0; max-width: 45%; }
.field-phone input { flex: 1 1 auto; min-width: 0; }
/* Inside the pair the halves are sized by the flex above, so neither may
   carry the column's cap of its own. */
body.buyer-journey form:not([data-checkout]) .field-phone :where(select, input) { max-width: none; }
body.buyer-journey form:not([data-checkout]) .field-phone select { max-width: 45%; }

/* shared/footer.css */
footer { color: var(--muted); display: flex; font-size: .8rem; gap: 1rem; margin-top: 3rem; }

/* shared/powered_by.css */
/* ---- "Powered by Tito" ----
   ONE recipe, on every surface a buyer can reach (Vicky, 2026-08-21: "powered
   by Tito shouldn't be underlined", and "it should be the same size
   everywhere, not too prominent").

   It was three. The journey's footer drew it muted at .8rem; the Pro event
   page drew it in full page ink at the body size, a third larger and far
   darker; Classic drew it at the body size in link blue. All three underlined
   it, so the quietest sentence on the page was styled as the loudest link on
   it — on the event page, the most prominent link above the fold after the
   ticket names themselves.

   Keyed on the attribute rather than a class because that is what every
   surface already puts on this anchor: `data-src="style:powered_by"` is how
   Inspect finds it, so the credit cannot be styled here and not be the credit.
   Lives in shared/ and is loaded by BOTH bundles for the same reason caret.css
   is (styles.go) — a copy under styles/event/ would be a second recipe to keep
   in step, which is the drift this rule exists to end.

   Hover restores the underline: it is still a link, and nothing else marks it
   as one. */
[data-src="style:powered_by"] {
  color: var(--muted, currentColor);
  font-size: .75rem;
  opacity: .85;
  text-decoration: none;
}
[data-src="style:powered_by"]:hover { opacity: 1; text-decoration: underline; }
[data-src="style:powered_by"]:focus-visible { border-radius: .2rem; outline: 2px solid currentColor; outline-offset: 2px; }

/* shared/detail_list.css */
.detail-list { display: grid; gap: .35rem 1.5rem; grid-template-columns: max-content 1fr; margin: 1.5rem 0; }
.detail-list dt { color: var(--muted); }
.detail-list dd { margin: 0; }

.detail-list { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg); gap: 0; margin: 1rem 0; padding: 0 1rem; }
/* Inside a .box the card chrome comes from the box — a bordered detail-list
   there reads as a double frame. */
.box > .detail-list { background: transparent; border: 0; border-radius: 0; margin: 0; padding: 0; }
/* Same reasoning under a card — and there the list is joined to the bar above
   it, so it keeps the bar's own horizontal padding rather than losing it. */
.card > .detail-list { background: transparent; border: 0; border-radius: 0; margin: 0; padding: 0 .85rem; }
.detail-list dt, .detail-list dd { border-top: 1px solid var(--line); padding: .6rem 0; }
.detail-list dd { padding-left: 1.5rem; }
.detail-list dt:first-of-type, .detail-list dd:first-of-type { border-top: 0; }
.detail-list dd { font-weight: 500; text-align: right; }

/* A ledger walks one figure down to another, so two rows need a rank the flat
   list doesn't have. `.sub` comes OFF the figure above it — indented, and its
   <small> names what it was. `.rule` is what the rows above it add up to:
   the heavier hairline is the line you'd draw with a pen. */
.detail-list dt.sub { padding-left: 1.1rem; }
.detail-list dt small { color: var(--text-muted); display: block; font-size: 12px; font-weight: 400; }
.detail-list dt.rule { color: var(--text); font-weight: 600; }
.detail-list dt.rule, .detail-list dt.rule + dd { border-top-color: var(--line-strong); font-weight: 600; }

/* focus mode — edit/create views get modal manners without a modal: the
   save footer pins to the bottom of the scroll. The column keeps the
   standard width, so show ↔ edit never jumps. All CSS; a normal form POST
   underneath. (The sidebar used to fade here too — dropped, it read as a
   rendering glitch.) */

/* shared/sr_only.css */
/* The 1px box only stays 1px if nothing pads it. A .sr-only BUTTON inherits
   button.css's padding, and with content-box that padding is added OUTSIDE
   the 1px — so every list's sr-only search submit measured 28×14 and read to
   the screen audit as a real tap target too small to hit (WCAG 2.2 SC 2.5.8).
   It was never a pointer target at all: it is clipped. Zeroing the box is
   what makes the element as small as it claims to be. */
.sr-only { border: 0; clip: rect(0 0 0 0); clip-path: inset(50%); height: 1px; overflow: hidden; padding: 0; position: absolute; white-space: nowrap; width: 1px; }

/* shared/callout.css */
/* Callouts & alerts (from #69, re-toned to our palette). An in-flow block that
   draws the eye without stealing it: info (our blue, default), success (green),
   warning (amber), danger (red) — all our tokens, quiet tints in the same
   family as .notice. One callout vocabulary, three sizes: .notice is the
   one-line "Saved." confirmation chip, .form-error the inline field-validation
   line, <callout> the block-level alert with an icon + optional <strong> title
   + body. A danger callout reporting a live problem gets role="alert" in the
   markup; colour is never the only signal — the text (and icon) carry it too
   (WCAG 2.2 AA).
   The four things a callout lines up, settled 2026-08-22 (Vicky) and measured
   on every shape in the admin and on the buyer journey:
     - the ICON is top-left, its optical centre ON the first text line — level
       with the title when there is one, with the body line when there isn't.
       Never floating centered over a tall block.
     - the TEXT starts beside it on that same line, and the whole icon+text row
       is centred in the box, one line or many. That falls out of equal padding
       once the icon box stops being taller than the line it sits on — see
       .co-ic below for the 1.88px that did not.
     - a worded ACTION is centred in the BOX, at the right — the middle of the
       whole alert, not the middle of its first line.
     - a dismiss X, where there is no worded action to centre, takes the
       top-right CORNER instead. */
callout { align-items: flex-start; background: color-mix(in srgb, var(--accent) 8%, var(--bg));
  border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--line)); border-radius: var(--r-lg);
  display: flex; gap: .55rem; margin: 1rem 0; padding: .7rem .85rem; }
/* An author `display:` outranks the UA's [hidden] rule, so a callout a
   template ships hidden would lay out perfectly visibly without this — the
   attribute is right there in the markup, which is what makes it so hard to
   spot. Restated here rather than at each call site so it is true of every
   callout, not the ones somebody remembered. */
callout[hidden] { display: none; }
/* height = the first line's OWN line-box, which is `1.5em` and never `1.5rem`:
   .co-body sets `line-height: 1.5`, unitless, so the first line is 1.5 × the
   font-size the callout INHERITED — and that is not 16px everywhere. The admin
   shell runs at 13.5px (app_shell.css: "the admin is an instrument panel"), so
   a 1.5rem box was 24px against a 20.25px line: 3.75px too tall. Both boxes are
   top-aligned, so the icon's centre sat 1.88px BELOW the title's, and on a
   one-line callout the oversized icon drove the row height and pushed the
   sentence 1.88px ABOVE the box's middle. Every callout in the admin — the
   whole product — carried it (Vicky, 2026-08-22: "some of them look slightly
   off"). Measured, not guessed; the buyer surfaces are 16px and are unchanged
   by this to the pixel, which is exactly why it went unnoticed on the
   styleguide's own terms.
   `em` is the fix rather than a second hardcoded number because it tracks
   .co-body's own line by construction: any surface that rescales the shell gets
   the alignment for free, and there is no figure here to go stale. */
callout > .co-ic { align-items: center; color: var(--accent); display: inline-flex; flex: none;
  height: 1.5em; justify-content: center; width: 1.25rem; }
callout > .co-ic svg { display: block; height: 1.25rem; width: 1.25rem; }
callout > .co-body { flex: 1 1 12rem; line-height: 1.5; min-width: 0; }
callout > .co-body > strong { display: block; font-weight: 600; }
callout > .co-body > p { color: var(--muted); margin: .15rem 0 0; }
callout > .co-body > p:only-child { color: inherit; margin: 0; }
/* An icon-only dismiss sits in the callout's top-right CORNER, at every width
   (Vicky, 2026-08-14). It used to ride the vertical centre on a wide screen
   and only take the corner on a phone, which meant the same control moved as
   the window resized — the corner is where a dismiss lives, and it does not
   depend on how much text happens to be beside it.

   OUT OF FLOW, which is what makes one rule work at both widths. The wrap rule
   below is about a callout whose action is a button with WORDS — those need
   the width and stay in flow; a 32px x does not.

   This rule carried `flex-wrap: nowrap` for a day, and it is gone because the
   thing it was holding back moved. It was stopping .co-body — then `min-width:
   0` with no basis — from dropping to its own line under the icon when the row
   wrapped. .co-body now has `flex: 1 1 12rem`, which keeps it beside the icon
   on a 375px phone by construction, so nowrap had stopped earning its keep and
   started costing: it outranks the ≤620px wrap rule below, so the series
   nudge's "Create a series" refused to drop and squeezed the body to one word
   per line instead — the exact failure the comment above records nowrap being
   introduced to fix, arriving from the other direction. Checked at 375px and
   1280px on the one callout that has both a worded action and a dismiss.
   The padding is still what stops the title running under the x.

   One :has(), with the whole path inside it — :has() may not be NESTED in
   another :has(), and a selector breaking that rule is thrown away silently
   (TestNoNestedHas).

   width: auto on both boxes — the form takes a width from the admin bundle,
   and a positioned box with an explicit width does not shrink-wrap, so the x
   sat mid-callout while its own box measured correctly against the corner. */
callout:has(> .co-act > .icon-button) { padding-right: 2.75rem; position: relative; }
callout > .co-act:has(> .icon-button) { height: auto; margin: 0; position: absolute; right: .5rem; top: .5rem; width: auto; }
callout > .co-act:has(> .icon-button) > .icon-button { width: auto; }
/* A callout body may carry a short list (e.g. CSV rows to fix) — same
   register as its paragraphs: muted, tight. */
callout > .co-body > ul { color: var(--muted); margin: .15rem 0 0; padding-left: 1.1rem; }
callout > .co-body > ul > li { margin: .15rem 0; }
callout[tone=success] { background: color-mix(in srgb, var(--ok) 10%, var(--bg)); border-color: color-mix(in srgb, var(--ok) 34%, var(--line)); }
callout[tone=success] > .co-ic { color: var(--ok-ink); }
callout[tone=warning] { background: color-mix(in srgb, var(--warn) 13%, var(--bg)); border-color: color-mix(in srgb, var(--warn) 40%, var(--line)); }
callout[tone=warning] > .co-ic { color: var(--warn-ink); }
callout[tone=danger] { background: color-mix(in srgb, var(--danger) 9%, var(--bg)); border-color: color-mix(in srgb, var(--danger) 34%, var(--line)); }
callout[tone=danger] > .co-ic { color: var(--danger-ink); }
/* QUIET — the aside that answers a question without claiming to be news
   (Vicky, 2026-08-28, on Custom themes: the info callout pointing at the
   Customize studio "still needs to be more subtle"). It is the fourth tone
   rather than a muted line because the reader IS asking the question it
   answers, and muted grey text reads as skippable — the two dials are tone
   and prominence, and this turns tone down without turning prominence off.
   No tint and a plain --line hairline: what makes a callout loud is the
   COLOUR, so taking the blue out is the whole change. The body keeps the
   normal ink (--text-muted on its own tint is a contrast fight nobody needs),
   and only the box and the mark go quiet — so the sentence is exactly as
   readable as it was, in a container that no longer announces itself.
   The link still takes --accent-ink: it is a link. */
callout[tone=quiet] { background: none; border-color: var(--line); }
callout[tone=quiet] > .co-ic { color: var(--text-muted); }
callout[tone=quiet] > .co-body a { color: var(--accent-ink); }
/* A link inside the danger callout takes the tone's INK, for the same reason
   the icon above it does: --accent on this tint measures 4.37 in light, which
   is under AA for body text, and the link is part of the danger message
   rather than a stray blue thing sitting in it. Measured 6.2 after. Only
   danger has one today (the Workflows list's "See what happened"); the other
   two tones get the same rule when they earn it, not before. */
callout[tone=danger] > .co-body a { color: var(--danger-ink); }
/* ...and the default (info) tone has now earned it, which is why the note
   above says "not before" rather than "never". The events list's series
   invitation is the first link in an untoned callout, and --accent on the
   8% accent tint measures 4.40 in light — the same near-miss, from the same
   cause (AGENTS.md's rule: text on a tint is always --accent-ink). Measured
   5.68 light / 9.00 dark after. Success still has no body link; warning has
   one as of 2026-08-14 and takes its own ink, below.

   `:not([tone])` IS how the default is spelled, here and in
   public/journey_callout.css, and there is exactly one way to write it in
   markup: leave the attribute off. `<callout tone="info">` looks like the
   more careful spelling and is not — it matches neither of these rules, so
   it silently kept the 4.40 link on two admin pages until 2026-08-14.
   TestCalloutTonesAreSpelledTheWayTheCSSReadsThem refuses it now; a tone
   only exists here if it has a `callout[tone=…]` rule of its own. */
callout:not([tone]) > .co-body a { color: var(--accent-ink); }
/* Warning earns the same rule as danger above: the duplicate's "Review
   coupons" and the message preview's "show it blank" are the first links to
   sit on an amber tint. */
callout[tone=warning] > .co-body a { color: var(--warn-ink); }

/* The action on the right (.co-act), which was ticket-requests-only vocabulary
   until 2026-08-14 and is now how EVERY callout carrying one action is built.
   The alternative — the link on its own third line under the body — is what
   the shape replaced: it made the commonest alert in the admin three stacked
   lines tall for one short label, and put the one thing you are meant to press
   furthest from the eye. Wide screens end the row with it; under 620px it
   drops beneath the words rather than squeezing the sentence that explains it.
   The 12rem basis on .co-body is what makes that drop happen on its own when
   the row cannot hold both — and 12 rather than 18 because the basis is read
   BEFORE any shrinking: at 18rem a 375px phone could not fit the icon and the
   body on one line either, so the words wrapped under the icon and left it
   sitting alone above the title.
   Note this is the DIRECT child only: `<p class="co-act">` INSIDE .co-body is
   a different, older thing (the importers' inline "Undo") and keeps its own
   below-the-body placement. */
/* The action is centred in the BOX (Vicky, 2026-08-22) — the middle of the
   whole alert, not the middle of its first line. On a one-line callout those
   are the same place; on a title-plus-body one the button used to sit 9.4px
   high, level with the title, with empty box under it.
   THE HEIGHT IS WHAT MAKES `center` SAFE, and the pair must not be split up.
   A worded button is ~29px against a ~20px line, so a .co-act sized by its own
   content is the tallest thing in a one-line row: it drives the row height,
   and the icon and the sentence — both top-aligned — end up ~2.5px above the
   button's middle (Vicky, 2026-08-20: "alignment on this is slightly off").
   That is the failure the previous rule fixed by anchoring to the first line,
   and it is a real one. Pinning .co-act to `1.5em` — the same line-box .co-ic
   takes, and `em` for the same reason: the admin shell is 13.5px, not 16 —
   fixes it at the cause instead: the box is the height of a line, the button
   overflows it ~4px each way into the .7rem padding, and the row is back to
   being as tall as its text. So `center` now means what it says, and gets the
   multi-line case right as well.
   Reverting `height` alone silently reintroduces the 2026-08-20 bug; reverting
   `align-self` alone reintroduces the 2026-08-22 one.
   Safe because .co-act is `flex: none` — it takes its content's width, so
   the button never wraps to a second line and never outgrows the box. */
callout > .co-act { align-items: center; align-self: center; display: inline-flex; flex: none;
  gap: .4rem; height: 1.5em; margin-left: auto; }
/* a.button ships margin-top: 1.25rem for the end of a form, which inside a
   callout drops the button off its row (and, where two of them sit together,
   was the only thing keeping them apart). Reset here rather than leaning on
   .compact: that class lives in an admin-only stylesheet and the shape has to
   hold on any surface callout.css is bundled into. */
callout > .co-act > .button { margin: 0; }
@media (max-width: 620px) {
  callout:has(> .co-act) { flex-wrap: wrap; }
  callout > .co-act { margin: .55rem 0 0 1.8rem; } /* clears the icon column */
}
/* The button wears its callout's tone, for the reason the body links below do:
   a plain blue-bordered button in an amber box reads as a stray control that
   landed in the alert rather than the alert's own answer. Its fill is
   --surface, not the tint, so each tone's ink clears AA on it comfortably.
   :hover would otherwise take button.css's neutral --text-faint border and
   drop the tone at exactly the moment the pointer is on it.
   A .primary button is excluded rather than overridden: Ticket requests' "Run the
   draw" is the page's one action and is meant to keep its accent fill. Excluded
   IN the selector, so there is no pair of rules whose order decides it.

   The border is a 70% mix of the tone's INK, and the number was measured
   rather than picked. This button's fill is --surface on a tint it barely
   differs from (1.14:1 in light), so the border really is the only thing
   drawing the control — WCAG 2.2 SC 1.4.11's 3:1, not the decorative hairline
   budget. The tone token itself (--warn at 45%) came to 1.46:1, and the
   neutral --line-strong it replaced was worse at 1.25:1, so this had been
   under the bar on the Ticket requests page since it shipped. Measured against
   both the tint and the button's own fill, light is the binding case: 70%
   gives 3.36-3.67 on the tint (success is the floor) and 3.90-4.34 on the
   fill, with dark at 5.15-6.41. Anything that lifts the tints has to
   re-measure this. */
callout > .co-act > .button:not(.primary):hover { border-color: currentColor; }
callout[tone=success] > .co-act > .button:not(.primary) { border-color: color-mix(in srgb, var(--ok-ink) 70%, var(--line-strong)); color: var(--ok-ink); }
callout[tone=warning] > .co-act > .button:not(.primary) { border-color: color-mix(in srgb, var(--warn-ink) 70%, var(--line-strong)); color: var(--warn-ink); }
callout[tone=danger] > .co-act > .button:not(.primary) { border-color: color-mix(in srgb, var(--danger-ink) 70%, var(--line-strong)); color: var(--danger-ink); }
callout:not([tone]) > .co-act > .button:not(.primary) { border-color: color-mix(in srgb, var(--accent-ink) 70%, var(--line-strong)); color: var(--accent-ink); }

/* shared/form_extras.css */
/* Field widths — a box is as wide as what goes in it.
 *
 * A 500px-wide box for a two-digit quantity is the loudest generic-form tell
 * there is: it tells the reader nothing about what is expected, and it makes a
 * six-field form look like six identical slots rather than six different
 * questions. The app already knew this — `short` has existed for the quantity
 * boxes all along — it just was not applied, so 167 of 224 fields defaulted to
 * 100% (Vicky, 2026-08-27: size every input to its content).
 *
 * The scale is in `ch` where the content is TEXT, because a ch is the width of
 * a "0" in the field's own font and therefore tracks the type; and in `rem`
 * where the box is really a CONTROL whose size is about the tap target rather
 * than the content.
 *
 * Sizes are generous on purpose. The failure mode of too-wide is a form that
 * looks untended; the failure mode of too-narrow is a person unable to see
 * what they typed, which is worse. When in doubt, go one step up.
 *
 * The first pass went one step too far the other way (Vicky, 2026-08-29: "the
 * short text fields are too narrow now"), and the reason is that the admin
 * sets type at 13.5px. A `ch` tracks the type, which is the point — but it
 * means the same 22ch that reads as a comfortable name box at 16px is 167px
 * here, a quarter of the 820px form it sits in, and the field ends up looking
 * stranded above a full-width description box. `mid` could not show its own
 * placeholder ("What are your dietary requirements?") to the end. So the two
 * TEXT steps each went up TWO: a `mid` now fits about 46 characters and a
 * `long` about 62, and every step stays visibly a different size from its
 * neighbours, which is what makes the scale legible at all. (30/44 was the
 * first try at this fix and she asked for wider still, so do not read these
 * as a ceiling arrived at cautiously — they are the width she picked.)
 *
 * What the wider numbers buy is a RELATIONSHIP rather than a character count:
 * a `mid` is now about half the 820px form it sits in and about two-thirds of
 * a settings modal's 605px column, so a short-answer box reads as the short
 * one NEXT TO the full-width description under it, instead of as a box that
 * ran out of room.
 *
 * `full` stays for genuinely full-width content: a description, a textarea, a
 * value whose length nobody can predict.
 */
.full { width: 100%; }

/* EVERY SIZE IS A PREFERENCE, NEVER A FLOOR — hence max-width on all of them.
 * A `ch` width does not shrink on its own, so at 320px the `long` fields ran
 * past the viewport and /admin/events/new scrolled sideways (caught by
 * TestAdminNarrowUI, which is a browser test and therefore invisible to
 * `go test -short`). max-width: 100% costs nothing at any comfortable width and
 * hands the narrow case back to the container. */
input.short, input.code, input.mid, input.long { max-width: 100%; }

/* A number, a count, a quantity, a number of days. */
input.short { width: 6.5rem; }
/* A reference, a code, an IBAN fragment, anything typed in caps or digits.
 *
 * `code` IS NOT DEFINED HERE — money_input.css owns it, and has since long
 * before this scale existed: `input.code { font-family: ui-monospace…;
 * width: 11ch }`, unscoped, so it reaches any field wearing the class. This
 * file listing it again at the same width was a duplicate waiting to drift.
 *
 * Reusing the name therefore ships the MONOSPACE FACE too, not only a width.
 * That is kept deliberately rather than by accident: the seven fields the
 * sizing sweep gave `Size: "code"` are a BIC, a VAT number, a company
 * registration number, a location id, a ref prefix and two workflow mapping
 * keys — all of them typed in caps and digits, all of them read character by
 * character, which is what a monospace face is for. If a field wants the
 * width WITHOUT the face, it wants `short`, not a new class. */
/* A short human answer: a name, a label, a title, a subject, a town. */
input.mid { width: 46ch; }
/* A long one that is still bounded: an email, a URL, a domain, an address. */
input.long { width: 62ch; }

/* Dates and times size THEMSELVES. A native date control draws its own
 * spinners and its own placeholder text, so its intrinsic width is already the
 * right one — stretching it to 100% leaves a picker with a hand's width of
 * empty box after it, which is where this looked worst of all. `max-width`
 * rather than `width`, so a narrow column can still squeeze it. */
input[type=date], input[type=time], input[type=datetime-local] { max-width: max-content; width: auto; }

/* shared/money_input.css */
/* money-input: a price box with its currency pinned in a leading cell — an
   amount in it always reads as flat money, never a percentage. The house
   pattern for every admin price/amount input (money-field partial). The
   ring treats the whole control as one field, like the screenshot pattern. */
.money-input { border: 1px solid var(--line); border-radius: var(--r-md); display: inline-flex; }
.money-input > .cur { align-items: center; background: var(--surface-2); border-right: 1px solid var(--line); border-radius: var(--r-md) 0 0 var(--r-md); color: var(--text-muted); display: flex; padding: 0 .6rem; }
.money-input > input { border: 0; width: 6.5rem; }
.money-input:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
.money-input > input:focus-visible { outline: none; }
input.code { font-family: ui-monospace, "SF Mono", Menlo, monospace; width: 11ch; }

/* shared/path_input.css */
/* path-input: a web-address box with the part the organizer doesn't type
   pinned in a leading cell — the series an event sits under (event_path.go).
   Deliberately the money-input pattern, borrowed rather than reinvented: the
   ring treats the whole control as one field, and the cell is quiet enough to
   read as context rather than as a second input. */
.path-input { border: 1px solid var(--line); border-radius: var(--r-md); display: flex; width: 100%; }
.path-input > .pre { align-items: center; background: var(--surface-2); border-radius: var(--r-md) 0 0 var(--r-md); border-right: 1px solid var(--line); color: var(--text-muted); display: flex; padding: 0 .6rem; white-space: nowrap; }
.path-input > .pre[hidden] { display: none; }
.path-input > input { border: 0; flex: 1; min-width: 0; }
.path-input:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
.path-input > input:focus-visible { outline: none; }

/* The addresses an event used to answer at, under the field that renamed it.
   Quiet by construction: this is a reassurance somebody went looking for, not
   news — the help line above it makes the promise and this makes it checkable. */
.old-addresses { margin: -.15rem 0 .9rem; }
.old-addresses .oa-lead { color: var(--text-muted); font-size: 12px; }
.old-addresses ul { list-style: none; margin: .2rem 0 0; padding: 0; }
.old-addresses li { padding: .12rem 0; }
/* The tint hugs the address rather than the row: three full-width mono chips
   stacked read as one filled block, not as three things you could copy. */
.old-addresses li .mono { font-size: 12px; }

/* shared/check_label.css */
label.check { align-items: center; color: inherit; display: flex; font-size: 1rem; gap: .45rem; margin: .8rem 0 .2rem; }
/* field-desc: an organizer's explainer under a custom-field question —
   quieter than the label, tight to the control it introduces. Inside a
   check label it stacks under the question text, so the label, description
   and box are one hit area (the label wraps the input). */
.field-desc { color: var(--muted); display: block; font-size: .8rem; font-weight: 400; }
/* A checkbox QUESTION is a form field, not an action row — its title
   matches the sibling field labels, not the 1rem check voice. */
label.check.check-field { color: var(--muted); font-size: .85rem; }
p.field-desc { margin: -.1rem 0 .3rem; }
label .field-desc { margin: .15rem 0 0; }
label.check:has(.field-desc) { align-items: flex-start; }
label.check:has(.field-desc) > input { margin-top: .3rem; }
/* Inside the 13.5px admin shell a 1rem check label towers over body text. */
app-shell label.check { font-size: inherit; }
/* A checkbox QUESTION is the one raw checkbox left in the admin — the house
   toggle is the switch (field-check) and the list checkbox is drawn
   (bulk_select.css), so this row was the only control on an admin form still
   wearing the browser's own box, beside fields that all wear ours. Same
   drawing as the list checkbox, for the same reason it is drawn there: an
   empty bordered square until ticked, never a filled block.

   Scoped to a custom field's own rows and nothing else — .check-field (a
   checkbox question) and .cg-opt (one answer of a choose-several question,
   check_group.css). Every other admin checkbox is already hidden behind a
   switch or drawn by its own component, and widening this further would
   repaint all of them. The two share the drawing because they are the same
   thing from the organizer's side: a box they tick about an attendee. */
/* margin-inline, because the browser's own 4px/3px sat under this drawing and
   nothing here had replaced it: a checkbox QUESTION was starting 4px right of
   every field label above it, near enough to read as a mistake rather than as
   an indent (Vicky, 2026-08-22). The buyer's copy of this control has zeroed
   it since it was drawn. The BLOCK margin is left alone — .3rem of it is set
   above, deliberately, to hold the box on the first line of a row that has a
   description under it. */
app-shell :is(.check-field, .cg-opt) > input[type=checkbox] {
  appearance: none; background: var(--surface); border: 1.5px solid var(--line-strong);
  border-radius: var(--r-sm); cursor: pointer; flex: none; height: 16px; margin-inline: 0;
  position: relative; width: 16px; }
app-shell :is(.check-field, .cg-opt) > input[type=checkbox]:hover { border-color: var(--text-faint); }
app-shell :is(.check-field, .cg-opt) > input[type=checkbox]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
app-shell :is(.check-field, .cg-opt) > input[type=checkbox]:checked { background: var(--accent); border-color: var(--accent); }
app-shell :is(.check-field, .cg-opt) > input[type=checkbox]:checked::after {
  border: solid var(--on-accent); border-width: 0 2px 2px 0; content: ""; height: 9px; left: 50%;
  position: absolute; top: 50%; transform: translate(-50%, -62%) rotate(45deg); width: 5px; }
/* Form voice, admin: a label is a question — text-coloured and clearly
   heavier than its hint, tight to its own control (.3rem) with the breathing
   room above (1.15rem), so each field reads as one unit and the gap between
   fields is unmistakably bigger than the gap inside one. Check/switch rows
   and toggle-block heads carry values, not questions — they stay quiet. */

/* THE BUYER PAGE'S TICKBOX: 15px, drawn, filled with the page's own ink
   (Vicky, 2026-08-19). journey_form.css sizes every buyer checkbox and radio
   at 20px with accent-color, and at that size a saturated fill reads as a
   slab rather than as a mark — it was the single biggest thing making a
   choose-several question look clunky, and the note applied to the whole
   class rather than to that one control.

   ONE drawing for every tickbox a buyer meets, so a terms box, "send me the
   first ticket", a checkbox question and one answer of a choose-several
   question are all the same control. .cg-opt rides this too (check_group.css
   says so and draws nothing itself). The sibling radio treatment lives in
   yesno_field.css, which is where a buyer meets a radio.

   :where() in journey_form.css carries no specificity, so the class alone
   wins here. --fg on --journey-card is the ink-on-card pairing every word on
   these pages already uses, which is what makes the tick legible on whatever
   palette an organizer picked, and it keeps the account's colour for the
   things that ARE the account's colour rather than spending it on every box.

   Admin is deliberately NOT included: its own drawing sits above, at 16px
   with --accent, and it matches the switches and list checkboxes it sits
   among. */
body.buyer-journey label.check > input[type=checkbox] {
  appearance: none; background: var(--journey-card, #fff); block-size: 15px;
  border: 1.5px solid var(--control-line); border-radius: var(--r-sm); cursor: pointer;
  flex: none; inline-size: 15px; margin: 0; position: relative; }
body.buyer-journey label.check > input[type=checkbox]:hover {
  border-color: color-mix(in srgb, var(--fg) 45%, var(--control-line)); }
body.buyer-journey label.check > input[type=checkbox]:focus-visible {
  outline: 2px solid var(--p-accent); outline-offset: 2px; }
body.buyer-journey label.check > input[type=checkbox]:checked {
  background: var(--fg); border-color: var(--fg); }
body.buyer-journey label.check > input[type=checkbox]:checked::after {
  border: solid var(--journey-card, #fff); border-width: 0 2px 2px 0; content: ""; height: 8px;
  left: 50%; position: absolute; top: 50%; transform: translate(-50%, -58%) rotate(45deg); width: 4px; }

/* shared/check_group.css */
/* check_group.css — the answers of a "choose several" question
   (multiselect_field.html). ONE bordered field with a hairline between each
   answer (Vicky's shape B, 2026-08-19), so the question still reads as a
   single control the way the list box it replaced did, and a form of these
   still scans as label-then-field down the page.

   A box around every answer was built first and rejected: four separate
   cards is a lot of furniture for four words, and it read as heavy beside
   the plain inputs above it.

   The whole row is the hit area, which is what makes ticking one thing one
   tap on a phone, and a real checkbox stays visible because it is the thing
   that says "you may pick several of these" — exactly what the <select
   multiple> never said.

   NOTHING marks a picked row except its own tick. A tint or a ring per row
   would put four emphasis treatments in one field, which is the weight this
   shape exists to lose; it also keeps the component clear of the
   colour-on-tint contrast rule (colors.css, TestAccentOnTintIsInk) with no
   per-surface palette to reason about.

   THE TICK ITSELF IS 15px AND FILLS WITH THE PAGE'S OWN INK (Vicky,
   2026-08-19). The buyer journey's native checkbox is 20px with
   accent-color (journey_form.css), and at that size a saturated fill reads
   as a slab rather than as a tick — "the giant checkboxes with the purple
   fill" were doing more of the clunk than the rows around them. Filling
   with --fg rather than --p-accent keeps the account's colour for the
   things that ARE the account's colour (links, the accent button) instead
   of spending it four times inside one answer.

   Every tickbox on a buyer page took the same treatment in the end
   (check_label.css), so a terms checkbox and a choose-several answer still
   look like the same control. Leaving this component alone would have put
   two sizes of checkbox in one form.

   It takes the width of the field it sits among rather than a cap of its own:
   two one-word answers read as buttons when they stretch, but a list of
   answers is the same shape as the <select class="full"> it replaced, and
   anything narrower than the boxes above it reads as a different kind of
   thing. On a buyer page those boxes are --bj-field wide (journey_tokens.css)
   and so is this — the rule still holds, against a measure now rather than
   against whatever the column happened to leave. */
.checkgroup { border: 1px solid var(--field-line); border-radius: var(--r-md); display: flex;
  flex-direction: column; margin: 0 0 .2rem; overflow: hidden;
  --cg-accent: var(--accent); }
/* margin overrides label.check's own .8rem/.2rem: that spacing separates one
   checkbox QUESTION from the next field, and inside a group it would push the
   rows off their own dividers.

   THE SIDE PADDING IS THE SURROUNDING FORM'S OWN TEXT INSET, not a number of
   this component's choosing (Vicky, 2026-08-22, on the admin's add-order
   screen: "alignment of this element is a bit off"). This box is the same
   shape as the <select class="full"> above it, so the eye reads the two as
   one column and catches a tick that starts a couple of pixels right of the
   words in the box above it. The inset that makes them agree is
   border + padding of the plain controls beside it, which is a different
   number per surface: .6rem here for the admin's 1px + .6rem fields, .65rem
   on a buyer page for its 1px + .65rem ones (journey_form.css). Change one of
   those and this follows it. */
.checkgroup .cg-opt { align-items: center; border-top: 1px solid var(--line); cursor: pointer;
  display: flex; gap: .55rem; margin: 0; padding: .6rem; }
.checkgroup .cg-opt:first-child { border-top: 0; }
.checkgroup .cg-opt:hover { background: color-mix(in srgb, var(--fg) 4%, transparent); }
.checkgroup .cg-opt > input { flex: none; margin: 0; }
/* Inset, because a row's outline drawn outward would sit on top of the
   container's own border and get clipped by its overflow at the ends. */
.checkgroup .cg-opt:has(input:focus-visible) { outline: 2px solid var(--cg-accent); outline-offset: -2px; }

/* Buyer journey: the field border the rest of the journey's controls wear,
   and the account's colour kept for the focus ring, which is the one place
   here it says something. */
body.buyer-journey .checkgroup { border-color: var(--control-line); max-width: var(--bj-field); --cg-accent: var(--p-accent); }
/* The rules BETWEEN the rows take the card's own hairline, not --line
   (2026-08-21). --line is the organizer's decorative border colour, chosen
   against the PAGE, and on a dark theme that is a light colour — so the
   dividers inside this box came out brighter than the box itself and far
   brighter than every field around it: the loudest lines on the screen
   drawn by the quietest thing on it. --hairline is derived from the field
   boundary against this card (journey_tokens.css), which is what every other
   divider on a buyer page already uses. */
body.buyer-journey .checkgroup .cg-opt { border-top-color: var(--hairline); padding-inline: .65rem; }
/* The 15px ink box these rows wear is not drawn here: a .cg-opt IS a
   label.check, so it takes the one buyer-journey checkbox drawn in
   check_label.css along with every other tickbox on a buyer page. */

/* shared/yesno_field.css */
/* yesno_field.css — the two answers of a yes/no question (yesno_field.html).
   Both are cards rather than bare radios so the whole answer is the hit area,
   which is what makes this one tap on a phone; the native input stays visible
   because it is the thing that says "pick one of these", and because a
   checkmark drawn in CSS would disappear with the icon font.

   The pair sits on one row and wraps rather than shrinking: two answers never
   need more than half a line each, and a translation that does ("Ano"/"Ne" is
   short, "Ja, ich brauche eines" is not) is better on two rows than clipped.

   ONE component, two palettes — this partial renders in the admin AND on the
   buyer's own pages, and the buyer journey re-derives its colour from the
   ORGANIZER's palette (journey_tokens.css), where --accent is not the admin
   blue and --accent-weak is a wash of whatever the account chose. Styling
   against the admin's tokens alone put near-black on a grey tint with blue
   text on top of it. So the picked state is expressed once through local
   --yn-* and remapped below. */
/* max-width, because the admin's forms are far wider than two one-word
   answers: without it "Yes" and "No" each stretch to a third of the window
   and the pair reads as two buttons rather than one question's answers. The
   cap is generous enough for the longest translation at either end. */
.yesno { display: flex; flex-wrap: wrap; gap: .5rem; margin: 0 0 .2rem;
  /* 26rem off a buyer page (the admin's own forms), the column's full width
     on one: --bj-field is 100% there, because a box that stops short of the
     card's edge leaves a gutter beside it and the shell does not narrow to
     meet it. */
  max-width: var(--bj-field, 26rem);
  --yn-accent: var(--accent); --yn-line: var(--field-line); }
/* The side padding is the surrounding form's own text inset, so the radio
   starts where the words in the plain controls above it start — the same rule
   a choose-several answer follows, and for the same reason (check_group.css).
   .6rem is the admin's 1px + .6rem field; the buyer's .65rem is set below. */
.yesno .yn { align-items: center; border: 1px solid var(--yn-line); border-radius: var(--r-md);
  cursor: pointer; display: flex; flex: 1 1 8rem; gap: .45rem; margin: 0;
  padding: .5rem .6rem; }
.yesno .yn:hover { border-color: color-mix(in srgb, var(--yn-accent) 40%, var(--yn-line)); }
.yesno .yn > input { accent-color: var(--yn-accent); flex: none; margin: 0; }
.yesno .yn:has(input:checked) { border-color: var(--yn-accent); }
.yesno .yn:has(input:focus-visible) { outline: 2px solid var(--yn-accent); outline-offset: 2px; }

/* Admin: the accent ring plus the tint its choice cards already wear, so a
   picked answer reads the same as every other picked thing in there. Text on
   the tint is --accent-ink, never --accent (which misses AA on it in light —
   see colors.css and TestAccentOnTintIsInk). */
app-shell .yesno .yn:has(input:checked) { background: var(--accent-weak); }
app-shell .yesno .yn:has(input:checked) > span { color: var(--accent-ink); }

/* Buyer journey: the ring ALONE, following reg_tickets.css — no tint and no
   recoloured text. The palette is the organizer's, so we cannot know what a
   tint of it does to a word sitting on it; the ring carries the state at any
   hue, and the answer stays the page's own ink. */
body.buyer-journey .yesno { --yn-accent: var(--p-accent); --yn-line: var(--control-line); }
body.buyer-journey .yesno .yn { padding-inline: .65rem; }
/* The ring goes to ink with the dot. A picked card was carrying the
   account's colour on its edge AND in the middle, which is the same twice
   inside one answer; ink on the edge is a plain difference from the
   unpicked card's grey without the doubling, and it needs no second inset
   line to be seen. --yn-accent stays the account's colour on :focus-visible
   below, which is the one place here it says something. */
body.buyer-journey .yesno .yn:has(input:checked) { border-color: var(--fg); }
/* The dot: 15px, drawn, filled with the page's own ink — the same treatment
   a choose-several answer's tick takes (check_group.css), and for the same
   reason. journey_form.css sizes every buyer radio at 20px with
   accent-color, and at that size a saturated fill is a blob rather than a
   mark; it also spent the account's colour on the answer AND on the ring
   around it, twice inside one question. Ring keeps the colour, dot takes the
   ink. A ring with an inner dot, because that is what a radio looks like —
   the tick shape belongs to the checkbox. */
body.buyer-journey .yesno .yn > input[type=radio] {
  appearance: none; background: var(--journey-card, #fff); block-size: 15px;
  border: 1.5px solid var(--control-line); border-radius: 50%; cursor: pointer;
  inline-size: 15px; position: relative; }
body.buyer-journey .yesno .yn > input[type=radio]:hover {
  border-color: color-mix(in srgb, var(--fg) 45%, var(--control-line)); }
body.buyer-journey .yesno .yn > input[type=radio]:checked { border-color: var(--fg); }
body.buyer-journey .yesno .yn > input[type=radio]:checked::after {
  background: var(--fg); border-radius: 50%; content: ""; height: 7px; left: 50%;
  position: absolute; top: 50%; transform: translate(-50%, -50%); width: 7px; }

/* shared/form_actions.css */
/* Save sits bottom-right everywhere (Vicky, 2026-07-23): actions right-align
   and the escape hatch (Cancel, an <a>) orders before the primary. */
.form-actions { align-items: center; display: flex; flex-wrap: wrap; gap: .9rem; justify-content: flex-end; margin-top: 1.75rem; }
.form-actions button, .form-actions a.button { margin-top: 0; }
.form-actions > a { order: -1; }
/* confirm-check: the one opt-out a confirm step may carry ("and also do
   X?"). .form-actions is a right-aligned flex ROW, so a full basis is what
   puts the question on its own line ABOVE the button rather than beside it —
   it changes what the button does, so it has to be read first. order -2
   rather than the default, because the cancel link above already claims -1
   and a default-ordered box would wrap BELOW the row it governs. */
.form-actions .confirm-check { flex: 0 0 100%; margin: 0; order: -2; text-align: left; }
/* remove-link: the quiet way out of an editor towards its confirm step, on
   its own line under the form's actions and set LEFT, away from Save — the
   two must never read as a pair. It is a link, so it has no spinner and no
   form; the destructive POST lives on the page it leads to. */
.remove-link { margin: 1rem 0 0; text-align: left; }
.remove-link a.button { margin-top: 0; }

/* shared/matrix_wrap.css */
/* .matrix-wrap is for tables that are genuinely WIDER than the box they sit
   in: the Team tab's capability matrix on phones, the CSV sample preview (one
   column per CSV column, so it scales with the file), and the API key list,
   which wants ~649px inside the 614px settings panel on a 1280px desktop.
   They scroll inside their own container, never the page.

   It is NOT a wrapper to reach for by default, because a scroll container on
   one axis is a scroll container on BOTH: per CSS Overflow 3, overflow-x:auto
   forces a visible overflow-y to compute to auto. So a .matrix-wrap clips
   anything a child paints outside its box — an open dropdown, a popover —
   at every viewport, not just the phone width the scroll was written for.
   A table of form controls gets .flow-table instead: the same block box (so
   margins collapse the same way and spacing is unchanged) with no clipping. */
.matrix-wrap { overflow-x: auto; }
.flow-table { display: flow-root; }
.num { font-variant-numeric: tabular-nums; text-align: right; }

/* shared/blank_art.css */
/* blank-art — the section's menu icon over a soft blob. Every colour is a
   theme token, so light and dark come free (no per-theme SVG variants). */
.blank-art { display: block; height: 132px; margin: 0 auto 1rem; position: relative; width: 165px; }
.blank-art .blob { display: block; height: 100%; width: 100%; }
.blank-art .blob .b1 { fill: var(--accent-weak); }
.blank-art .blob .b2 { fill: var(--accent-weak-2); }
.blank-art > svg:last-child { color: var(--accent); height: 52px; inset: 0; margin: auto; position: absolute; width: 52px; }
/* The art breathes — the top blob swells on a slow loop and the dots drift,
   so an empty section reads as dormant rather than dead. The offset blob
   copy keeps its SVG attribute transform (a CSS transform would clobber
   it), so only the top copy and the dots move. */
.blank-art .blob path.b1:last-of-type { animation: blob-breathe 7s ease-in-out infinite alternate; transform-box: fill-box; transform-origin: center; }
.blank-art .blob .b2 { animation: dot-drift 5s ease-in-out infinite alternate; transform-box: fill-box; transform-origin: center; }
.blank-art .blob circle.b2:nth-of-type(2) { animation-delay: -2s; animation-duration: 6.5s; }
.blank-art .blob circle.b2:nth-of-type(3) { animation-delay: -3.5s; animation-duration: 4.5s; }
.blank-art > svg:last-child { animation: icon-bob 7s ease-in-out infinite alternate; }
@keyframes blob-breathe { to { transform: scale(1.1) rotate(-4deg); } }
@keyframes dot-drift { to { transform: translate(2px, -8px); } }
@keyframes icon-bob { to { transform: translateY(-4px); } }
@media (prefers-reduced-motion: reduce) { .blank-art .blob path.b1:last-of-type, .blank-art .blob .b2, .blank-art > svg:last-child { animation: none; } }

/* shared/box.css */
/* ---- Design-system component vocabulary (docs/design-system.md §Component
   vocabulary). Structural components (box, toolbar, overlay, accordion,
   master-detail, app-shell) are classes/elements; leaf components (field*,
   status-pill, badge, pagination, page-header, empty-state, page-loader) are
   partials in admin_partials.html. All use the semantic tokens. */

/* box — the universal container. A screen is a stack of "section header + card":
   the .box-head sits ABOVE its .box (a sibling just before it), so the card holds
   only its own content. */
.box { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg);
  margin: 1rem 0; padding: 1.1rem 1.25rem; }
.box > :first-child { margin-top: 0; }
.box > :last-child { margin-bottom: 0; }
/* box-head — a standalone section header above a card: title (fs-m) + optional
   right-aligned action on one baseline-aligned row. The larger top margin sets
   the vertical rhythm between stacked sections; the smaller bottom margin is the
   gap down to this section's own card, which sits flush beneath it. A box-head
   that opens its container (first child, e.g. a dash-card) needs no top margin. */
/* flex-wrap for the reason .page-header has it, one level down: this row
   cannot shrink past its actions. Every one of them is a button whose text
   does not wrap, so its min-content width IS its max-content width, and a
   heading beside three of them ("Promotion codes" over Generate / Import /
   Add a code) has nothing to give — the heading broke over two lines while
   the buttons scattered across two of their own, each on its own baseline,
   anchored to nothing (Vicky, 2026-08-28, on a phone). Wrapping drops the
   whole action group onto its own line, left-aligned under the title, which
   is what .page-header already does with the same row. It costs nothing at
   any width where the row fits, which is every width the desktop is read
   at. */
.box-head { align-items: baseline; display: flex; flex-wrap: wrap; gap: 1rem; justify-content: space-between;
  margin: 1.5rem 0 .5rem; }
.box-head:first-child { margin-top: 0; }
.box-head + .box { margin-top: 0; }
.box-head h2 { font-size: var(--fs-m); margin: 0; }
/* A SUB-section head: same row, same action slot, a heading one level down.
   The invitation's Answers panel is a block per person, so each name is a
   heading in its own right and the hierarchy has to survive being drawn —
   h2 for the section, h3 for the person. The trailing small is the person's
   role beside their name ("the invitee"), which is not part of the name. */
.box-head h3 { font-size: 13.5px; font-weight: 500; margin: 0; }
.box-head h3 small { color: var(--text-muted); font-size: 11.5px; font-weight: 400; margin-left: .4rem; }
/* hint — a fact about the section, on the box-head's right, where an action
   would otherwise sit. The order's Tickets heading uses it to say how many
   tickets there are and how many are still waiting for a name, which is the
   only place on that page either number is stated. Quiet and small: it
   describes the section rather than offering anything, so it must not read
   as a control or as a status chip. */
.box-head .hint { color: var(--text-muted); font-size: 12.5px; }
/* A box-head's action is a real (compact) button — a .pill there reads as a
   status chip, not a way in (Vicky, 2026-07-23). Quiet "All X →" nav links
   stay pills. */
.box-head a.button { font-size: 12.5px; margin-top: 0; padding: .22rem .65rem; }
.box-foot { border-top: 1px solid var(--line); color: var(--text-muted); margin-top: 1rem; padding-top: .75rem; }

/* list-row — a standalone (outside master-detail) row: swatch/dot, primary
   text, meta, right value/pill; selected = accent-weak fill + accent left bar
   (no full outline ring). */

/* confirm-notes — one bullet per EXCEPTION on a bulk confirm
   (admin_bulk_confirm.html): who is getting a second copy, what is being
   skipped. A list rather than a paragraph, because these are the lines
   somebody has to read before pressing an irreversible button and a run-on
   sentence is what they skim (Vicky, 2026-08-17).

   Muted ink, not danger: an exception is a fact about the press, and red here
   would compete with the danger button that some of these confirms carry. */
.confirm-notes { color: var(--text-muted); margin: .6rem 0 0; padding-left: 1.15rem; }
.confirm-notes li { margin: .3rem 0; }
/* note-preview — the email a note is about, on a bulk confirm step. Shut by
   default: this page's job is the decision, and a full email above the button
   turns a confirm into a scroll (Vicky, 2026-08-25). The iframe is sandboxed
   so a rendered email cannot script or navigate the admin page around it, and
   sized generously enough that the whole of a short email is visible without
   a second scroller inside the first.

   The summary and the edit link are SET to 24px tall rather than left at
   their line box: at 12.5px they measured 19px, which is under WCAG 2.2's
   24x24 minimum for a tap target, and TestAdminScreenAuditEventDesk fails a
   screen that ships one. align-items rather than padding alone, so the words
   stay optically centred in the taller box. */
.note-preview { margin: .35rem 0 .1rem; }
.note-preview > summary { align-items: center; color: var(--accent); cursor: pointer; display: flex; font-size: 12.5px; font-weight: 550; list-style: none; min-height: 24px; width: fit-content; }
.note-preview > summary::-webkit-details-marker { display: none; }
.note-preview > summary:hover { text-decoration: underline; }
.note-preview > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.note-preview iframe { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-md); display: block; height: 360px; margin: .5rem 0 .35rem; width: 100%; }
.note-preview .note-edit { align-items: center; color: var(--accent); display: inline-flex; font-size: 12.5px; font-weight: 550; min-height: 24px; text-decoration: none; }
.note-preview .note-edit:hover { text-decoration: underline; }

/* shared/badge.css */
/* badge — a small chip naming what a thing IS: Live, Finish setup, Secret,
   Fully booked, Sandbox, a coupon's kind.

   It is status-pill's quiet sibling and is styled to match it exactly, minus
   the dot: same size, same weight, same tint, same radius, sentence case.
   It used to be bordered, bold, letter-spaced and UPPER CASE, which read as a
   different design language on screens that show both — the invitations list
   next to the widgets list (Vicky, 2026-08-28: "the outline and the all caps
   make it look inconsistent with styling elsewhere around the app").

   The admin's micro-labels went to sentence case on 2026-08-27 and this one
   was missed, because TestTheAdminHasOneQuietLabel only reads styles/admin
   and the badge lives in styles/shared. The fence reads both now.

   What keeps the two components separate is the DOT, not the chrome: a
   status-pill wears one because it reports a STATE that changes (paid,
   pending, checked in), and a badge does not because most of what it names
   is not a state at all — a field's kind, a discount's kind, Pro. Reach for
   the pill when the word can change on its own, and this when it names the
   thing. */
.badge { background: color-mix(in srgb, var(--neutral-pill) 12%, transparent);
  border-radius: var(--r-pill); color: var(--neutral-pill); display: inline-block;
  font-size: 11.5px; font-variant-numeric: tabular-nums; font-weight: 550;
  line-height: 1.5; padding: .16rem .6rem; white-space: nowrap; }
.badge-live { background: var(--ok-weak); color: var(--ok-ink); }
.badge-test { background: var(--warn-weak); color: var(--warn-ink); }
/* secret is the plain tone. It keeps a class of its own because the templates
   say which of the five they mean, and event_secret_test.go reads this one
   off the events list to prove a secret event is marked there at all. */
.badge-secret { background: color-mix(in srgb, var(--neutral-pill) 12%, transparent); color: var(--neutral-pill); }
/* Sold out keeps --danger-badge rather than taking --danger-weak with the
   others: colors.css measured that pair for this chip and spells out why it
   does not inherit the weaker fill. Two per cent of tint in dark mode is not
   worth reopening a contrast measurement over. */
.badge-soldout { background: var(--danger-badge); color: var(--danger-ink); }

/* shared/notice.css */
/* ---- Admin standard vocabulary ----
   The CSS half of admin_partials.html: one way to say back-nav, notice,
   error, form row, confirm actions, table. Alphabetical-ish by concern. */
.back-nav { margin: 0 0 .75rem; }
.back-nav a { color: var(--muted); text-decoration: none; }
.back-nav a:hover { color: inherit; text-decoration: underline; }
.notice { background: color-mix(in srgb, var(--accent) 10%, transparent);
  border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--line));
  border-radius: var(--r-md); display: inline-block; padding: .45rem .9rem; }
.form-error { color: var(--danger-ink); font-weight: 600; }
/* The refusal that belongs to ONE control, rendered under it (the field
   partials' Error key). Lighter than .form-error: the banner is the whole
   form's problem and has to carry from the top of the page, this one sits
   against the box it is about and the proximity does that work. The colour
   is never the only signal — the text says what is wrong, and the control
   carries aria-invalid for anyone not reading colour at all. */
.field-error { color: var(--danger-ink); font-size: .85rem; margin: .25rem 0 0; }
input[aria-invalid="true"], textarea[aria-invalid="true"], select[aria-invalid="true"] {
  border-color: var(--danger-ink);
}

/* shared/email_check.css */
/* email-check.js: an inline spinner on the input itself (on-blur check is
   quiet by design — no button, no busy state) plus a note below for the
   verdict. Reuses --danger, which is already themed per light/dark, so no
   separate dark-mode override is needed here. */
.email-check-note { font-size: .875rem; margin: .25rem 0 0; }
.email-check-note.err { color: var(--danger-ink); }
.email-check-note button { font: inherit; }
/* The SUGGESTION is the one verdict that is a question rather than a
   statement, and it gets a card under the field to say so — the same frame
   the box above it wears, so the two read as one control and its follow-up
   (Vicky, 2026-08-17). The other two verdicts stay a plain line: a card
   around "Checking address…" would be a box that appears and vanishes on
   every blur.
   --control-line falls back to --line the way name_field.css does: the
   buyer journey defines the stronger token, the admin does not, and one
   shared component has to sit correctly in both. */
.email-check-note.suggest { align-items: center; background: var(--surface);
  border: 1px solid var(--control-line, var(--line)); border-radius: var(--r-md);
  display: flex; gap: .15rem; margin-top: .4rem; padding: .35rem .35rem .35rem .7rem; }
.email-check-note.suggest > .ec-q { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
/* The address is the thing being asked about, so it carries the weight —
   at 600 rather than the browser's bold, which at this size goes heavy
   enough to read as a warning. */
.email-check-note.suggest strong { font-weight: 600; }
/* Icon-only, and sized as a target rather than as a glyph: 28px clears the
   24px WCAG 2.2 asks for (SC 2.5.8), which the bare mark would not.
   margin:0 is load-bearing — button.css gives every button a 1.25rem top
   margin for the buttons that end a form, and inherited here it pushed
   both marks 20px below the sentence they answer and left the card
   half-empty above them. */
.email-check-note.suggest > button { align-items: center; background: transparent;
  block-size: 1.75rem; border: 0; border-radius: var(--r-md); color: inherit; cursor: pointer;
  display: inline-flex; flex: none; inline-size: 1.75rem; justify-content: center;
  margin: 0; padding: 0; }
/* The kit draws these two at 13px, sized for the door's 26px result discs.
   In a 28px button beside 14px text they read as specks — and the tick is
   the smaller of the pair inside its own viewBox, so it takes the extra
   point to sit at the same visual weight as the cross. */
.email-check-note.suggest > button > svg { block-size: 16px; inline-size: 16px; }
.email-check-note.suggest > .ec-use > svg { block-size: 17px; inline-size: 17px; }
.email-check-note.suggest > button:hover { background: var(--accent-weak); }
.email-check-note.suggest > button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
/* <email-suggest> is the same did-you-mean, server-rendered: what an admin
   form shows when a save was refused over a typo-looking address. Toned
   like callout[tone=warning] because that is what it is — a question, not
   a complaint — and deliberately NOT .form-error red: nothing is wrong
   yet, and the organizer may well be right. Its own element rather than a
   <callout> because it carries form controls (the accept button and the
   hidden inputs that make "save again to keep" work), which a callout's
   icon/body row has no slot for. */
email-suggest { background: color-mix(in srgb, var(--warn) 13%, var(--bg));
  border: 1px solid color-mix(in srgb, var(--warn) 40%, var(--line)); border-radius: var(--r-lg);
  display: block; margin: 0 0 1rem; padding: .85rem 1rem; }
email-suggest > .es-q { font-weight: 600; margin: 0; }
/* The accept button leads, so it takes the same weight a primary action
   does anywhere else; the keep line below it is the quieter alternative. */
email-suggest > .es-use { margin: .6rem 0 0; }
/* .925em, not .875rem: the admin's body is 13.5px here, so a rem-based
   "small" renders at 14px — LARGER than the question it sits under. Sized
   against its own inherited context it lands at ~12.5px, the same note size
   the save bar uses. */
email-suggest > .es-keep { color: var(--muted); font-size: .925em; margin: .5rem 0 0; }
input.email-checking { background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6' fill='none' stroke='%23888' stroke-width='2' stroke-dasharray='28' stroke-dashoffset='10'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 8 8' to='360 8 8' dur='0.8s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/svg%3E") right 8px center / 16px no-repeat; }

/* public/journey_text.css */
body.buyer-journey li ticket-row label.grow,
body.buyer-journey .row label.grow, body.buyer-journey ticket-row label.grow,
body.buyer-journey .row label, body.buyer-journey ticket-row label,
body.buyer-journey :where(li, .row, ticket-row) :where(.grow > strong, .grow > span:first-child) {
  color: var(--fg); font-size: var(--bj-body); font-weight: 500; margin-bottom: 0; }

/* public/public_brand.css */
/* The account's name in the stub band: a mark saying who is selling, never a
   link (layout.html). It inherits the page's ink and carries its weight
   alone. */
.public-brand { font-weight: 700; margin-bottom: 2rem; }

/* public/journey_tokens.css */
/* ---- Sponsor portal (portal.html) ----
   The sponsor's window front: precision minimalism. Hairlines, not borders;
   the allocation number is the one loud thing; everything else whispers.
   All colour derives from the organizer palette (--p-accent = link colour),
   so it themes per account and per scheme. */
body.buyer-journey { --p-accent: var(--journey-link, var(--accent));
  /* A divider is a fraction of a field outline, never its equal: at the same
     weight the page reads as a grid of boxes instead of a hierarchy. Derived
     from --control-line so the two can never drift apart. */
  --hairline: color-mix(in srgb, var(--control-line) 65%, var(--journey-card));
  /* A CONTROL's boundary is not decoration: WCAG 1.4.11 wants 3:1, which the
     organizer's own --line (a decorative hairline colour) never reaches.
     Computed in Go (eventDesign.ControlLine) as the LIGHTEST blend that still
     clears it against this card — a flat percentage was far too heavy on a
     near-black palette and barely enough on a mid-tone one. */
  /* NO DEPTH ON A FIELD (Vicky, 2026-08-21: "in general I feel like we can
     make the form fields slightly more elegant — maybe drop the shadow
     altogether?"). It was Stripe's move, two soft shadows letting the border
     stay very quiet. Two things were wrong with it here. It read UNEVEN
     across control types — the same shadow hugs a text box's border and sits
     away from a select's native chrome, which is what made one look darker
     than the other on a screen where every number was identical. And a lift
     on something you type into is a decoration on the one element that has
     real work to do.
     The token stays, rather than the rules losing their box-shadow line: a
     high-contrast reader already overrode it to none, and keeping one name
     for "the field's depth" is what lets that stay a one-line answer. */
  --control-shadow: none;
  /* The journey's type scale. Three sizes of prose and nothing else: body,
     the supporting line under it, and the micro-caps eyebrow. Everything that
     does the same job uses the same token — that is the whole system. */
  --bj-body: 1rem; --bj-support: .875rem; --bj-micro: .75rem;
  --bj-h1: 1.6rem; --bj-h2: 1.125rem; --bj-h3: 1rem;
  /* The one number or name a page is about: an amount due, an order
     total, the holder on a ticket. One size, one weight, everywhere. */
  --bj-display: 1.35rem; --bj-display-weight: 640; --bj-display-ink: var(--journey-heading, var(--fg));
  /* Hierarchy comes from space first and rules second. */
  --bj-section: 2.25rem; --bj-after-heading: .75rem;
  /* The card's own padding, as a token rather than a number in one
     rule: the stub band bleeds to the card's edges by negating it, so
     the two cannot drift apart. */
  --bj-pad: 2.25rem;
  /* Corners, derived. Every box INSIDE the card takes the CARD's corner
     rather than a number of its own, so an organizer who squares their card
     off gets a squared-off page instead of a 5px card full of 10px and 14px
     boxes (Vicky, 2026-08-21: no surprises with UI elements I haven't got to
     see). Capped, not followed all the way up: --journey-radius goes to 16 on
     Classic and higher if an account asks, and a 16px corner on a 32px
     control is a lozenge.

     Two sizes, because a panel and a button are not the same object: a
     SURFACE (a card, a callout, a panel) may be rounder than a CONTROL (a
     button, an input, a chip) at the same card radius, which is the ladder
     the page had by hand before it had it by rule. Round things — a disc, a
     dot, a progress bar — are still 50%/99px and always were; they are not
     corners. */
  /* THE FORM'S MEASURE. One width for an answer box, and it is the size of
     the ANSWER rather than the size of the column (Vicky, 2026-08-21:
     "something about these forms feels slightly inelegant").

     Measured on the add-tickets card before this existed: name, email, the
     choose-several group, both selects, the company box and the textarea all
     came out at 532px — not because 532 means anything but because that is
     what was left. Two controls didn't: a yes/no at 416 (its own 26rem cap)
     and a number at 72 (excluded from the width rule). So one column carried
     three unrelated measures, and the two that were sized to their answer
     read as mistakes beside the six that were not.

     640 IS THE SHELL, EVERYWHERE (Vicky, 2026-08-21). Narrowing the card on
     the asking screens was tried and rejected: a buyer must not meet a
     different width halfway through one journey. So the measure is the
     card's own content column — every answer box fills it, and the form has
     one right-hand edge with nothing left over beside it.

     A number stays small — that exception is the ONE deliberate narrowing on
     the card, rather than one of three widths nobody picked. */
  --bj-field: 100%;
  --bj-radius: min(var(--journey-radius), 12px);
  --bj-radius-sm: min(var(--journey-radius), 8px);
}

/* public/journey_headings.css */
/* Headings own the space above them and give the copy below room to breathe.
   Before this the journey leaned on hairlines to separate sections, which put
   a rule the same weight as a field outline between every block. */
body.buyer-journey :where(h1, h2, h3) { margin-block: var(--bj-section) var(--bj-after-heading); }
body.buyer-journey > main > :first-child, body.buyer-journey :where(h1, h2, h3):first-child { margin-block-start: 0; }
body.buyer-journey h1 { font-size: var(--bj-h1); line-height: 1.2; }
body.buyer-journey h2 { font-size: var(--bj-h2); }
body.buyer-journey h3 { font-size: var(--bj-h3); }

/* public/journey_support.css */
/* One supporting-text recipe. An add-on's description, a bundle note, a field
   hint and a ticket's meta line are the same job and now read the same. */
body.buyer-journey :where(.field-desc, .addon-desc, .tk-meta, .hint, .bj-support),
body.buyer-journey :where(li, .row, ticket-row, .change-row, .tkt-stub, .tkt-scan) :where(.muted, small) {
  color: var(--muted); font-size: var(--bj-support); }
/* An aside offering a different route (register as a buyer, sign in) is a
   change of subject — it needs the same air a new section gets, not the
   paragraph gap it was inheriting. */
body.buyer-journey .bj-aside { margin-block-start: var(--bj-section); }
p.lead { font-size: 1.35rem; font-weight: 600; margin: .35rem 0; }

/* public/journey_divider.css */
/* One divider colour on a buyer page. .rows/.row and friends drew theirs
   straight from the organizer's --line, which lands heavier than the field
   outline; a rule that outweighs a control inverts the hierarchy. */
body.buyer-journey :where(.rows, .row, .door-types li, hr) { border-color: var(--hairline); }
body.buyer-journey hr { background: var(--hairline); block-size: 1px; border: 0; margin-block: var(--bj-section); }

/* public/head_row.css */
/* ---- The confirmation's header row ----
   The event's name with, on its right, the one thing you do about the event:
   add it to your calendar (Vicky, 2026-08-25). Receipt and invoice left this
   row to go and live with the total they are about, which is what made room
   for a header that is about the EVENT rather than about the order.

   A GRID and not a flex row, because there are three children and only one of
   them belongs on the right: the name and the date line stack in the first
   column, the action spans both of their rows in the second. Flex would have
   needed a wrapper div around the two left-hand children for the same result.

   The action is centred on the HEADING's row and not on the pair of rows
   below it (Vicky asked for it level with the event's name). Centred on the
   pair, it sat in the gap between the name and the date and read as belonging
   to neither. Where the name wraps to two lines it stays centred on the name,
   which is still the row it was asked to be level with. */
/* The row carries the heading's own top margin, because the heading has lost
   it: journey_headings.css zeroes margin-block-start on a :first-child, and
   inside this grid the h1 became one. Without this the confirmation state and
   the event's name closed up to a paragraph gap. */
head-row {
  align-items: center;
  column-gap: 1rem;
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  margin-block-start: var(--bj-section);
}
/* The heading gives up its own bottom margin to the grid's row-gap, so the
   gap between the name and the date is one number rather than a margin plus
   whatever the centred row has left over. The row is the BUTTON's height now,
   which is a few pixels taller than the heading's line — a header holding a
   button is taller than a header holding only words, and that is honest. */

head-row > h1 { grid-area: 1 / 1; margin-block-end: 0; }
head-row > .support { grid-area: 2 / 1; margin-block: var(--bj-after-heading) 0; }
/* The directions menu is the date line's third line, in the same column: it
   answers "where", which is what the line above it just said. */
head-row > venue-links { display: block; grid-area: 3 / 1; margin-block-start: .25rem; }
/* No margin at all, matching the heading above: align-items centres MARGIN
   boxes, so an action and a heading with different margins centre on
   different lines — which is how this first landed, six pixels low. */
head-row > head-action {
  align-self: center;
  grid-area: 1 / 2;
  margin: 0;
}
/* On a phone the card runs edge to edge and the two would fight for ~340px,
   so the action drops below — AFTER the date line, never between the event's
   name and the date it is about, which is the whole reason it moved here.
   Source order already says so; the stack just stops overriding it. */
@media (max-width: 599px) {
  head-row { display: block; }
  /* Air, on the one screen where all four of these are a single column: the
     name, the date, the directions and the button stacked at even gaps read as
     one undifferentiated block (Vicky, 2026-08-25: "the spacing is a bit
     cramped"). The button is a different KIND of thing from the two lines
     above it, so it takes the bigger gap, and the header takes a gap of its
     own before whatever follows — on the order page that is a sentence about
     email, which had been sitting right under the button. */
  head-row { margin-block-end: 1.1rem; }
  head-row > head-action { margin: 1.25rem 0 0; }
}

/* public/journey_row_form.css */
/* Row forms keep their controls on the row. The journey-wide width:100% is
   right for a stacked form and wrong here: it broke the add-on row into three
   lines with the button orphaned at the left. */
body.buyer-journey form:where(.row, .change-row) { align-items: center; }
body.buyer-journey form:where(.row, .change-row) :where(input:not([type=checkbox]):not([type=radio]), select, textarea) {
  width: auto; }
body.buyer-journey form:where(.row, .change-row) select { flex: 1 1 9rem; min-inline-size: 7rem; }
body.buyer-journey form:where(.row, .change-row) > button[type=submit] { margin-inline-start: auto; }

/* public/journey_field.css */
/* A labelled control with its hint: one block, so the label, the control and
   the explanation travel together instead of floating as three loose lines. */
body.buyer-journey .bj-field { margin: 1.5rem 0; }
/* The exclusions are the labels that are not field NAMES: a checkbox's
   sentence, a row's own text, a ticket-choice card. A card carries its layout
   (.reg-ticket: a flex row, radio then name then price flush right) and this
   rule's display:block would quietly take it apart — the border and padding
   survive, so all you see is a price that stopped being flush right and a
   radio dropped to the text baseline. Scoping by ANCESTOR is what let that
   through: .portal-register exempted the portal's cards while the invitation
   page's identical cards, in a form with no such class, were not. The class
   itself is the honest test.

   .yn (a yes/no answer, yesno_field.css) and .cg-opt (one answer of a
   choose-several question, check_group.css) are the same shape and arrived
   the same way: shared/ is concatenated BEFORE public/, so this rule lands
   after the component's own display:flex and quietly wins. It presented
   exactly as described above, the border and padding intact and the radio
   26px above its own word. Anything that draws a radio inside its own card belongs on this
   list. */
body.buyer-journey label:not(.check):not(.rowhit):not(.grow):not(.reg-ticket):not(.yn):not(.cg-opt):not(.ps-choice):not(.mode-body label):not(.pi-manage label):not(.portal-register label) { color: var(--fg); display: block;
  font-size: var(--bj-support); font-weight: 600; margin-bottom: .4rem; }
/* A checkbox's label is the sentence you agree to: body copy, not a field
   name. One recipe, whether or not it carries a description. */
body.buyer-journey label.check, body.buyer-journey label.check.check-field {
  color: var(--fg); font-size: var(--bj-support); font-weight: 400; }
body.buyer-journey label.check .field-desc { font-weight: 400; }
body.buyer-journey .bj-field > .hint { margin: .4rem 0 0; }

/* public/back_link.css */
/* Going back to a screen you came from is navigation, not an action: a link,
   never a button (Vicky, 2026-07-31).

   ONE ARROW, ONE PLACE, ONE SIZE (Vicky, 2026-08-21). There were two of
   everything: a ‹ drawn by this file and a literal ← typed into a dozen
   templates, some at the top of the page and some at the foot, at body size
   and permanently underlined. It is the arrow now, at the supporting size,
   underlined only under the pointer — and every template puts it AFTER its
   content, because a way back is what you want when you have finished
   reading rather than the first thing on the page. */
body.buyer-journey a.back-link {
  background: none;
  border: 0;
  color: var(--journey-link, var(--accent));
  display: inline-block;
  font-size: var(--bj-support);
  font-weight: 400;
  margin-top: var(--bj-section);
  padding: 0;
  text-decoration: none;
}
body.buyer-journey a.back-link:hover { text-decoration: underline; text-underline-offset: .15em; }
body.buyer-journey a.back-link::before { content: "\2190\00a0"; text-decoration: none; }

/* public/static_page.css */
/* An organizer's own content page (static_page_public.go).

   The page itself is a journey card like any other, so it inherits the event's
   look. What is here is only what the page's own parts need: the rhythm of a
   long read, and the two blocks a page can carry. */

.static-page .sp-content > :first-child { margin-top: 0; }
.static-page .sp-content > :last-child { margin-bottom: 0; }

.static-page .sp-content h1 { margin: 1.6em 0 .4em; }
.static-page .sp-content h2 { margin: 1.3em 0 .3em; }
.static-page .sp-content p,
.static-page .sp-content ul,
.static-page .sp-content ol { margin: 0 0 .85em; }
.static-page .sp-content li { margin: .2em 0; }

.sp-picture { margin: 1.2em 0; }
.sp-picture img { border-radius: 8px; display: block; height: auto; max-width: 100%; }

.sp-button-row { margin: 1.2em 0; }

/* public/alloc_card.css */
/* Allocation: no box — a quiet stanza between hairlines. The eyebrow
   whispers what it is; number and noun share one baseline; the covered
   types are hairline chips; used/remaining anchor the bar's two ends. */
.alloc-card { border-block: 1px solid var(--hairline); margin: 1.6rem 0 2rem; padding: 1.3rem 0 1.25rem; }
.alloc-label { color: var(--muted); font-size: .75rem; font-weight: 600; letter-spacing: .1em; margin: 0 0 .55rem; text-transform: uppercase; }
.alloc-bar { background: var(--hairline); border-radius: 99px; height: 4px; margin-top: 1.1rem; overflow: hidden; }
.alloc-bar i { background: var(--p-accent); border-radius: 99px; display: block; height: 100%; min-width: 2px; transition: width .4s ease; }
.alloc-stats { align-items: baseline; display: flex; justify-content: space-between; }
.alloc-stats div { display: flex; flex-direction: column; gap: .15rem; }
.alloc-stats strong { font-size: 1.7rem; font-weight: 640; letter-spacing: -.02em; line-height: 1; font-variant-numeric: tabular-nums; }
.alloc-stats small { color: var(--muted); font-size: .75rem; font-weight: 600; letter-spacing: .09em; text-transform: uppercase; }
.alloc-mid { align-items: center; text-align: center; }
.alloc-remaining { align-items: flex-end; text-align: right; }
.alloc-remaining strong { color: var(--p-accent); }
/* A per-type allocation runs out per type, so the card carries a line each:
   name left, what is left of that type right, on the same hairline rhythm as
   the stanza above it. */
.alloc-types { border-top: 1px solid var(--hairline); list-style: none; margin: 1.1rem 0 0; padding: .85rem 0 0; }
.alloc-types li { display: flex; font-size: .9rem; gap: 1rem; justify-content: space-between; padding: .18rem 0; }
.alloc-types .at-left { color: var(--muted); font-variant-numeric: tabular-nums; }

/* public/dl_list.css */
/* Files the organiser hands out. Same hairline rhythm as the allocation
   stanza above it — no boxes, no card shadows — but each row is a real hit
   target with a kind chip on the left and a download glyph on the right, so
   what a click does is legible before it happens. */
.dl-list { list-style: none; margin: .9rem 0 0; padding: 0; }
.dl-list li + li { border-top: 1px solid var(--hairline); }
.dl-list a { align-items: center; color: inherit; display: flex; gap: .85rem; padding: .8rem .35rem; text-decoration: none; }
.dl-list a:hover { background: color-mix(in oklab, var(--p-accent) 6%, transparent); }
.dl-list a:focus-visible { outline: 2px solid var(--p-accent); outline-offset: -2px; }
/* The chip carries the extension, not an icon font: it stays legible with no
   network, and a file kind we have never seen still reads as itself. */
.dl-kind {
  align-items: center; background: var(--hairline); border-radius: var(--bj-radius-sm); color: var(--muted);
  display: flex; flex: none; font-size: .62rem; font-weight: 700; height: 34px; justify-content: center;
  letter-spacing: .04em; width: 34px;
}
.dl-text { display: flex; flex-direction: column; gap: .1rem; min-width: 0; }
.dl-text strong { font-size: .95rem; font-weight: 560; overflow-wrap: anywhere; }
.dl-text small { color: var(--muted); font-size: .78rem; }
/* What the organiser says the file is. It sits above the download/size line
   and takes ordinary text colour, because it is the sentence that tells a
   holder whether this is the file they need — the filename frequently isn't. */
.dl-text small.dl-desc { color: inherit; font-size: .82rem; overflow-wrap: anywhere; }
.dl-arrow { color: var(--muted); flex: none; margin-left: auto; }
.dl-list a:hover .dl-arrow { color: var(--p-accent); }

/* public/mode_card.css */
/* Mode cards: weightless — hairline + breath of shadow; hover lifts. */
.mode-card { background: var(--journey-card, transparent); border: 1px solid var(--hairline);
  border-radius: var(--bj-radius); box-shadow: var(--shadow-hairline); margin: .7rem 0;
  transition: border-color .2s, box-shadow .2s; }
.mode-card:hover, .mode-card[open] { border-color: color-mix(in srgb, var(--p-accent) 35%, transparent);
  box-shadow: var(--shadow-hairline-lift); }
.mode-card > summary { align-items: center; cursor: pointer; display: flex; gap: .9rem; list-style: none; padding: 1rem 1.15rem; position: relative; }
.mode-card > summary::-webkit-details-marker { display: none; }
.mode-card > summary::after { border-right: 1.5px solid var(--muted); border-bottom: 1.5px solid var(--muted);
  content: ""; height: 7px; position: absolute; right: 1.25rem; top: 50%; transform: translateY(-65%) rotate(45deg);
  transition: transform .2s; width: 7px; }
.mode-card[open] > summary::after { transform: translateY(-35%) rotate(225deg); }
.mode-icon { color: var(--p-accent); display: flex; flex: none; opacity: .9; }
.mode-text { display: flex; flex-direction: column; gap: .12rem; padding-right: 1.75rem; }
.mode-text strong { font-weight: 600; }
.mode-text small { color: var(--muted); }
.mode-card .mode-body { border-top: 1px solid var(--hairline); padding: .4rem 1.15rem 1.15rem; }

/* Form voice: quiet labels, hairline fields that sharpen on focus.

   Broken the same way email_fix.css was, and by the same event: in #322 this
   was one selector list covering .mode-body, .pi-manage and .portal-register,
   the halves went to two files, and the declarations left with
   portal_register.css. What stayed was two selectors and a comma.

   It has looked right anyway, purely by luck of the running order —
   portal_register.css happens to follow this file in publicStyles, so the
   dangling comma glued these two selectors onto the very rule that took the
   declarations. Reorder that list (styles.go reorders it deliberately, with
   reasons) and these labels lose their voice while some unrelated file's
   first rule silently gains two selectors. Written out in full so it is true
   on its own. TestStyleFilesEndOnACompleteRule is the fence. */
body.buyer-journey .mode-body label, body.buyer-journey .pi-manage label {
  color: var(--muted); display: block; font-size: var(--bj-micro); font-weight: 600;
  letter-spacing: .08em; margin: .9rem 0 .3rem; text-transform: uppercase; }

/* public/portal_register.css */
/* The page's FIELD labels — the micro-caps over Name and Email address.
   .reg-ticket is excused because it is not one: it is a label that lays its
   own children out, and journey_field.css's :not() chain already excuses it
   from the block-label rule for exactly that reason. This selector is more
   specific than that one, so without the same exclusion it quietly undid the
   fix — .reg-ticket's display:flex never fired, which is why align-items and
   gap did nothing and the radio sat above its own words. It also meant a
   ticket NAME was rendered as a field label: muted, letter-spaced and
   uppercased, unlike the identical rows on the invitation claim page. */
body.buyer-journey .portal-register label:not(.reg-ticket) {
  color: var(--muted); display: block; font-size: var(--bj-micro); font-weight: 600;
  letter-spacing: .08em; margin: .9rem 0 .3rem; text-transform: uppercase; }
.mode-body input:where([type=text], [type=email], [type=number], [type=file]),
.pi-manage input:where([type=text], [type=email]),
.portal-register input:where([type=text], [type=email]) {
  background: transparent; border: 1px solid var(--control-line); border-radius: var(--bj-radius-sm);
  font: inherit; padding: .55rem .7rem; transition: border-color .15s; width: 100%; }
.mode-body input:focus-visible, .pi-manage input:focus-visible, .portal-register input:focus-visible {
  border-color: var(--p-accent); outline: none; }
.mode-body input[type=number] { width: 6.5rem; }
.mode-body .mono { display: inline-block; margin: .5rem 0; overflow-wrap: anywhere; }

/* public/journey_actions.css */
.mode-body .actions, .pi-manage .actions, .portal-register .actions {
  display: flex; gap: .6rem; justify-content: flex-end; margin-top: 1.15rem; }
body.buyer-journey :where(.mode-body, .pi-manage, .portal-register) button {
  border: 0; border-radius: var(--button-radius, var(--bj-radius-sm)); cursor: pointer; font: inherit; font-size: .92rem;
  font-weight: 600; padding: .58rem 1.35rem; transition: filter .15s, transform .1s; }
body.buyer-journey :where(.mode-body, .pi-manage, .portal-register) button:hover { filter: brightness(1.08); }
body.buyer-journey :where(.mode-body, .pi-manage, .portal-register) button:active { transform: translateY(.5px); }
/* The portal's ghosts sit beside copy-buttons and pill-links; without this
   they inherited the generic button's radius and weight and read as a third
   kind of secondary on one page. */
body.buyer-journey :where(.mode-body, .pi-manage) button.ghost,
body.buyer-journey a.button.ghost { background: transparent; border: 1px solid var(--control-line);
  border-radius: var(--button-radius, var(--bj-radius-sm)); color: var(--fg); font-size: .85rem; font-weight: 600; padding: .42rem 1rem; }

/* The shareable address: a hairline field holding the link and its copy
   button — the admin's grey .mono chip reads as a glitch out here. */
body.buyer-journey .mode-body .mono, body.buyer-journey .portal-register .mono { background: transparent; color: var(--p-accent); padding: 0; }

/* public/share_link.css */
.share-link { align-items: center; border: 1px solid var(--hairline); border-radius: var(--bj-radius);
  display: flex; gap: .7rem; justify-content: space-between; margin: .55rem 0 .2rem; padding: .5rem .5rem .5rem .75rem; }
.share-link a { color: var(--p-accent); font-size: .95rem; font-family: ui-monospace, "SF Mono", Menlo, monospace;
  overflow-wrap: anywhere; text-decoration: none; }
.share-link a:hover { text-decoration: underline; }
body.buyer-journey .copy-btn { background: transparent; border: 1px solid var(--control-line); border-radius: var(--button-radius, var(--bj-radius-sm));
  color: var(--fg); cursor: pointer; flex: none; font: inherit; font-size: .85rem; font-weight: 600;
  padding: .42rem 1rem; transition: border-color .15s, color .15s; }
body.buyer-journey .copy-btn:hover { border-color: var(--p-accent); color: var(--p-accent); }
body.buyer-journey .copy-btn.done { border-color: var(--p-accent); color: var(--p-accent); }

/* Success: a slim confirmation line, not a slab. */

/* public/portal_flash.css */
.portal-flash { align-items: center; color: var(--p-accent); display: flex; font-size: .9rem;
  font-weight: 600; gap: .45rem; margin: .8rem 0 0; }
.portal-flash::before { background: var(--p-accent); border-radius: 50%; content: ""; height: 6px; width: 6px; }

/* Issued: a hairline ledger, not boxes. */

/* public/portal_package.css */
/* The holder page's header: who this is (the event line), whose it is (the
   name), and what they bought (the package). One block, one set of gaps.

   It used to be three loose children of <main> held together by an
   `h1:has(+ .portal-package)` rule — which worked while the package line was
   the only thing that could follow the name. There is a summary under the
   allocation now, so the header is a real <header> that owns its own spacing
   rather than an adjacency that has to guess.

   Colour and size for the package line come from .bj-support, the journey's
   one supporting-text recipe; this file owns nothing but the spacing. */
body.buyer-journey .portal-head { margin-block-end: 0; }
body.buyer-journey .portal-head > p { margin-block: 0; }
/* The name sits under the event line, not a section away from it, and the
   package sits tighter still under the name. */
body.buyer-journey .portal-head h1 { margin-block: .1rem 0; }
body.buyer-journey .portal-head .portal-package { margin-block-start: .2rem; }

/* What the deal cost, and the gate under it (portal_package.go).

   The same hairline stanza the allocation uses, for the same reason: this is
   a fact about what the holder has, not a box asking them for something. The
   two sit directly above and below each other once the package is settled,
   so a second visual treatment here would read as two unrelated products.

   .due is the state where this band is the WHOLE page — nothing below it
   renders until the money lands — so it earns the accent rule and a little
   more room. Settled, it falls back to a hairline like everything else,
   because a paid package is a receipt rather than an instruction. */
body.buyer-journey .pkg-card { border-block: 1px solid var(--hairline); margin: 1.6rem 0 2rem; padding: 1.3rem 0 1.25rem; }
body.buyer-journey .pkg-card.due { border-inline-start: 3px solid var(--p-accent); padding-inline-start: 1.1rem; }
body.buyer-journey .pkg-label { color: var(--muted); font-size: .75rem; font-weight: 600; letter-spacing: .1em; margin: 0 0 .55rem; text-transform: uppercase; }
body.buyer-journey .pkg-total { font-size: 1.7rem; font-variant-numeric: tabular-nums; font-weight: 640; letter-spacing: -.02em; line-height: 1; margin: 0; }
/* The organiser's own words, so their line breaks are theirs. pre-wrap keeps
   them without letting a long unbroken string push the page sideways. */
body.buyer-journey .pkg-detail { color: var(--muted); margin: .7rem 0 0; overflow-wrap: anywhere; white-space: pre-wrap; }
body.buyer-journey .pkg-note { margin: .8rem 0 1rem; }
body.buyer-journey .pkg-paid { color: var(--muted); margin: .7rem 0 0; }

/* public/portal_overview.css */
/* The holder's overview (portal_overview.go): the short list a busy portal
   opens with. Same vocabulary as the allocation stanza directly above it —
   a micro-caps eyebrow, hairline rules, no box — because a second card here
   would read as a second thing to deal with rather than the index of the one
   below. Rows borrow the download row's anatomy (glyph, two lines, chevron)
   so the page speaks one language whichever section you land in.

   Every colour derives from the organizer's palette, so this survives an
   account whose background_color is near-black. */
/* The holder's page is the one buyer-journey page that is a DASHBOARD rather
   than a linear form: an allocation, a summary, three issuing modes, a store,
   two question blocks, a file list and an issued list, all on one screen. The
   journey's 510px is sized for a checkout that should be read top to bottom;
   at that width every summary row wraps its status line and the store's
   name/price/quantity columns fight each other. So this page — and only this
   page — takes the Classic skin's wider measure.
   Selected by the page's own header rather than a flag threaded through the
   layout: :has() is already the journey's idiom, and a browser too old for it
   simply gets today's 510px, which is not a broken page. */
body.buyer-journey > main:has(> .portal-head) { max-width: 680px; }

body.buyer-journey .ov-block { margin-block: 0 var(--bj-section); }
body.buyer-journey .ov-label {
  color: var(--muted); font-size: var(--bj-micro); font-weight: 600;
  letter-spacing: .1em; margin: 0 0 .3rem; text-transform: uppercase; }
body.buyer-journey .ov {
  border-block-start: 1px solid var(--hairline); list-style: none;
  margin: 0; padding: 0; }
body.buyer-journey .ov li { border-block-end: 1px solid var(--hairline); }
body.buyer-journey .ov a {
  align-items: center; color: inherit; display: flex; gap: .85rem;
  padding: .8rem .25rem; text-decoration: none;
  transition: background .15s; }
/* A whisper, not the download row's outline: these rows have no border of
   their own, so the fill IS the row and anything heavier reads as selected. */
body.buyer-journey .ov a:hover { background: color-mix(in srgb, var(--p-accent) 5%, transparent); }
body.buyer-journey .ov a:focus-visible { outline: 2px solid var(--p-accent); outline-offset: -2px; }
body.buyer-journey .ov-ic { color: var(--p-accent); display: flex; flex: none; }
body.buyer-journey .ov-txt {
  display: flex; flex: 1; flex-direction: column; gap: .1rem; min-width: 0; }
body.buyer-journey .ov-txt strong { font-size: var(--bj-body); font-weight: 600; }
body.buyer-journey .ov-txt small { color: var(--muted); font-size: var(--bj-support); }
/* The amber flag. It never carries meaning on its own — the line above it
   always says the state in words — so this is emphasis, not the message.
   --warn-ink is the journey's own per-scheme amber (layout.html), which is
   what keeps it legible on a dark account palette where the shared token
   would not be. */
body.buyer-journey .ov-chip {
  background: var(--warn-weak);
  border: 1px solid color-mix(in srgb, var(--warn) 40%, var(--line));
  border-radius: 99px; color: var(--warn-ink); flex: none;
  font-size: var(--bj-micro); font-weight: 650; letter-spacing: .02em;
  padding: .16rem .55rem; }
body.buyer-journey .ov-arrow { color: var(--muted); flex: none; }

/* public/portal_signin.css */
/* The locked portal's front door and the "signed in as" line it leads to
   (portal_access.go).

   Two small components, one file, because they are the two halves of one idea:
   the page that asks who you are, and the line that says who you turned out to
   be. Neither earns a file alone, and splitting them would put the answer in a
   different place from the question.

   Neither owns a colour or a control surface. The sign-out button wears
   .pill-link inside .bj-actions — the journey's own recipe for a secondary
   action that POSTs (bj_actions.css) — because a rule of our own would be
   competing with `body.buyer-journey form:not([data-checkout]) button[type=submit]`
   at (0,3,3), and winning that race by piling on selectors is how a
   stylesheet stops being readable. */

/* The form is the whole content of the sign-in page, so it takes the journey's
   ordinary field spacing and nothing else. */
body.buyer-journey .portal-signin { margin-block-start: 1rem; }

/* "Signed in as paul@amgen.com  ·  Sign out" reads as one line, so the control
   sits beside the address rather than on a row of its own.

   BASELINE, not centre (Vicky, 2026-08-27): the pill is taller than the line of
   text beside it, so centring the two put the address a couple of pixels below
   the middle of the button and the pair read as misaligned. Aligning the text
   to the button's own first baseline is what makes them sit on one line. */
.portal-whoami {
  align-items: baseline;
  display: flex;
  flex-wrap: wrap;
  gap: .35rem .6rem;
  margin-block-start: .45rem;
}

/* A flex item's baseline is its own first line box; for a <form> wrapping a
   button that is the form's, which is not the button's text. Making the form a
   flex container of its own hands the button's baseline up to the row. */
.portal-whoami > .bj-actions { align-items: baseline; }

/* .bj-actions is a flex row with its own bottom margin, which is spacing for a
   block of actions at the foot of a page. Here it is one control inline in a
   sentence, so the margin comes off. */
.portal-whoami .bj-actions { margin: 0; }
.portal-whoami form { margin: 0; }

/* public/portal_issued.css */
.portal-issued { list-style: none; margin: .4rem 0 0; padding: 0; }
.portal-issued li { border-bottom: 1px solid var(--hairline); }
.portal-issued li:first-child { border-top: 1px solid var(--hairline); }
.portal-issued li.is-revoked { opacity: .5; }
/* One line per guest: name left, count right; manageable rows are the
   summary of their own edit form, cued by the mode-card chevron. */
.pi-row { align-items: baseline; display: flex; gap: 1rem; justify-content: space-between; padding: .6rem .1rem; }
.pi-name { flex: 1; font-weight: 550; overflow-wrap: anywhere; }
.pi-name em { color: var(--muted); font-size: .8rem; font-style: normal; }
.pi-count { color: var(--muted); flex: none; font-size: .84rem; font-variant-numeric: tabular-nums; }
summary.pi-row { cursor: pointer; list-style: none; }
summary.pi-row::-webkit-details-marker { display: none; }
.pi-row::after { align-self: center; border-right: 1.5px solid transparent;
  border-bottom: 1.5px solid transparent; content: ""; flex: none; height: .38em; transform: rotate(45deg); transition: transform .18s ease; width: .38em; }
summary.pi-row::after { border-color: var(--muted); }
.pi-manage[open] > summary.pi-row::after { transform: rotate(225deg); }
.pi-manage[open] { padding-bottom: 1rem; }

/* public/portal_doc.css */
/* One document on the portal holder's page (templates/portal.html): the
   question, its state and deadline, whatever is already uploaded, and the
   file input that replaces it. A rule rather than a card — the whole form is
   a list of these, and boxing each one would out-shout the ticket sections
   above it, which are what the portal is actually for.

   The rule needs EQUAL air on both sides or it stops being a divider (Vicky,
   2026-08-07). It used to sit 4px under the previous document's last field
   and 29px above the next document's label — so on a question that asks for
   an expiry date it read as a second underline on the date input, and a form
   of four documents looked like a page of stray rules. The padding is
   symmetric now, and the first label's own top margin is dropped so it cannot
   put the gap back on one side only. */
body.buyer-journey .portal-doc { border-top: 1px solid var(--hairline); padding: 1.35rem 0; }
body.buyer-journey .portal-doc:first-of-type { border-top: 0; padding-top: 0; }
body.buyer-journey .portal-doc > label { display: block; }
body.buyer-journey .portal-doc > label:first-child { margin-top: 0; }
/* The state line sits directly under the question, tight to it, because it
   is about that question and not a hint for the input below. */
body.buyer-journey .portal-doc .form-help { margin: .2rem 0 .5rem; }
/* The covers line is this holder's own answer, so it sits under the state
   rather than beside it. Flagged, it takes the warning's ink — never the only
   carrier of the meaning, since the sentence names both dates. */
body.buyer-journey .portal-doc .covers { margin-top: -.3rem; }
body.buyer-journey .portal-doc .covers-flag { color: var(--warn-ink); }
body.buyer-journey .portal-doc .file-list { margin: 0 0 .5rem; }

/* public/portal_store.css */
/* The portal store: what comes with the package and what can be ordered on
   top of it, in ONE list. Rows share the issued list's hairline rhythm so the
   holder's page reads as one page rather than two components — name left,
   price and quantity flush right, an included row carrying a chip where a
   stepper would be. */
.portal-store { list-style: none; margin: .4rem 0 0; padding: 0; }
.portal-store li { align-items: baseline; border-bottom: 1px solid var(--hairline);
  display: flex; gap: 1rem; padding: .7rem .1rem; }
.portal-store li:first-child { border-top: 1px solid var(--hairline); }
/* A group heading inside the list, not above it: the whole point is that
   included and extra sit in the same column. */
.portal-store li.ps-head { border-bottom: 0; color: var(--muted); font-size: .78rem;
  letter-spacing: .04em; padding-bottom: .2rem; padding-top: 1.1rem; text-transform: uppercase; }
/* ps-group is the wrapper the choose-one radios need to be a real
   radiogroup (portal.html says why). It is a list item only because a <ul>
   cannot hold another <ul> directly — it draws nothing of its own, so it
   gives back every one of the row rules above. */
.portal-store li.ps-group { border-bottom: 0; display: block; padding: 0; }
.portal-store .ps-group > ul { list-style: none; margin: 0; padding: 0; }
.ps-main { display: flex; flex: 1; flex-direction: column; gap: .12rem; overflow-wrap: anywhere; }
.ps-name { font-weight: 500; }
.ps-main small { color: var(--muted); font-size: .82rem; }
.ps-cap { font-variant-numeric: tabular-nums; }
.ps-price { flex: none; font-variant-numeric: tabular-nums; }
.ps-note { color: var(--muted); flex: none; font-size: .84rem; }
/* Included: the chip is the whole answer, so it is the row's only control. */
.ps-chip { background: color-mix(in srgb, var(--p-accent) 12%, transparent); border-radius: 999px;
  color: var(--p-accent); flex: none; font-size: .76rem; font-weight: 550; padding: .16rem .5rem; }
/* A label that lays its own children out has to be excused from the buyer
   journey's block-label rule (journey_field.css's :not() chain) — that
   selector outranks this one, so display:flex here is inert without it, and
   align-items never fires. .reg-ticket is on that list for the same reason.
   The margin reset is the second half: a UA radio carries an asymmetric
   margin, and centring the MARGIN box leaves the control off its own words. */
.ps-choice { align-items: center; cursor: pointer; display: flex; gap: .5rem; }
.ps-choice input { accent-color: var(--p-accent); margin: 0; }
.ps-qty { align-items: center; display: flex; flex: none; gap: .3rem; }
.ps-qty input { text-align: center; width: 3.4rem; }
.ps-window { font-size: .84rem; margin-top: .8rem; }

/* public/reg_tickets.css */
/* Registration page: ticket choices as the same weightless cards. */
.reg-tickets { border: 0; margin: 0 0 .5rem; padding: 0; }
.reg-tickets legend { font-weight: 650; margin-bottom: .45rem; }
.reg-ticket { align-items: center; border: 1px solid var(--hairline); border-radius: var(--bj-radius);
  box-shadow: var(--shadow-hairline); cursor: pointer; display: flex; gap: .7rem;
  margin: .45rem 0; padding: .7rem .9rem; transition: border-color .15s, box-shadow .15s; }
.reg-ticket:hover { border-color: color-mix(in srgb, var(--p-accent) 35%, transparent); }
.reg-ticket:has(input:checked) { border-color: var(--p-accent); box-shadow: 0 0 0 1px var(--p-accent) inset; }
/* A UA radio carries an asymmetric margin (WebKit's is 3px 3px 0 5px), and
   align-items centres the MARGIN box, so without this reset the control sits
   off its own words. Same pairing .ps-choice has carried since it was
   written. Note this reset is not what fixed the register page's radios —
   there, display:flex was not firing at all, so nothing was being centred;
   see portal_register.css. */
.reg-ticket input { accent-color: var(--p-accent); margin: 0; }
.reg-ticket small { color: var(--muted); }
/* Invitation variant: the name grows, the price sits flush right. */
.reg-ticket .grow { flex: 1; }
.reg-ticket .price { font-variant-numeric: tabular-nums; }
.reg-single { margin: .4rem 0 .8rem; }
@media (prefers-reduced-motion: reduce) {
  .mode-card, .mode-card > summary::after, .alloc-bar i, .reg-ticket,
  .namefield-details > summary::before,
  body.buyer-journey :where(.mode-body, .pi-manage, .portal-register) button { transition: none; }
}

/* public/lang_switch.css */
.lang-switch { font-size: .85rem; position: absolute; right: 1rem; top: 1rem; z-index: 30; }
.lang-switch > summary { align-items: center; background: var(--surface, #fff); border: 1px solid var(--line, #d9d9e0); border-radius: 99px; cursor: pointer; display: flex; gap: .35rem; list-style: none; padding: .35rem .8rem; }
.lang-switch > summary::-webkit-details-marker { display: none; }
.lang-switch > summary > svg { flex: none; }
/* The summary sets its own rhythm with gap, so the house caret's own
   margin-left would double-count it and push the caret off the globe's
   spacing. */
.lang-switch > summary > .caret { margin-left: 0; }
.lang-switch > nav { background: var(--surface, #fff); border: 1px solid var(--line, #d9d9e0); border-radius: .5rem; box-shadow: var(--shadow-menu-soft); display: flex; flex-direction: column; margin-top: .35rem; min-width: 10rem; padding: .3rem; position: absolute; right: 0; }
.lang-switch > nav :is(a, [aria-current]) { border-radius: .3rem; color: inherit; padding: .35rem .55rem; text-decoration: none; white-space: nowrap; }
.lang-switch > nav a:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
.lang-switch > nav [aria-current] { font-weight: 700; }
.lang-switch > nav > .ls-search { margin: 0 0 .3rem; }
.lang-switch > nav > .ls-search > input { border: 1px solid var(--line, #d9d9e0); border-radius: .3rem; box-sizing: border-box; font: inherit; padding: .35rem .55rem; width: 100%; }
.lang-switch > nav > .ls-none { color: var(--muted, #6b6b76); padding: .35rem .55rem; }

/* Buyer capability and checkout-adjacent pages follow the event design too.
   Root mode consumes the constrained Classic pair; the account host gets the
   richer Pro palette. The printable ticket deliberately remains independent. */

/* On a phone the switch stops floating and takes a line of its own, above the
   card.

   Absolute is right on a wide screen: the control is chrome about the page
   rather than part of it, and the corner it sits in is empty. On a phone that
   corner is NOT empty — stub-band opens every buyer page with the organizer's
   own name on the left and the order or ticket ref on the right, and an
   out-of-flow pill over the top of them reserves no width, so the name simply
   ran underneath it. "Northwind Software Conferences" rendered as "Northwind
   Software C" on the first screen a buyer sees, with 87px of it painted under
   the pill and clipped by the card (2026-08-28). Nothing in the band can
   shrink to avoid it, because nothing in the band knows it is there.

   In flow the width is reserved by definition, so the collision cannot come
   back for the ref either. margin-left:auto rather than an alignment property,
   because .pro-shell is a column flex and a <details> has no text-align of its
   own to right its summary with. The right margin matches the shell's own
   inset — the card is edge to edge at this width (journey_shell.css), so
   without it the pill sits flush against the window. */
@media (max-width: 599px) {
  .lang-switch {
    margin: 0 var(--bj-pad, 1.25rem) .55rem auto;
    /* relative, NOT static: the open panel is `position: absolute; right: 0`
       against this element, so taking the switch out of the overlay must not
       also take away the box that panel is anchored to — static would send it
       to the nearest positioned ancestor, which is the whole page shell. It
       still lays out exactly where a static box would; all it keeps is the
       containing block. */
    position: relative;
    width: max-content;
  }
}

/* The currency switch (layout.html currency-switch) wears these clothes too,
   and a page with both stacks the second pill under the first: they share
   the corner, and side by side would put two variable-width pills in a
   fight for it. In flow on a phone they simply follow each other.

   Wide screens only, and that is the whole point of the query rather than
   tidiness: on a phone the pills are `position: relative` (above), and `top`
   moves a relative box just as readily as an absolute one. Unqualified, the
   3.25rem shifted the second pill 44px clear of its own flow position and
   dropped it straight onto the panel above it — the same collision this rule
   exists to prevent, arriving from the other direction. */
@media (min-width: 600px) {
  .lang-switch ~ .lang-switch { top: 3.25rem; }
}

/* …and 3.25rem is exactly where the first switcher's open panel begins
   (`> nav` is absolute, margin-top .35rem under a pill that is ~2.1rem
   tall). Both pills carry z-index 30, so paint order fell to DOM order and
   the LATER one drew over the earlier one's open menu: at 1440px the open
   language nav ran y 94-232 with the currency pill sitting at y 91-125 on
   top of it, and the first entry in the list could not be clicked at all.
   Past langSearchMin languages the child it covered was the type-to-find
   field rather than a language.

   So an open switcher outranks every pill that follows it. Written as "the
   ones after an open one drop" rather than "the open one rises" because it
   holds when both are open too: the panel a person is actually reading is
   the earlier one, and raising both would put the currency pill back on top
   of it. 29 is still above everything else either bundle draws (venue.css
   and cal_menu.css are the next ones down, at 20).

   The switchers also share a `name` (layout.html), so opening one closes the
   other and two panels are not normally on screen at once. This is what the
   corner does when that is unavailable — an older browser with no exclusive
   accordion — and it is CSS, so it holds with scripting off, which is how
   these controls work in the first place. */
.lang-switch[open] ~ .lang-switch { z-index: 29; }

/* public/journey_form.css */
/* ---- Buyer order & ticket pages ----
   Same dialect as the sponsor portal: hairlines, micro-caps eyebrows, pill
   ghosts. The one loud thing on the ticket page is the ticket card itself. */
/* The buyer skin is single-scheme — the organizer's palette, not the OS's.
   The card's luminance decides which side it is on (eventDesign.Scheme, a
   two-value enum stamped on <body data-scheme>), so a dark-OS buyer never
   gets a light chevron on a light card, or vice versa. Both glyphs are
   static: a url() whose value carries a template action puts
   html/template's escaper into an ambiguous URL context. */
body.buyer-journey select:not([multiple]), body.buyer-journey .ss-combo > .ss-input {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%235b6579' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); }
body.buyer-journey[data-scheme="dark"] select:not([multiple]),
body.buyer-journey[data-scheme="dark"] .ss-combo > .ss-input {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2398a2b5' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); }
body.buyer-journey select { max-width: 100%; }
/* width AND a cap: the box still fills a narrow column, and stops at the
   measure on a wide one. */
body.buyer-journey form:not([data-checkout]) :where(input:not([type=checkbox]):not([type=radio]):not([type=number]), select, textarea) {
  box-sizing: border-box; max-width: var(--bj-field); width: 100%; }
body.buyer-journey form:not([data-checkout]) :where(input:not([type=checkbox]):not([type=radio]), select, textarea) {
  background-color: transparent; border: 1px solid var(--control-line); border-radius: var(--bj-radius-sm); color: var(--fg);
  font: inherit; padding: .55rem .65rem; }
body.buyer-journey form:not([data-checkout]) select:not([multiple]) { padding-right: 2rem; }
/* Checkboxes and radios are targets, not glyphs: the browser default is 13px,
   under half the 24px WCAG 2.2 asks for. The label around them carries the
   rest of the target. */
body.buyer-journey :where(input[type=checkbox], input[type=radio]) {
  accent-color: var(--accent); block-size: 1.25rem; inline-size: 1.25rem; margin: 0; }
body.buyer-journey label.check { align-items: center; display: flex; gap: .55rem; min-block-size: 1.75rem; }
/* A journey check is normally the one-line sentence you agree to, so it
   centres. A confirm step's opt-out carries a hint under its label, and
   centring a 20px box against two lines floats it into the gap between them.
   The shared rule (check_label.css) already says flex-start for any check
   with a hint; it loses here on specificity, so the journey repeats it for
   its own. min-block-size goes too — it is there to give a single-line check
   a comfortable hit area, and a two-line one already has it. */
body.buyer-journey label.check.confirm-check:has(.field-desc) {
  align-items: flex-start; min-block-size: 0; }
body.buyer-journey label.check.confirm-check:has(.field-desc) > input { margin-block-start: .15rem; }
/* An ordinary journey check is body copy — the sentence you agree to — and
   carries no hint, so nothing has to tell the two apart. This one has a hint
   under it, and hints here are the same 14px as labels by design
   (journey_support.css): the difference is meant to be WEIGHT. At body weight
   the two lines read as one grey paragraph with a box beside it (Vicky,
   2026-08-17), so the first line takes the same voice as the Name and Email
   labels above it and the hint keeps the one supporting-text recipe. */
body.buyer-journey label.check.confirm-check:has(.field-desc) > span { font-weight: 600; }
body.buyer-journey label.check.confirm-check .field-desc { font-weight: 400; }
/* Every control on a buyer page, checkout included: the global input rule
   paints borders with the organizer's decorative --line, which measured
   1.23–1.48:1 against the card. WCAG 1.4.11 wants 3:1 for a control's own
   boundary, and a checkout field is not the place to make that exception. */
body.buyer-journey :where(input:not([type=checkbox]):not([type=radio]):not([type=hidden]), select, textarea) {
  border-color: var(--control-line); box-shadow: var(--control-shadow); }
body.buyer-journey form:not([data-checkout]) :where(input, select, textarea):focus-visible {
  border-color: var(--p-accent); outline: 2px solid var(--accent-weak-2); outline-offset: 1px; }
/* Primary submits: full width and properly dressed, the journey's own
   convention (the event page's checkout button styles itself). */
/* A TRANSPARENT border, not no border (Vicky, 2026-08-21: "is the I'm not
   attending button taller than the attending button?" — it was, by 2px). The
   quiet button beside it draws a 1px edge, and with box-sizing on content the
   bordered one is exactly two pixels taller than the filled one at the same
   padding. Declaring the border and hiding it keeps both boxes the same
   height, which is the only thing that ever made them differ. */
body.buyer-journey form:not([data-checkout]) button[type=submit] {
  background: var(--accent); border: 1px solid transparent; border-radius: var(--button-radius, var(--bj-radius-sm)); color: var(--accent-fg);
  font-size: .95rem; font-weight: 600; justify-content: center; padding: .7rem 1.2rem; }
body.buyer-journey form:not([data-checkout]) > button[type=submit] { width: 100%; }
/* …and the same button while it is working. The rule above is (0,3,3) —
   `:not([data-checkout])` counts toward that — so button_busy.css's single
   `.busy` class could never take the label away here, and the spinner was
   drawn on top of fully-visible words. This is that rule plus one class, so
   it wins by specificity rather than by source order, and it sits here
   rather than in the shared component because this is the rule that causes
   the problem. The ring takes the button's own foreground: --text-muted is
   a grey for a light surface and all but disappears on the accent fill. */
body.buyer-journey form:not([data-checkout]) button[type=submit].busy { color: transparent; }
body.buyer-journey form:not([data-checkout]) button[type=submit].busy::after {
  border-color: var(--accent-fg); border-top-color: transparent; }

/* ---- Choose add-ons for each ticket (assign_addons.html) ----
   A row is a ticket's NAME and one narrow choice, not a label squeezed into a
   third of the column beside a box the width of the page (Vicky, 2026-08-21:
   "the dropdowns take up too much space and the ticket name has to wrap
   loads"). The name was in a .grow, so the select ate whatever was left and
   "Ticket 1" broke across two lines beside it.

   The select is sized to its own longest option instead, and the name takes
   the room it needs. Under 420px they stack, because two things side by side
   in a phone's width is where the wrapping started. */
body.buyer-journey form:not([data-checkout]) .row:has(> .grow + select) { align-items: center; gap: .75rem; }
body.buyer-journey form:not([data-checkout]) .row > .grow + select {
  flex: 0 0 auto;
  inline-size: auto;
  max-inline-size: 60%;
  min-inline-size: 11rem;
}
body.buyer-journey form:not([data-checkout]) .row > .grow:has(+ select) { white-space: nowrap; }
@media (max-width: 420px) {
  body.buyer-journey form:not([data-checkout]) .row:has(> .grow + select) { flex-wrap: wrap; }
  body.buyer-journey form:not([data-checkout]) .row > .grow + select { max-inline-size: none; inline-size: 100%; }
}

/* ---- One field looks like the next ----
   "The treatment of form fields isn't completely uniform — slightly different
   border radius, weights, colours and shadows" (Vicky, 2026-08-21). Measured
   on one screen, the difference was exactly between the plain controls and
   the two COMPOSITE answers:

     input / select / textarea   radius 5px   control shadow   16px
     .yesno .yn                  radius 8px   no shadow        13.6px
     .checkgroup                 radius 8px   no shadow        —

   Both composites carry their own numbers from the shared stylesheets, where
   the admin's radius language is 8px and there is no --control-shadow to
   take. Out here they answer the same question as the box above them and
   should look like it: the card's own corner, the same lifted edge, the same
   reading size.

   NOT written with :where(). The rule it has to beat is `.yesno .yn` (0,2,0)
   in the shared sheet, and :where() contributes nothing, so the first attempt
   landed at 0,1,1 and lost silently — the shadow and the reading size took
   (nothing else was setting those) while the corner stayed at the admin's 8px,
   which is exactly the half-fixed state the note above describes. Spelling the
   scope out on each selector puts it at 0,2,1. */
body.buyer-journey .checkgroup,
body.buyer-journey .yesno .yn {
  border-radius: var(--bj-radius-sm);
  box-shadow: var(--control-shadow);
}
body.buyer-journey .yesno .yn { font-size: var(--bj-body); }

/* public/bj_actions.css */
.bj-eyebrow { color: var(--muted); font-size: .75rem; font-weight: 600; letter-spacing: .1em;
  margin: 0 0 .3rem; text-transform: uppercase; }
/* order-actions / ticket-actions are the two named rows a theme may style
   (docs/themes.md §The CSS contract); .bj-actions is the same shape wherever
   a buyer page needs it without being one of those two, and stays internal. */
/* head-action (the calendar button beside the event's name) and
   order-documents (receipt + invoice under the total) are the same row in two
   more places, since the order page took its action row apart on 2026-08-25.
   Named elements rather than .bj-actions modifiers because journey_primary_
   action.css has to name them too, and a class list there would have swept in
   every OTHER .bj-actions on the journey. */
order-actions, ticket-actions, head-action, order-documents, .bj-actions { display: flex; flex-wrap: wrap; gap: .5rem; margin: 1.1rem 0; }
ticket-actions { justify-content: center; }
/* The calendar takes a line of its own, centred, above the rest (Vicky,
   2026-08-24 — the row read "messy" with four things fighting for it). A
   full-width flex item is what breaks the line without a second container or
   a media query, and `order: -1` on the primary action still decides which
   one leads, so the markup stays in the order a theme's drop renders it. */
body.buyer-journey ticket-actions > .ta-lead {
  display: flex; flex-basis: 100%; justify-content: center; margin-bottom: .35rem;
}
/* The pill is worn by a <summary> as well as an <a>: "Add to calendar" is a
   disclosure that opens its three destinations (cal_menu.css), and it has to
   be the same control as the pills beside it. */
body.buyer-journey :is(order-actions, ticket-actions, head-action, order-documents, .bj-actions) :is(a, summary).pill-link {
  background: transparent; border: 1px solid var(--control-line);
  border-radius: var(--button-radius, var(--bj-radius-sm)); color: var(--fg); display: inline-block; font-size: .85rem; font-weight: 600;
  padding: .42rem 1rem; text-decoration: none; transition: border-color .15s, color .15s; }
body.buyer-journey :is(order-actions, ticket-actions, head-action, order-documents, .bj-actions) :is(a, summary).pill-link:hover {
  border-color: var(--p-accent); color: var(--p-accent); }
/* The DIRECT child only: that is the pill's leading icon. A caret sits at the
   far end inside its own .caret span (shared/caret.css) and must not take the
   leading icon's right margin. */
:is(order-actions, ticket-actions, head-action, order-documents, .bj-actions) :is(a, summary).pill-link > svg {
  margin-right: .35rem; vertical-align: -.18em; }
/* A secondary action that has to POST rather than navigate — declining the
   free-seat offer carries the whole cart with it — wears the same pill. The
   [type=submit] keeps it ahead of the primary-submit rule in journey_form. */
body.buyer-journey .bj-actions button.pill-link[type=submit] { background: transparent;
  border: 1px solid var(--control-line); border-radius: var(--button-radius, var(--bj-radius-sm)); color: var(--fg); cursor: pointer;
  font-size: .85rem; font-weight: 600; padding: .42rem 1rem;
  transition: border-color .15s, color .15s; }
body.buyer-journey .bj-actions button.pill-link[type=submit]:hover {
  border-color: var(--p-accent); color: var(--p-accent); }
/* The public error page: empty-state composition inside the journey card. */
.error-state { padding: 1rem 0; text-align: center; }
.error-state .bj-actions { justify-content: center; margin-bottom: 0; }
/* Checkout details: one soft card per attendee; legends whisper. */

/* public/cal_menu.css */
/* "Add to calendar" as a zero-JS disclosure: the pill opens a short list of
   the places a buyer might put the date — Google, Outlook, the .ics file.

   A menu rather than three pills in the row, because order-actions is capped
   at three actions: a fourth wraps it onto a second line. The <details>
   mechanics are .lang-switch's (lang_switch.css), which is the buyer
   journey's other dropdown — same open-on-click, same panel, no script. The
   pill itself is bj_actions.css's, worn by the <summary>, so this file only
   owns the disclosure: the marker, the caret and the panel.

   The panel opens LEFT-aligned under its pill (the switcher's is pinned
   right, being in the corner) and carries z-index so it draws over the
   ticket list that follows it in the card. */
/* .dir-menu is the venue's "Get directions" and rides the same mechanics: it
   came off the event page on 2026-08-25 to sit under the date line on the
   order and ticket pages (venue-directions, event.html), where the event
   bundle's own venue.css does not reach — that file dresses it in the event
   page's --custom-* palette, which no journey page defines. The disclosure,
   the marker and the panel are the same component; only the summary differs,
   and that one rule is below. */
:is(.cal-menu, .dir-menu) { position: relative; }
:is(.cal-menu, .dir-menu) > summary { cursor: pointer; list-style: none; }
:is(.cal-menu, .dir-menu) > summary::-webkit-details-marker { display: none; }
:is(.cal-menu, .dir-menu) > nav { background: var(--surface, #fff); border: 1px solid var(--line, #d9d9e0);
  border-radius: .5rem; box-shadow: var(--shadow-menu-soft); display: flex; flex-direction: column;
  left: 0; margin-top: .35rem; min-width: 11rem; padding: .3rem; position: absolute; z-index: 20; }
/* body.buyer-journey is load-bearing, not decoration: these are menu ITEMS
   and must read as text, but a bare `.cal-menu > nav a` ties the journey's
   own link colour on specificity and loses on source order — the entries come
   out in link blue at 5.6:1 while the pills beside them are near-black. The
   switcher's panel gets away with `color: inherit` only because its
   `:is(a, [aria-current])` borrows the attribute selector's weight. */
body.buyer-journey :is(.cal-menu, .dir-menu) > nav a { border-radius: .3rem; color: var(--fg); font-size: .85rem;
  padding: .4rem .55rem; text-decoration: none; white-space: nowrap; }
body.buyer-journey :is(.cal-menu, .dir-menu) > nav a:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
/* The directions summary is a quiet LINK under the date, not a pill: it is
   the venue line finishing its own sentence, and a second bordered control
   under the heading would compete with the calendar button beside it. Sized
   and coloured like the date line's own supporting text, in the journey's
   link colour so it reads as something to press. */
body.buyer-journey .dir-menu > summary {
  align-items: center;
  color: var(--journey-link, var(--accent));
  display: inline-flex;
  font-size: var(--bj-support);
  gap: .2rem;
}
body.buyer-journey .dir-menu > summary:hover { text-decoration: underline; }
body.buyer-journey .dir-menu > summary:focus-visible { border-radius: .25rem; outline: 2px solid currentColor; outline-offset: 2px; }
body.buyer-journey .dir-menu .caret { transition: transform .12s ease; }
body.buyer-journey .dir-menu[open] .caret { transform: rotate(180deg); }

/* public/journey_primary_action.css */
/* ---- One primary per journey row ----
   order-actions and ticket-actions used to be a row of identical pills —
   three on an order, five on a ticket, none of them more important than any
   other, and Receipt and Invoice sitting above the thing the buyer came for
   (Vicky, 2026-08-19).

   What changes is the DRESS, not the contents. Both elements are named in
   docs/themes.md's CSS contract and a themed page gets whatever is inside
   them, so taking Receipt, Invoice, Print or Edit out of the row would
   silently delete them from every themed order and ticket page. They all
   stay: the MARKED action wears the page's one filled button and the rest go
   quiet.

   An ATTRIBUTE, `primary`, and never a position. :first-child was tried and is
   wrong: order-actions is receipt / invoice / calendar in source order, so on
   an event with no date — where the calendar action is absent — it promoted
   RECEIPT to the page's one loud button. A marked action that does not render
   simply leaves the row with no primary at all, which is the honest answer: an
   order for an undated event has no next thing to do.

   head-action is deliberately NOT in this list. The calendar action moved out
   of both rows on 2026-08-25 to sit beside the event's name, and up there it
   wears bj_actions.css's plain pill — a small ghost button, not the page's
   filled one (Vicky: "more compact/less intense primary styling"). A header
   is not a row of actions with one winner; it is one action, and a filled
   button at that size shouted over the heading it sits beside.

   The marked action was "Add to calendar" on both pages, which was deliberate:
   it is the one thing a confirmed buyer actually wants next, and wallet passes
   are a per-account switch, so a wallet-first rule would leave a hole half the
   time. `order: -1` puts it first VISUALLY without moving the markup, which
   TestBlockOrderActionsMatchesBuiltinEmbed and its ticket sibling pin byte for
   byte against what a theme's drop renders. */
body.buyer-journey :is(order-actions, ticket-actions, ticket-actions > .ta-lead) > [primary] > .pill-link,
body.buyer-journey :is(order-actions, ticket-actions, ticket-actions > .ta-lead) > .pill-link[primary] {
  background: var(--accent);
  border: 0;
  border-radius: var(--button-radius, var(--bj-radius-sm));
  color: var(--accent-fg);
  font-size: .95rem;
  font-weight: 600;
  padding: .62rem 1.15rem;
}
body.buyer-journey :is(order-actions, ticket-actions, ticket-actions > .ta-lead) > [primary] > .pill-link:hover,
body.buyer-journey :is(order-actions, ticket-actions, ticket-actions > .ta-lead) > .pill-link[primary]:hover {
  border-color: transparent;
  color: var(--accent-fg);
  filter: brightness(.94);
}
/* Everything after it is a record or a second route, not an action: no pill
   outline, no border, just the page's link colour at supporting size. The
   row keeps its gap, so they read as a line of links beside the button
   rather than a second tier of buttons.

   order-documents — the order page's receipt and invoice, under the total
   since 2026-08-25 — is named HERE and deliberately not in the filled rules
   above: it is a pair of records with no action among them, so a [primary]
   selector naming it could never match anything and would be a rule that
   does nothing. This is the dress those two links have always worn; what
   changed is the element they wear it in. */
body.buyer-journey :is(order-actions, ticket-actions, order-documents) > :not([primary], .ta-lead):is(a, summary).pill-link,
body.buyer-journey :is(order-actions, ticket-actions, order-documents) > :not([primary], .ta-lead):is(a, details, form) > .pill-link {
  background: none;
  border: 0;
  border-radius: 0;
  color: var(--journey-link, var(--accent));
  font-size: var(--bj-support);
  font-weight: 500;
  padding: .42rem 0;
}
body.buyer-journey :is(order-actions, ticket-actions, order-documents) > :not([primary], .ta-lead):is(a, summary).pill-link:hover,
body.buyer-journey :is(order-actions, ticket-actions, order-documents) > :not([primary], .ta-lead):is(a, details, form) > .pill-link:hover {
  text-decoration: underline;
}
/* The quiet ones keep their icons but at the size of the words beside them. */
body.buyer-journey :is(order-actions, ticket-actions, order-documents) > :not([primary], .ta-lead) .pill-link > svg { height: 13px; width: 13px; }
/* First in the eye, wherever it sits in the markup. */
body.buyer-journey :is(order-actions, ticket-actions, order-documents) > [primary] { order: -1; }
/* The row is a button followed by links, so they line up on the button's
   centre rather than on the row's top edge. */
body.buyer-journey :is(order-actions, ticket-actions, order-documents) { align-items: center; column-gap: 1rem; }

/* public/checkout_attendee.css */
/* co-item is the same card for a THING rather than a person — the size of
   each t-shirt (item_fields.go). One rule rather than a copy: the two cards
   sit in the same column of the same form, so they cannot be allowed to
   drift apart. */
/* The corner is the CARD's corner, like every other nested surface on a
   buyer page (ticket_card.css, and the buttons in journey_shell.css). A
   fixed 12px was a rounder corner than the page card's own once the
   default came down to 5. */
/* NO BOX (Vicky, 2026-08-21: "I don't think the container border around each
   ticket's fields works well"). It was a framed card per person, and on a
   checkout that already draws a box round every input it made a box of boxes:
   the frame competed with the fields inside it and repeated for every seat on
   the order, so a three-ticket checkout was three nested frames deep in
   outlines before a word was read.

   What the frame was doing — saying where one person's questions stop and the
   next person's start — is done by SPACE and the name at the top, which is how
   the rest of the journey separates its sections (--bj-section). A rule above
   each group but the first draws the boundary at one hairline instead of four,
   and only between groups, where the question actually is.

   Still a <fieldset> with a <legend>: the grouping is what a screen reader
   announces before each field ("Ada Lovelace, T-shirt size"), and that is the
   part a border was never carrying. */
fieldset.co-attendee, fieldset.co-item {
  border: 0; margin: 0; padding: 0;
}
/* ONE gap, split evenly around the rule (Vicky, 2026-08-21: "the first one is
   too cramped and subsequent ones have too much space underneath"). It was a
   full --bj-section of MARGIN plus a full --bj-section of PADDING — 4.5rem
   between two groups, with the hairline sitting at the midpoint so each
   heading floated in the middle of a gap instead of belonging to the fields
   under it. Half above the rule, half below, and the heading lands where it
   reads as the start of something. */
/* ---- The header IS the boundary, and the way back in ----
   Vicky, 2026-08-24, twice over: "it should be really clear where one ticket
   ends and another begins", and "we shouldn't collapse a ticket with no
   visible way to reopen and edit it". A shaded bar answers both with one
   element — you can see where a ticket starts, and the thing you can see is
   the thing you press.

   It replaced a 1.5px dashed tear, which was her own earlier idea and was
   right about the metaphor and too quiet to do the job. A heavier tear and a
   notched perforation were both drawn and both lost to this: a line between
   two things asks you to notice an absence, and a header tells you where
   something STARTS, which is the easier thing to see.

   Still not a box (Vicky, 2026-08-21). The shading wraps the HEADING, never
   the fields — a checkout already draws a box round every input, and a frame
   per person made a box of boxes. */
fieldset.co-attendee > legend, fieldset.co-item > legend {
  background: color-mix(in srgb, var(--fg) 5%, transparent);
  border-radius: var(--bj-radius-sm);
}
/* A legend does not fill its fieldset the way a block child does, so the bar
   has to be told to. width:100% alone leaves it starting at the legend's own
   inline offset; the pair is what makes it a full-width band. */
fieldset.co-attendee > legend, fieldset.co-item > legend { margin-inline: 0; }
/* The state, on the right of the bar: what this ticket still needs, or that
   it is done. Written by checkout-cards.js from words the template hands it,
   and empty until then — an empty span draws nothing. */
fieldset.co-attendee legend .cg-state, fieldset.co-item legend .cg-state {
  color: var(--muted); font-size: var(--bj-support, .875rem); font-weight: 400;
  margin-left: auto; text-align: right;
}
/* Pressable once the module has upgraded it. Zero-JS never folds anything, so
   it never needs a control, and a dead one would be worse than none. */
fieldset.co-attendee > legend[role="button"] { cursor: pointer; }
fieldset.co-attendee > legend[role="button"]:hover {
  background: color-mix(in srgb, var(--fg) 9%, transparent);
}
fieldset.co-attendee > legend[role="button"]:focus-visible {
  outline: 2px solid var(--p-accent); outline-offset: 1px;
}
fieldset.co-attendee + fieldset.co-attendee,
fieldset.co-attendee + fieldset.co-item,
fieldset.co-item + fieldset.co-item,
fieldset.co-item + fieldset.co-attendee {
  /* No rule any more — the header above carries the boundary. The GAP stays,
     because two shaded bars with nothing between them read as one striped
     list rather than as separate tickets. */
  margin-top: 1.5rem; padding-top: 0;
}
/* The space below the rule goes on the LEGEND, not on the fieldset, and that
   is the whole reason the heading kept floating clear of its first field
   however small its own margin got (measured: 43px of gap against a 6px
   margin-bottom).
   A <legend> does not sit in the fieldset's padding box the way an ordinary
   first child does — it is rendered over the top border, and the fieldset's
   padding-top opens up UNDER it. So every pixel meant to separate one group
   from the previous one was landing between this heading and the field it
   heads. Put it on the legend's own padding and it lands where it was aimed:
   above the words, under the perforation. */
/* The 1.5rem that used to ride the legend's padding-top is on the fieldset's
   margin now (above). A legend is rendered over its fieldset's top border, so
   padding there opened up INSIDE the bar and made every later ticket's header
   twice the height of the first one's. */
/* And the FIRST one gets air above it too. It had none: the heading sat
   directly under whatever ended the block before it — on the details step,
   the "Give the first ticket to me" checkbox. */
fieldset.co-attendee:first-of-type, fieldset.co-item:first-of-type { margin-top: 1.5rem; }
/* The name reads as the heading it is now that nothing frames it: the
   journey's own h3 size and ink, not a letterspaced micro-caps label floating
   in a border. A <legend> is still a legend — it just stops pretending to be
   a tab on a folder. */
/* One line, two weights: the type at heading weight, the counter beside it in
   the supporting voice. Baseline-aligned rather than centred — they are one
   line of text, and centring a small run against a larger one leaves it
   floating above the letterforms it sits with. */
fieldset.co-attendee legend, fieldset.co-item legend {
  /* .4rem, not --bj-after-heading: a section heading on this form sits
     directly above a FIELD LABEL, which brings its own space with it, so the
     standard gap stacked two of them and left the heading floating clear of
     the thing it heads (Vicky, 2026-08-21: "less space between the heading
     and the first field"). */
  align-items: baseline; display: flex; flex-wrap: wrap; gap: .55rem;
  margin-bottom: .55rem; padding: .55rem .7rem; width: 100%;
}
fieldset.co-attendee legend .cg-type, fieldset.co-item legend .cg-type {
  color: var(--journey-heading, var(--fg)); font-size: var(--bj-h3, 1rem); font-weight: 600;
}
fieldset.co-attendee legend .cg-count, fieldset.co-item legend .cg-count {
  color: var(--muted); font-size: var(--bj-support, .875rem); font-weight: 400;
  font-variant-numeric: tabular-nums;
}
/* THE CARET ONLY APPEARS WHERE IT CAN DO SOMETHING. checkout-cards.js is what
   makes this header a control, and `aria-expanded` is the attribute it adds,
   so keying on that ties the mark to the behaviour rather than to the markup.
   With no JavaScript nothing folds and the caret stays away.

   Rotation follows the same attribute, and the direction matches the total's
   own disclosure: pointing right when the ticket is folded, down when it is
   open. Full ink at 14px, not --muted at 10: it went in at the promo caret's
   own size and colour and Vicky could not see it (2026-08-24). That caret sits
   inside a link, where the surrounding blue carries it; this one sits on a
   grey band beside a heading and has to hold its own. */
fieldset.co-attendee > legend > .cg-caret { display: none; }
fieldset.co-attendee > legend[aria-expanded] > .cg-caret {
  align-self: center; color: var(--fg); display: block; height: 14px; width: 14px;
}
fieldset.co-attendee > legend[aria-expanded="true"] > .cg-caret { transform: rotate(90deg); }


/* And the first label in a group takes no top margin of its own: the heading
   above it has already set the distance. */
/* The first thing after the legend, WHATEVER element it is. Listing element
   names missed the one that is actually first on the details step — a
   <div class="namefield"> — so the heading kept a field's whole top margin
   under it and floated (Vicky, 2026-08-21, twice). legend + * is the honest
   selector: it says "the thing after the heading" rather than guessing which
   thing that is. */
/* The first thing after the legend, and the first thing INSIDE it. The
   details step's first field is a <div class="namefield"> whose own label
   carries the margin — and with neither border nor padding on the div, that
   margin collapses straight through it, so zeroing the div alone changed
   nothing. */
fieldset.co-attendee > legend + *,
fieldset.co-item > legend + * { margin-top: 0; }
fieldset.co-attendee > legend + * > :first-child,
fieldset.co-item > legend + * > :first-child { margin-top: 0; }

/* The tucked questions on somebody else's seat: the summary is a quiet line,
   not a control fighting the header above it. The caret is the promo/total
   disclosure's own, turned by open state; the fields get a little air from
   the line once they show. */
.cg-optional > summary {
  align-items: center;
  color: var(--muted);
  cursor: pointer;
  display: flex;
  font-size: .875rem;
  gap: .4rem;
  list-style: none;
  padding: .2rem 0;
  width: fit-content;
}
.cg-optional > summary::-webkit-details-marker { display: none; }
.cg-optional > summary .promo-caret { transition: rotate .15s ease; }
.cg-optional[open] > summary .promo-caret { rotate: 90deg; }
@media (prefers-reduced-motion: reduce) {
  .cg-optional > summary .promo-caret { transition: none; }
}
.cg-optional[open] > summary { margin-bottom: .3rem; }

/* public/checkout_bar.css */
/* ---- The checkout's own foot ----
   The button that finishes a checkout used to sit wherever the last question
   left it, which on a five-ticket order is two or three screens below the
   fold. It is pinned to the bottom of the viewport now, with the count of
   what is still to fill in beside it.

   Sticky is CSS, so the pinning is part of the zero-JS baseline: with no
   scripts the bar is still a bar, holding the same button submitting the same
   form. Only the counter needs JS (checkout-cards.js), and it ships `hidden`
   so a page that never runs the module shows no empty line. */
checkout-bar {
  align-items: center;
  background: var(--journey-card);
  border-top: 1px solid var(--hairline);
  bottom: calc(var(--bj-pad) * -1);
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
  /* Bleeds to the card's edges, so the rule reads as the foot of the card
     rather than a box drawn inside it. Sticky holds the MARGIN box, which is
     why `bottom` is the same negative number: at bottom:0 the fill would park
     one padding below the viewport edge and content would scroll through a
     bare strip under it (the trap modal_head.css already records). */
  margin: var(--bj-section) calc(var(--bj-pad) * -1) calc(var(--bj-pad) * -1);
  padding: .9rem var(--bj-pad) calc(.9rem + var(--bj-pad));
  position: sticky;
  z-index: 3;
}
/* The bleed above assumes the bar IS the card's foot. Where something follows
   it — the details step's "Finish this later" escape — the negative margin
   pulled that content up under the bar's own painted padding, which sat over
   it at z-index 3: the link was swallowed whole, and what Vicky saw was its
   ghost (2026-08-25, "smaller gap under the save details button" — the gap
   WAS the covered link plus the bleed). A bar with a next sibling ends at its
   rule like any other block, and the escape link gets the floor. */
form > checkout-bar:not(:last-child) {
  bottom: 0;
  margin-bottom: 0;
  padding-bottom: .9rem;
}
/* One quiet centred line, sized as support text: the escape under the bar on
   the details step, and the way into their details on a ticket page with no
   answers to list. Unstyled it was a full-size left-flush paragraph. The
   class carries its own look now rather than only having one after a bar,
   because the second use has no bar in front of it. */
body.buyer-journey .journey-support {
  font-size: .875rem;
  margin: .9rem 0 0;
  text-align: center;
}
/* The count of what is left, not a status: it takes the supporting size and
   the muted ink, and the button is the only thing in the bar with weight. */
/* ---- How much of this there still is ----
   A bar over the count (Vicky, 2026-08-24, asking for one by name). The words
   were already here and were carrying the whole job alone: "2 of 5 named" is
   accurate and takes a beat to read, where a bar is the same fact before you
   have read anything.

   It is not the only signal — the sentence under it says the same thing in
   words, so nothing here depends on seeing a length or a colour (WCAG 2.2 AA).
   aria-hidden on the bar for that reason: a screen reader gets the sentence,
   and hearing the same count twice is worse than once. */
checkout-bar .cb-progress {
  color: var(--muted); display: flex;
  /* Its own full-width line above the button, in both layouts. As a flex item
     sized to its text the bar came out 88px long beside the action — a
     progress bar the width of the words it repeats is decoration. */
  flex: 1 1 100%;
  flex-direction: column; font-size: var(--bj-support);
  /* Its own full-width line above the button, in both layouts. As a flex item
     sized to its text the bar came out 88px long beside the action — a
     progress bar the width of the words it repeats is decoration. */
  gap: .4rem; margin: 0;
}
checkout-bar .cb-bar {
  background: color-mix(in srgb, var(--fg) 12%, transparent);
  border-radius: 999px; display: block; height: 5px; overflow: hidden;
}
/* The fill is the journey's OK ink, the colour this journey already uses for
   a thing that is done, rather than the account's accent — the accent is what
   the buyer presses, and a bar is not pressable. */
checkout-bar .cb-bar > i {
  background: var(--ok-ink, var(--ok)); border-radius: 999px; display: block; height: 100%;
  transition: width .3s ease; width: 0;
}
@media (prefers-reduced-motion: reduce) {
  checkout-bar .cb-bar > i { transition: none; }
}
/* Full width at every size, not just on a phone: every other screen in the
   buy flow ends in a full-width primary (journey_form.css's direct-child
   rule), and this bar's button ending the same flow at auto width read as a
   different control (Paul, 2026-08-22). The count wraps onto its own line
   above it. */
checkout-bar { flex-wrap: wrap; }
checkout-bar button { inline-size: 100%; margin: 0; }
/* THE PRIMARY'S OWN DRESS, restated here because the button left the form.
   journey_form.css binds it to `form:not([data-checkout]) button[type=submit]`
   — a DESCENDANT of a form — and this button is now a sibling of one, reaching
   it with a `form` attribute so the second column can place it beside the
   total (checkout_cols.css). Out of that rule it kept only the accent fill
   journey_shell.css gives every journey button and fell through to the ADMIN's
   button scale for everything else: 13px on a 32px pill, where every other
   screen in the buy flow ends in 15px on a 44px block. Vicky caught it on the
   rig, 2026-08-23.
   It is a copy, which is a cost — so TestTheCheckoutBarsButtonIsDressedLikeEvery
   OtherPrimary compares the two rules property by property and fails if either
   moves without the other. Same trade journey_primary_action.css already makes
   for the link-button doing this job. */
body.buyer-journey checkout-bar button[type=submit] {
  background: var(--accent); border: 1px solid transparent; border-radius: var(--button-radius, var(--bj-radius-sm)); color: var(--accent-fg);
  font-size: .95rem; font-weight: 600; justify-content: center; padding: .7rem 1.2rem; }
/* …and the same button while it is WORKING, for the reason journey_form.css
   spells out about its own copy of this pair. The rule above is (0,2,3) —
   `[type=submit]` counts toward that — so journey_shell.css's
   `body.buyer-journey button.busy` at (0,2,2) cannot take the label away, and
   the spinner drew straight over fully-visible words. This is that rule plus
   one class, so it wins by specificity rather than by source order.
   Not hypothetical, and not caught by eye: the button dress above landed
   without this and TestDemoEventPageUI/details_spinner_nav went red on CI,
   which is the whole reason that test measures the RENDERED state rather than
   the class. */
body.buyer-journey checkout-bar button[type=submit].busy { color: transparent; }
body.buyer-journey checkout-bar button[type=submit].busy::after {
  border-color: var(--accent-fg); border-top-color: transparent; }
/* ---- A folded card ----
   Everything but the legend and the one-line summary goes. It is display,
   not [hidden]: the fields must stay in the form and stay submitted — a
   folded card is a card whose answers are already given. */
fieldset.co-attendee[data-folded] { cursor: pointer; padding-bottom: .35rem; }
/* !important, and it is the small choice. journey_field.css dresses every
   buyer-page label with

     body.buyer-journey label:not(.check):not(.rowhit):not(.grow)…

   — ten :not()s, each contributing its argument's specificity, so that rule
   lands around (0,10,3) and no honest selector for "a child of a folded
   fieldset" can outweigh it. The alternatives were worse: widening that
   :not() chain with a class about folding (a rule in the wrong file, owned by
   the wrong screen) or wrapping every field in a container the template does
   not otherwise need. What it hides is inputs and their labels inside one
   fieldset, which is as small a blast radius as a forced declaration gets. */
fieldset.co-attendee[data-folded] > :not(legend):not(.co-sum) { display: none !important; }
fieldset.co-attendee[data-folded]:hover { border-color: color-mix(in srgb, var(--p-accent) 35%, var(--hairline)); }
.co-sum { display: none; }
fieldset.co-attendee[data-folded] .co-sum {
  align-items: center;
  display: flex;
  gap: .5rem;
  margin: 0 0 .6rem;
  overflow-wrap: anywhere;
}
/* NO TICK (Vicky, 2026-08-25: "they disagree with 2 questions left"). The
   folded line used to carry a green check meaning "done", and since the chip
   started reporting what is OUTSTANDING the two sat on the same card saying
   opposite things — a tick beside "2 questions left" on the booker's own
   ticket, which is the card most likely to have questions left on it.
   The chip is the one that knows, so the tick goes and the folded line is
   what it always really was: who is on this ticket. */

/* public/journey_misc.css */
/* .admin-note lived here and had one user: the payments note on the event
   page, which moved to a full-bleed .owner-strip above the whole page
   (styles/event/owner_strips.css) on 2026-08-12. Nothing else ever adopted
   it, so it went with its only caller rather than staying as a class the
   next organiser-only note would be tempted to reuse — an accent tint inside
   the page reads as part of the event's design, which is the thing that
   version got wrong. */
body.buyer-journey footer { justify-content: center; text-align: center; }

/* ---- What you are confirming (free_claim.html) ----
   Two facts about the buyer, shown back to them before they press Confirm.
   They were a .detail-list, which brings the admin's card chrome with it — a
   tinted, bordered, 10px-cornered box holding two rows, with an Edit link
   loose underneath (Vicky, 2026-08-21: "the box around the details on the
   Confirm your order page looks clumsy and unfinished").

   No box. The same hairline rows the order page shows answers in, so the
   screen has one way of displaying a label and a value rather than two. */
body.buyer-journey .claim-who > .detail-list {
  background: transparent;
  border: 0;
  border-radius: 0;
  margin: 0;
  padding: 0;
}
body.buyer-journey .claim-who > .detail-list :where(dt, dd) {
  border-top: 1px solid var(--hairline);
  padding: .65rem 0;
}
body.buyer-journey .claim-who > .detail-list :where(dt, dd):first-of-type { border-top: 0; }

/* public/doc_lines.css */
.doc-lines .row { border-bottom: 1px solid var(--hairline); }
.doc-refunded { opacity: .55; text-decoration: line-through; }
.doc-tax { text-align: right; }
.doc-total { font-size: 1.15rem; text-align: right; }
body.buyer-journey .doc-total { color: var(--bj-display-ink); font-size: var(--bj-display); font-weight: var(--bj-display-weight); }
@media print { body.buyer-journey { padding: 0; } body.buyer-journey > main { box-shadow: none; } order-actions, ticket-actions, head-action, order-documents, venue-links, .bj-actions, .lang-switch, footer { display: none; } }

/* public/add_tickets.css */
/* The add-more page's picker rows (add_tickets.html): name and price grow
   left, the box you type a quantity or amount into sits right and the row
   wraps on a narrow phone. Was style= on every row — only what a REQUEST
   computes stays inline (AGENTS.md, Code organization). The amount box is
   addressed by its inputmode rather than by the donation control's own
   class — donation_amount.css stays the one place that name appears. */
.row[data-add] { flex-wrap: wrap; gap: .5rem; }
.row[data-add] input[inputmode="decimal"], .row[data-add] input[type="number"] { width: 4.5rem; }
.row[data-add] input[name="quantity"][type="number"] { width: 4rem; }

/* public/email_fix.css */
.email-line { align-items: center; color: var(--muted); display: flex; flex-wrap: wrap; gap: .35rem; margin: .3rem 0 0; }
.email-fix { display: inline; }
.email-fix > summary { align-items: center; border: 1px solid var(--hairline); border-radius: 99px;
  color: var(--muted); cursor: pointer; display: inline-flex; height: 1.6rem;
  justify-content: center; list-style: none; transition: border-color .15s, color .15s;
  width: 1.6rem; }
.email-fix form { flex-basis: 100%; }
.email-fix > summary:hover, .email-fix[open] > summary { border-color: var(--p-accent); color: var(--p-accent); }
.email-fix > summary svg { height: 12px; width: 12px; }
.email-fix > summary::-webkit-details-marker { display: none; }
.email-fix form { align-items: stretch; display: flex; flex-wrap: wrap; gap: .5rem; margin: .6rem 0 .2rem; }
/* These two carry more specificity than they look like they need, and it is
   load-bearing. journey_form.css dresses every control on a buyer page —
   `body.buyer-journey form:not([data-checkout]) :where(input, select,
   textarea) { width: 100% }` at (0,2,2) and the same shape plus
   `> button[type=submit]` at (0,3,3) — which beat a bare `.email-fix
   input[type=email]`, and source order cannot save it: email_fix.css already
   loads after journey_form.css. Left alone, the box and its button each took
   a full row and stacked, which is what this panel did on a real order page
   until 2026-08-20. */
body.buyer-journey .email-fix form input[type=email] { flex: 1 1 0; min-width: 9rem; width: 0; }
body.buyer-journey .email-fix form button[type=submit] { flex: none; white-space: nowrap; width: auto; }
/* Open, the panel takes the whole row. Closed it is a 1.6rem pill sitting
   beside the sentence, and a shrink-to-fit box is right for that; open, that
   same box sized itself to the form's max-content and squeezed it. */
body.buyer-journey .email-fix[open] { flex: 1 0 100%; }
.email-fix form label { flex-basis: 100%; margin: 0 0 -.35rem; }
/* The refusal sits under the box it is about, on its own line — the form is a
   wrapping flex row, so a bare paragraph would try to share one with the
   button. Left-aligned with the input, not the row. */
.email-fix form .form-error { flex-basis: 100%; margin: -.1rem 0 0; }
/* Composite rows: field and button share one height, one baseline.
   This rule lost its body in #322, the split of the one big stylesheet into
   per-component files: it was written as one selector list covering both this
   panel and .change-row, the two halves went to two files, and the
   declarations went with change_row.css. What stayed here was a selector
   ending in a comma and then end-of-file — so the rule never applied, the
   field and its button never got their shared height, and (because the
   bundle is a plain concatenation) the dangling comma also glued this
   selector onto the first rule of whatever file came next. */
.email-fix form :where(input, button) { block-size: 2.6rem; box-sizing: border-box; margin: 0; padding-block: 0; }

/* public/head_edit.css */
/* head-edit — the small circular pencil beside a section heading, for a
   section the buyer may go and change ("Your details" → the order's checkout
   answers). Visually the same control the email line above it already uses
   (email_fix.css), as a LINK rather than a disclosure: this one goes to a
   page instead of opening a field in place.

   It lives in the heading rather than the order's action row because the
   action row is documents and the calendar, and a fifth pill there wrapped
   onto a second line (Vicky, 2026-08-10). An icon-only control, so the
   template owes it an aria-label. */
/* Qualified `body.buyer-journey a.head-edit`, the same shape back_link.css
   uses and for the same reason: `body.buyer-journey a` (journey_shell.css)
   paints every link --journey-link, which outranks a lone class and would
   leave this pencil purple three lines below an identical grey one. */
body.buyer-journey a.head-edit { align-items: center; border: 1px solid var(--hairline);
  border-radius: 99px; color: var(--muted); display: inline-flex; height: 1.6rem;
  justify-content: center; margin-left: .35rem;
  transition: border-color .15s, color .15s; vertical-align: middle; width: 1.6rem; }
body.buyer-journey a.head-edit:hover { border-color: var(--p-accent); color: var(--p-accent); }
.head-edit svg { height: 12px; width: 12px; }

/* public/checkout_cols.css */
/* ---- The buy flow's second column ----
   The last two screens before money moves carry a rail: what is being bought,
   what it comes to, and nothing else. The rule it is built on is Vicky's
   (2026-08-22) — a buyer screen earns a second column if it has a live
   updating amount, or an amount you need to reference while paying. Both of
   these qualify on the second clause, and the details step qualifies hardest
   on the event that asks questions, where the form runs to about 1700px and
   the figure would otherwise sit several screens above the button.

   PHONE FIRST, and that is the order this file is written in. Everything down
   to the container query is the narrow rendering — a small block of type above
   the form, no box, no second surface, nothing folded away. The rail is only
   a rail once there is room for one. 640px is unchanged and stays the narrow
   case; this is a per-screen argument, not a new house layout. */

/* Keyed on the RAIL, which both templates draw only when there is money to
   reference. A screen whose rail was withheld — a free selection, a breakdown
   that would not add up — leaves the card exactly as it was: no container, so
   the query below matches nothing, and <checkout-cols> stays the plain block
   it already is. One condition, in one place, for the width and the layout. */
body.buyer-journey > main:has(order-rail) {
  /* A CONTAINER QUERY, not a media query, and the container is the CARD.
     What decides whether a rail fits is how much room the card has, which is
     not the window: the same page renders inside the Customize studio's
     iframe (a preview only (laptop - 584)px wide) and inside the embed
     widget's 460px sheet, and a media query would put a 320px rail in both.

     It goes HERE rather than on <checkout-cols> for two separate reasons. An
     element cannot query itself, so the thing that becomes the grid can never
     be the thing that is measured. And a container-type on the element also
     carrying the layout's state resolves its own subtree a pass behind, which
     is a class of flicker nobody enjoys diagnosing. The card is measured, the
     child reacts.

     No overflow anywhere on this path, and none may be added: `hidden` on any
     ancestor makes a scroll container and freezes the sticky rail inside it
     without a word. `clip` where something genuinely has to be cut off. */
  container-type: inline-size;
  /* 1020, the same card the event page went to in #1552, and with the same
     rail beside it — 22rem at a 1.75rem gap (event_columns.css). Matching it
     is not tidiness: the summary panel is the one thing a buyer carries from
     the event page through this step to the payment page, and a panel that
     changed width between two steps of one purchase would read as the page
     redrawing itself.
     What falls out of those numbers is the point. This card's content column
     is 948px, less the gap and the rail leaves 568px — which is EXACTLY what
     a question measures at 640px today. The form does not move a pixel; the
     rail is paid for entirely by the card getting wider. That is Vicky's
     one-column-questions ruling (2026-08-20) left alone rather than argued
     with: questions stay one column, full width, in the organiser's order. */
  max-width: 1020px;
}
checkout-cols, checkout-body { display: block; }

/* The session recap above the columns gets the section gap under it.

   The details step opens with the times this selection holds (a .rows block,
   checkout_details.html), and <checkout-cols> carried no top margin — so the
   recap's closing hairline and the rail's top border met at ZERO pixels and
   the two blocks read as one (Vicky, 2026-08-25). The heading gives the recap
   1.25rem above it; below it there was nothing at all.

   --bj-section because what follows IS the next section: it is the gap this
   journey puts between a heading and the block before it
   (journey_headings.css), and the gap the rail already keeps under itself
   when the column stacks.

   The adjacent sibling is what scopes it. Only the details step puts anything
   between the heading and the columns; the payment page and the Elements page
   open straight onto <checkout-cols>, where the rail sitting under the title
   at --bj-after-heading is right and must not gain a gap it never needed. An
   event with no sessions renders no recap and lands in that same case. */
main > .rows + checkout-cols { margin-block-start: var(--bj-section); }

/* Stacked, the rail is a block above the form — which is where it has to be
   on the details step, because a wallet button charges the moment it is
   tapped and the total must already be on the screen above it. That is also
   why it comes FIRST in the markup rather than being moved there by CSS: the
   order a screen reader and a keyboard meet it in is the order it is written
   in, and this one is load-bearing.

   NO BOX on a phone. A checkout already draws a border round every field, and
   the page is already a card; a third frame around four lines of type is the
   box-of-boxes this product keeps deciding against (Vicky, 2026-08-21). What
   separates it from the form below is the section gap and the hairlines the
   rows carry anyway. */
order-rail { display: block; margin-block-end: var(--bj-section); }
/* A quiet subheading, at the supporting size — the mockup's own dress. The
   rail names itself once and the figures are what there is to read; at the
   full h2 size it outranked the question the buyer is actually being asked
   underneath it, which is what shipped in the first cut and is why the size
   is stated here at all.
   NOT the eyebrow's uppercase: the details step already opens with one
   (the event's name), and a second in the same column reads as a label
   pattern rather than as this thing having a name.
   body.buyer-journey, not a bare element selector: journey_headings.css sets
   the h2 size at (0,1,1) and two element names cannot outweigh it. */
body.buyer-journey order-rail > h2 { font-size: var(--bj-support); margin: 0 0 .6rem; }
order-rail .doc-lines { margin: 0; }
/* Tighter than a page row and in the supporting size: this is a recap of a
   decision already made, sitting above the thing actually being asked. */
order-rail .doc-lines .row { font-size: var(--bj-support); padding-block: .5rem; }
/* The total is the last row of the breakdown and sits in the same column as
   every figure above it — the treatment doc_lines.css already gives a
   receipt's and a quote's totals. It is the one loud thing in here. */
order-rail .rail-total {
  display: flex;
  gap: 1rem;
  justify-content: space-between;
  margin: 0;
  padding-block-start: .7rem;
}
order-rail .rail-total > .grow { color: var(--bj-display-ink); font-weight: var(--bj-display-weight); }
order-rail .rail-total > .price {
  color: var(--bj-display-ink);
  font-size: var(--bj-display);
  font-weight: var(--bj-display-weight);
  font-variant-numeric: tabular-nums;
}

/* ---- What Adaptive Pricing does to a breakdown ----
   When Stripe is charging the buyer in their own currency, the figures this
   page rendered from integer cents are no longer what is being taken, and a
   rail of stale numbers beside Stripe's own currency selector would be the
   page lying about the charge. checkout-elements.js sets this attribute when
   the session it is holding disagrees with the order — a different currency,
   or a different amount — and the rail drops to the names of what is being
   bought, with the one figure Stripe itself supplies underneath.

   Dropping the numbers rather than converting them is the whole point: our
   session line items are split by discount run and carry separate tax rows
   (stripeLineItems), so they do not map back onto these rows one for one, and
   a mapping that is wrong is worse than an absence that is honest.

   No JavaScript means no Adaptive Pricing either — this page's entire payment
   form is script, so a browser running none takes the hosted redirect and
   never sees these rows at all. */
order-rail[data-converted] .doc-lines .price,
order-rail[data-converted] .rail-tax { display: none; }

/* ---- Two columns, once the CARD has room for them ---- */
@container (min-width: 900px) {
  checkout-cols {
    /* start, not stretch: a rail stretched to the form's height has no room
       left to slide in, and sticky on a full-height box never moves. */
    align-items: start;
    display: grid;
    /* The event page's numbers, for the reason given on max-width above. The
       mockup drew 320px at a 1.5rem gap; 22rem won because #1552 got there
       first and a rail that changes width mid-purchase is worse than a rail
       32px off a mockup. It also fixes something the mockup could not show:
       at 320px the longest line either rail draws — a group discount's
       "Free tickets — 1 × Supporter" — wraps onto two. */
    gap: 1.75rem;
    grid-template-columns: minmax(0, 1fr) 22rem;
  }
  /* min-inline-size:0 or a long ticket-type name makes the form column refuse
     to shrink and pushes the rail off the card — grid's auto minimum is the
     content's, not zero. */
  checkout-body { grid-area: 1 / 1; min-inline-size: 0; }
  order-rail {
    grid-area: 1 / 2;
    margin-block-end: 0;
    /* The rail adds the TOTAL staying visible, and only that. The primary
       action is already pinned without JavaScript (checkout_bar.css), so
       there is no button in here and there must never be one: two "Continue
       to payment" buttons on one screen is a question about which one is
       real. */
    position: sticky;
    top: 1.25rem;
  }
  /* NOW it earns an edge. Standing alone beside a column of form, a block of
     unframed type reads as something that fell out of the page; the hairline
     and the card's own corner make it a panel. No fill — the buyer palette
     has one surface and a second tint invented here would be one more thing
     an organiser never chose. */
  order-rail {
    border: 1px solid var(--hairline);
    border-radius: var(--bj-radius);
    padding: 1.25rem;
  }
  /* Inside a panel the rows' full-bleed hairlines would run into its border,
     so the last one goes and the total sits under a rule of its own. */
  order-rail .doc-lines .row:last-child { border-bottom: 0; }
  order-rail .rail-total { border-top: 1px solid var(--hairline); margin-block-start: .2rem; padding-block-start: .8rem; }
  /* THE ACTION JOINS THE TOTAL (Vicky, 2026-08-23). The event page's own
     Continue moved into its rail one screen earlier (#1552), and two steps of
     one purchase disagreeing about where the button lives is the thing that
     reads as a fault rather than as a layout.

     It is a MOVE, not a copy: one button, in one place in the markup, put in
     the right column by the grid. A second button in the rail would be two
     controls doing one job and a question about which is real.

     Row 2 of the second column, and pinned to the BOTTOM of it rather than
     tucked under the summary. Under the summary was the first cut and it is
     wrong on the screen this exists for: an event asking questions runs to
     about 1700px, so a button that scrolls away with the total is a button
     you have to go back up for. Sticky-bottom keeps it in reach the whole way
     down and lands it directly beneath the total on a short order, which is
     the same behaviour it had in one column, moved into the column that
     holds the number it is about. */
  checkout-cols { grid-template-rows: auto 1fr; }
  checkout-body { grid-row: 1 / 3; }
  checkout-bar {
    /* end, so the bar starts at the foot of its own row and sticky has the
       whole of that row to slide through. */
    align-self: end;
    bottom: 1.25rem;
    grid-area: 2 / 2;
  }
  /* Its one-column dress is a bleed to the CARD's edges and a rule across the
     top, which is what makes it the foot of a card. In the rail's column it
     is a button under a panel, so all three go: no bleed, no rule, and the
     block margins that held it flush with the bottom of the card. */
  checkout-bar {
    background: none;
    border-top: 0;
    margin: 1rem 0 0;
    padding: 0;
  }
}

/* public/changes_closed.css */
/* The line that stands in for an edit control the event's policy has taken
   away (theme_blocks.html, order.html).

   A box, but a quiet one: it has to be findable by the one person hunting
   for the button that isn't there, without competing with the ticket — which
   is the page's only loud thing. So it borrows the ticket pad's own recipe
   (ticket_card.css) at half the strength: a few percent of the page's ink
   mixed into the card, and the same hairline. Both sides are tokens the
   account's own palette computes, so it follows a dark theme without a
   second rule.

   Centred ONLY under ticket-actions, whose pills are centred
   (bj_actions.css) — on the order page the same note sits under a
   left-aligned list of answers and follows that instead, which is why the
   alignment rides the neighbour rather than the class. */
.changes-closed { background: color-mix(in srgb, var(--journey-card, var(--bg)) 96%, var(--fg));
  border: 1px solid var(--hairline); border-radius: var(--bj-radius); font-size: .875rem;
  margin: .9rem 0 0; padding: .7rem .9rem; }
ticket-actions + .changes-closed { text-align: center; }

/* public/email_choice.css */
/* The did-you-mean page's one block per flagged address (email_confirm.html).
   A chrome-less fieldset: the legend names WHOSE address is being asked
   about, which is the whole reason the group exists — a checkout can flag the
   purchaser and every ticket holder at once, and three unlabelled pairs are
   three questions nobody can tell apart. */
.email-choice { border: 0; margin: 1.4rem 0 0; min-inline-size: 0; padding: 0; }
.email-choice > legend { font-weight: 600; padding: 0; }
.email-choice-typed { color: var(--muted); margin: .2rem 0 .6rem; }
/* The "different address" box belongs to the radio above it, so it sits
   indented under that row rather than starting a new column of its own. */
.email-choice > input[type=email] { margin: .35rem 0 0 1.55rem; max-width: 22rem; width: calc(100% - 1.55rem); }
/* The hard-verdict block has no radios, so its box belongs to nothing above
   it and takes the full column back. Selected by the refusal it follows
   rather than by a second class name: the two shapes differ by whether that
   line is there at all. */
.email-choice > .form-error { margin: .2rem 0 .5rem; }
.email-choice > .form-error ~ input[type=email] { margin-left: 0; width: 100%; }

/* public/change_row.css */
.change-row :where(select, button) { block-size: 2.6rem; box-sizing: border-box; margin: 0; padding-block: 0; }
.change-row { align-items: center; border-bottom: 1px solid var(--hairline); display: grid;
  gap: .6rem .8rem;
  grid-template-columns: minmax(8rem, 1fr) minmax(0, 1.4fr) 7.5rem; margin: 0; padding: .7rem 0; }
.change-row .cr-what { font-weight: 550; overflow-wrap: anywhere; }
.change-row .cr-what small { color: var(--muted); display: block; font-weight: 400; }
.change-row select { max-width: none; width: 100%; }
.change-row button[type=submit] { min-width: 0; width: 100%; }
@media (max-width: 480px) { .change-row { grid-template-columns: 1fr; } }

/* public/change_list.css */
/* ---- Change this order ----
   Everything a buyer can still DO to an order, in one list, at the bottom of
   the page (Vicky, 2026-08-20).

   It replaced five separate invitations to change something, none of them
   next to each other: two rows of rounded pills, a "Cancel complete RSVP"
   link, a "Cancel order and refund" link, and an add-on shop under a heading
   of its own. That is most of what "the confirmation screen is very busy,
   littered with links" was pointing at — a buyer scanning for their tickets
   had to read past four ways of not having them.

   A row is a LINK when the thing it does has a page of its own, and a
   <details> when it does not. The add-on shop is a form and has nowhere else
   to live, so it opens in place rather than growing a route for the sake of
   the pattern. Both wear the same row, so the list reads as one list and not
   as two mechanisms — the arrow is the only tell, and it tells the truth:
   pointing right means you are going somewhere, pointing down means it opens
   here. */
/* Bordered top and bottom, exactly like the tickets list two headings above
   it (tix_list.css). A single-row list drawn with no rules at all floated in
   the white space under its heading and read as one more line of the page
   rather than as the first entry in a list — which is half of why "Add more
   tickets" competed with the total (Vicky, 2026-08-21). */
/* THE WHOLE SECTION IS QUIET (Vicky, 2026-08-21: "the change this order
   section can be deemphasised altogether"). It is the least important thing
   on a confirmation — a buyer who wants it will look for it, and everybody
   else is here for their tickets — so its heading drops to the micro-caps
   eyebrow the rest of the journey uses for a label, and its rows take the
   supporting size rather than body size. Nothing is hidden and nothing moves;
   it simply stops competing with "Your tickets" and the total. */
/* body-scoped, because journey_headings.css sets every h2's size at
   `body.buyer-journey h2` — (0,1,2) — and a bare h2.quiet-heading is (0,1,1)
   and loses. */
/* Quiet, not shouted: smaller and muted, in the same sentence case every
   other title on a buyer page uses (Vicky, 2026-08-21: "doesn't need text
   transform uppercase. No titles do"). Uppercase survives on LABELS — the
   eyebrow above a heading, a fieldset's legend, a document's field names —
   which are not titles and are the one place the caps still say something. */
body.buyer-journey h2.quiet-heading {
  color: var(--muted);
  font-size: var(--bj-support);
  font-weight: 600;
  margin-block: var(--bj-section) .35rem;
}
change-list { display: block; margin: .3rem 0 0; }
change-list > * { border-bottom: 1px solid var(--hairline); }
change-list > :first-child { border-top: 1px solid var(--hairline); }
/* One row, whichever of the two it is. Padded rather than tall: the hit
   target is the whole row, which is why the <a> is a flex box and not a word
   with a link on it. */
change-list > a,
change-list > details > summary {
  align-items: center;
  color: inherit;
  cursor: pointer;
  display: flex;
  gap: .85rem;
  list-style: none;
  /* .1rem, the tickets list's own inset: a row that starts further right than
     the heading above it reads as indented by accident. */
  padding: .62rem .1rem;
  text-decoration: none;
}
change-list > details > summary::-webkit-details-marker { display: none; }
/* Hover colours the ROW's words, it does not paint a band behind them. A
   tinted band on a row that is inset .1rem from the card's text column ran
   effectively edge to edge, with square corners against the hairlines above
   and below, and read as a stripe laid over the list rather than as one row
   lighting up (Vicky, 2026-08-21).

   Rounding and insetting the band was the other way out and loses on
   consistency: the tickets list two headings above is the same shape of list
   and has always hovered by colouring .tx-who. Two lists on one page with two
   hover idioms is the kind of near-miss that makes a page feel assembled. */
change-list > a:hover .cl-t .cl-l,
change-list > details > summary:hover .cl-t .cl-l { color: var(--p-accent); }
change-list > a:focus-visible,
change-list > details > summary:focus-visible { outline: 2px solid var(--p-accent); outline-offset: -2px; }
/* The label takes the page's ink, not the link colour. `color: inherit` on
   the row cannot do it on its own — journey_shell.css paints every buyer-page
   <a> with the organizer's link colour at (0,1,2) and this row is (0,0,2) —
   so the rule that beats it says so out loud. Five blue links stacked in a
   box is the litter this list was built to gather up; what says "these are
   things you can do" here is the row and the arrow, once each. */
body.buyer-journey change-list > a { color: var(--fg); }
change-list .cl-t { display: flex; flex-direction: column; gap: .1rem; min-width: 0; }
/* 450, under the tickets list's 550 and well under the total's 600. The
   ladder is the point: an amount you paid is the loudest thing in its
   section, a person's name on a ticket is next, and the name of something you
   could go and do is a label. It was a <strong> at 550 and sat level with
   "Total paid" directly above it. */
change-list .cl-t .cl-l { font-size: var(--bj-support); font-weight: 500; overflow-wrap: anywhere; }
change-list .cl-t small { color: var(--muted); font-size: var(--bj-support); }
/* The arrow is drawn, not an icon: two borders of an empty box, rotated. It
   survives a theme with no icon font and costs no request. */
change-list .cl-arrow {
  block-size: 7px;
  border-bottom: 1.5px solid var(--muted);
  border-right: 1.5px solid var(--muted);
  flex: none;
  inline-size: 7px;
  margin-inline-start: auto;
  transform: rotate(-45deg);
  transition: transform .2s;
}
change-list > details > summary .cl-arrow { transform: rotate(45deg); }
change-list > details[open] > summary .cl-arrow { transform: rotate(225deg); }
change-list > a:hover .cl-arrow,
change-list > details > summary:hover .cl-arrow { border-color: var(--p-accent); }
/* What opens underneath. Its own rule under the summary, because the row
   above it is still a row and has to keep reading as one. */
change-list .cl-body { border-top: 1px solid var(--hairline); font-size: var(--bj-body); padding: .35rem .1rem 1rem; }
/* One quiet line per ticket inside an opened row — "which of these?" — rather
   than a link under every ticket up the page. */
change-list .cl-body .cl-pick { align-items: baseline; border-top: 1px solid var(--hairline); display: flex;
  gap: .6rem; justify-content: space-between; padding: .6rem 0; }
change-list .cl-body .cl-pick:first-child { border-top: 0; }
change-list .cl-body .cl-pick > span { font-size: var(--bj-support); }
change-list .cl-body .cl-pick > a { font-size: var(--bj-support); white-space: nowrap; }
change-list .cl-body > .muted:first-child { margin-top: .7rem; }
/* Undoing the order is the last thing in the list and wears the danger ink —
   a WORD in a colour, never a colour alone, and the word is already the
   organizer's own "Cancel order and refund". The confirm step it links to is
   unchanged: nothing here is the destructive act, only the way to it. */
change-list > a[data-undo] .cl-t .cl-l { color: var(--danger-ink); }
change-list > a[data-undo]:hover .cl-t .cl-l { color: var(--danger-ink); }
change-list > a[data-undo]:hover .cl-arrow { border-color: var(--danger-ink); }
change-list > a[data-undo]:focus-visible { outline-color: var(--danger-ink); }

/* public/order_flash.css */
.order-flash { color: var(--fg); font-weight: 550; margin: .6rem 0; }
.order-flash::before { color: var(--p-accent); content: "● "; font-size: .7em; vertical-align: .15em; }
/* Door (on-site sales): the lead line is the amount being charged; the
   type picker is a quiet label/quantity ledger, same bones as .tix-list. */

/* public/order_state.css */
/* ---- The confirmation state ----
   "You're in" is a STATE, not the headline, so it wears the shape a state
   wears: small, above the heading, with a tick in a tinted disc. The event's
   name is the heading (order.html).

   --ok-ink and not --ok: words and icons take the ink, the surface token
   misses AA in light mode on a plain card as well as on its own tint
   (TestStatusWordsTakeTheInk). The ink is then mixed toward the page's own
   ink rather than used neat. A buyer
   page is painted in the organizer's palette, and a fixed success green is
   the one colour on the screen that answers to nobody — it can land on a dark
   card at any contrast at all. Mixing it 88% with --fg keeps the hue on a
   light card and pulls it toward legibility on a dark one, which is the same
   move eventDesign.ControlLine already makes for a field outline.

   The word carries the meaning either way: colour is never the only signal
   (docs/design-system.md §3, WCAG 2.2 AA). */
.order-state {
  align-items: center;
  color: color-mix(in srgb, var(--ok-ink, #146c43) 88%, var(--fg));
  display: flex;
  font-size: var(--bj-support);
  font-weight: 600;
  gap: .4rem;
  margin: 0 0 .55rem;
}
.order-state > .os-tick {
  align-items: center;
  background: color-mix(in srgb, currentColor 15%, transparent);
  block-size: 1.15rem;
  border-radius: 50%;
  display: inline-flex;
  flex: none;
  inline-size: 1.15rem;
  justify-content: center;
}
/* `display: block` is what centres the tick, and it is not decoration. The
   flex item inside the disc is co-ic-success's own wrapper span, not the svg —
   so the svg is an INLINE box sitting on that span's text baseline, and the
   descender space under the baseline is dead weight the disc has to centre
   along with it. The tick came out .95px high in an 18.4px disc: a twentieth
   of the disc, invisible at 1× and unmissable the moment anybody zooms
   (Vicky, 2026-08-25). Blocking the svg collapses the wrapper onto the icon's
   own height, so align-items has the icon to centre rather than a line box.
   callout.css's `.co-ic svg` rule makes the same move for the same reason;
   it is scoped to `callout >` and never reached here. */
.order-state > .os-tick svg { block-size: .7rem; display: block; inline-size: .7rem; }

/* The total under "What you paid": one rule above it and nothing else, which
   is the only place on a buyer page a rule earns its keep — it is the line
   between the things and their sum. */
/* The rule belongs BETWEEN the things and their sum, so it only exists when
   there are things: after a list, never on its own under a heading. */
.rows + .paid-total { border-top: 1px solid var(--control-line); padding-top: .7rem; }
.paid-total {
  display: flex;
  font-weight: 600;
  gap: 1rem;
  justify-content: space-between;
  margin: .35rem 0 0;
}

/* The two documents, directly under the sum they evidence (Vicky,
   2026-08-25). Tighter above than a .bj-actions row sets for itself: this one
   is not a row of its own between two sections, it is the foot of the block
   above it, and 1.1rem of air read as a separation rather than an
   attachment. */
order-documents { margin: .85rem 0 0; }

/* public/door_shell.css */
/* door_shell.css — the desk console (door_pin.html, door.html, door_sale.html).

   The door is the one public surface nobody BUYS anything on: it is a staff
   tool, held one-handed by a steward with a queue in front of them, and it
   was the last public page still wearing raw headings on the bare shell
   (Vicky, 2026-08-19: "more in keeping with the app").

   It is deliberately NOT the buyer-journey card. That card carries the
   ORGANIZER's palette, and a pale custom theme is the wrong dress for a
   phone in a dim marquee — the desk gets Tito's own colours, so what a
   steward reads never depends on what an account chose. Three parts and no
   more: a bar that says which door this is, the body, and the foot holding
   the one action.

   The foot is IN FLOW, never fixed. A fixed bar is the classic phone bug:
   the soft keyboard lifts over it and the button a steward is reaching for
   is behind the number pad. min-height on the shell puts the foot at the
   bottom of a short page and lets a long picker push it down the scroll. */

body[data-door] > main { display: flex; flex-direction: column; max-width: 30rem;
  min-height: 100dvh; padding: 0; }

door-bar { align-items: center; background: var(--surface); border-bottom: 1px solid var(--line);
  display: flex; gap: .75rem; justify-content: space-between; padding: .7rem 1.1rem; }
/* Which door, then which event: a steward working two of them reads the
   reader's own name first, and it is the half that must not wrap away. */
door-bar > door-who { display: flex; flex-direction: column; min-width: 0; }
door-bar > door-who > strong { font-size: .95rem; font-weight: 600; overflow: hidden;
  text-overflow: ellipsis; white-space: nowrap; }
door-bar > door-who > small { color: var(--muted); font-size: .8rem; overflow: hidden;
  text-overflow: ellipsis; white-space: nowrap; }
door-bar > .badge { flex: none; }

door-body { display: flex; flex: 1; flex-direction: column; padding: 1.4rem 1.1rem; }
door-body > :first-child { margin-top: 0; }
door-body > h1 { font-size: 1.45rem; line-height: 1.2; margin: 0 0 1rem; }
/* The amount a card is about to be asked for, on the waiting screen. */
door-body > door-amount { font-size: 2.1rem; font-weight: 640; letter-spacing: -.02em;
  margin-bottom: .35rem; }

/* The credit line belongs with the body, not under the foot: the foot is the
   bottom edge of the console and anything below it reads as a second, broken
   bar. Ordered rather than moved in the template, because the footer is the
   shell's own and every public page renders it the same way. */
body[data-door] > main > door-body { order: 1; }
body[data-door] > main > footer { order: 2; padding: 0 1.1rem 1.1rem; }
body[data-door] > main > door-foot { order: 3; }

door-foot { align-items: center; border-top: 1px solid var(--line); display: flex;
  gap: .9rem; padding: .85rem 1.1rem; }
/* The running total, beside the button about to charge it (door-total.js).
   It is a preview of quantity times price — the server prices the real
   basket — and with no JS it renders the zero and stays there, which is what
   the basket costs before anything is picked. */
door-total { font-size: 1.15rem; font-weight: 620; white-space: nowrap; }
/* One action, thumb-sized. The button reaches back into the form in the body
   by id rather than the form wrapping the foot, so the control sits under a
   thumb without the fields above having to come down to meet it — and no
   stray <form> is introduced for layout. It never carries the button's
   default margin-top: the foot's own padding is the spacing here. */
door-foot > form { flex: 1; margin: 0; }
door-foot > :is(button, a.button, form) { flex: 1; }
/* With a total beside it the action takes the room left over, never half. */
door-total ~ :is(button, a.button) { flex: 0 1 auto; }
door-foot :is(button, a.button) { font-size: 1rem; justify-content: center; margin-top: 0;
  padding: .8rem 1rem; width: 100%; }

/* The PIN is typed on a phone: 16px is the floor at which iOS Safari stops
   zooming the page on focus, and a zoomed door page is one a steward then
   has to pinch back out of mid-queue. Centred and spaced because it is six
   digits read off a screen, not a word. */
body[data-door] input#door-pin { font-size: 1.35rem; letter-spacing: .35em;
  padding: .75rem .8rem; text-align: center; width: 100%; }
body[data-door] input#door-pin::placeholder { letter-spacing: normal; }

/* public/door_types.css */
/* The desk's currency row: quiet pills, the live one filled. */
.door-currencies { display: flex; flex-wrap: wrap; gap: .35rem; margin: .75rem 0 0; }
.door-currencies a, .door-currencies strong { border: 1px solid var(--line); border-radius: 99px;
  color: inherit; padding: .3rem .8rem; text-decoration: none; }
.door-currencies strong { background: color-mix(in srgb, currentColor 12%, transparent); font-weight: 700; }
.door-types { list-style: none; margin: 1rem 0; padding: 0; }
.door-types li { align-items: center; border-bottom: 1px solid var(--line); display: flex;
  gap: 1rem; justify-content: space-between; padding: .55rem 0; }
.door-types li:first-child { border-top: 1px solid var(--line); }
.door-types label { flex: 1; margin: 0; }
.door-types input { margin: 0; width: 5rem; }
/* Tickets as a quiet ledger: holder (or type) left, meta right, whole row a link. */

/* public/tix_list.css */
.tix-list { list-style: none; margin: .3rem 0 0; padding: 0; }
.tix-list li { border-bottom: 1px solid var(--hairline); }
.tix-list li:first-child { border-top: 1px solid var(--hairline); }
body.buyer-journey a.tix-row { align-items: center; color: var(--fg); display: flex; gap: 1rem;
  justify-content: space-between; padding: .65rem .1rem; text-decoration: none; }
a.tix-row .tx-who { flex: 1; font-weight: 550; overflow-wrap: anywhere; }
a.tix-row .tx-who small { color: var(--muted); display: block; font-weight: 400; }
a.tix-row .tx-meta { color: var(--muted); flex: none; font-size: var(--bj-support, .875rem); }
a.tix-row::after { align-self: center; border-bottom: 1.5px solid var(--muted);
  border-right: 1.5px solid var(--muted); content: ""; flex: none; height: .38em; transform: rotate(-45deg); width: .38em; }
a.tix-row:hover .tx-who { color: var(--p-accent); }
/* ---- A ticket nobody is named on yet ----
   "Ticket 3" sat in .tx-who at the row's full weight, so an empty ticket was
   drawn exactly like a person called Ticket 3 (Vicky, 2026-08-24). Dropping
   it to the muted, regular-weight treatment its own type line already uses
   says "nothing here" without adding a word.

   The marker beside it is OUTLINED where a ref is FILLED, which is what
   separates them at a glance: two grey filled chips in one row would read as
   two codes. Its colour comes from --fg like the ref's does, never from the
   accent — the palette is the organizer's, and an accent tint that clears
   contrast on one card fails on the next. Nothing here depends on colour
   anyway: the marker is a word, and the word is the signal (WCAG 2.2 AA). */
a.tix-row .tx-who .tx-gap { color: var(--muted); font-weight: 400; }
/* ---- The supporting line: what this ticket IS, and its code ----
   The code moved off the right-hand column and in under the name (Vicky,
   2026-08-29), and it shares the line the type already had rather than taking
   a third one. On its own line first, it made every row half again as tall —
   fine on a two-ticket order, a long scroll on a twenty-ticket one, and it
   left the state on the right floating against a three-line block.

   No middot between the facts. The gap BETWEEN them is what separates them,
   and it is several times the space inside either — a separator narrower than
   the space around it cannot separate anything. They wrap rather than push
   the row sideways, because a session summary and a code on one phone line is
   the case that overflows. */
a.tix-row .tx-who .tx-facts { column-gap: 1.1rem; display: flex; flex-wrap: wrap; row-gap: .1rem; }
/* ---- What is outstanding on this ticket ----
   The row's whole right-hand column now, the code having moved up under the
   name. No box: an outlined marker beside a filled ref chip was two boxes in
   one row reading as two codes, which is what Vicky called out on 2026-08-29.

   A DOT CARRIES THE STATE AND THE WORDS CARRY THE MEANING. Colour is never
   the only signal here (WCAG 2.2 AA, 1.4.1) — every state has its own phrase,
   and the dot is aria-hidden in the markup so a screen reader hears the
   phrase once. */
body.buyer-journey .tx-state { align-items: center; color: var(--muted); display: inline-flex;
  gap: .4rem; white-space: nowrap; }
body.buyer-journey .tx-state > .tx-dot { block-size: .45rem; border-radius: 50%; flex: none;
  inline-size: .45rem; }
/* MIXED TOWARD THE PAGE'S OWN INK — the same move .order-state makes for its
   tick, and eventDesign.ControlLine for a field outline. A buyer page is
   painted in the ORGANIZER's palette: a fixed amber or green is the one
   colour on the screen that answers to nobody, and it can land on a card of
   any lightness at any contrast at all. The 88/12 mix keeps the hue on a
   light card and pulls it towards legibility on a dark one.

   GREEN COMES FROM --ok AND NOT --ok-ink. The ink is darkened for WORDS,
   which have strokes a hair wide and need 4.5:1; a filled 7px disc is a
   graphic and reads better at the surface hue's saturation. layout.html
   re-declares --ok per event and per scheme, so it resolves against this
   card rather than the admin's :root. */
body.buyer-journey .tx-state[data-done] > .tx-dot {
  background: color-mix(in srgb, var(--ok, #1a7f37) 88%, var(--fg));
}
/* AMBER IS SPELLED OUT HERE, and that is deliberate — though NOT for the
   reason this comment first gave. It claimed --warn had no per-event
   declaration and so followed the READER's OS. The first half was true and
   has since been fixed (layout.html); the second half was never true. A
   light-dark() inside a custom property resolves against the color-scheme of
   the element that USES it, and every buyer surface inherits
   `color-scheme: {{.Scheme}}` from body.buyer-journey — so var(--warn) was
   already landing on the event's side, measured on a real dark-scheme page
   with the OS emulated light (2026-08-29). callout[tone=warning],
   email-suggest, doc_status and portal_overview never needed re-checking.

   What is true is that neither shared amber suits a 7px disc. --warn-ink is
   a TEXT ink — #8a5200 renders as brown at that size, and Vicky asked for a
   yellow (2026-08-29) — and --warn's light value (amber-500 #eab308) is too
   pale to carry 3:1 as a graphic. So the mark takes its own amber, picked
   off the body's data-scheme the way journey_form.css picks its select
   chevron: amber-600 on a light card (4.0:1 against white once mixed,
   comfortably past the 3:1 a meaningful graphic wants), and on a dark one
   the same value --warn already resolves to there. Nothing depends on
   either: the words beside the dot carry the state. */
body.buyer-journey .tx-state[data-todo] > .tx-dot {
  background: color-mix(in srgb, #d97706 88%, var(--fg));
}
body.buyer-journey[data-scheme="dark"] .tx-state[data-todo] > .tx-dot {
  background: color-mix(in srgb, #f2a33c 88%, var(--fg));
}
/* ---- The offer under the list ----
   Where the organizer asks nothing about the people on the tickets, naming is
   something a buyer may do rather than something outstanding (Vicky,
   2026-08-25), so it is a plain link and not the callout the asking events
   get. Left-flush under the list it belongs to, at the supporting size —
   .journey-support was the other candidate and is centred under a bar, which
   is a different position for a different job. */
body.buyer-journey .add-names { font-size: .875rem; margin: .75rem 0 0; }
/* Aligned self-service rows (type changes, add-ons): one grid, no drift. */
/* One aligned table: name column, select column, button column — every
   row's controls share the same widths. */

/* public/order_progress.css */
/* ---- How far through this order you are ----
   The bar the order page leads with (Vicky, 2026-08-24). Same component as
   the checkout bar's, deliberately: it is the same fact on the same purchase,
   and a buyer who watched it fill while checking out should meet the same
   thing when they come back.

   aria-hidden on the bar, because the sentence under it says the same count
   in words — a screen reader gets the sentence, and hearing one number twice
   is worse than hearing it once. Nothing here depends on colour or length. */
body.buyer-journey .order-progress {
  display: flex;
  flex-direction: column;
  gap: .4rem;
  margin: var(--bj-section) 0 0;
}
body.buyer-journey .order-progress .op-bar {
  background: color-mix(in srgb, var(--fg) 12%, transparent);
  border-radius: 999px;
  display: block;
  height: 5px;
  overflow: hidden;
}
/* The journey's OK ink rather than the account's accent: the accent is what a
   buyer presses, and a bar is not pressable. */
body.buyer-journey .order-progress .op-bar > i {
  background: var(--ok-ink, var(--ok));
  border-radius: 999px;
  display: block;
  height: 100%;
}
body.buyer-journey .order-progress .op-words {
  color: var(--muted);
  font-size: var(--bj-support, .875rem);
  margin: 0;
}
/* The one action is a CALLOUT now (Vicky, 2026-08-25), so it wears the
   journey callout's own dress and needs no rules of its own — only the space
   between it and the bar above, which the bar cannot set because it does not
   know what follows it. .order-next, .order-next > .button and
   .order-next-why went with the markup they styled. */
body.buyer-journey .order-progress + callout {
  margin-top: .9rem;
}

/* public/series_page.css */
/* The series page's fold of finished events: a quiet disclosure under the
   ledger, never a second list competing with what is on sale. The rows
   inside are the shared tix-list — only the summary is new here. */
.past-events { margin-block-start: var(--bj-section, 2.25rem); }
.past-events > summary { color: var(--p-accent); cursor: pointer; font-size: var(--bj-support, .875rem);
  font-weight: 550; }
.past-events[open] > summary { margin-block-end: var(--bj-after-heading, .75rem); }
/* A finished event is still a link, and still readable — dimming it would
   cost the contrast the rest of the page keeps. Only the weight steps back. */
.past-events a.tix-row .tx-who { font-weight: 500; }

/* public/pay_list.css */
/* The order page's Payments list: the original payment and every change
   invoiced against the order (adjustment_transfer.go). Hairline rows, the
   amount and its state right-aligned as one block — .rows/.row carry the
   structure, so this file only says what those two do not.

   A canceled row stays legible rather than going grey-on-grey: it is an
   accounting record of a change that was undone, and --text-faint is not a
   text colour. It loses its emphasis, not its contrast. */
.pay-state { text-align: right; }
.pay-state .price { font-weight: 600; }
.pay-due { color: var(--warn-ink); font-weight: 600; }
.pay-list .row > .grow > a { text-decoration: none; }
.pay-list .row > .grow > a:hover { text-decoration: underline; }
.pay-void > .grow { color: var(--muted); }
/* The way to settle a change that has no invoice: a button under its state in
   the right-hand block. A link here read as a label rather than an action,
   because the row above carries a blue link that IS its title (Vicky,
   2026-08-26). The margin is what stops it touching the state above it, and
   the cell's own text-align does the rest. */
.pay-state .pay-go { display: inline-block; margin-top: .15rem; }

/* public/answer_rows.css */
/* What the buyer was asked, on their order and ticket pages.
   It reads as a LIST OF PAIRS, not as a two-column table, and that is the
   whole decision (Vicky, 2026-08-18). Two columns only work when both sides
   are short: the moment a question is a real sentence the row has to choose
   what to do, and whichever way it chooses some rows keep their answer out to
   the right while others wrap it back to the left — so the eye has no single
   place to look and the list stops being scannable. Stacking is the same
   shape on every row whatever length either side runs to.
   The question is the quiet half: it takes the support size and the muted
   ink, so the ANSWER is what the page hands you and the question is the label
   on it. Before that they were identical body text stacked in pairs, which
   left no way to tell which line was which except by counting. */
body.buyer-journey .rows.answers .row { display: block; }
body.buyer-journey .rows.answers .row > .grow {
  color: var(--muted);
  font-size: var(--bj-support);
  margin-bottom: .1rem;
}
/* An answer is whatever somebody typed — a URL or an unspaced list has no
   break opportunity in it, and would otherwise run out past the hairline. */
body.buyer-journey .rows.answers .row > span { display: block; overflow-wrap: anywhere; }
/* A file answer is a link per file; they read as a list, not as a sentence. */
body.buyer-journey .rows.answers .row > span > a { overflow-wrap: anywhere; }

/* public/ticket_card.css */
/* The ticket card: what you show at the door. Two halves, each with its own
   border, corner-masked at the join — real punched notches the border can't
   cross (an overflow-hidden bite left the outer border line intact). */
.tkt-card { margin: 1.4rem auto; max-width: 360px; }
/* On the ticket's own page the card IS the page: it takes the full width and
   the code grows with it, because the one thing this screen exists for was
   232px wide in a 640px card and read as a thumbnail of itself. */
.tkt-card.hero { margin-block: 1.6rem; max-width: none; }
.tkt-card.hero .tkt-stub { padding: 1.35rem 1.5rem 1.2rem; }
/* The holder's name is NOT enlarged with the rest of the hero card (Vicky,
   2026-08-25: "the ticketholder's name shouldn't be the same size as the event
   name"). It was 1.65rem here against a 1.6rem h1, so the biggest words on the
   ticket page were the attendee's own name and the event they are going to
   came second. It keeps the base card's --bj-display (1.35rem), which is a
   clear step below the heading while still being the loudest line on the card
   it belongs to. The QR and the ref still scale up: those are what a door
   reads, and neither competes with a heading for the same job. */
.tkt-card.hero .tkt-scan { padding: 1.6rem 1.5rem 1.4rem; }
/* width, not max-width: the <img> carries width="256" as an attribute, so a
   max-width can only ever shrink it. The PNG itself is encoded at 512
   (handleTicketQR), so asking for 300 is still downscaling and the modules
   stay square. */
.tkt-card.hero .tkt-scan img { max-width: none; width: min(300px, 62vw); }
.tkt-card.hero .tkt-scan .tk-ref { font-size: 1.05rem; letter-spacing: .2em; }
/* The pad is deliberately NOT the page background and NOT a customizer
   token: a loud --bg turned the credential the same colour as the surround
   (seen with a strong green palette). Derived instead — the card nudged 7%
   toward the text colour — so it reads as a quiet neutral on light and dark
   card themes alike, and the palette's text stays readable on it. */
body.buyer-journey :where(.tkt-stub, .tkt-scan) .tk-ref { font-size: .95rem; }
/* The credential's own edge, and its own TEAR (Vicky, 2026-08-21: "has the
   ticket block lost its perforation dashed line? it's not visible now").
   It had. Both were drawn in --hairline, which is derived from the field
   boundary — and the field boundary went deliberately soft the same
   afternoon, so a line that exists to look like a physical perforation
   inherited a weight chosen to make a text box recede.
   A ticket is not a field. Its edge and its tear come off the INK instead, at
   a weight that survives on a light card and a dark one, and they no longer
   move when somebody tunes how loud an input looks.
   They are TWO tokens, because they are two jobs (Vicky, 2026-08-21, on the
   edge: "a bit too dark, knock it down quite a bit"). The tear is the loud
   one on purpose — it is the thing that says ticket. The edge only has to
   close the shape, and the fill has already done most of that work, so at the
   tear's weight it read heavier than the page's own structural line (--line)
   and boxed the credential in. The edge lands just under that line instead:
   present, and not the first thing the eye finds. */
.tkt-card { --tk-line: color-mix(in srgb, var(--fg) 20%, transparent);
  --tk-edge: color-mix(in srgb, var(--fg) 10%, transparent); }
.tkt-stub, .tkt-scan { background: color-mix(in srgb, var(--journey-card, var(--bg)) 93%, var(--fg)); border: 1px solid var(--tk-edge); }
.tkt-stub { -webkit-mask-image: radial-gradient(circle 10px at 0 100%, transparent 9.5px, #000 10px),
    radial-gradient(circle 10px at 100% 100%, transparent 9.5px, #000 10px); border-bottom: 0;
  /* The credential's corner is the CARD's corner (--journey-radius), not a
     number of its own. It was a fixed 18px, which was invisible while the
     page card shipped at 14 and became a card-inside-a-card with a rounder
     inner corner the moment the default dropped to 5 (Vicky, 2026-08-21).
     One vocabulary for corners on a buyer page: an organizer who squares
     off their card squares off their ticket with it. */
  border-radius: var(--bj-radius) var(--bj-radius) 0 0;
  mask-image: radial-gradient(circle 10px at 0 100%, transparent 9.5px, #000 10px),
    radial-gradient(circle 10px at 100% 100%, transparent 9.5px, #000 10px);
  -webkit-mask-size: 51% 100%;
  mask-size: 51% 100%; -webkit-mask-position: left, right;
  mask-position: left, right; -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat; padding: 1.15rem 1.25rem 1rem; }
.tkt-stub .tk-event { color: var(--muted); font-size: .75rem; font-weight: 600; letter-spacing: .1em;
  margin: 0 0 .35rem; text-transform: uppercase; }
.tkt-stub .tk-holder { color: var(--bj-display-ink); font-size: var(--bj-display); font-weight: var(--bj-display-weight);
  letter-spacing: -.01em; line-height: 1.2; margin: 0; overflow-wrap: anywhere; }
.tkt-stub .tk-meta { color: var(--muted); font-size: var(--bj-support); margin: .3rem 0 0; }
.tkt-scan { -webkit-mask-image: radial-gradient(circle 10px at 0 0, transparent 9.5px, #000 10px),
    radial-gradient(circle 10px at 100% 0, transparent 9.5px, #000 10px); border-radius: 0 0 var(--bj-radius) var(--bj-radius);
  border-top: 1.5px dashed var(--tk-line); mask-image: radial-gradient(circle 10px at 0 0, transparent 9.5px, #000 10px),
    radial-gradient(circle 10px at 100% 0, transparent 9.5px, #000 10px);
  -webkit-mask-size: 51% 100%;
  mask-size: 51% 100%;
  -webkit-mask-position: left, right; mask-position: left, right;
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  padding: 1.15rem 1.25rem 1.05rem; text-align: center; }
.tkt-scan img { background: #fff; border-radius: var(--bj-radius-sm); height: auto; max-width: min(232px, 70vw); padding: 12px; }
.tkt-scan .tk-ref { color: var(--muted); font-size: .95rem; font-family: ui-monospace, "SF Mono", Menlo, monospace;
  font-size: .82rem; letter-spacing: .12em; margin: .5rem 0 0; }
.tkt-scan .tk-state { margin: .35rem 0 0; }

/* The name and the control that changes it share a line: the name takes the
   room, the pencil sits at the end of it. In the FLOW rather than positioned,
   so a long name wraps against the pencil instead of running under it. */
.tk-holder { align-items: center; display: flex; gap: .5rem; }
body.buyer-journey .tk-holder > a.tk-edit {
  /* The card's own surface, not the page's hairline. head-edit is drawn for
     a heading on the white card, where a --hairline ring reads; on the
     ticket's tinted stub that ring came out the same colour as the panel
     behind it and the control looked like a loose glyph. A chip in the card
     colour reads as something to press, which is the answer raised surfaces
     get everywhere else on the journey. */
  background: var(--journey-card, var(--bg));
  border-color: var(--tk-edge);
  flex: none;
  margin-inline-start: auto;
}

/* public/journey_shell.css */
body.buyer-journey { background: var(--bg); min-height: 100dvh; padding: 3rem 1.25rem; }
/* 640px, not 510 (Vicky, 2026-08-20). The old measure squeezed a three-ticket
   order, its payment summary and its add-on shop into a column narrower than a
   paperback.

   The second half of this note used to say the cap stops at 640 because a
   wider card is invisible in the Customize studio — studio-shell is
   232px | 1fr | 320px with 1rem of stage padding either side, so an
   organizer's preview is only (their laptop - 584)px, 856px on a 1440 machine,
   and being an IFRAME it fires a page's own queries at THAT width.

   That was a CONSTRAINT, and on 2026-08-22 Vicky lifted it. The studio's
   canvas now lays out at a fixed 1280px behind an explicit zoom control
   (#1545), so a wide layout is no longer one organizers cannot see. What is
   left is a PREFERENCE, and 640 is still what this rule gives every page — a
   second column is a per-screen argument (a live updating amount, or an amount
   you need while paying), not a new house width.
   THREE screens have made the argument so far, and each widens itself rather
   than widening this: the event page in its own bundle (styles/event/
   event_pro.css, event_columns.css), and the details step and the payment page
   through checkout_cols.css, which raises the cap only for a card that
   actually contains a rail. Everything else here is still 640, and a page that
   has not earned a rail cannot accidentally inherit one. */
/* NO EDGE AT ALL (Vicky, 2026-08-21: "the event page doesn't have a border,
   while other pages do. I think no border works best"). She is describing a
   real inconsistency — .pro-card has never drawn one, so the first screen of
   the journey and the five that follow it disagreed — and choosing the event
   page's answer for all of them.
   What separates the card from the page is the TINT it sits on: --journey-card
   against --bg, which is the same contrast the event page has always relied
   on. The drawn edge was doing a second job, hairline-matching the dividers
   inside so the boundary and the internal rules read as one system; those
   rules are still there and still hairlines, they just no longer have a frame
   around them.
   The notch survives on its own terms. It was drawn as a bite out of a 1px
   line; it is now a bite out of the fill, which is what a torn stub is, and
   the perforation across the card is unchanged. */
body.buyer-journey > main { background: var(--journey-card); border: 0;
  border-radius: var(--journey-radius);
  box-shadow: var(--shadow-journey);
  color: var(--fg); max-width: 640px; padding: var(--bj-pad); }
/* The band bleeds to the card's edges, so the card cannot also pad above it. */
body.buyer-journey > main:has(> stub-band) { padding-top: 0; }
/* An owner strip (shared/owner_strips.css) is a line from Tito ABOUT the page,
   above it — so it has to reach the window's own edges, and the shell's inset
   is exactly what stops it. Cancelled HERE rather than in owner_strips.css
   because this file is what creates the inset; the strip cannot know what it
   is escaping, and the event page's own document insets nothing.
   The strip then carries the gap the padding used to give the card, so the
   content below it still breathes. */
body.buyer-journey:has(> .page-preview) { padding-top: 0; }
body.buyer-journey > .page-preview { margin: 0 -1.25rem 3rem; }
/* A button's corner follows the CARD's corner, through the control token
   (journey_tokens.css). button.css ships a fixed 7px, which was invisible
   beside a 14px card and became the rounder of the two the moment the default
   dropped to 5 — the page's smallest object wearing its softest edge (Vicky,
   2026-08-21). A theme manifest asking for `buttons = "pill"` still wins
   outright: layout.html emits that block after this stylesheet. */
body.buyer-journey :is(button, a.button) { border-radius: var(--button-radius, var(--bj-radius-sm)); }
body.buyer-classic > main { max-width: 760px; }
body.buyer-journey h1, body.buyer-journey h2 { color: var(--journey-heading); }
body.buyer-journey a { color: var(--journey-link); }
body.buyer-journey button { background: var(--accent); color: var(--accent-fg); }
/* Every other buyer-journey button while it is working — the event page's
   own checkout button among them. Same reason as journey_form.css's copy:
   the rule above is (0,1,2) and beats button_busy.css's single class, so
   the label stayed put and the ring drew over it. */
body.buyer-journey button.busy { color: transparent; }
body.buyer-journey button.busy::after { border-color: var(--accent-fg); border-top-color: transparent; }
/* A destructive journey confirm keeps its danger dress — the accent repaint
   above must not make "Cancel this quote" look like any other primary. */
body.buyer-journey button.danger,
body.buyer-journey form:not([data-checkout]) button[type=submit].danger {
  background: color-mix(in srgb, var(--danger), #000 28%); color: #fff; }
body.buyer-journey button.danger:hover,
body.buyer-journey form:not([data-checkout]) button[type=submit].danger:hover {
  background: color-mix(in srgb, var(--danger), #000 34%); }
/* A page's main action that NAVIGATES rather than submits — "Complete
   payment" on an unpaid order, "View order" on a converted quote, "New sale"
   at the door. The journey dresses its primary submit at 15px on a 44px
   block (journey_form.css) but that rule is bound to
   `form button[type=submit]`, so a link doing the same job fell through to
   the ADMIN's button scale: 13px on a 32px pill. On the unpaid order page
   that put the smaller of the two buttons on the screen where the buyer
   decides, and the bigger one on the screen after it.
   The convention is the SIZE, not the element.
   Scoped to a link-button standing alone in its own paragraph, which is the
   shape a PAGE-LEVEL action has here. That child combinator is what keeps it
   off the ones that are genuinely something else and carry their own dress: a
   callout's compact action, the portal's ghosts, the split layout's pair. */
body.buyer-journey p > a.button {
  border-radius: var(--button-radius, var(--bj-radius-sm)); font-size: .95rem; font-weight: 600; justify-content: center;
  padding: .7rem 1.2rem; width: 100%; }
/* Filled for the action itself. An ALTERNATIVE standing beside it — the
   transfer door under "Pay securely" on the picker — takes the journey's own
   quiet dress, the colours button.subtle already gives a submit doing that
   job, rather than the admin's surface-and-hairline default. Scoping the size
   rule above to .primary left exactly that button at 13px beside a 15px one,
   where it read as unstyled rather than as quieter (Vicky, 2026-08-19). */
/* 1px transparent, matching the submit exactly — the fence beside this
   (TestJourneyPrimaryLinkMatchesItsSubmit) exists because the filled
   action must not end up a different HEIGHT from the quiet one beside
   it, whichever element it happens to be. */
body.buyer-journey p > a.button.primary { border: 1px solid transparent; }
body.buyer-journey p > a.button:not(.primary) {
  background: transparent; border-color: var(--control-line); color: var(--fg); }
body.buyer-journey p > a.button:not(.primary):hover {
  border-color: var(--p-accent); color: var(--p-accent); }

/* The last rung of an action ladder: a way out, centred under the two
   buttons above it, in the supporting size. Not a button, because it is not
   the same kind of thing as the two things it sits under (Vicky,
   2026-08-21). */
body.buyer-journey p.quiet-action { font-size: var(--bj-support); margin-top: .9rem; text-align: center; }
/* A journey button opting out of the accent (e.g. the dashboard's sign-out):
   same quiet dress as the pill-link doors. */
body.buyer-journey button.subtle,
body.buyer-journey form:not([data-checkout]) button[type=submit].subtle {
  background: transparent; border: 1px solid var(--control-line); color: var(--fg); }
/* A submit inside a repeated row is a row action, not the page's action:
   small and inline, and never full width. It was a 999px pill, which is the
   last fully-round control a buyer met — on Change a ticket type, on the
   add-on rows and on the move-an-add-on rows (Vicky, 2026-08-21). Same corner
   as every other control on the page now; what makes it read as a row action
   is its SIZE, which is the honest signal anyway. */
body.buyer-journey form:where(.row, .change-row):not([data-checkout]) button[type=submit].subtle,
body.buyer-journey form:where(.row, .change-row):not([data-checkout]) > button[type=submit].subtle {
  border-radius: var(--button-radius, var(--bj-radius-sm)); font-size: .85rem; padding: .42rem 1rem; width: auto; }
body.buyer-journey button.subtle:hover,
body.buyer-journey form:not([data-checkout]) button[type=submit].subtle:hover {
  border-color: var(--p-accent); color: var(--p-accent); }
.journey-logo { display: block; margin: 0 0 1.5rem; max-height: 72px; max-width: 140px; object-fit: contain; }
@media (max-width: 599px) {
  body.buyer-journey { padding: 0; --bj-pad: 1.25rem; }
  body.buyer-journey > main { border: 0; border-radius: 0; box-shadow: none; min-height: 100dvh; padding: var(--bj-pad); }
  body.buyer-journey > main:has(> stub-band) { padding-top: 0; }
}

/* public/stub_band.css */
/* ---- The stub band ----
   Every buyer page opens with the top half of a ticket: who issued it on the
   left, the code or figure it is about on the right, and a perforation with
   two torn notches between that and the body.

   It exists to solve a layout problem as much as to carry a look. layout.html
   used to stack the logo and the account name as two separate blocks at the
   top of the card, each with its own margin, so a page with both opened with
   three stacked zones before its own heading. The band is one row that holds
   whichever of them the organizer has set.

   The notch is punched in the PAGE background, never a colour of ours, so it
   is derived from the organizer's palette and cannot clash with a theme. It is
   decoration in the strict sense — nothing is lost with it hidden, which is
   what the narrow branch below relies on. */
/* The band takes the CARD's colour, not a tint of its own (Vicky,
   2026-08-20). A tint would be a fifth surface colour on a page whose other
   four are all the organizer's, and it would owe a control of its own the
   moment anyone asked to change it. The tear does the separating instead,
   which is the honest way round: a real stub is the same paper as the ticket,
   told apart by the perforation and nothing else. */
stub-band {
  align-items: center;
  background: var(--journey-card);
  border-radius: var(--journey-radius) var(--journey-radius) 0 0;
  display: flex;
  gap: 1rem;
  justify-content: space-between;
  margin: 0 calc(var(--bj-pad) * -1) var(--bj-section);
  padding: 1rem var(--bj-pad) 1.05rem;
  position: relative;
}
/* The line under the band. Plain by default — a hairline, the same weight as
   every other divider on a buyer page — and a perforation when the torn edge
   is on. Inset from the corners so it never crosses the radius.

   --hairline, not --control-line: a divider is a fraction of a field outline
   and never its equal (journey_tokens.css). The tear was drawn at the
   control weight to begin with and read as a seam rather than a perforation
   (Vicky, 2026-08-21). */
stub-band::after {
  border-bottom: 1px solid var(--hairline);
  bottom: -1px;
  content: "";
  left: 10px;
  position: absolute;
  right: 10px;
}
stub-band[torn]::after { border-bottom-style: dashed; border-bottom-width: 1.5px; }
/* ONE TEAR PER PAGE (Vicky, 2026-08-21). A page showing an actual ticket has
   a real perforation on it already — .tkt-card punches its own notches and
   draws its own dashes (ticket_card.css) — and a torn header above a torn
   credential reads as a pattern rather than as a signature. The band gives
   way, because the credential is the thing and the band is the frame.

   Falls back to exactly what the Torn edge switch produces when it is off,
   so there is one untorn appearance rather than two. Matches .tkt-card
   generally, not just the ticket page's .hero: the order page shows the same
   card whenever the event mints an order-level code. */
body.buyer-journey main:has(.tkt-card) stub-band[torn]::after {
  border-bottom-style: solid;
  border-bottom-width: 1px;
}
body.buyer-journey main:has(.tkt-card) .sb-notch { display: none; }
stub-band > .sb-l { min-width: 0; }
stub-band > .sb-l > img { display: block; max-height: 30px; max-width: 118px; object-fit: contain; }
/* The account name in the left slot: the page's own name for itself, so it
   keeps the weight it had as a standalone block and loses only the margin. */
stub-band > .sb-l > .public-brand { margin: 0; }
/* The right slot is the one code or figure this page is about — an order ref,
   a ticket ref, an amount. Mono and letterspaced: the box-office voice, and
   the one place a buyer page prints something to be read aloud at a desk. */
stub-band > .sb-r {
  color: var(--bj-display-ink);
  font-family: var(--font-mono);
  font-size: .86rem;
  font-weight: 600;
  letter-spacing: .14em;
  white-space: nowrap;
}
/* A HALF disc, flush with the card's outer edge — not a full one straddling
   it (Vicky, 2026-08-21: "the drop shadows we offer don't work with the
   notch").

   The two look identical on a card with no shadow, because the outer half is
   painted in the page colour ON the page. They stop looking identical the
   moment the organizer picks any of the offered card shadows: the outer half
   then paints the page colour over the shadow instead, so each notch reads as
   a pale disc floating beside the card rather than a hole punched in it. A
   half disc has nothing outside the card to paint with, so every shadow
   preset — and any a theme sets — passes underneath it untouched.

   -1px, not 0: the card carries a 1px border (journey_shell.css) and the flat
   side belongs on the OUTSIDE of it. A bite that leaves the edge line running
   straight across its mouth is a grey blob on the card, which is the thing
   this is here to stop being. */
.sb-notch {
  background: var(--bg);
  block-size: 20px;
  bottom: -10px;
  inline-size: 10px;
  position: absolute;
  z-index: 2;
}
.sb-notch.l { border-radius: 0 10px 10px 0; left: -1px; }
.sb-notch.r { border-radius: 10px 0 0 10px; right: -1px; }
/* On a phone the card runs edge to edge (journey_shell.css drops its radius
   and its margins), so there is no page background beside it for a notch to
   be punched out of — it would draw two circles of --bg floating on the card.
   The perforation alone carries the tear at that size. */
@media (max-width: 599px) {
  stub-band { border-radius: 0; }
  .sb-notch { display: none; }
}

/* public/doc_head.css */
/* ---- Money documents & pay page (quotes / bank-transfer polish) ----
   doc-head: the document masthead — title block left, seller identity
   right (invoice); collapses gracefully when there's no right column. */
.doc-head { align-items: flex-start; display: flex; gap: 1rem; justify-content: space-between; }
.doc-head .doc-seller { text-align: right; }
/* An address is typed as several lines and has to print as several lines —
   a postal address run together on one line is wrong on a document somebody
   sends to their finance office. Both addresses on an invoice (the seller's
   and the buyer's) are one free-text field, so both take this. */
.doc-addr { display: inline-block; white-space: pre-line; }

/* public/ref_chip.css */
/* ---- A reference always looks like a reference ----
   Every code a buyer might read aloud at a desk, quote in an email or type
   into a search box wears ONE dress: monospace, letterspaced, and set in a
   chip so the eye can find it without reading the sentence around it (Vicky,
   2026-08-21).

   Before this there were four dresses for the same kind of thing on the same
   journey — the stub band's code was mono and letterspaced, the code under a
   QR was mono and muted, a coupon or bank reference was mono at body weight,
   and the ref on a row of the tickets list was plain sans, indistinguishable
   from the words beside it. A reader had no way to learn what a code looks
   like here, which is the whole job of a house style for one.

   The fill is derived from the organizer's own text colour rather than a grey
   of ours, so it holds on a dark card and on a coloured one, and never
   becomes a fifth surface a customizer would have to own.

   Deliberately NOT the buyer's order-page token: a URL capability is a
   credential, never printed as a code (AGENTS.md, References vs URL tokens). */
body.buyer-journey :where(.ticket, .tk-ref, .tx-ref, stub-band > .sb-r) {
  color: var(--fg);
  font-family: var(--font-mono);
  font-size: .84em;
  font-weight: 600;
  letter-spacing: .09em;
  white-space: nowrap;
}
/* ---- The dress, and the chip that is not always part of it ----
   The FILL is what makes a code findable in a paragraph, a document line or a
   stub band — one code among words. On the tickets list it is one code on
   every row, where it stops distinguishing anything and starts out-shouting
   the marker beside it that a reader is actually meant to act on (Vicky,
   2026-08-29: the chips "are really ugly"). handoff.css already made this
   call in a comment — a filled chip on every row out-shouts the original
   above it — and this is the same rule.

   So the chip is split OUT of the dress rather than the list overriding it
   afterwards: one file still decides what a reference looks like, and the one
   exception is written where the rule is. .tx-ref keeps the family, the size,
   the weight and the letterspacing, so a code is still recognisably a code. */
body.buyer-journey :where(.ticket, .tk-ref, stub-band > .sb-r) {
  background: color-mix(in srgb, var(--fg) 6%, transparent);
  border-radius: var(--bj-radius-sm);
  padding: .18rem .42rem .18rem .5rem;
}
/* The list's own code is muted: it is the quietest fact on a row whose
   subject is a person. */
body.buyer-journey .tx-ref { color: var(--muted); }
/* A code with no spaces in it can still be longer than a phone — an IBAN is
   22 characters and the bank-transfer panel prints one. Those wrap rather
   than push the row sideways; the nowrap above is for the refs, which are
   never long enough to need it. */
body.buyer-journey .doc-lines .ticket { white-space: normal; word-break: break-all; }
/* The one on the stub band keeps the band's own scale: it is the page's
   headline code, not a code mentioned in passing. */
body.buyer-journey stub-band > .sb-r { font-size: .8rem; }
/* Under a QR the code is a <p> of its own, so the chip has to stop being as
   wide as the column without leaving the block flow — inline-block does the
   first and undoes the second, which put the chip beside the square instead
   of under it. */
body.buyer-journey .tk-ref { inline-size: fit-content; margin-inline: auto; }

/* public/doc_status.css */
/* ---- The status band at the head of a money document ----
   The mockup's shape: a full-bleed band across the top of the paper with a
   hairline under it, not a floating pill. "Above the paper, not inside it"
   means above the DOCUMENT — the formal thing that starts at the invoice
   heading — while still being part of the card the buyer is looking at.

   It carries four things side by side: a filled icon disc, the state in
   words, a muted line saying when and how, and the amount. Colour is never
   on its own doing the work, which is what makes it survive a black-and-white
   print and a colour-blind reader.

   Ink is the -ink token on its own tint, never the base hue: --ok and --warn
   are chosen to sit on a page background and both miss AA on a tint of
   themselves (the same rule TestAccentOnTintIsInk pins for the accent). The
   DISC is the exception and deliberately so — it is a solid fill of the base
   hue carrying white, not ink on a tint. */
/* A LINE ABOVE IT AS WELL AS UNDER IT (Vicky, 2026-08-21: "I think maybe the
   strip on the invoice does need a border though"). Every other surface on
   the journey lost its edge that afternoon, and this one wants one for a
   reason none of the others share: the band is a tinted block that bleeds to
   the card's edges, so with a rule only underneath, the colour began out of
   nowhere and ended in a line — a block with a floor and no ceiling. Closed
   top and bottom it reads as one band.
   ALL FOUR SIDES, not just top and bottom (Vicky, 2026-08-21: "it's the left
   and right edges of this banner that bleed into the background"). Closing it
   above and below was half the answer — the band bleeds to the card's edges,
   and the card has no border of its own since that morning, so the tint ran
   straight off the sides into the page. The colour follows the state, the
   same line continued round rather than a new decision per side. */
/* AN INSET BLOCK, not a band (Vicky, 2026-08-21: "this looks even more
   inelegant than before, we need a different approach").
   It bled to the card's edges, and every attempt to close that edge made it
   worse: with no border the tint ran off into the page, and with one the band
   read as a box that had been sliced at both ends. Both are the same thing —
   a full-bleed element inside a card that no longer draws an edge of its own
   has nothing to bleed TO.
   So it stops bleeding. It sits inside the card's padding like everything
   else on the page, takes the card's corner, and is a contained block that
   states the document's state. Nothing has to be negated, nothing has to be
   clipped, and it looks the same whatever corner the card is set to. */
doc-status { align-items: center; background: var(--surface-2, var(--journey-card));
  border: 1px solid var(--hairline); border-radius: var(--bj-radius);
  display: flex; flex-wrap: wrap; gap: .7rem;
  margin: 0 0 1.75rem; padding: .8rem 1rem; }
doc-status > .ds-disc { align-items: center; border-radius: 50%; display: flex; flex: 0 0 auto;
  height: 26px; justify-content: center; width: 26px; }
doc-status > .ds-disc > svg { height: 15px; width: 15px; }
doc-status > .ds-say > .ds-word { font-size: .95rem; font-weight: 650; letter-spacing: -.008em; }
doc-status > .ds-say > .ds-meta { color: var(--muted); font-size: .8rem; }
doc-status > .ds-amount { font-size: 1.06rem; font-variant-numeric: tabular-nums;
  font-weight: 650; letter-spacing: -.01em; margin-left: auto; }
doc-status[data-state=paid] { background: var(--ok-weak);
  border-color: color-mix(in srgb, var(--ok) 26%, var(--hairline)); }
doc-status[data-state=paid] > .ds-disc { background: var(--ok); color: #fff; }
doc-status[data-state=paid] > .ds-say > .ds-word,
doc-status[data-state=paid] > .ds-amount { color: var(--ok-ink); }
doc-status[data-state=due] { background: var(--warn-weak);
  border-color: color-mix(in srgb, var(--warn) 30%, var(--hairline)); }
doc-status[data-state=due] > .ds-disc { background: var(--warn-ink); color: #fff; }
doc-status[data-state=due] > .ds-say > .ds-word,
doc-status[data-state=due] > .ds-amount { color: var(--warn-ink); }
/* Nothing to pay is neither a good nor a bad state, so it takes neither hue. */
doc-status[data-state=none] > .ds-disc { background: var(--muted); color: var(--journey-card); }
doc-status[data-state=none] > .ds-amount { color: var(--muted); }
@media (max-width: 599px) {
  doc-status { margin-bottom: 1.25rem; padding-block: .7rem; }
}

/* The band bleeds to the card's edges, and the card has a corner. With
   nothing clipping it, the band's square ends paint over that corner and read
   as a strip escaping the box it is in (Vicky, 2026-08-21: "the green strip
   looks weird against the edges of the box") — which the card losing its
   border the same afternoon made plainer, since there is no longer a drawn
   edge for the tint to stop against.

   Clipping the CARD is the fix, and it is safe to do here and nowhere else:
   scoped with :has() to a card that actually carries a status band, which is
   a money document — the one buyer surface with no dropdown inside it. The
   order and ticket pages carry the calendar menu and the language switcher,
   both of which open a panel positioned outside the flow, and clipping those
   would cut the panel off. */
/* No clip needed: the band is inset now, so nothing reaches the card's
   corner for the corner to cut. */

/* public/doc_grid.css */
/* ---- The document's facts, lines and totals ----
   A money document states its facts as LABEL OVER VALUE in a grid, not as a
   list of label-left/value-right rows: an address and a date are answers of
   very different heights, and a two-column row makes the short ones look
   unfinished beside the tall ones. The rows shape stays for the wire details
   at the foot, which really are one short value each. */
/* TWO columns, not three. Three fits the width and reads badly: the labels
   are uppercase and letterspaced, so a two-word one ("Purchase order",
   "Supply date") wraps in a third of the paper and the cell below it starts
   a line lower than its neighbours. Two columns give every label a line of
   its own and the answers a common baseline. */
.doc-grid { border-bottom: 1px solid var(--hairline); border-top: 1px solid var(--hairline);
  display: grid; gap: 1.1rem 2rem; grid-template-columns: repeat(auto-fit, minmax(10.5rem, 1fr));
  margin-bottom: 1.25rem; padding: 1.25rem 0; }
/* SENTENCE CASE, and at the size this rule always asked for (Vicky,
   2026-08-21: "the text transform uppercase on invoices feels a bit massive
   and inelegant compared to what we have elsewhere"). Two faults, and the
   second is why it looked the way it did:

   Measured, the label rendered at 16px — LARGER than the 13.6px answer under
   it — because `.doc-grid h3` is (0,1,1) and `body.buyer-journey h3` in
   journey_headings.css is (0,1,2). The .7rem here has never once applied on a
   buyer-facing document. An uppercase, letterspaced, semibold line a size up
   from its own answer is not an eyebrow, it is a headline, which is exactly
   what "massive" describes. Scoping to body.buyer-journey wins it back.

   And with the size right, the uppercase is still the wrong voice for this
   page: everywhere else on the journey a label sits QUIETLY above its answer.
   The letterspacing goes with the capitals — it exists to open caps up and
   does nothing but loosen lowercase. */
body.buyer-journey .doc-grid h3, .doc-grid h3 {
  color: var(--muted); font-size: var(--bj-support, .875rem); font-weight: 600;
  margin: 0 0 .2rem; }
.doc-grid h3 + * + h3 { margin-top: .9rem; }
.doc-grid .dg-v { font-size: .85rem; line-height: 1.55; }
/* An address is typed as several lines and prints as several lines. */
.doc-grid .dg-v .muted { display: block; white-space: pre-line; }

.doc-items { border-bottom: 1px solid var(--hairline); margin-bottom: .75rem; }
.doc-item { border-bottom: 1px solid var(--hairline); display: flex; gap: .9rem; padding: .55rem 0; }
.doc-item:last-child { border-bottom: 0; }
.doc-item > .di-name { flex: 1; }
.doc-item > .di-name > .di-qty { color: var(--muted); font-variant-numeric: tabular-nums; }
.doc-item > .di-name > .di-sub { color: var(--muted); display: block; font-size: .78rem; }
.doc-item > .di-amount { font-variant-numeric: tabular-nums; min-width: 5.5rem; text-align: right; }

.doc-totals { display: flex; flex-direction: column; gap: .3rem; margin-bottom: 1rem; }
.doc-totals > .dt-line { align-self: flex-end; color: var(--muted); display: flex;
  font-size: .8rem; font-variant-numeric: tabular-nums; gap: 1.6rem; }
.doc-totals > .dt-line > .dt-amount { min-width: 5.5rem; text-align: right; }
/* NO RULE OF ITS OWN (Vicky, 2026-08-21: "we don't need the extra partial hr
   above the total"). This row is align-self: flex-end, so its border-top was
   only as wide as the row's own content — a short line floating under the
   full-width one the items table already draws, two rules a few pixels apart
   with nothing between them. The items table's rule is the boundary; the
   weight of the total says it is the total. */
.doc-totals > .dt-grand { align-self: flex-end;
  color: var(--bj-display-ink); display: flex; font-size: 1rem; font-variant-numeric: tabular-nums;
  font-weight: 650; justify-content: space-between; margin-top: .2rem; min-width: 15rem;
  padding-top: .5rem; }

/* The one sentence a tax situation requires, and nothing more. */
.doc-legal { background: var(--surface-2, transparent); border: 1px solid var(--hairline);
  border-radius: var(--bj-radius-sm); color: var(--muted); font-size: .8rem; margin-bottom: .9rem;
  padding: .7rem .8rem; }
/* Where the business name should be, when there isn't one. */
/* Where the business name would be. Its own block rather than an inline one:
   as an inline-block it sat level with the eyebrow opposite and read as a
   collision rather than as the missing name. */
.doc-gap { background: var(--warn-weak); border-radius: var(--bj-radius-sm); color: var(--warn-ink);
  font-size: .75rem; line-height: 1.45; margin-bottom: .4rem; padding: .4rem .55rem;
  text-align: left; }
/* The seller column is capped so the document's own identity — the number in
   the eyebrow — never gets squeezed onto two lines beside it. */
.doc-head { gap: 2rem; margin-bottom: 1.25rem; }
.doc-head > div:first-child { min-width: 9rem; }
.doc-head .doc-seller { font-size: .8rem; line-height: 1.6; max-width: 13rem; }
/* Each fact is its own line. Inline spans ran the country into the
   registration number ("Ireland Reg. no. 634219"). */
.doc-head .doc-seller > * { display: block; }

/* public/pay_amount.css */
body.buyer-journey hr { border: 0; border-top: 1px solid var(--hairline); margin: 1.6rem 0; }
body.buyer-journey .pay-amount { color: var(--bj-display-ink); font-size: var(--bj-display); font-weight: var(--bj-display-weight); margin: .4rem 0 1rem; }
/* Sat under the breakdown, the amount is the last row of it and belongs in
   the same column as every figure above — left, it read as a heading that had
   wandered away from its own numbers. Same treatment .doc-total already gives
   the receipt's and the quote's totals.
   Adjacent sibling on purpose: with no breakdown (paymentBreakdown refuses, or
   the order has no listable lines) the amount is a lone figure under a
   sentence, and left is where that belongs. */
body.buyer-journey .doc-lines + .pay-amount { text-align: right; }
/* bj-split: a matched pair of secondary doors under the primary — equal
   halves, dressed like the journey's bordered ghosts. */

/* public/bj_split.css */
body.buyer-journey .bj-split { display: flex; gap: .6rem; margin: .9rem 0; }
body.buyer-journey .bj-split a.button { background: transparent; border: 1px solid var(--hairline);
  border-radius: var(--button-radius, var(--bj-radius-sm)); color: var(--fg); flex: 1; font-size: .875rem; font-weight: 600;
  justify-content: center; margin-top: 0; padding: .55rem 1rem; text-decoration: none;
  transition: border-color .15s, color .15s; }
body.buyer-journey .bj-split a.button:hover { border-color: var(--p-accent); color: var(--p-accent); }

/* public/embedded_checkout.css */
/* Stripe's embedded form, mounted into the payment page.

   Nothing here reaches the form itself — it renders in an iframe on
   js.stripe.com and the same-origin policy stops every rule at the boundary.
   What the form paints itself is sent through Stripe instead
   (branding_settings[background_color], embeddedBrandingBackground in
   connect.go); this file only governs the box we mount it in.

   Which is the other half of the same problem: with the colours matched, our
   own padding around the frame becomes the only padding, and the frame
   carries its own generous inset already. So the container adds none, and
   gives the frame the room it needs instead. */
body.buyer-journey [data-embedded-checkout] { margin: 1.25rem 0 0; min-height: 12rem; padding: 0; }

/* The frame is Stripe's element and arrives sized by their script; a width
   of our own would fight it. Held to the card's inner width so a narrow
   phone never gets a horizontal scroll on a payment page. */
body.buyer-journey [data-embedded-checkout] iframe { max-width: 100%; }

/* public/express_gate.css */
/* The buyer journey's last two screens, each arriving as one thing.

   Two gates share this file because they share the skeleton beneath them:
   [data-express-pending] holds the DETAILS step until the wallets have made
   up their mind, and [data-pay-pending] holds the PAYMENT page until every
   Stripe element has reported ready. Different conditions, one idea, and one
   set of skeleton-line rules rather than two that drift.

   The details step, arriving as one thing.

   Three problems, one mechanism. The form must not appear before the wallets
   (a buyer who starts typing has been shown the slow way first, and the
   wallets are what they would have chosen); the wait must not be a blank
   card; and when the form does arrive it should arrive WITH the wallets
   rather than after them.

   [data-express-pending] on <html> is the whole state. It is set by an inline
   script on the details page and removed by whichever comes first, the
   wallets reporting or that script's own cap. Set by script deliberately:
   with no JavaScript the attribute never exists, none of these rules match,
   and the page is the plain form it has always been. */

:root {
  /* Stripe fades its wallet buttons in left to right over roughly a third of
     a second. The form starts its own fade about halfway through that, so the
     two read as one movement instead of two events (Paul, 2026-08-21). Named
     here rather than buried in a shorthand because they are a judgement about
     how the page feels, and somebody will want to tune them. */
  --express-fade: 300ms;
  --express-fade-delay: 180ms;
}

/* While waiting, the fields are held but keep their box, so nothing below
   them moves when they arrive. */
/* checkout-bar joins the held block, and has to be named separately now that
   it is no longer inside it: the foot moved out of the form so that the second
   column can place it beside the total (checkout_cols.css). Left out, the real
   button would sit there in full view beside the skeleton's own stand-in for
   it — two buttons during the wait, one of them fake. */
:root[data-express-pending] body.buyer-journey [data-details-block],
:root[data-express-pending] body.buyer-journey checkout-cols > checkout-bar {
  opacity: 0;
  visibility: hidden;
}

body.buyer-journey [data-details-block],
body.buyer-journey checkout-cols > checkout-bar {
  opacity: 1;
  transition: opacity var(--express-fade) ease var(--express-fade-delay);
}

/* The gap under the wallets is the SAME 1.25rem the page's buttons carry
   above themselves (button.css), which is Paul's rule stated as a measure:
   the space between the express row and the card options must equal the
   space between the options and the Pay securely button beneath them. One
   rhythm down the whole column, on both surfaces that mount the row. */
body.buyer-journey [data-express-checkout] { margin-block-end: 1.25rem; }

/* The wallet row's height is reserved only WHILE waiting. Reserving it for
   good would leave a permanent gap above the form on every browser with no
   wallet to offer, which is most desktop ones. */
:root[data-express-pending] body.buyer-journey [data-express-checkout] {
  min-height: 2.75rem;
}

/* The stage holds both: the fields, and the placeholder standing in for them
   while they wait. The placeholder is absolutely positioned INSIDE it, so the
   card's height is the form's alone. As siblings they stacked, and the wait
   was a skeleton with an empty white page underneath it. */
body.buyer-journey [data-details-stage] { position: relative; }

/* What fills the wait. Hidden by default and shown only while pending, so a
   page with no JavaScript never renders a skeleton nothing would clear. */
form-skeleton { display: none; }

:root[data-express-pending] body.buyer-journey form-skeleton {
  display: block;
  inset-block-start: 0;
  inset-inline: 0;
  position: absolute;
}

skeleton-line {
  background: light-dark(#00000010, #ffffff14);
  border-radius: 6px;
  display: block;
  height: 2.75rem;
  margin-block-end: 1rem;
  overflow: hidden;
  position: relative;
}

skeleton-line[short] { height: 0.9rem; margin-block-end: 0.5rem; width: 5rem; }
skeleton-line[button] { height: 3rem; margin-block-end: 0; }

/* A slow sheen rather than a pulse: a pulsing block reads as something
   flashing for attention, which is the opposite of what a wait should say. */
skeleton-line::after {
  animation: skeleton-sheen 1.4s linear infinite;
  background: linear-gradient(90deg, transparent, light-dark(#ffffff99, #ffffff12), transparent);
  content: "";
  inset: 0;
  position: absolute;
}

@keyframes skeleton-sheen {
  from { transform: translateX(-100%); }
  to { transform: translateX(100%); }
}

/* Somebody who has asked for less movement gets the content, not the show. */
@media (prefers-reduced-motion: reduce) {
  body.buyer-journey [data-details-block],
  body.buyer-journey checkout-cols > checkout-bar { transition: none; }
  skeleton-line::after { animation: none; }
}

/* While a wallet's own overlay is up, the form beneath it steps back: still
   there, visibly not the thing to use. The interaction half is the inert
   attribute the module sets alongside this — one attribute for the whole
   block, so nothing is disabled piecemeal or forgotten piecemeal. Dismissing
   the sheet removes both and the form is exactly as it was. */
body.buyer-journey [data-details-block][data-express-hold],
body.buyer-journey [data-custom-pay][data-express-hold] {
  opacity: 0.35;
  transition: opacity 150ms ease;
}

/* The payment page, arriving as one thing.

   Its own attribute rather than the details step's, because the condition is
   different: there the gate opens when the wallets have decided, here when
   the wallets, the card fields and the currency selector have all said they
   are on screen. Sharing one attribute would mean one of the two pages
   opening on the other's answer.

   visibility and opacity rather than display: the elements have to LAY OUT
   while they are held, or Stripe renders into a box with no size and reports
   ready for something the buyer will never see. That is the same reason the
   details block above is hidden this way. */
:root[data-pay-pending] body.buyer-journey [data-pay-block] {
  opacity: 0;
  visibility: hidden;
}

body.buyer-journey [data-pay-block] {
  opacity: 1;
  transition: opacity var(--express-fade) ease var(--express-fade-delay);
}

body.buyer-journey [data-pay-stage] { position: relative; }

:root[data-pay-pending] body.buyer-journey [data-pay-stage] form-skeleton {
  display: block;
  inset-block-start: 0;
  inset-inline: 0;
  position: absolute;
}

/* The skeleton IS the waiting message, so the words are not also the waiting
   message. Left visible they sit under the placeholder saying the same
   thing twice, and they are still there for the browser that never gets a
   skeleton because the gate script never ran. */
:root[data-pay-pending] body.buyer-journey [data-custom-loading] { display: none; }

/* The wallet row's stand-in carries the express row's own bottom margin
   (1.25rem above), not the 1rem every other line has, so the skeleton and
   the thing it stands for occupy the same height. */
skeleton-line[wallets] { margin-block-end: 1.25rem; }

@media (prefers-reduced-motion: reduce) {
  body.buyer-journey [data-pay-block] { transition: none; }
}

/* public/claim_edit.css */
/* claim-who — who the order is going to, on the free claim screen's
   confirming branch (free_claim.html). Two values you READ, and an Edit link
   over the fields that produced them.

   The values use .detail-list, which is what the order page already reads a
   confirmed value with, so the buyer meets the same shape twice in one
   journey rather than a bespoke one here.

   A <summary> is not an anchor, so the journey's link colour has to be asked
   for by name — body.buyer-journey a (journey_shell.css) does not reach it,
   and inheriting would leave the one link on the page in body ink. */
.claim-who { margin: 1.25rem 0; }
.claim-who > .detail-list { margin-block-end: 0; }
.claim-edit { margin-top: .6rem; }
/* list-style covers Firefox and the ::-webkit-details-marker rule below
   covers Safari and older Chrome; without both, the row wears a disclosure
   triangle that makes a link look like a folder. */
.claim-edit > summary { color: var(--journey-link, var(--accent)); cursor: pointer;
  display: inline-block; font-size: var(--bj-support); list-style: none;
  text-decoration: underline; text-underline-offset: 2px; }
.claim-edit > summary::-webkit-details-marker { display: none; }
.claim-edit > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.claim-edit[open] > summary { margin-bottom: .5rem; }

/* public/donation_amount.css */
/* The donation amount control: a currency symbol and the box the buyer types
   in, reading as one field. Used on the order page's "Add to this order" row
   and on the embed widget's review step, both of which sit inside a flex row
   — hence inline-flex, so the pair is one item of that row rather than two
   that its gap could split.

   The event page draws the same control and keeps its own rule
   (event/event_ticket_row.css): there the pair is one line of a flex COLUMN
   that also carries a tax note, and the event bundle loads none of this
   sheet. Same three properties, two contexts; the event selector is the more
   specific of the two, so nothing here could reach it either way. */
.donation-amount { align-items: center; display: inline-flex; gap: .4rem; }

/* public/journey_callout.css */
/* Journey callouts whisper: the default (informational) tone drops the
   accent tint for a faint wash and hairline — the buyer card is quiet, so
   a loud box reads as an error. Warning/danger tones keep their colour. */
body.buyer-journey callout:not([tone]) { background: color-mix(in srgb, var(--fg) 4%, transparent);
  border-color: var(--hairline); border-radius: var(--bj-radius); }
body.buyer-journey callout:not([tone]) > .co-body > p:only-child { color: var(--muted); }
/* Admin quote composer rows: catalogue left, labelled amounts right — one
   grid so every row's controls share the same columns (the change-row
   idea, admin-side). Labels are quiet column captions, not field labels. */

/* A callout's action wears the JOURNEY's quiet button, not the admin's
   accent-ink outline (Vicky, 2026-08-21: "the add a name button still seems
   unstyled"). On a buyer page that outline was a blue-bordered, blue-lettered,
   full-size button — the only control on the journey drawn that way, and it
   read as something nobody had got to yet.

   The size comes with it: .compact lives in an admin-only stylesheet, so out
   here the button had no size rule at all and took the full-height default
   meant for the foot of a form.

   TONED callouts keep their tone (callout.css says why: a blue-bordered
   button in an amber box reads as a stray control). This is the untoned one,
   which is every callout a buyer meets. */
body.buyer-journey callout:not([tone]) > .co-act > .button:not(.primary) {
  border-color: var(--control-line);
  color: var(--fg);
  font-size: .85rem;
  padding: .42rem 1rem;
}
body.buyer-journey callout:not([tone]) > .co-act > .button:not(.primary):hover {
  border-color: var(--p-accent);
  color: var(--p-accent);
}

/* public/promo_note.css */
/* Promo-code feedback on the checkout details step (yste-coupons session):
   the details render prices an event-page-entered code so the buyer sees it
   working — or fixes it — before paying. */
.promo-note { font-size: .85rem; margin: .35rem 0 0; }
.promo-note.ok { color: var(--ok-ink, #2e7d32); }
.promo-note.bad { color: var(--danger-ink, #b3261e); }

/* public/file_field.css */
/* File-upload field (file-field.js + templates/file_field.html): the plain
   input is the zero-JS baseline; the module turns the block into a dropzone
   with chips and a progress bar. Focus/keyboard behavior is the native
   input's — the zone only decorates. */
body.buyer-journey .file-field { border: 1px dashed currentColor; border-radius: .5rem; padding: .75rem; }
body.buyer-journey .file-field.dragging { background: color-mix(in srgb, currentColor 8%, transparent); border-style: solid; }
body.buyer-journey .file-field .file-drop-hint { font-size: var(--bj-support); margin: .35rem 0 0; }
body.buyer-journey .file-field progress { display: block; margin-top: .5rem; width: 100%; }
/* An author display rule beats the UA's [hidden] { display: none } — this
   restores it, or the retired progress bar would stay on screen forever. */
body.buyer-journey .file-field progress[hidden] { display: none; }
body.buyer-journey .file-field .file-status { font-size: var(--bj-support); margin: .35rem 0 0; }
body.buyer-journey .file-field .file-error { color: var(--danger-ink, #b00020); font-size: var(--bj-support); margin: .35rem 0 0; }
body.buyer-journey .file-chips { list-style: none; margin: .35rem 0 0; padding: 0; }
body.buyer-journey .file-chips .file-chip { align-items: center; display: flex; gap: .5rem; margin: .25rem 0; }
body.buyer-journey .file-chips .file-chip button { background: none; border: 0; color: inherit; cursor: pointer; font-size: 1rem; line-height: 1; padding: .25rem; }
body.buyer-journey .file-list { list-style: none; margin: 0 0 .5rem; padding: 0; }

/* public/embed.css */
/* The embed widget — the buyer journey rendered inside somebody else's
   website. Narrow by assumption: a 320px box in a stranger's sidebar is a
   different design problem from a full event page, so the row stacks below
   the sidebar width rather than squeezing. Everything reads from the
   journey tokens, because looking like the account is the whole point. */

/* The frame is a CARD ON SOMEBODY ELSE'S PAGE, never a page of ours.
   Without this it is both: the buyer shell paints the account's page
   background across the whole document (journey_shell.css), and the card
   sits on top of it filling the frame almost exactly — so the only pixels
   that background reaches are the ones the card's rounded corners leave
   over. Four dark notches and a hairline along the bottom, which on the
   organiser's own white page reads as a broken outline rather than as
   anything anybody chose (reported on a real site, 2026-08-05). Above
   599px it stops being subtle: the shell's page padding comes back and
   the box gets a dark band on all four sides.

   This was `background: transparent` (#450), on the reasoning that
   transparent lets the organiser's own page show through the card's
   rounded corners. IT DOES NOT, and the difference is the whole bug: a
   CROSS-ORIGIN iframe never composites onto its parent. The browser paints
   the frame's own canvas backdrop, and an unpainted backdrop follows the
   USED color-scheme, which comes from the VISITOR's device. So a
   dark-mode visitor got a BLACK backdrop behind a light card — the same
   four notches and the same edge hairline as before, now only for half the
   audience, which is why it read as clean in light mode and was reported
   as a new bug (2026-08-10).

   So the frame paints, explicitly, the colour of the CARD — never a page
   colour. That distinction is not pedantry: it is what keeps true the
   thing the palette already tells organisers, that the page background is
   not a colour this widget paints (embeds_setup.go's paletteTokens
   deliberately has no `background` swatch). There is nothing here anybody
   can set separately; --journey-card is the card's own fill, and the
   frame is the card.

   `var(--journey-card)`, not the Go-side design token, because Classic
   re-declares that variable to #fff — the variable is what the card
   actually paints in either mode, and the backdrop has to agree with the
   card exactly or the seam comes back.

   What pins the SCHEME, so nothing in here follows the visitor's device,
   is `:root { color-scheme }` in layout.html's head — on the root, because
   the canvas takes its scheme from there and not from body.

   Scoped by :has(tito-embed) rather than a class on the body, because that
   element is what MAKES a document the widget — a theme shipping its own
   embed view cannot forget to opt in, and this file rides the public
   bundle every buyer page loads, where an unscoped body rule would repaint
   all of them. */
body.buyer-journey:has(tito-embed) {
  background: var(--journey-card);
  min-height: 0;
  padding: 0;
}

/* The card fills the frame exactly, so this border sits ON the widget's
   outer edge: it is the only thing between the widget and the organiser's
   page, and the radius here is what the loader rounds the iframe itself to
   (embed-frame.js reports it, embed.js applies it).

   --hairline, not --control-line: a card's edge is not a control's
   boundary, and at field weight the widget read as a drawn box rather than
   as a card (journey_tokens.css says the same thing about dividers).
   Softening it is safe in both directions — the two are derived from one
   another, so under `prefers-contrast: more` the head's stronger
   --control-line lifts this line with it. */
tito-embed {
  background: var(--journey-card);
  border: 1px solid var(--hairline, var(--control-line, var(--line)));
  border-radius: var(--journey-radius, 12px);
  color: var(--fg);
  display: block;
  padding: 1.15rem;
}

.embed-types {
  display: grid;
  list-style: none;
  margin: 0;
  padding: 0;
}

/* A divider is a fraction of a field outline, never its equal (the rule
   journey_tokens.css states for --hairline): at --line weight four tickets
   read as a grid of boxes instead of a list. The row breathes instead —
   space is what separates them, the rule only confirms it. */
.embed-type {
  align-items: baseline;
  border-top: 1px solid var(--hairline, var(--line));
  display: flex;
  flex-wrap: wrap;
  gap: .2rem 1rem;
  justify-content: space-between;
  padding: .8rem 0;
}

/* The rule goes on the TOP of each row rather than the bottom of all but
   the last, so the list needs no :last-child exception and the first row
   sits straight under the card's own padding. */
.embed-type:first-child { border-top: 0; padding-top: .15rem; }

/* `flex: 1 1 …`, not the default `0 1 auto`: a flex line wraps BEFORE it
   shrinks, so with content-sized bases a long ticket name pushed the price
   onto its own line at any width a sidebar actually is — and once wrapped
   it sat at the left, under the description, reading as a third line of
   copy rather than as the price. The name shrinks now and keeps the price
   beside it; the basis is the width below which wrapping really is the
   better answer. */
.et-main { display: grid; flex: 1 1 8rem; gap: .2rem; min-width: 0; }
.et-name { font-weight: 600; line-height: 1.35; }

.et-desc {
  color: var(--muted);
  font-size: var(--bj-support, .875rem);
  line-height: 1.4;
  white-space: normal;
}

/* Price and availability are one answer to one question ("what does this
   cost, and can I still have it?"), so they stack right-aligned as a unit
   rather than sitting in a row that wraps into the ticket name at 320px.
   With no chip the column is just the price, which is the common case. */
.et-side {
  align-items: flex-end;
  display: flex;
  flex: 0 0 auto;
  flex-direction: column;
  gap: .3rem;
  /* Holds the right edge even on the wrapped line, where space-between has
     nothing to push against. */
  margin-left: auto;
  text-align: right;
  white-space: nowrap;
}

.et-price {
  font-variant-numeric: tabular-nums;
  font-weight: 600;
}

.et-tax {
  color: var(--muted);
  font-size: var(--bj-micro, .75rem);
  font-weight: 400;
  margin-left: .2rem;
}

/* "4 left" is the one line here that changes what a buyer does, so it is a
   chip rather than another grey caption — but a QUIET one, because the
   widget wears the organiser's brand and this must not out-shout their own
   accent. The amber is self-contained: both the tint and the ink derive
   from --warn, so the chip stays legible on any card colour an organiser
   picks. Text takes the INK and never the base (colors.css: the status
   hues paint surfaces only). */
.et-left,
.et-gone {
  border-radius: 999px;
  font-size: var(--bj-micro, .75rem);
  font-weight: 600;
  line-height: 1.5;
  padding: .05rem .45rem;
}

.et-left {
  background: color-mix(in srgb, var(--warn) 18%, transparent);
  color: var(--warn-ink);
}

/* Sold out is a state, not urgency: no colour, just a quiet chip that says
   the row is closed. */
.et-gone {
  background: color-mix(in srgb, var(--fg) 7%, transparent);
  color: var(--muted);
}

/* The quantity control, on its own flex line at the end of the row.
   `flex-basis: 100%` rather than sitting beside the price: the box is
   often a 320px sidebar, and a stepper squeezed in next to a price and a
   "3 left" chip is three things fighting for one line — the row wraps, and
   what wraps is the least readable of them. Below, it is one full-width
   control per ticket, which is also the biggest tap target the width can
   give it. */
.et-qty { display: flex; flex: 0 0 100%; justify-content: flex-end; }

/* A row nobody can buy keeps its price and its "sold out" chip and loses the
   control entirely — a disabled stepper is a thing to try pressing. */
.embed-type:has(input:disabled) .et-qty { display: none; }

body.buyer-journey .embed-cta {
  background: var(--accent);
  border: 0;
  border-radius: calc(var(--journey-radius, 12px) / 2);
  color: var(--accent-fg);
  cursor: pointer;
  display: block;
  /* It is a submit button now rather than a link (the widget's list is a
     real form), so it needs the two things a button does not inherit and a
     link never needed: the page's type, and the full width the link had
     from display:block. */
  font: inherit;
  font-weight: 600;
  margin-top: .9rem;
  padding: .7rem 1rem;
  text-align: center;
  text-decoration: none;
  width: 100%;
}

body.buyer-journey .embed-cta:hover { filter: brightness(1.06); }

.embed-none {
  color: var(--muted);
  margin: 0;
  text-align: center;
}

.embed-secured {
  align-items: center;
  color: var(--muted);
  display: flex;
  font-size: .75rem;
  gap: .3rem;
  justify-content: center;
  margin: .75rem 0 0;
}

body.buyer-journey .embed-secured a { color: inherit; }

/* The refusal state: shown when the widget has been made too small or
   invisible to read (embed-frame.js). It says what happened rather than
   rendering nothing, so an organiser whose own CSS squeezed the box has
   something to go on. */
.embed-obscured {
  padding: .5rem;
  text-align: center;
}

.eo-title {
  font-weight: 600;
  margin: 0 0 .25rem;
}

.eo-body {
  color: var(--muted);
  font-size: .875rem;
  margin: 0;
}

/* Button shape: the widget collapses to a single button until it is
   pressed, then the parent lifts the whole frame over the page
   (embed.js) and the same ticket list is revealed in place.

   Collapsed, it is a BUTTON — not a card with a button inside it (Vicky,
   2026-08-10). The card treatment above is unconditional, so "a button" put
   a bordered white box with 1rem of padding on the organiser's page and the
   frame stood 126px tall for one 44px control. It is also why that shape
   used to need a card colour and an edge colour it had no business having:
   with the box gone, a button embed is exactly two colours, the button and
   the writing on it (embeds_setup.go's paletteRoles).

   Keyed on the collapsed BUTTON being present rather than on the shape
   alone, so the card comes back the moment the list does — including with
   JavaScript off, where nothing collapses and the list is all there is.

   Taking the card off this element is only half of it, because the frame's
   own backdrop is painted too (the paragraph at the top of this file): left
   at the card colour, the button would sit on a card-coloured rectangle
   exactly the shape of the box just removed. So the backdrop becomes the
   BUTTON's colour and the radius that travels to the loader becomes the
   button's radius — collapsed, the frame IS the button, corners included.

   `body:has(.embed-open:not([hidden]))` rather than a :has() inside a
   :has(): that element exists only in button shape, and a relative selector
   cannot itself carry :has(). */
body.buyer-journey:has(.embed-open:not([hidden])) {
  background: var(--accent);
}

tito-embed[data-shape="button"]:has(.embed-open:not([hidden])) {
  background: none;
  border: 0;
  border-radius: calc(var(--journey-radius, 12px) / 2);
  padding: 0;
}

/* ...and it wears no gap above it either. The shared button base
   (styles/shared/button.css) gives every button a 1.25rem margin-top, which
   inside the card is the space under the ticket rows — right there, and
   invisible until the card came off. Collapsed, that button IS the widget:
   with the padding gone the margin collapses straight out of tito-embed and
   out of body, so the frame measures the button (getBoundingClientRect on
   the element, embed-frame.js) and reports a height those 20px are not in.
   The host page then sizes the iframe to the report, and the button is
   drawn 20px down it — text sitting low in a box too tall for it, its foot
   past the bottom edge. Keyed on the collapsed button being present, like
   the rest of this block, so the gap is untouched everywhere the list is
   drawn, JavaScript off included. */
tito-embed[data-shape="button"]:has(.embed-open:not([hidden])) .embed-open {
  margin-top: 0;
}

/* ...and nothing under it either. The signature moved out of .embed-body so
   that it sits below Close rather than between the two actions (embed.html),
   which also puts it outside the element the collapsed state hides — and a
   line of small print under a bare button is furniture that shape exists not
   to have, on top of height the organiser did not ask for. It comes back
   with the card, like everything else in this block. */
tito-embed[data-shape="button"]:has(.embed-open:not([hidden])) .embed-secured {
  display: none;
}

.embed-open {
  background: var(--accent);
  border: 0;
  border-radius: calc(var(--journey-radius, 12px) / 2);
  color: var(--accent-fg);
  cursor: pointer;
  display: block;
  font: inherit;
  font-weight: 600;
  padding: .7rem 1rem;
  text-align: center;
  width: 100%;
}

.embed-close {
  background: none;
  border: 1px solid var(--line);
  border-radius: calc(var(--journey-radius, 12px) / 2);
  color: var(--muted);
  cursor: pointer;
  display: block;
  font: inherit;
  margin-top: .9rem;
  padding: .5rem 1rem;
  width: 100%;
}

/* Close is the way BACK, so it must not carry the weight of the way forward
   — and it did, on every opened widget: `body.buyer-journey button` paints
   every button in the accent, and at (0,1,2) that outranks the block above.
   The quiet look was never what a visitor saw; they got a second solid
   accent button under the first, competing with the one thing the card is
   for (Vicky, 2026-08-10). Doubling the selector is the house answer to that
   same rule — the pattern styles/shared/name_field.css already uses — rather
   than !important or a new token.

   The PAINT only, in its own block. Carrying `display` up here too would
   raise it above `.embed-close[hidden]` below, and a collapsed widget would
   then show Close beside Get tickets — which is not a cosmetic slip: on the
   host page the visitor's press lands in the middle of a box sized for one
   button and would hit the wrong one. Caught by
   TestEmbedButtonUI/and lifts over the host page, which is what that test is
   for. */
body.buyer-journey .embed-close {
  background: none;
  border: 1px solid var(--line);
  color: var(--muted);
}

/* Both buttons carry their own `display`, which beats the user agent's
   `[hidden] { display: none }` — so `el.hidden = true` alone left the
   collapsed widget showing Get tickets AND Close at once. The attribute is
   how embed-frame.js swaps the two states (and how ?state=open renders the
   opened one), so it has to win here. */
.embed-open[hidden],
.embed-close[hidden] {
  display: none;
}

/* Expanded, the frame owns the viewport, so the card fills it and scrolls
   its own content rather than the page behind it. */
tito-embed[data-shape="button"]:has(.embed-close:not([hidden])) {
  max-height: 100dvh;
  overflow-y: auto;
}

/* public/embed_check.css */
/* The public "is it working?" page the hand-off email links to
   (embeds_handoff.go). A buyer-journey page, because it renders on the
   account's own host in the account's own design — but its reader is the
   organiser's web person, so it is three short lines and the code. */

.ec-state {
  font-size: 1.05rem;
  margin: 0 0 .5rem;
}

.ec-state.ok {
  color: var(--ok-ink, currentColor);
  font-weight: 600;
}

/* Wrap, never scroll: a snippet you cannot see the end of reads as
   truncated, and comparing it with what was pasted is the whole point of
   showing it here. */
.ec-code {
  background: var(--journey-card, transparent);
  border: 1px solid var(--line, currentColor);
  border-radius: var(--bj-radius-sm);
  font-family: ui-monospace, "SF Mono", Menlo, monospace;
  font-size: 12.5px;
  line-height: 1.6;
  margin: 0 0 1.5rem;
  overflow-wrap: anywhere;
  padding: .85rem 1rem;
  white-space: pre-wrap;
}

.ec-code code {
  font: inherit;
}

/* public/apply.css */
/* The public request form (requests.go). Almost everything on this
   page is ordinary buyer-journey furniture; the only thing that needs its own
   rule is the line saying applying costs nothing, which has to read as a
   reassurance rather than as small print. */
/* A MESSAGE, not a field (Vicky, 2026-08-21: "the costs-nothing note styling
   looks like a populated field rather than a message"). It was a rounded
   surface-2 rectangle with a 1px border — which is, to the pixel, what every
   input on the journey looks like, so a sentence of reassurance read as a
   read-only box somebody had typed into.
   A rule down the side and a tint says aside: the same recipe the organizer's
   own Note wears in the confirmation email, so the one thing that means "this
   is a remark, not a control" looks the same in both places. */
.apply-note {
  background: color-mix(in srgb, var(--p-accent) 6%, transparent);
  border: 0;
  border-left: 3px solid color-mix(in srgb, var(--p-accent) 45%, transparent);
  color: var(--fg);
  font-size: var(--bj-support, .875rem);
  line-height: 1.55;
  margin: 1.4rem 0 0;
  padding: .7rem .9rem;
}

/* The request's own facts: label over value, in a column, with nothing
   drawn round them (2026-08-21). The admin's .detail-list used to do this and
   brought a bordered grid with it. */
.apply-facts { display: grid; gap: .9rem; margin: 1.6rem 0; }
.apply-facts dt { color: var(--muted); font-size: var(--bj-support, .875rem); }
.apply-facts dd { font-size: var(--bj-body, 1rem); font-weight: 500; margin: .15rem 0 0; }

.apply-closes { font-size: .9rem; margin-top: 1rem; text-align: center; }

/* shared/owner_strips.css */
/* owner strips — the full-bleed lines a signed-in organiser meets above an
   event's own public page, and (with one deliberate exception below) nobody
   else ever does. Two of them today:

   .secret-preview   the page opens for you and answers not-found for
                     everyone else (event_secret.go). Without it, a page that
                     loads fine is the thing most likely to be read as
                     working when it isn't.
   .owner-strip      paid tickets are being kept off the page because no
                     payment door is open (shop.go's paidHidden). It reports
                     an ABSENCE, so there is nothing on the page beside it to
                     notice — which is why it moved up here from a tinted
                     note under the description (Vicky, 2026-08-12).

   .sandbox-strip    this whole page is a copy of the account, for testing,
                     and nothing on it is charged for real (sandbox.go). THE
                     ONE STRIP IN THIS FILE EVERYBODY SEES rather than only a
                     signed-in organiser — a sandbox address is openable by
                     anyone holding the link, on purpose, so the strip is the
                     only thing telling whoever is looking that the checkout
                     in front of them is not real.

   LITERAL COLOURS, and that is the whole point of the file. Two reasons:

   1. The event bundle carries no shared tokens at all (styles.go — event.html
      is its own document on the account's own --custom-* palette), so
      var(--warn) and friends do not exist here. Written against them the
      strip rendered as unstyled bold text.
   2. Even if they did, this is a message from Tito ABOUT the page rather than
      part of the page, and an organiser palette could tint it into invisibility
      or into looking like the event's own design. Sitting deliberately outside
      the account's colours is what says "this is not your event page talking".

   The pair is fixed and measures 15.0:1, so it clears AA on any account
   palette, and the strips are full-bleed above everything so neither can be
   mistaken for a banner the organiser chose. Both can be up at once — a
   secret event with no payments connected stacks them, caveat first.

   A THEMED PAGE LOADS NONE OF THIS. docs/themes.md § The CSS contract says so
   outright, and it is a promise to theme authors rather than an oversight —
   so the sandbox strip injected into a themed document carries the same
   declarations inline, as sandboxStripDecls in sandbox.go.
   TestSandboxStripStyleMatchesItsStripsRule compares the two declaration sets
   and fails if either side gains, loses or changes one. */
.secret-preview,
.owner-strip,
.page-preview,
.sandbox-strip {
  /* Near-black, deliberately not a hue: most of these report something the
     reader has to weigh (paid tickets are being held back, this page is a
     draft, this shop is a copy that takes no real money), and a coloured band
     would either look like part of the design or like a warning about the
     wrong thing. */
  background: #1b1e27;
  color: #f8f9fb;
  font: 550 13px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  letter-spacing: .01em;
  margin: 0;
  padding: .65rem 1rem;
  text-align: center;
}

/* The secret strip takes the brand cobalt instead (Vicky, 2026-08-28): it is
   the one of the three that carries no problem at all — the event is secret
   because somebody chose that, and the strip exists so a page that loads for
   you is not read as a page that loads for everybody. Louder than the
   near-black on a white event page, and unmistakably ours.

   #1a45c4 is --cobalt-600 written out, for the reason the whole file exists:
   the event bundle carries no shared tokens, so var(--accent) would render as
   nothing here. Against #f8f9fb it measures 7.4:1, so it clears AA for this
   13px/550 text on any account palette, the same guarantee the pair above
   makes. If the brand blue ever moves, this line moves with it by hand. */
.secret-preview { background: #1a45c4; }

/* .page-preview is the third strip: an organiser looking at one of their own
   static pages from the admin (static_page_public.go). It is the only one that
   carries a link and a state, because it is the only one whose two states LOOK
   identical — a draft and a live page render the same, and whether anyone else
   can open it is the one thing a preview cannot show.

   Its link takes the strip's own ink rather than an accent: the pair above is
   fixed at 15.0:1 precisely so no palette can weaken it, and a blue link
   inside it would be the one thing on the strip an account's colours could
   argue with. Underlined, so it still reads as a link without a hue. */
.page-preview {
  align-items: center;
  display: flex;
  flex-wrap: wrap;
  gap: .4rem .9rem;
  justify-content: center;
}

/* a[href], not a bare `a`: the buyer journey paints every link
   --journey-link from `body.buyer-journey a`, which outranks a lone class and
   would leave this one the account's blue on a near-black bar — the exact
   trap head_edit.css already records. The attribute buys the specificity
   without borrowing the journey's own class, which this file cannot depend on:
   the event page is a different document and has none. */
.page-preview a[href] {
  color: #f8f9fb;
  text-decoration: underline;
  text-underline-offset: 2px;
}

.page-preview a[href]:hover { text-decoration-thickness: 2px; }

/* Amber for a draft, green once it is live — said before the sentence is read.
   Never the only carrier: the words say the same thing. */
.page-preview .strip-dot {
  background: #f2a33c;
  border-radius: 50%;
  flex: none;
  height: 7px;
  width: 7px;
}

.page-preview[data-state="live"] .strip-dot { background: #43c489; }

/* shared/studio_signpost.css */
/* studio-signpost — the dashed line that stands where an organizer's own words
   WOULD go, on a slot nobody has written in yet (Vicky, 2026-08-29: "Signpost
   on"). It answers the one question an always-present-but-empty slot raises:
   how do you know it's there?

   IT ONLY EVER RENDERS IN THE CUSTOMIZE CANVAS. shop.go sets StudioSignpost
   from previewState, which is set on the preview engine's shadow servers and
   nowhere else, so a buyer can never meet this on a live page and no email can
   carry it. That is a property of the render, not of this file — but it is the
   reason the file may look like admin chrome while living in a buyer bundle.

   IT EDITS NOTHING. Clicking it takes the organizer to the row in the Words
   sidebar that does (studio.js); it is a pointer, not a control, which is why
   it carries no border-radius flourish and no hover fill that would suggest
   something happens here.

   LITERAL COLOURS, for exactly the reason owner_strips.css spells out: this
   file is listed by the event bundle, which is a standalone document carrying
   none of the shared tokens, so var(--accent) would render as nothing. The
   cobalt is --cobalt-600 written out (#1a45c4), the same hand-copy the secret
   strip keeps. If the brand blue moves, this line moves with it by hand. */
studio-signpost {
  align-items: center;
  background: rgb(26 69 196 / .04);
  border: 1px dashed rgb(26 69 196 / .45);
  border-radius: 8px;
  color: #1a45c4;
  cursor: pointer;
  display: flex;
  font: 500 13px/1.45 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  justify-content: center;
  margin-top: 1.75rem;
  min-height: 3.25rem;
  padding: .8rem 1rem;
  text-align: center;
}

studio-signpost:hover { background: rgb(26 69 196 / .09); }

/* A visible focus ring on its own terms. The canvas is an iframe inside the
   studio and an organizer can tab into it, so this has to be reachable and
   obviously reachable — and the page it sits on may be any account palette, so
   the ring is drawn rather than inherited. */
studio-signpost:focus-visible {
  outline: 2px solid #1a45c4;
  outline-offset: 2px;
}

/* shared/notes_block.css */
/* event-notes / order-notes — the two blocks of words an organizer writes in
   their own voice: one under the tickets for whoever is deciding, one on the
   order page for whoever has already booked (event_notes.go, order_notes.go,
   GitHub #1674). Both are documented parts of the buyer journey's CSS contract
   (docs/themes.md), which is why every rule here is written against the
   element and never against a wrapper class.

   ONE FILE FOR TWO ELEMENTS, because it is one component. The words come from
   the same editor, carry the same marks and are read at the same size, so two
   copies of these rules would be two answers to "how does an organizer's own
   prose look on a buyer page" waiting to disagree. :is() rather than a repeated
   selector for the same reason: a rule that has to be written twice is a rule
   that gets changed once.

   NOTHING HERE MAY NAME A PALETTE, because the three surfaces it lands on do
   not share one. Pro paints from --custom-text-color on the account's own
   colours; Classic is a white card with literal ink (#30303a) and no --custom-*
   at all; the order page is the buyer journey's own chrome. A var() would
   render as nothing on at least one of them. Everything below therefore
   inherits, or derives from currentColor — which is the reader's ink on every
   one of them by construction.

   The words came from the composer, so the marks that can land in here are
   exactly the composer's: two heading levels, bold, italic, links, and the two
   kinds of list (message_marks.go). Nothing else is possible, so nothing else
   is styled. */
:is(event-notes, order-notes) {
  border-top: 1px solid color-mix(in srgb, currentColor 18%, transparent);
  display: block;
  /* Smaller than the description above the tickets, deliberately. That block
     is the event's pitch; this is what somebody has to KNOW, read after they
     have decided, and it must not compete with the button it sits under. */
  font-size: .95rem;
  line-height: 1.6;
  margin-top: 1.75rem;
  max-width: 60ch;
  padding-top: 1.5rem;
}

:is(event-notes, order-notes) p { margin: 0 0 .75rem; }

/* h1 and h2 are what the composer's two heading buttons produce here, the same
   pair a static page emits (static_page_render.go). They are sized as headings
   INSIDE a block, not as the page's own — the event's name is the h1 a reader
   has already met at the top, and a second one at that scale under the tickets
   would read as a second page starting.

   CLAIMED TWICE, and this is the one thing in the file that is not simply a
   rule. Every frame this block lands in sizes its own headings from a CLASS —
   `.pro-card h1`, `.classic-card`'s card, `body.buyer-journey h1` — and a class
   outranks any bare element selector however it is written. Unclaimed, the
   organizer's "When you get here" came out at the page's own title size: 36px
   light on the Pro event page, 1.6rem on the order page, against a block set at
   .95rem. Naming the three frames is what makes one set of declarations hold on
   all of them.

   It is a SELECTOR LIST and not a second copy of the rule, so the numbers move
   once. And the bare form is still first, because a THEMED page has none of
   those classes and is the case the plain rule is for. */
:is(event-notes, order-notes) h1,
:is(event-notes, order-notes) h2,
:is(.pro-card, .classic-card, body.buyer-journey) :is(event-notes, order-notes) h1,
:is(.pro-card, .classic-card, body.buyer-journey) :is(event-notes, order-notes) h2 {
  font-size: 1.05rem;
  font-weight: 700;
  line-height: 1.35;
  margin: 1.25rem 0 .5rem;
}

:is(event-notes, order-notes) h2,
:is(.pro-card, .classic-card, body.buyer-journey) :is(event-notes, order-notes) h2 { font-size: 1rem; }

:is(event-notes, order-notes) ol,
:is(event-notes, order-notes) ul { margin: 0 0 .75rem; padding-left: 1.25rem; }

:is(event-notes, order-notes) li { margin: 0 0 .3rem; }

/* The block owns the space above and below itself — the rule and the margin
   are its own — so its first and last children may not add to it. Without this
   an organizer whose block opens with a heading gets a gap that reads as a
   mistake in the page rather than as a choice they made.

   LAST IN THE FILE, and claimed at both weights for the same reason the
   headings are. The heading rule above is the more specific of the two at every
   weight and would win wherever these were written, so being last is what
   settles it. */
:is(event-notes, order-notes) > :first-child,
:is(.pro-card, .classic-card, body.buyer-journey) :is(event-notes, order-notes) > :first-child { margin-top: 0; }
:is(event-notes, order-notes) > :last-child,
:is(.pro-card, .classic-card, body.buyer-journey) :is(event-notes, order-notes) > :last-child { margin-bottom: 0; }

/* Underlined, always. A link inside a paragraph of somebody else's prose is
   the one place colour alone cannot carry it — the surrounding ink is the
   organizer's and the link's colour is the organizer's too, so on a palette
   where those are close the link would be invisible. The Pro skin additionally
   gives it --custom-link-color (event_pro.css), beside <event-description> and
   <venue-links>, which are the other two elements in this contract. */
:is(event-notes, order-notes) a {
  text-decoration: underline;
  text-underline-offset: 2px;
}

:is(event-notes, order-notes) a:hover { text-decoration-thickness: 2px; }

/* shared/embed_sheet.css */
/* The widget's purchase sheet: what a buyer page looks like when it is being
   rendered inside the iframe on the organiser's own website, lifted over
   their page (embed_journey.go, layout.html's embed-sheet-top/bottom).

   THE SHEET'S RECTANGLE IS THE SAME ON EVERY STEP, and that is the point of
   nearly every rule here (Vicky, 2026-08-10: "it needs to be static in the
   centre of the page", after an earlier attempt where the modal jumped
   around). Tickets, details, payment and confirmation are four very
   different heights, so a box sized by its content re-centres on each one
   and hops up the screen under the buyer's pointer. What stops it:

     - a fixed WIDTH and HEIGHT, not max-height and not content-driven;
     - `inset: 0; margin: auto`, which centres a fixed-size fixed-position
       box in both axes and depends on nothing about what is inside it;
     - one scrolling region, <sheet-scroll>, between a header and a footer
       that are outside it.

   Change any of those three and the sheet starts moving again.

   IT IS IN shared/ BECAUSE THE SHEET SPANS TWO DOCUMENTS, and that is the
   one thing to know before editing it. The first step renders the event's
   own purchase form, which is styled by the EVENT bundle (styles/event/, on
   the account's --custom-* palette) in a document of its own — exactly as
   event.html is. Every step after it is an ordinary buyer page in the
   layout.html shell, on the public bundle's --journey-* palette. Two
   bundles, one chrome, out of one file, so the box cannot drift between the
   step that sells and the step that asks; styles.go lists it as a trailing
   group on both.

   That is why every colour here resolves through --sheet-*: each chain names
   the journey token first, the event page's equivalent second, and Classic's
   own literal last (Classic sets neither vocabulary — its card is #fff and
   its rules are hardcoded, so the last leg is that card, copied from
   event_classic.css). The legs are the same colour by construction —
   --journey-card and --custom-text-background-color are both
   Design.CardBackground, and so on down the list. A token that existed on
   only one side would be a sheet that changed colour on step two.

   THE SCRIM DOES NOT DIM THE ORGANISER'S PAGE, and cannot. A cross-origin
   iframe never composites onto the page behind it — the browser paints the
   frame's own canvas backdrop (#450, #732) — so what is behind the sheet is
   OUR backdrop, and the scrim is what makes it read as a page dimmed under a
   modal rather than as a flat colour. Nothing here may reintroduce
   transparency on the frame; that road was walked and written down twice. */

/* The frame's canvas is PAINTED, never left to the browser — the same
   lesson as #450/#732, arriving one document further in. What is behind the
   sheet is our own canvas (a cross-origin iframe does not composite onto the
   page behind it), and an unpainted canvas is filled by the browser from
   whatever it decides the context is: viewed here it came out near-black
   under a light design, which turns the scrim below from a dim into a void.
   So the account's own page colour goes on deliberately, and the scrim dims
   THAT. --bg carries it in both documents — layout.html's shell sets it from
   the journey palette, event.html's head sets it from the account theme —
   which is why this one is not a --sheet-* chain like the rest. */
body[data-embed-sheet] { background: var(--bg, #fff); min-height: 0; padding: 0; }

/* Every chrome rule below is written `body[data-embed-sheet] .es-…`, and the
   prefix is load-bearing rather than tidy scoping. On the later steps this
   file is read inside the public bundle, where `body.buyer-journey footer`
   and `body.buyer-journey a` outrank a bare class — measured, and what it
   cost was a footer that spread across the sheet on the step that sells and
   huddled in the middle on the step that asks, with the close control
   turning link-blue on the way. Same box, same chrome, both documents: the
   prefix is what makes the second half of that true. */

/* The page inside the sheet gives up the buyer journey's card: the sheet IS
   the card, and a card inside a card at 460px is two borders and two lots of
   padding around a form that has neither to spare. Only the layout.html
   steps have a <main> to undo — the first step's own document has none.

   `sheet-scroll > main`, not `body > main`, and the difference was 64px of
   dead space under every confirmed order (Vicky, 2026-08-10: "quite a lot of
   white space at the bottom"). The chrome wraps the page — scrim, sheet,
   scroll region — so <main> is two levels down and a child selector on body
   matched nothing at all, silently: the rule was there, it was correct, and
   it applied to no element on any step. The journey's own rule is
   `body.buyer-journey > main` and lands a class-and-two-types, so this has
   to carry one more than that to win rather than merely tie. */
body[data-embed-sheet] sheet-scroll > main {
  background: none; border-radius: 0; box-shadow: none; margin: 0;
  max-width: none; min-height: 0; padding: 0;
}

/* The page's own credit line is emptied in sheet mode (layout.html: the
   sheet's foot carries the Tito line already), and an emptied element still
   brings its 48px top margin with it — 48px of nothing under the last thing
   a buyer read. Zero-height and no words means no gap either. */
body[data-embed-sheet] sheet-scroll > main > footer:empty { margin: 0; }

/* THE SHEET'S SIZE IS DECLARED ONCE, HERE, AND THE LOADER READS THE SAME
   NUMBERS. Since the lift, the organiser's page is dimmed by a scrim in
   THEIR document and our iframe is sized to the sheet and nothing more —
   which means embed.js has to know how big the sheet is, and two files now
   have an opinion about it. They agree because
   TestSheetSizeAgreesWithTheLoader parses both and compares; a number
   changed here alone fails that test rather than leaving a modal with a
   transparent margin around it. Change them together.
     --tito-sheet-w: 460px  --tito-sheet-h: 640px  --tito-sheet-full: 520px
   (full = at and below this viewport width the sheet IS the screen). */
:root {
  --tito-sheet-full: 520px;
  --tito-sheet-h: 640px;
  --tito-sheet-w: 460px;
}

/* The scrim inside the frame. Since the lift there is nothing behind the
   sheet for it to dim — the frame is the sheet's own rectangle now, and the
   dimming the visitor sees is the loader's, drawn on the organiser's page
   where their content actually is. It stays for the one case that still has
   a surround: the sheet opened on our own host, outside any widget, which is
   how it is reviewed and how a `?embed=` link behaves if somebody shares
   one. */
embed-scrim {
  background: color-mix(in srgb, #000 55%, transparent);
  inset: 0; position: fixed; z-index: 0;
}

embed-sheet {
  /* The two token vocabularies this chrome can find itself inside, and
     Classic's literals last. See the note at the top of this file. */
  --sheet-card: var(--journey-card, var(--custom-text-background-color, #fff));
  --sheet-fg: var(--fg, var(--custom-text-color, #30303a));
  --sheet-heading: var(--journey-heading, var(--custom-heading-color, #252535));
  --sheet-line: var(--line, var(--custom-control-border, #e1e1e1));
  --sheet-muted: var(--muted, var(--custom-text-color-muted, #65656d));
  --sheet-radius: var(--journey-radius, var(--custom-card-border-radius, 16px));

  background: var(--sheet-card);
  border-radius: var(--sheet-radius);
  color: var(--sheet-fg);
  display: flex;
  flex-direction: column;
  /* Fixed, not max-content: see the note at the top of this file. */
  height: min(var(--tito-sheet-h), 100%);
  inset: 0;
  margin: auto;
  overflow: hidden;
  position: fixed;
  /* No drop shadow, and that is a decision rather than an omission: the
     sheet always sits on the scrim above, 55% black in either scheme, where
     a black shadow paints nothing anybody can see. The elevation tokens are
     the shared bundle's and do not reach the event document at all, so
     asking for one here would be a shadow that appeared on step two and not
     on step one — the exact drift this file exists to prevent. */
  width: min(var(--tito-sheet-w), 100%);
  z-index: 1;
}

body[data-embed-sheet] .es-head {
  align-items: center;
  border-bottom: 1px solid var(--sheet-line);
  display: flex;
  flex: none;
  gap: .75rem;
  /* Back on the left where there is one, close on the right always — the
     event's name has gone from between them (layout.html says why). */
  justify-content: space-between;
  padding: .85rem 1.15rem;
}

/* THE EVENT'S NAME DOES NOT APPEAR INSIDE THE WIDGET, on any step. The
   journey's steps each open with it as an eyebrow, which is right on our own
   site — a buyer arriving from an email needs telling what they are paying
   for — and wrong in a box pasted onto the event's own page, where they read
   it a second ago (Vicky, 2026-08-10).
   Matched by data-src rather than by class: the SAME eyebrow carries the
   order reference on the order page ("Order R7X2QK"), which is not the event
   name and is worth keeping. The attribute is what tells them apart, and it
   is already there for the Customize studio's source map. */
body[data-embed-sheet] .bj-eyebrow[data-src="content:event.name"] { display: none; }

body[data-embed-sheet] .es-title {
  color: var(--sheet-heading);
  font-weight: 600;
  /* One line, ever: the header is chrome, and an event with a long name must
     not be allowed to push the tickets down the sheet. */
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

body[data-embed-sheet] .es-back {
  align-items: center;
  color: var(--sheet-muted);
  display: inline-flex;
  font-size: .9rem;
  gap: .25rem;
  text-decoration: none;
}

body[data-embed-sheet] .es-back:hover { color: var(--sheet-fg); }
body[data-embed-sheet] .es-back:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }

/* margin-left:auto rather than the header's justification: the close is the
   only thing in the header on most steps, and space-between with one child
   puts it at the START. Pushed from the element itself, it sits right
   whether or not a back link is beside it. */
body[data-embed-sheet] .es-close {
  align-items: center;
  border-radius: 999px;
  color: var(--sheet-muted);
  display: flex;
  flex: none;
  height: 32px;
  justify-content: center;
  margin-left: auto;
  text-decoration: none;
  width: 32px;
}

body[data-embed-sheet] .es-close:hover { background: color-mix(in srgb, var(--sheet-fg) 8%, transparent); color: var(--sheet-fg); }

/* Drawn INSIDE the control, in the control's own colour: an indicator that
   contrasts by construction against whatever it sits on, and one that a
   years-old `:focus { outline: none }` on the page cannot reach — this is
   our own document, but the rule is the same one #743's button follows and
   it costs nothing to keep them alike. */
body[data-embed-sheet] .es-close:focus-visible { outline: 2px solid currentColor; outline-offset: -3px; }

/* The order review (embed_review.html), the modal's first screen once the
   box out on the organiser's page has done the asking. A receipt: each line
   is a name on the left and a price on the right, the total under a rule,
   and the button. Sheet-only vocabulary, so it lives here rather than in
   either bundle's own components. */
/* ADD-ON ROWS ARE ALWAYS VISIBLE ON THE REVIEW, and the rule they undo is
   the reason they have to be. On the event page an add-on hides until its
   ticket's stepper goes above zero (`.js [data-addon-for]`, event_addons.css)
   — addon-rows.js reveals it, keyed to that input. The review has no
   steppers for tickets: the choosing happened out in the box, and the rows
   it offers were filtered to the chosen types SERVER-side. So the script has
   no input to watch, would never reveal anything, and the heading sat over
   an empty space. Shown from the start here, which is also what the rows
   mean here. */
body[data-embed-sheet] .js [data-addon-for],
body[data-embed-sheet] li[data-addon-for] { display: list-item; }

body[data-embed-sheet] .review-lines { margin: 0 0 1rem; }

body[data-embed-sheet] .review-line {
  border-bottom: 1px solid var(--sheet-line);
  display: flex;
  gap: 1rem;
  justify-content: space-between;
  padding: .7rem 0;
}

body[data-embed-sheet] .review-line dt { min-width: 0; }
body[data-embed-sheet] .review-line dd { flex: none; font-variant-numeric: tabular-nums; margin: 0; }
body[data-embed-sheet] .review-qty { color: var(--sheet-muted); }

body[data-embed-sheet] .review-total {
  display: flex;
  gap: 1rem;
  justify-content: space-between;
  margin: 0 0 1.25rem;
}

body[data-embed-sheet] .review-total strong { font-size: 1.15rem; }

/* The purchase form's own footer bleeds to the edges of whatever holds it,
   and what holds it here is <sheet-scroll> rather than the event page's
   card — so the bleed is retuned to this padding instead of that one. The
   bottom is squared off: on the page the footer is the last thing before the
   card ends, and in the sheet there is a real footer under it. */
body[data-embed-sheet] .pro-form-footer {
  margin: 1rem -1.15rem 0;
  padding: 1rem 1.15rem;
}

sheet-scroll {
  display: block;
  flex: 1;
  min-height: 0;
  /* The sheet is over somebody else's page: a wheel that reaches the end of
     this list must not start scrolling their article underneath. */
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: 1.15rem;
}

body[data-embed-sheet] .es-foot {
  align-items: center;
  border-top: 1px solid var(--sheet-line);
  display: flex;
  flex: none;
  font-size: .8rem;
  gap: .75rem;
  justify-content: space-between;
  padding: .7rem 1.15rem;
}

body[data-embed-sheet] .es-out { color: var(--sheet-muted); text-decoration-color: var(--sheet-line); }
body[data-embed-sheet] .es-secured { align-items: center; color: var(--sheet-muted); display: flex; gap: .3rem; }
body[data-embed-sheet] .es-secured a { color: inherit; text-decoration: none; }
body[data-embed-sheet] .es-secured svg { flex: none; }

/* INSIDE THE WIDGET'S FRAME THE SHEET FILLS IT, and the two rules below are
   why this is not the phone rule underneath.

   Since the lift, the loader sizes our iframe to the sheet exactly — so in
   here the viewport IS the sheet, 460px wide, which is under the phone
   breakpoint. Left to the media query the sheet would go full-screen and
   square off its corners on a 27-inch monitor, and the frame around it would
   still be a rounded 460px box. The outer screen is cross-origin and cannot
   be read from in here at all, so what the sheet keys off is the one thing
   it does know: that it is framed (embed-sheet.js sets the attribute). The
   loader owns the size and the corners, because the loader can see the
   screen; the sheet just fills what it is given.

   The inner scrim goes with it: there is nothing behind the sheet in here
   any more, and the dimming the visitor sees is the loader's, over the
   organiser's own page. */
:root[data-framed] embed-sheet { height: 100%; width: 100%; }
:root[data-framed] embed-scrim { display: none; }

/* On a phone the sheet is the screen: no corners to round, no margin to
   centre in, and every pixel of width spent on the form. Standalone only —
   framed, the loader has already made the frame the screen and the rules
   above have made the sheet fill it. */
@media (max-width: 520px) {
  :root:not([data-framed]) embed-sheet { border-radius: 0; height: 100%; width: 100%; }
  :root:not([data-framed]) embed-scrim { display: none; }
}

/* shared/doc_paper.css */
/* THE PAPER — the invoice and the receipt, and nothing else.
 *
 * A document is read by a stranger in an accounts department, beside forty
 * other documents, often printed on a machine with one colour of ink. So this
 * sheet answers to its own tokens (design.go's DocPaper / DocInk / DocLine /
 * FontDoc) and never to the buyer journey's palette: an account's brand colour
 * following the buyer onto their invoice is the thing this file exists to
 * prevent. Generic is the default, not a lock — the four are ordinary
 * Customize controls.
 *
 * Everything hangs off [data-doc], which the document shell sets. The buyer
 * journey's rules never see it and it never sees theirs — which also means
 * this file is the ONLY thing laying the document out, and a selector here
 * that matches nothing is a block with no styling at all rather than a block
 * that falls back to something reasonable. TestThePaperStylesWhatTheDocument
 * DrawsAndNothingElse is the fence, and it exists because the first version of
 * this file styled `.doc-items .row` where the markup says `.doc-item`.
 */

[data-doc] {
  /* The shipped defaults. The document shell writes the account's own values
     into an inline block after this sheet (layout.html's doc-tokens), so these
     are what an account that has changed nothing gets — and what a document
     falls back to if a value ever fails its read-time validation. */
  --doc-ink: #1a1a1a;
  --doc-line: #e4e4e7;
  --doc-paper: #ffffff;
  /* Computed in Go (eventDesign.DocMuted): the lightest blend of the ink into
     the paper that still clears AA for body text. A percentage cannot promise
     that across every pair an account might pick — 58% measured 4.29:1 against
     white and failed. The wash is a background and answers to no contrast
     rule, so it stays a mix. */
  --doc-muted: #686868;
  --doc-faint: color-mix(in srgb, var(--doc-ink) 7%, var(--doc-paper));
  background: var(--doc-paper);
  color: var(--doc-ink);
  font-family: var(--doc-font, "Helvetica Neue", Arial, "Liberation Sans", "Noto Sans", sans-serif);
  /* 15px/1.55: a document is read once, carefully, by somebody deciding
     whether to pay it — nearer a letter than a UI. */
  font-size: 15px;
  /* Money and counts are COLUMNS, not sentences, and tabular figures are what
     make a column of amounts scannable. The one typographic thing a financial
     document cannot do without, so it is set once at the root. */
  font-variant-numeric: tabular-nums;
  line-height: 1.55;
}

[data-doc] .muted { color: var(--doc-muted); }

/* ── the state line ──────────────────────────────────────────────────────── */

/* A STAMP, which is the one piece of decoration native to paper: a word in a
   ruled box, the way a document that has been through an office is marked. It
   needs only the ink token, so it survives any palette an account picks and
   any mono printer — where a coloured band across the head of the paper, which
   is what this was, survives neither.

   No rule under it. The document's ink is spent on three lines and they all
   bracket the money (the items table, above the total, below it); a fourth
   here would rank with them and rank nothing. */
[data-doc] doc-status {
  align-items: baseline;
  display: flex;
  gap: .6rem;
  margin: 0 0 2.5rem;
}
/* The tick is decoration that is NOT native to paper — the word beside it
   already says "Paid", and an icon is the half that reproduces worst. */
[data-doc] .ds-disc { display: none; }
[data-doc] .ds-say { flex: 1; }
[data-doc] .ds-word {
  border: 1px solid var(--doc-ink);
  display: inline-block;
  font-size: .6875rem;
  font-weight: 600;
  letter-spacing: .08em;
  padding: .22rem .5rem;
  text-transform: uppercase;
}
[data-doc] .ds-meta { color: var(--doc-muted); font-size: .8125rem; margin-left: .5rem; }
[data-doc] .ds-amount { font-size: 1.0625rem; font-weight: 600; }

/* ── the head: what it is, and who issued it ─────────────────────────────── */

/* 2.5rem under the head, and that is the most valuable space on the sheet:
   white space at the top of a document is what expensive stationery is made
   of, and it was 2rem doing nothing in particular. */
[data-doc] .doc-head {
  display: flex;
  gap: 2.5rem;
  justify-content: space-between;
  margin: 0 0 2.5rem;
}
[data-doc] .doc-head h1 {
  font-size: 1.75rem;
  font-weight: 600;
  letter-spacing: -0.02em;
  line-height: 1.2;
  margin: 0 0 .2rem;
}
[data-doc] .doc-head p { margin: 0; }
/* The eyebrow carries the document's NUMBER, which is what gets quoted on the
   phone. Body face at reading size rather than the journey's small caps: it is
   a name, not a label. */
[data-doc] .bj-eyebrow {
  color: var(--doc-muted);
  font-size: .875rem;
  font-weight: 500;
  letter-spacing: 0;
  margin: 0 0 .3rem;
  text-transform: none;
}
[data-doc] .doc-seller {
  max-width: 22rem;
  text-align: right;
}
/* WITH NO LOGO PERMITTED, THE NAME IS THE LETTERHEAD. Nothing can be fetched
   into this document (Chromium has no route back), so the issuer's legal name
   is the whole of the mark at the top of the page, and it was set at body
   size in bold. */
[data-doc] .doc-seller strong {
  display: block;
  font-size: 1rem;
  font-weight: 600;
  margin: 0 0 .2rem;
}
/* The issuer's address is INK, not chrome. An accounts department reads it
   against their vendor record; it is data they act on. Later in source than
   .muted, which is what lets it win the class it shares. */
[data-doc] .doc-addr {
  color: var(--doc-ink);
  display: block;
  white-space: pre-line;
}
[data-doc] .doc-gap { display: block; }

/* ── the facts: dates, references, bill-to ───────────────────────────────── */

/* A FIXED SHAPE. This was auto-fit/minmax, which means the number of columns —
   the shape of the document itself — changed with how many questions an
   organizer chose to print and how wide the frame was. Two invoices from one
   account looked like two different documents. A filed document has one shape.

   No rule under it either: 2.5rem of white does that job, and the ink is
   spent on the money. */
[data-doc] .doc-grid {
  display: grid;
  gap: 1.25rem 2.5rem;
  grid-template-columns: 2fr 1fr 1fr;
  margin: 0 0 2.5rem;
}
/* Drawn into a page on a phone rather than converted, the three columns have
   nowhere to go. The PDF never takes this branch — its viewport is a sheet of
   A4. */
@media (max-width: 34rem) {
  [data-doc] .doc-grid { grid-template-columns: 1fr 1fr; }
}
[data-doc] .doc-grid h3,
[data-doc] .doc-grid .k {
  color: var(--doc-muted);
  font-size: .6875rem;
  font-weight: 600;
  letter-spacing: .06em;
  margin: 0 0 .15rem;
  text-transform: uppercase;
}
/* A second and third field in one column need the gap the heading's margin
   does not give them. */
[data-doc] .doc-grid h3 + .dg-v + h3 { margin-top: 1.25rem; }
[data-doc] .dg-v { margin: 0 0 .1rem; white-space: pre-line; }
/* The name and what follows it are separate lines, not one run of text. */
[data-doc] .dg-v .muted { display: block; }

/* ── the lines ───────────────────────────────────────────────────────────── */

/* The first of the three ink rules: one opening the table, hairlines between
   the rows, and nothing under the last — the totals rule takes over there, so
   two lines do not stack a millimetre apart. */
[data-doc] .doc-items {
  border-top: 1px solid var(--doc-ink);
  margin: 0 0 1.5rem;
}
[data-doc] .doc-item {
  align-items: baseline;
  border-bottom: 1px solid var(--doc-line);
  display: flex;
  gap: 1.5rem;
  justify-content: space-between;
  padding: .65rem 0;
}
[data-doc] .doc-item:last-child { border-bottom: 0; }
[data-doc] .di-name { flex: 1; }
[data-doc] .di-qty { color: var(--doc-muted); white-space: nowrap; }
[data-doc] .di-sub { color: var(--doc-muted); display: block; font-size: .8125rem; }
/* One notch of weight over the names beside them. The money column reads as a
   column without being shouted. */
[data-doc] .di-amount { font-weight: 500; white-space: nowrap; }
[data-doc] .doc-refunded .di-name { text-decoration: line-through; }

/* ── the totals stack ────────────────────────────────────────────────────── */

/* Right-aligned and narrow, the way every printed invoice sets it: the eye
   runs down one column of figures and stops at the rule above the total. */
[data-doc] .doc-totals {
  margin-left: auto;
  max-width: 20rem;
}
[data-doc] .dt-line,
[data-doc] .dt-grand {
  display: flex;
  gap: 2rem;
  justify-content: space-between;
}
[data-doc] .dt-line { padding: .3rem 0; }
[data-doc] .dt-amount { white-space: nowrap; }
/* A SINGLE RULE ABOVE A SUM AND A DOUBLE RULE UNDER THE FINAL FIGURE. Two
   centuries of accounting convention, read without being noticed by anybody
   who files invoices, and it costs no colour: both rules are the ink token, so
   they survive a mono laser and any palette an account picks. It is also what
   closes the document — the reader knows the page is finished. */
[data-doc] .dt-grand {
  align-items: baseline;
  border-bottom: 3px double var(--doc-ink);
  border-top: 1px solid var(--doc-ink);
  font-size: 1rem;
  font-weight: 600;
  margin-top: .5rem;
  padding: .65rem 0;
}
/* The one number the reader opened the document for. */
[data-doc] .dt-grand span:last-child { font-size: 1.375rem; }

/* ── the quote's own shape ───────────────────────────────────────────────── */

/* A quote is drawn from a different vocabulary than an invoice: .doc-lines /
   .row / .grow / .price where the other two use .doc-items / .doc-item /
   .di-name / .di-amount. That is not tidy, and it is not this file's to fix —
   .doc-lines is a shared buyer-journey component that the order page's
   payments list deliberately reads as a sibling of. So the paper styles both
   vocabularies rather than renaming one out from under a page that is not a
   document. TestThePaperStylesWhatTheDocumentDraws renders a quote too, which
   is what stops this half going stale.

   Merging the two shapes is worth doing on its own, on a day when the buyer
   journey's own use of .doc-lines can be moved with it. */
[data-doc] .doc-lines .row {
  align-items: baseline;
  border-bottom: 1px solid var(--doc-line);
  display: flex;
  gap: 1.5rem;
  justify-content: space-between;
  padding: .7rem 0;
}
[data-doc] .doc-lines .grow { flex: 1; }
[data-doc] .doc-lines .price { white-space: nowrap; }
[data-doc] .doc-lines { border-top: 1px solid var(--doc-ink); }
[data-doc] .doc-lines .row:last-child { border-bottom: 0; }
/* The same bracket the invoice's total wears, on the quote's own vocabulary. */
[data-doc] .doc-total {
  align-items: baseline;
  border-bottom: 3px double var(--doc-ink);
  border-top: 1px solid var(--doc-ink);
  display: flex;
  font-weight: 600;
  gap: 2rem;
  justify-content: space-between;
  margin-left: auto;
  margin-top: .5rem;
  max-width: 20rem;
  padding: .65rem 0;
}
[data-doc] .doc-total strong { font-size: 1.375rem; }

/* ── payment details and the small print ─────────────────────────────────── */

[data-doc] .paydet {
  background: var(--doc-faint);
  margin: 2rem 0 0;
  padding: 1rem 1.15rem;
}
[data-doc] .paydet .pr {
  display: flex;
  gap: 1.5rem;
  justify-content: space-between;
  padding: .2rem 0;
}
[data-doc] .paydet .k { color: var(--doc-muted); }
[data-doc] .ticket { letter-spacing: .02em; }
[data-doc] .doc-legal,
[data-doc] .doc-tax {
  color: var(--doc-muted);
  font-size: .8125rem;
  margin: 1.5rem 0 0;
}

/* public/doc_page.css */
/* The page ABOUT a document — an ordinary buyer-journey page, wearing the
 * account's palette like every other. Only the paper inside it is generic
 * (shared/doc_paper.css), which is the whole split: the page is the account's,
 * the document is the accounts department's.
 */

/* Wider than the journey's 640, and it asks for the width HERE rather than in
   journey_shell.css — the same way checkout_cols.css raises the cap only for a
   card that actually contains a rail. A page that has not earned the width
   cannot inherit it. */
body.buyer-journey > main:has(> doc-page) { max-width: 64rem; }

/* The way to take the document away, at the top of the page rather than under
   it (Paul, 2026-08-29). It is the one action the page offers, and a reader who
   has already decided they want the file should not have to pass the whole
   document to reach the button that saves it.

   Right-aligned with nothing beside it: the facts rail immediately below leads
   with the document's own name and number, so a title here would be the same
   words twice on one screen. */
doc-top {
  display: flex;
  justify-content: flex-end;
}
/* The control carries the journey's own action-row margins, which are for a
   row that FOLLOWS content. Here it leads. */
doc-top .bj-actions { margin: 0; }

doc-page {
  align-items: start;
  display: grid;
  gap: 2rem;
  grid-template-columns: 15rem 1fr;
  margin: .75rem 0 0;
}

/* One column on a phone, FACTS FIRST. A preview is the least useful thing on a
   small screen — it is a page of A4 scaled to a thumb — and the numbers
   somebody opened this for are the most. */
@media (max-width: 46rem) {
  doc-page { grid-template-columns: 1fr; }
}

doc-facts { display: grid; gap: 1.5rem; }
doc-facts section { display: grid; gap: .3rem; }
doc-facts h2 {
  color: var(--muted);
  font-size: .75rem;
  font-weight: 600;
  letter-spacing: .04em;
  margin: 0;
  text-transform: uppercase;
}
doc-facts dl { display: grid; gap: .25rem; margin: 0; }
doc-facts .df-row {
  display: flex;
  gap: 1rem;
  justify-content: space-between;
}
doc-facts dt { color: var(--muted); font-size: .8125rem; }
doc-facts dd { font-variant-numeric: tabular-nums; margin: 0; text-align: right; }
/* The amount is the one fact somebody opened the page for. */
doc-facts .df-total dd { font-size: 1.0625rem; font-weight: 600; }
doc-facts .df-line { white-space: pre-line; }

/* The preview sits on the page's own ground with a hairline, so the white of
   the paper reads as paper rather than as a hole in the card. */
doc-preview {
  border: 1px solid var(--line);
  border-radius: var(--bj-radius-sm, 5px);
  display: block;
  overflow: hidden;
}
doc-preview iframe {
  border: 0;
  display: block;
  /* Tall enough that a one-page document is read rather than scrolled, short
     enough that the page still ends somewhere on a laptop. */
  height: min(78vh, 46rem);
  width: 100%;
}
/* On a phone the frame takes the SHAPE OF THE PAGE instead of a fixed height.
   A fitted A4 page in a letterbox is a small page with bands above and below
   it; in a box of its own proportions it fills the width, which is the whole
   of the room a phone has. With #view=Fit on the src that means the reader
   sees the entire document without scrolling inside the frame — the thing they
   actually came for — and goes to the PDF itself for a closer look. */
@media (max-width: 46rem) {
  doc-preview iframe {
    aspect-ratio: 1 / 1.4142;
    height: auto;
  }
}
/* The no-converter and studio case: the document drawn straight into the page.
   Same sheet, same tokens, so it is the same drawing the PDF would be. */
doc-preview [data-doc] { display: block; }

