/* --- Tokens -----------------------------------------------------------------

   Two layers, and the split is what makes a second theme possible at all.

   The *roots* are the handful of colours a theme actually chooses: the brand,
   the ink, the two grounds, the four state hues and the category hues.
   Everything else is *derived* from them with `color-mix`, so a wash is written
   once as "the brand at 5% of the surface" rather than as a hex that has to be
   recomputed by hand for every ground it might sit on. The dark theme at the
   foot of this file redefines the roots and nothing else.

   That also collapses a duplication this file used to carry: `--brand-tint`,
   `--row-band` and half a dozen inline `rgb(47 96 148 / 0.14)` borders were
   separate values of one blue, tuned separately and drifting apart, when they
   are stops on one scale. They are stops on one scale now.
   --------------------------------------------------------------------------- */
:root {
  color-scheme: light;

  /* How far down the page a sticky element has to start to clear a fixed bar
     above it. Zero as the fallback; the public shell and the signed-in shell
     each set their own below. */
  --sticky-top: 0px;

  /* --- Roots ---------------------------------------------------------------

     The logo's blue. The mark itself is #769FCD on #B9D7EA, which is too pale
     to carry text: white on it measures 2.8:1, and so does it on white, against
     the 4.5:1 a label needs. So the interface takes the logo's *hue* (211°) at
     a step dark enough to be read - 6.5:1 on white - and the logo's own colours
     stay where they are only ever a fill. */
  --brand: #2f6094;
  /* A *darker* brand, for emphasis. On the dark theme this goes the other way:
     emphasis against a dark ground means brighter, so the two are mirror images
     rather than the same colour. */
  --brand-dark: #26527f;
  /* Text on a brand-filled control. White here; on the dark theme the brand is
     a pale blue and the text has to go the other way, which is why every such
     rule reads this token rather than saying `#fff`. */
  --on-brand: #ffffff;
  /* The mark's own two colours, for fills that carry no text. Deliberately not
     themed: they are the logo. */
  --logo-blue: #769fcd;
  --logo-blue-pale: #b9d7ea;

  --ink: #171e28;
  /* Darkened from #6c7a89 and #9aa7b4. Both were under AA on white before any
     row tint was under them - 4.0:1 and 2.6:1 - and everything wearing them is
     small text: the stat labels, the chart's scale, a table's dates and types.
     They needed the same 4.5:1 floor as any other prose, not the 3:1 a
     decorative rule gets. `--ink-faint` now sits close to `--ink-muted`; the
     hierarchy between them is carried by size and weight, which survives. */
  --ink-muted: #55636f;
  --ink-faint: #5e6a77;

  --surface: #ffffff;
  --canvas: #f6f7fb;

  /* The four state hues. Each is dark enough to be read as *text* on its own
     palest wash, which is what the badges and the row tints are made of - the
     whole scheme falls apart the moment a status has to be printed in a colour
     its own background swallows. Verified against the strongest tint each is
     used at; see "Row state" below for those strengths.

     `--warn` was #b45309, which measured 4.2:1 on its own chip and needed a
     second darker token (`--warn-ink`) beside it to print the word. One hue
     that can do both jobs is one hue; `--warn-ink` is kept as an alias so the
     rules that already name it keep working. */
  --ok: #167048;
  --warn: #8a5600;
  --danger: #b3261e;
  /* The fourth meaning: neither good, nor a warning, nor a failure - a count of
     something that simply happened. Downloads on the chart, and any category
     that needs a hue outside the traffic-light three. Violet also survives the
     two common colour deficiencies alongside red and green, which amber does
     not. */
  --alt: #5d3fa8;

  /* How strongly a chip, a wash and a hairline take their hue. One place, so
     the state and category surfaces below cannot drift apart from each other -
     and so the dark theme can lift all of them at once, which it has to: the
     same percentage of a hue reads far weaker against a dark ground. */
  --chip-mix: 12%;
  --hairline-mix: 26%;
  --tint-mix: 5%;
  --light-mix: 11%;

  /* How strongly a surface takes a state's hue. Three stops, here rather than
     at each rule because the dark theme has to lift all of them together.

     `--tint-1` is a table row carrying a state, and it is shallow on purpose:
     the badge in the status column is what *says* the state, and the wash is
     only there to make the rows findable without reading it. `--tint-2` is a
     whole tile going amber or red, which is a louder thing and the only one
     that is meant to be noticed from across the page.

     The quiet stop is lower than either and has to be: it is the only one mixed
     from the *ink* rather than from a hue, and near-black at 9% over white is a
     far heavier wash than 9% of a green is - heavy enough that the faint ink
     printed on it fell under AA. Measured, like the rest, in
     `apps/core/tests/test_palette.py`. */
  --tint-1: 4%;      --tint-1-hover: 8%;
  --tint-2: 9%;
  --tint-quiet: 4%;  --tint-quiet-hover: 7%;

  /* What a shadow is made of. A shadow on a light ground is the ink at a low
     alpha; on a dark one there is nothing darker than the ground to go to, so
     the dark theme swaps this for black and lifts the alphas. Every shadow in
     this file is written against it rather than repeating a colour. */
  --shadow-rgb: 16 24 40;

  /* --- Derived: the brand scale --------------------------------------------
     Five stops, one hue. The two opaque ones are grounds; the translucent ones
     are washes and edges that have to let whatever is under them show through -
     a banded row also carries a state wash of its own, and an opaque band would
     cover it on every second row, which is to say the table would report a menu
     as parked or not depending on where it landed in the list. */
  --brand-tint: color-mix(in srgb, var(--brand) var(--tint-mix), var(--surface));
  --brand-light: color-mix(in srgb, var(--brand) var(--light-mix), var(--surface));
  --row-band: color-mix(in srgb, var(--brand) 2.5%, transparent);
  --brand-wash: color-mix(in srgb, var(--brand) 8%, transparent);
  --brand-edge: color-mix(in srgb, var(--brand) 16%, transparent);
  --brand-edge-strong: color-mix(in srgb, var(--brand) 35%, transparent);

  /* --- Derived: rules ------------------------------------------------------ */
  /* --- Picked, hovered, neither ---------------------------------------------

     The three states of a segmented control - the theme switch, the language
     flags, the pager's page numbers - as an ordered ladder rather than three
     values that happened to be near each other.

     They were measured, and both themes were wrong in a different way. On light
     the hover ground was `--brand-tint`, 5% brand over *white*, which lands on
     #f5f7fa against a #f6f7fb track: a ratio of 1.00, an invisible hover. On
     dark the hover came out at 1.20x of the track while the *selected* pill -
     `--surface` on `--canvas` - was only 1.08x, so the state under the pointer
     shouted louder than the state that was chosen. That is not weak contrast,
     it is the hierarchy upside down.

     Now: idle is the bare track, hover is a wash of the brand on it, and picked
     is stronger again with the brand as an inset edge. Picked beats hover by
     1.42x on light and 1.48x on dark, which is the number that matters - a
     selected control is only selected relative to the others in its row.

     The two themes reach it differently and have to. Light keeps the raised
     white pill, on a track deepened 6% so white has something to be brighter
     than; dark cannot use that idiom at all, because `--surface` is 1.08x of
     `--canvas` and no shadow shows on a dark ground, so it takes a brand wash
     instead. Same three rules, different roots - which is the whole point of
     the token layer. */
  --pick-track: color-mix(in srgb, var(--ink) 6%, var(--canvas));
  --pick-hover: color-mix(in srgb, var(--brand) 12%, var(--pick-track));
  --pick-bg: var(--surface);
  --pick-edge: color-mix(in srgb, var(--brand) 45%, var(--surface));
  /* The darker brand: 8.09:1 on the white pill, where the plain brand is 6.5. */
  --pick-ink: var(--brand-dark);

  --line: color-mix(in srgb, var(--ink) 11%, var(--surface));
  --line-strong: color-mix(in srgb, var(--ink) 22%, var(--surface));
  /* A rule inside a banded table: the full `--line` between every pair of rows
     draws a grid, which is one visual system too many once the rows are
     striped. */
  --line-soft: color-mix(in srgb, var(--ink) 6%, var(--surface));

  /* --- Derived: one chip and one hairline per state -------------------------
     `-bg` is the badge chip: a step stronger than a row wash, so a badge sitting
     on a row of its own colour still reads as a chip on a ground. `-line` is its
     edge, which is what keeps the chip a chip once the row beneath it is tinted
     the same colour. */
  --ok-bg: color-mix(in srgb, var(--ok) var(--chip-mix), var(--surface));
  --ok-line: color-mix(in srgb, var(--ok) var(--hairline-mix), var(--surface));
  --warn-bg: color-mix(in srgb, var(--warn) var(--chip-mix), var(--surface));
  --warn-line: color-mix(in srgb, var(--warn) var(--hairline-mix), var(--surface));
  --danger-bg: color-mix(in srgb, var(--danger) var(--chip-mix), var(--surface));
  --danger-line: color-mix(in srgb, var(--danger) var(--hairline-mix), var(--surface));
  --alt-bg: color-mix(in srgb, var(--alt) var(--chip-mix), var(--surface));
  --alt-line: color-mix(in srgb, var(--alt) var(--hairline-mix), var(--surface));
  --info-bg: color-mix(in srgb, var(--brand) 9%, var(--surface));
  /* The stateless chip - a draft, an unset value. Grey rather than a hue,
     because it is the absence of a state rather than one of them. */
  --neutral-bg: color-mix(in srgb, var(--ink) 7%, var(--surface));
  --neutral-line: color-mix(in srgb, var(--ink) 16%, var(--surface));
  /* Kept as an alias: this was a second, darker amber that existed only because
     `--warn` could not be printed as text. It can now. */
  --warn-ink: var(--warn);

  /* --- The category palette ------------------------------------------------

     A code's *type* is not a state - a wifi code is not worse than a link - so
     these are seven hues of equal weight rather than a scale, and none of them
     is the red that means something has gone wrong.

     The eighteen content types collapse into seven families, because eighteen
     hues is not a palette, it is a colour wheel: nobody can hold eighteen
     colour-to-meaning pairs, and the differences between them would be smaller
     than the differences between two screens. A family is what the eye actually
     uses when scanning a column - "the messaging ones", "the social ones" - and
     `apps/codes/content_types.py` is where a type is assigned to one.

     Every hue clears 4.5:1 on its own chip, because in a `.type-tag` the hue is
     the text as well as the ground, and every pair is at least dE2000 10 apart
     under normal vision. Both are held by `apps/core/tests/test_palette.py`.

     What is deliberately *not* claimed is the guarantee the chart's four hues
     carry, which is that the same separation survives the three dichromacies.
     Seven categories cannot have it - measured, and it is a fact about seven
     rather than about these seven: under deuteranopia the slate and the teal
     land 1.1 apart, and under tritanopia the blue and the teal do. Four or five
     hues is the practical ceiling for a set that has to survive every kind of
     vision on colour alone, and this set is seven because the families are
     seven.

     What makes that acceptable here and not on the chart: a type chip is never
     colour alone. It arrives with the type's own glyph and the type's name in
     words, and the colour is the third channel - the one that makes a column
     scannable without reading, for the readers who can use it. The chart is the
     opposite case, four lines crossing in a plot with the words in a key above,
     which is why the four were searched for and these seven were chosen.

     Ordered as the builder orders them: what a code points at, then who it
     reaches, then where and when, then getting in, then paying. */
  --cat-link: #2f6094;      /* url - the brand blue: the default, and the commonest */
  --cat-text: #4b5563;      /* text - slate, because plain text has no colour of its own */
  --cat-contact: #0d6b6b;   /* email, phone, sms, whatsapp, telegram, vcard */
  --cat-social: #6d3fa0;    /* instagram, facebook, x, linkedin, youtube, tiktok */
  --cat-place: #8a5600;     /* geo, event - where and when */
  --cat-access: #a3286a;    /* wifi - the one type that lets somebody in */
  --cat-pay: #166b3f;       /* paypal */

  /* --- The chart palette ---------------------------------------------------

     Four series on one plot, and this is the one place in the product where a
     colour is close to the only channel: four lines crossing one another in a
     150px-tall plot, named once in a key above it. So these four are the only
     colours here that were *searched for* rather than chosen, and
     `apps/core/tests/test_palette.py` holds them to what the search was asked
     for - every pair at least dE2000 10 apart under normal vision and under
     protanopia, deuteranopia and tritanopia, and every one of them at least 3:1
     on the card, which is the floor for a 2px stroke and a 7px marker.

     They still mean what they meant: blue is what was generated, green what is
     still kept, red the only loss on the chart, violet the downloads. The
     search moved each within 18 degrees of its own hue and was scored on how
     little it had to move - a palette maximally separated is a lime green and a
     dusty pink, which is legible and says nothing.

     Slot 1 is deliberately *not* `--brand`. The brand blue and a violet
     collapse into one colour under protanopia (dE 7.7), and the blue and the
     green collapse under tritanopia (dE 2.6, and dE 0.0 in the palette this
     replaced) - blue and green differ only on the axis tritanopia removes, so
     the fix is to separate them in lightness, which is a thing a chart hue may
     do and a brand colour may not. */
  --viz-cat-1: #024e94;
  --viz-cat-2: #22794d;
  --viz-cat-3: #b3201b;
  --viz-cat-4: #805fc4;

  /* --- Paper ---------------------------------------------------------------

     A rendered code is a picture of something that will be printed, and a
     printed code is whatever colours it was built in - on a sheet that is white
     in either theme. That is why the look, frame and logo tiles sit on `--paper`
     and why the dark theme deliberately does *not* redefine it: inverting the
     ground under a QR tile would not be a dark mode, it would be a claim about
     the code. `--preview-mat` is set inline by builder.js from the code's own
     background colour and is the same idea. */
  --paper: #ffffff;

  --radius: 12px;
  --radius-sm: 8px;
  --gap: 18px;
  --shadow: 0 1px 2px rgb(var(--shadow-rgb) / 0.04), 0 1px 3px rgb(var(--shadow-rgb) / 0.06);

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

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

body {
  margin: 0;
  font-family: var(--font);
  font-size: 15px;
  line-height: 1.5;
  color: var(--ink);
  background: var(--canvas);
}

h1 { font-size: 1.6rem; margin: 0 0 4px; }
h2 { font-size: 1.05rem; margin: 0; }
h3 { font-size: 1rem; margin: 0 0 4px; }
a { color: var(--brand); text-decoration: none; }
a:hover { text-decoration: underline; }
p { margin: 0 0 10px; }

.muted { color: var(--ink-muted); }
.tiny { font-size: 0.8rem; }
.strong { font-weight: 600; }
.nowrap { white-space: nowrap; }
.center { text-align: center; }
.break { word-break: break-all; }
.mono { font-family: var(--mono); font-size: 0.94em; }
.mb-1 { margin-bottom: 8px; }
.mb-2 { margin-bottom: 16px; }
.mb-3 { margin-bottom: 24px; }
.mt-1 { margin-top: 8px; }
.mt-2 { margin-top: 16px; }

/* --- Icons ------------------------------------------------------------------
   overflow: visible is not decoration. Most Bootstrap glyphs are drawn flush to
   the edge of their 0 0 16 16 viewBox, and an <svg> clips at its viewport by
   default - so at a fractional em size the outermost sliver of a circle is cut
   rather than antialiased, and a ring loses its bottom edge. */
.icon {
  width: 1em;
  height: 1em;
  fill: currentColor;
  flex: none;
  vertical-align: -0.125em;
  overflow: visible;
}
/* --- Brand -------------------------------------------------------------------
   The lockup is one image, so it is sized by height and left to work out its own
   width - the three files have different aspect ratios and hard-coding a width
   would squash whichever one is used next. */
.brand-logo { display: block; height: 40px; width: auto; max-width: 100%; }
.sidebar-brand .brand-logo { height: 38px; }
.auth-shell .brand-logo { height: 44px; margin: 0 auto; }
@media (max-width: 720px) { .brand-logo { height: 32px; } }

/* --- Buttons ---------------------------------------------------------------- */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
  padding: 9px 16px;
  border-radius: var(--radius-sm);
  border: 1px solid var(--line-strong);
  background: var(--surface);
  color: var(--ink);
  font: inherit;
  font-size: 0.89rem;
  font-weight: 550;
  cursor: pointer;
  white-space: nowrap;
  transition: background 0.12s ease, border-color 0.12s ease;
}
/* `--brand-light` rather than `--brand-tint`, and the border moves with it. The
   tint is 5% of the brand over the button's own white, which is a 1.07x change
   on light and 1.11x on dark - a hover you have to look for to confirm. The
   light stop is 1.17x and 1.27x, and the edge picking up the brand is what
   makes a small button read as hovered at a glance rather than on inspection. */
.btn:hover {
  background: var(--brand-light);
  border-color: color-mix(in srgb, var(--brand) 40%, var(--line-strong));
  text-decoration: none;
}
.btn:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
.btn[disabled] { opacity: 0.5; cursor: not-allowed; }
/* Both ends of the gradient clear 6.5:1 against the white label, so the sheen
   costs nothing in legibility. */
.btn-primary {
  background: linear-gradient(180deg, var(--brand), var(--brand-dark));
  border-color: var(--brand-dark); color: var(--on-brand);
}
.btn-primary:hover { background: var(--brand-dark); border-color: var(--brand-dark); }
.btn-ghost { border-color: transparent; background: transparent; }
.btn-danger { color: var(--danger); border-color: var(--danger-line); }
.btn-danger:hover { background: var(--danger-bg); }
.btn-block { width: 100%; }
.btn-sm { padding: 5px 10px; font-size: 0.82rem; }
.btn-icon {
  padding: 6px; width: 30px; height: 30px;
  color: var(--ink-muted); border-color: transparent; background: transparent;
}
.btn-icon:hover {
  background: var(--brand-light); color: var(--brand);
  border-color: color-mix(in srgb, var(--brand) 40%, var(--line-strong));
}
.btn-icon-danger:hover {
  background: var(--danger-bg); color: var(--danger);
  border-color: color-mix(in srgb, var(--danger) 40%, var(--line-strong));
}
.row-actions { display: inline-flex; gap: 2px; align-items: center; }

/* --- Cookie notice ----------------------------------------------------------
   A bar across the bottom until a choice is made. Both buttons are the same
   size and weight: an "accept" three times the size of a "reject", or a reject
   hidden behind a second screen, is the pattern regulators object to, and the
   only reason to build the gate in `core.consent` was to mean it.

   Fixed rather than sticky, and above everything except a modal - it is the one
   thing on the page that has to be answered before the rest matters. */
.cookie-notice {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 60;
  background: var(--surface);
  border-top: 1px solid var(--line-strong);
  box-shadow: 0 -6px 28px rgb(var(--shadow-rgb) / 0.09);
}
.cookie-inner {
  max-width: 1120px; margin: 0 auto; padding: 14px 24px;
  display: flex; align-items: center; justify-content: space-between; gap: 22px;
}
.cookie-text strong { display: block; font-size: 0.92rem; margin-bottom: 2px; }
/* Capped in characters rather than pixels: it is a paragraph, and a line of
   more than about 70 characters is one nobody finishes. */
.cookie-text p { margin: 0; font-size: 0.84rem; color: var(--ink-muted); max-width: 68ch; }
.cookie-actions { display: flex; align-items: center; gap: 10px; flex: none; }
.cookie-actions form { display: flex; gap: 8px; margin: 0; }

/* --- The privacy card on that bar -------------------------------------------
   The short version of the notice, without leaving the page. */
.privacy-hover { position: relative; }
.privacy-trigger {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 7px 12px;
  border: 1px solid var(--line-strong); border-radius: var(--radius-sm);
  background: var(--surface);
  font: inherit; font-size: 0.84rem; color: var(--ink-muted);
  cursor: pointer; white-space: nowrap;
}
.privacy-trigger:hover { color: var(--brand); border-color: var(--brand); }
.privacy-trigger:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
.privacy-trigger .icon { width: 13px; height: 13px; }

/* Anchored to the trigger's *right* edge, not its left. The trigger sits at the
   right end of the bar, so a 420px card opening rightwards runs off the side of
   the window - which is a poor thing for a privacy notice to do. Opening
   leftwards puts it over the page, where there is room. The narrow-screen rule
   further down stops anchoring it to the trigger at all. */
.privacy-card {
  position: absolute; bottom: calc(100% + 10px); right: 0; left: auto;
  width: min(420px, calc(100vw - 40px));
  max-height: min(60vh, 520px); overflow-y: auto;
  padding: 18px 20px;
  background: var(--surface);
  border: 1px solid var(--line-strong); border-radius: var(--radius);
  box-shadow: 0 18px 44px rgb(var(--shadow-rgb) / 0.16);
  opacity: 0; visibility: hidden; transform: translateY(4px);
  transition: opacity 0.16s ease, transform 0.16s ease, visibility 0.16s;
}
/* Hover for a mouse, focus-within for a keyboard or a tap. Hover alone leaves
   the card unreachable for both. */
.privacy-hover:hover .privacy-card,
.privacy-hover:focus-within .privacy-card {
  opacity: 1; visibility: visible; transform: translateY(0);
}
.privacy-card h3 { font-size: 0.95rem; margin: 0 0 12px; }
.privacy-card dl { margin: 0; }
.privacy-card dt {
  font-size: 0.78rem; font-weight: 680; text-transform: uppercase;
  letter-spacing: 0.04em; color: var(--ink-faint); margin-top: 12px;
}
.privacy-card dt:first-of-type { margin-top: 0; }
.privacy-card dd { margin: 3px 0 0; font-size: 0.85rem; line-height: 1.5; }
.privacy-foot {
  margin: 14px 0 0; padding-top: 12px;
  border-top: 1px solid var(--line);
  font-size: 0.78rem; color: var(--ink-faint);
}

@media (max-width: 760px) {
  /* The bar stacks: on a phone the paragraph needs the whole width, and the
     two buttons need to be big enough to hit. */
  .cookie-inner { flex-direction: column; align-items: stretch; gap: 12px; padding: 14px 16px; }
  .cookie-actions { width: 100%; flex-wrap: wrap; gap: 8px; }
  .cookie-actions form { flex: 1 1 100%; }
  /* Equal halves, and full height: a reject that is harder to hit than an
     accept is the same dark pattern as one that is harder to find. */
  .cookie-actions form .btn { flex: 1 1 0; justify-content: center; min-height: 42px; }
  .privacy-trigger { width: 100%; justify-content: center; min-height: 40px; }

  /* The card spans the bar rather than hanging off its trigger. Anchored to the
     trigger it would run off the side of a narrow screen - half a privacy
     notice being a poor thing for a privacy notice to be. `.privacy-hover`
     gives up `position: relative`, which hands the containing block to
     `.cookie-notice`: itself fixed and full width, so these gutters are gutters
     against the viewport, and `bottom: 100%` clears the whole bar however many
     rows it has wrapped to. */
  .privacy-hover { position: static; }
  .privacy-card {
    left: 12px; right: 12px; width: auto;
    bottom: calc(100% + 10px);
    /* Shorter, because the bar under it is taller once it stacks. The card
       scrolls inside itself, so this trims what is visible, not what is there. */
    max-height: 50vh;
  }
}

/* --- Layout ----------------------------------------------------------------- */
/* The shell's top bar is sticky, so everything sticky inside the shell starts
   below it. Without this the builder's preview pane slid up behind the bar and
   the code - the one thing on that page worth looking at - was the part that
   went under it. */
/* The sidebar's width and the bar's height as tokens, so the two numbers the
   shell is built from are named rather than repeated. */
:root { --sidebar-width: 232px; --topbar-height: 63px; }
.shell {
  --sticky-top: var(--topbar-height);
  display: grid; grid-template-columns: var(--sidebar-width) minmax(0, 1fr); min-height: 100vh;
}
.sidebar {
  background: var(--surface);
  border-right: 1px solid var(--line);
  display: flex; flex-direction: column;
}
/* The one corner of the column that is not a list, so it takes the same ruled
   grain the stat tiles and the plan cards carry - faded from the corner
   outwards, over nothing anybody has to read. */
.sidebar-brand {
  position: relative; overflow: hidden;
  padding: 16px 18px; border-bottom: 1px solid var(--line);
  background: linear-gradient(180deg, var(--brand-tint), var(--surface) 78%);
}
.sidebar-brand::after {
  content: ""; position: absolute; inset: 0 0 auto auto; width: 120px; height: 120px;
  background-image: repeating-linear-gradient(135deg, var(--brand-wash) 0 1px, transparent 1px 7px);
  -webkit-mask-image: radial-gradient(88px 88px at 100% 0, #000 6%, transparent 66%);
  mask-image: radial-gradient(88px 88px at 100% 0, #000 6%, transparent 66%);
  pointer-events: none;
}
.sidebar-brand > * { position: relative; z-index: 1; }
.sidebar-brand a { display: block; }

.sidebar-nav { padding: 8px 12px 16px; flex: 1; overflow-y: auto; }

/* A rule above each group but the first, so the column reads as two groups
   rather than as one list with a word dropped into it. The rule belongs to the
   heading rather than to the link under it, because the heading is what moves
   if a group is ever added. */
.nav-section {
  font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.1em;
  color: var(--ink-faint); padding: 10px 8px 8px; font-weight: 650;
}
.nav-section:not(:first-child) {
  margin-top: 10px; padding-top: 14px; border-top: 1px solid var(--line-soft);
}
/* Kept: `.nav-group` was the old name for the same thing and may still be in a
   template somewhere. */
.nav-group { font-size: 0.68rem; letter-spacing: 0.08em; text-transform: uppercase;
  color: var(--ink-faint); padding: 14px 10px 6px; }

.nav-link {
  position: relative;
  display: flex; align-items: center; gap: 11px;
  padding: 5px 10px 5px 8px; border-radius: 9px;
  color: var(--ink-muted); font-size: 0.92rem; font-weight: 500;
  margin-bottom: 2px;
  transition: background 0.14s ease, color 0.14s ease;
}

/* The glyph's chip. Transparent at rest so the column stays quiet, tinted under
   the pointer, and filled on the page you are on - which is what lets the
   active row be obvious without the whole strip going solid. It also gives the
   column a left edge of evenly spaced squares to read down, instead of four
   glyphs of four different widths. */
.nav-ico {
  display: inline-flex; align-items: center; justify-content: center; flex: none;
  width: 28px; height: 28px; border-radius: 8px; background: transparent;
  transition: background 0.14s ease, color 0.14s ease;
}
.nav-text { min-width: 0; }
.nav-link .icon { flex: none; width: 15px; height: 15px; opacity: 1; }

.nav-link:hover { background: var(--brand-tint); color: var(--brand); text-decoration: none; }
.nav-link:hover .nav-ico { background: var(--brand-light); color: var(--brand); }
.nav-link:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }

.nav-link.is-active { background: var(--brand-light); color: var(--brand); font-weight: 650; }
.nav-link.is-active .nav-ico { background: var(--brand); color: var(--on-brand); }

/* The marker for the page you are on, in the same language the tables use for a
   row's state: a short accent bar on the leading edge. Drawn into the sidebar's
   own padding so it sits against the column edge rather than inside the pill. */
.nav-link.is-active::before {
  content: ""; position: absolute; left: -12px; top: 50%;
  transform: translateY(-50%);
  width: 3px; height: 22px; border-radius: 0 3px 3px 0;
  background: var(--brand);
}

/* An entry the tier does not carry. Still legible - it is an advertisement for
   the tier that unlocks it - but it must not read as somewhere you can go, and
   it must not read as *louder* than the entries that are open. Faint ink and a
   faint glyph; deliberately no chip fill, which would make the one thing you
   cannot use the most solid mark in the column. */
.nav-link.is-locked { color: var(--ink-faint); }
.nav-link.is-locked .nav-ico { background: transparent; opacity: 0.75; }
.nav-link.is-locked:hover { background: var(--neutral-bg); color: var(--ink-muted); }
.nav-link.is-locked:hover .nav-ico { background: var(--neutral-bg); opacity: 1; }
.nav-lock { margin-left: auto; display: inline-flex; color: var(--ink-faint); }
.nav-lock .icon { width: 12px; height: 12px; }
.plan-chip {
  display: block; padding: 9px 11px; border-radius: var(--radius-sm);
  background: linear-gradient(135deg, var(--brand-light), var(--brand-tint));
  border: 1px solid var(--brand-edge);
  margin-bottom: 8px;
}
.plan-chip strong { color: var(--brand); }
.plan-chip .icon { margin-right: 6px; opacity: 0.6; }

.main { display: flex; flex-direction: column; min-width: 0; }
/* Sticky, because it now carries the plan and the way out. Above the date
   picker's popup, which is the only other thing on a page that floats -
   messages are in the flow of the page and float over nothing.

   Anything else on the page that sticks has to start below it - the builder's
   preview is the one that does - so the bar's height is a variable rather than
   a number buried in two places. It is measured, not guessed: 12px of padding
   top and bottom around a 39px row. Change the padding here and change it
   there. */
.topbar {
  position: sticky; top: 0; z-index: 30;
  display: flex; align-items: center; justify-content: space-between;
  gap: 12px; padding: 12px 24px;
  background: var(--surface); border-bottom: 1px solid var(--line);
}
.topbar-title { font-weight: 600; }
.topbar-actions { display: flex; align-items: center; gap: 10px; }

/* --- The account menu --------------------------------------------------------
   Who, which tier, and the way out - folded into one control, because a bar
   carrying all three side by side is three things to read before the page. */
.account { position: relative; }
.account > summary {
  display: inline-flex; align-items: center; gap: 8px;
  padding: 5px 9px 5px 5px; border: 1px solid var(--line); border-radius: 999px;
  background: var(--surface); cursor: pointer; list-style: none;
  transition: border-color 0.12s ease, background 0.12s ease;
}
/* Safari and Chrome each draw their own marker on a <summary>; both are the
   wrong triangle in the wrong place next to a caret of our own. */
.account > summary::-webkit-details-marker { display: none; }
.account > summary::marker { content: ""; }
.account > summary:hover { background: var(--brand-tint); border-color: var(--brand-light); }
.account[open] > summary { background: var(--brand-tint); border-color: var(--brand-edge-strong); }
.account > summary:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
/* The initial, which is identity enough at this size - an address in a bar is
   read once and then only ever takes up room. It is in the panel below. */
.account-mark {
  display: inline-flex; align-items: center; justify-content: center;
  width: 26px; height: 26px; border-radius: 50%;
  background: linear-gradient(135deg, var(--brand), var(--brand-dark));
  color: var(--on-brand); font-size: 0.78rem; font-weight: 650;
}
.account-plan { font-size: 0.82rem; font-weight: 600; color: var(--ink); }
.account-caret { width: 11px; height: 11px; color: var(--ink-faint); }
.account[open] .account-caret { transform: rotate(180deg); }

.account-panel {
  position: absolute; top: calc(100% + 8px); right: 0; z-index: 40;
  min-width: 244px; padding: 6px;
  background: var(--surface); border: 1px solid var(--line-strong);
  border-radius: var(--radius); box-shadow: 0 12px 28px rgb(var(--shadow-rgb) / 0.16);
}
.account-who {
  display: grid; gap: 1px; padding: 9px 10px 11px;
  border-bottom: 1px solid var(--line); margin-bottom: 6px;
}
.account-who strong { font-size: 0.9rem; }
.account-who .tiny { overflow: hidden; text-overflow: ellipsis; }
.account-item {
  display: flex; align-items: center; gap: 10px; width: 100%;
  padding: 8px 10px; border: none; border-radius: var(--radius-sm);
  background: none; color: var(--ink); font: inherit; font-size: 0.88rem;
  text-align: left; cursor: pointer;
}
.account-item:hover { background: var(--brand-tint); text-decoration: none; }
/* Where you are. The sidebar used to say it; these three pages left it when
   they moved into this menu, and a menu that opens on the page you are already
   looking at should admit as much. */
.account-item.is-active { background: var(--brand-light); color: var(--brand); font-weight: 600; }
.account-item.is-active .icon { color: var(--brand); }
.account-item .icon { color: var(--ink-faint); flex: none; }
.account-item:hover .icon { color: var(--brand); }
/* The tier sits at the end of its own row, so the row reads as a fact with a
   link on it rather than as a menu entry that happens to have a word after. */
.account-value { margin-left: auto; color: var(--brand); }
.account-signout { border-top: 1px solid var(--line); border-radius: 0 0 var(--radius-sm) var(--radius-sm); margin-top: 6px; padding-top: 11px; }
.account-signout:hover { background: var(--danger-bg); color: var(--danger); }
.account-signout:hover .icon { color: var(--danger); }

/* On a phone the tier drops off the button; the initial and the caret are
   enough to say what it opens, and the panel still names it. */
@media (max-width: 720px) { .account-plan { display: none; } }
/* The signed-in content column is capped, not stretched. Copied from the
   invoices project, including the two widths and the left alignment: a table or
   a four-card pricing row pulled across a 2560px monitor puts the row label and
   its number so far apart that reading one off the other needs a ruler.

   Which cap a page gets is chosen by `content_class` in core/base_app.html -
   wide for anything with a table or a grid, narrow for a single form. */
.content { padding: 24px; min-width: 0; }
.content-wide { max-width: 1200px; }
.content-narrow { max-width: 780px; }

.page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
.page-head h1 { display: flex; align-items: center; gap: 10px; }
.page-head h1 .icon { color: var(--ink-faint); }
.page-head .sub { color: var(--ink-muted); font-size: 0.9rem; }
/* Which subset of the codes is on screen, and the way back to all of them. */
.filter-chip {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 3px 9px; border-radius: 999px;
  background: var(--brand-tint); border: 1px solid var(--brand-edge);
  color: var(--brand); font-size: 0.82rem; font-weight: 550;
}
.filter-clear { margin-left: 10px; font-size: 0.82rem; }
.page-actions { display: flex; gap: 8px; }

/* --- Cards and grids -------------------------------------------------------- */
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
.card + .card { margin-top: var(--gap); }
/* Capped, but not centred: inside the shell a narrow card floated in the middle
   of the column with its own page heading still hard against the left, which
   reads as two pages. The sign-in and sign-up cards are unaffected - they sit
   in `.auth-shell`, which is what centres them there. */
.card.narrow { max-width: 460px; }
/* The head takes a wash of the brand, fading out before the border. Shallow on
   purpose: it is there to separate the head from the body without a second
   line, not to be a coloured bar. */
.card-head {
  display: flex; align-items: center; justify-content: space-between;
  gap: 12px; padding: 15px 18px; border-bottom: 1px solid var(--line);
  background: linear-gradient(180deg, var(--brand-tint), var(--surface));
  border-radius: var(--radius) var(--radius) 0 0;
}
.card-body { padding: 18px; }
.card-body.tight { padding: 0; }

/* A card that opens and closes. Only where one is written as a `<details>` -
   every other card on the site keeps a `<div class="card-head">` and none of
   these rules reach it, which is why they are scoped to the element rather than
   added to `.card-head` itself.

   The head was already a labelled strip over a panel, so it needs nothing to
   become the strip you click: the caret and the pointer are the whole change.
   See the menu editor, where the right-hand column is three of these. */
details.card > .card-head {
  cursor: pointer; list-style: none;
  border-radius: var(--radius) var(--radius) 0 0;
  transition: background 0.15s ease;
}
details.card > .card-head::-webkit-details-marker { display: none; }
details.card > .card-head:hover { background: linear-gradient(180deg, var(--brand-light), var(--brand-tint)); }
details.card > .card-head:focus-visible { outline: 2px solid var(--brand); outline-offset: -2px; }
/* Closed, the head *is* the card, so its lower corners have to be the card's -
   and the rule under it is the card's own border drawn twice. */
details.card:not([open]) > .card-head { border-bottom: none; border-radius: var(--radius); }
.card-caret {
  flex: none; width: 15px; height: 15px; color: var(--ink-faint);
  transition: transform 0.18s ease;
}
details.card[open] > .card-head .card-caret { transform: rotate(180deg); }
@media (prefers-reduced-motion: reduce) {
  details.card > .card-head, .card-caret, .group-title, .group-caret { transition: none; }
}

.grid { display: grid; gap: var(--gap); }
/* minmax(0, 1fr) rather than an implicit `auto` column: an auto column will not
   shrink below its content's minimum, so a card holding a wide table grows past
   the page instead of letting .table-wrap scroll inside it. */
.grid-1 { grid-template-columns: minmax(0, 1fr); }
.grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.grid-stats { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.grid > .card + .card { margin-top: 0; }

/* --- Stats ------------------------------------------------------------------ */
/* A tile carries one number, so everything else in it is kept quiet: a tinted
   chip for the icon, a wash that fades out before it reaches the figure, and
   the QR checkerboard from the plan cards in the corner. The texture is the one
   piece of decoration, at the same weight it has there - it should register as
   grain, not as a pattern anybody tries to read. */
.stat {
  position: relative; overflow: hidden;
  border: 1px solid var(--line); border-radius: var(--radius);
  padding: 16px 18px; box-shadow: var(--shadow);
  background: linear-gradient(180deg, var(--brand-tint), var(--surface) 62%);
  transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
}
/* The tiles are links to the rows they counted, so they inherit the page's ink
   rather than the link colour - the number is the subject and a card of blue
   figures reads as a menu. The lift on hover was already here and is now doing
   a second job: saying the tile can be followed. */
.stat { display: block; color: inherit; text-decoration: none; }
/* No underline on hover either: `a:hover` underlines every link on the page,
   which on a card means a line under the label and another under the note. The
   lift and the border are the hover state here. */
.stat:hover { text-decoration: none; }
.stat:hover { transform: translateY(-2px); box-shadow: 0 8px 20px rgb(var(--shadow-rgb) / 0.08); border-color: var(--brand); }
.stat:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
.stat::after {
  content: ""; position: absolute; inset: 0 0 auto auto; width: 132px; height: 132px;
  background-image: repeating-conic-gradient(var(--brand-wash) 0% 25%, transparent 0% 50%);
  background-size: 9px 9px;
  /* Faded from the corner outwards, so the pattern has reached nothing by the
     time it meets an edge - a linear fade leaves it looking like a pasted
     rectangle. Same mask as `.plan-card .card-head::after`. */
  -webkit-mask-image: radial-gradient(96px 96px at 100% 0, #000 6%, transparent 66%);
  mask-image: radial-gradient(96px 96px at 100% 0, #000 6%, transparent 66%);
  pointer-events: none;
}
/* Everything real sits over the texture. */
.stat > * { position: relative; z-index: 1; }

.stat-label {
  display: flex; align-items: center; gap: 9px;
  font-size: 0.7rem; letter-spacing: 0.07em; text-transform: uppercase; color: var(--ink-faint);
}
.stat-icon {
  display: inline-flex; align-items: center; justify-content: center; flex: none;
  width: 28px; height: 28px; border-radius: 9px;
  background: var(--brand-light); color: var(--brand);
}
.stat-icon .icon { width: 15px; height: 15px; opacity: 1; }
.stat-value { font-size: 1.8rem; font-weight: 650; margin: 6px 0 2px; }
.stat-value .of-limit { font-size: 1rem; color: var(--ink-faint); font-weight: 500; }
.stat-note { font-size: 0.8rem; color: var(--ink-muted); }

/* The track is a lighter step of the fill's own colour rather than a grey, so
   the whole bar reads as one scale in whichever state it is in. */
/* The quota meter. Thicker than it was, inset so the empty part reads as a
   channel rather than as a pale line, and the fill carries a gradient along its
   own length - which is what makes a bar look like a level rather than a rule.
   The rounded cap on the fill means a tiny value still draws as a mark instead
   of a sliver, so `min-width` keeps it from becoming a dot. */
.meter {
  height: 9px; border-radius: 999px; margin: 10px 0 8px;
  background: var(--brand-light); overflow: hidden;
  box-shadow: inset 0 1px 2px rgb(var(--shadow-rgb) / 0.12);
}
.meter span {
  display: block; height: 100%; min-width: 9px; border-radius: 999px;
  background: linear-gradient(90deg, color-mix(in srgb, var(--brand) 72%, white), var(--brand));
  box-shadow: 0 1px 2px rgb(var(--shadow-rgb) / 0.18);
  transition: width 0.35s ease;
}

/* Nearly out, and out. Only this tile ever takes them, and it takes the chip
   and the meter together - a warning in one of the two reads as a mistake. */
.stat.is-high { border-color: color-mix(in srgb, var(--warn) 35%, transparent); background: linear-gradient(180deg, color-mix(in srgb, var(--warn) var(--tint-2), transparent), var(--surface) 62%); }
.stat.is-high .stat-icon { background: var(--warn-bg); color: var(--warn); }
.stat.is-high .meter { background: color-mix(in srgb, var(--warn) 15%, transparent); }
.stat.is-high .meter span {
  background: linear-gradient(90deg, color-mix(in srgb, var(--warn) 70%, white), var(--warn));
}
.stat.is-high::after { background-image: repeating-conic-gradient(color-mix(in srgb, var(--warn) var(--chip-mix), transparent) 0% 25%, transparent 0% 50%); }

.stat.is-full { border-color: color-mix(in srgb, var(--danger) 40%, transparent); background: linear-gradient(180deg, var(--danger-bg), var(--surface) 62%); }
.stat.is-full .stat-icon { background: var(--danger-bg); color: var(--danger); }
.stat.is-full .stat-value { color: var(--danger); }
.stat.is-full .meter { background: color-mix(in srgb, var(--danger) 15%, transparent); }
.stat.is-full .meter span {
  background: linear-gradient(90deg, color-mix(in srgb, var(--danger) 70%, white), var(--danger));
}
.stat.is-full::after { background-image: repeating-conic-gradient(color-mix(in srgb, var(--danger) var(--chip-mix), transparent) 0% 25%, transparent 0% 50%); }
/* A shape or a look the plan cannot draw. Shown rather than removed - a picker
   with one tile in it says nothing about what the other tiers are for - and the
   padlock is what separates "not available" from "broken". */
.shape-card.is-locked, .template-card.is-locked { cursor: not-allowed; }
.shape-card.is-locked .shape-sample, .template-card.is-locked .swatch { opacity: 0.45; }
.shape-card.is-locked .shape-name, .template-card.is-locked .template-name { color: var(--ink-faint); }
.shape-card.is-locked:hover .shape-sample, .template-card.is-locked:hover .swatch { border-color: var(--line); }
.shape-sample, .template-card .swatch { position: relative; }
.lock {
  position: absolute; right: -5px; bottom: -5px;
  display: inline-flex; align-items: center; justify-content: center;
  width: 19px; height: 19px; border-radius: 50%;
  background: var(--surface); color: var(--ink-muted);
  box-shadow: 0 1px 3px rgb(var(--shadow-rgb) / 0.22);
}
.lock .icon { width: 10px; height: 10px; }

/* The plan chip, in a header rather than in the sidebar. */
.plan-chip-inline {
  display: inline-flex; align-items: center; gap: 7px; margin-bottom: 0;
  padding: 7px 11px; color: var(--ink); white-space: nowrap;
}
.plan-chip-inline:hover { text-decoration: none; border-color: var(--brand-edge-strong); }
.plan-chip-inline .icon { color: var(--brand); }
.plan-chip-inline strong { color: var(--brand); }
.public-actions form { display: inline-flex; }

/* --- Date picker -------------------------------------------------------------
   The popup for `core/js/datepicker.js`. Anchored to the field rather than the
   page, so it follows the input inside a scrolling step; `z-index` clears the
   fields under it and nothing else. */
.datepicker-wrap { position: relative; }
/* The calendar button, inside the field's own box. `pointer-events` stay on it
   alone - the input keeps the rest of the width, so clicking the text still
   puts the caret where it was clicked. */
.datepicker-wrap .control { padding-right: 38px; }
.datepicker-toggle {
  position: absolute; top: 50%; right: 6px; transform: translateY(-50%);
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; padding: 0;
  border: none; border-radius: var(--radius-sm);
  background: transparent; color: var(--ink-faint); cursor: pointer;
}
.datepicker-toggle:hover { background: var(--brand-light); color: var(--brand); }
.datepicker-toggle .icon { width: 16px; height: 16px; }
.datepicker {
  position: absolute; top: calc(100% + 6px); left: 0; z-index: 20;
  width: 268px; padding: 12px;
  background: var(--surface); border: 1px solid var(--line-strong);
  border-radius: var(--radius); box-shadow: 0 10px 26px rgb(var(--shadow-rgb) / 0.14);
}
.datepicker-head {
  display: flex; align-items: center; justify-content: space-between;
  gap: 8px; margin-bottom: 8px; font-size: 0.9rem;
}
.datepicker-head strong { text-transform: capitalize; }
/* Two steps each side: a month, and a year. The year arrows are the double
   chevrons, which is the one pairing people already read as "further". */
.datepicker-navs { display: inline-flex; gap: 2px; }
.datepicker-nav {
  display: inline-flex; align-items: center; justify-content: center;
  border: 1px solid var(--line); background: var(--surface); color: var(--ink-muted);
  width: 26px; height: 26px; border-radius: var(--radius-sm);
  font: inherit; line-height: 1; cursor: pointer;
}
.datepicker-nav .icon { width: 13px; height: 13px; }
.datepicker-nav:hover { background: var(--brand-tint); color: var(--brand); border-color: var(--brand-light); }

/* Seven columns, so the weekday row and the days share one grid and a month
   that starts on a Thursday cannot drift out of line with its own header. */
.datepicker-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
.datepicker-weekday {
  text-align: center; padding: 4px 0; font-size: 0.66rem; font-weight: 600;
  letter-spacing: 0.02em; text-transform: uppercase; color: var(--ink-faint);
}
.datepicker-day {
  padding: 6px 0; border: none; border-radius: var(--radius-sm);
  background: transparent; color: var(--ink); font: inherit; font-size: 0.82rem;
  cursor: pointer; font-variant-numeric: tabular-nums;
}
.datepicker-day:hover { background: var(--brand-tint); }
/* Today is outlined, the chosen day is filled: one is a reference point and the
   other is an answer, and they have to be told apart when they are the same day. */
.datepicker-day.is-today { box-shadow: inset 0 0 0 1px var(--line-strong); }
.datepicker-day.is-chosen { background: var(--brand); color: var(--on-brand); font-weight: 600; }

.datepicker-time {
  display: flex; align-items: center; gap: 6px;
  margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--line);
}
.datepicker-time select {
  flex: 1; padding: 6px 8px; font: inherit; font-size: 0.85rem;
  border: 1px solid var(--line-strong); border-radius: var(--radius-sm);
  background: var(--surface); color: var(--ink);
}
.datepicker-colon { color: var(--ink-faint); }
.datepicker-time > .icon { color: var(--ink-faint); flex: none; }
/* One button, and it confirms. Emptying the field is what the keyboard is for
   - a Clear beside a Done is a second decision on a row that has one. */
.datepicker-done {
  display: inline-flex; align-items: center; justify-content: center; flex: none;
  width: 32px; height: 32px; padding: 0;
}
.datepicker-done .icon { width: 15px; height: 15px; }
.datepicker-time select { min-width: 0; }

/* --- Charts ------------------------------------------------------------------
   Two shapes: a line over twelve months, and a ranked bar per row. All of it is
   HTML apart from the line itself, which is one stretched <svg>. The
   percentages come from `core.services.dashboard`; everything about how big the
   result is lives here.

   One colour throughout. Both charts plot a single thing - a count - so a
   second hue would be decoration, and shading each bar by its own size would
   encode the length twice while saying nothing the length does not. Marks stay
   thin and the rules stay a step off the surface, so the data is the only part
   of the picture with any weight. */
.chart { --plot: 150px; --gutter: 30px; position: relative; padding-left: var(--gutter); }

/* The plot: the scale, and the line drawn over it. Sized and placed once here,
   so the SVG and the markers in the columns below share one coordinate space -
   both are positioned in percentages of this box.

   `z-index` is doing real work. The hover band is the background of a
   `.chart-slot`, and a slot is `position: relative` - so it is a positioned
   element that comes *after* this one in the document and was painting on top
   of it. An opaque band over the plot meant that hovering a month rubbed that
   month's slice of the chart out: both gridlines and all four series lines
   disappeared for as long as the pointer was there. The lines belong above the
   band, so the plot is lifted over it and the dots are lifted over the plot
   (see `.chart-dot`).

   Lifting it makes `pointer-events` necessary rather than optional: the plot
   covers every column exactly, so above them it would swallow the pointer and
   no column would ever see a hover at all. Nothing here needs the pointer - the
   whole thing is `aria-hidden` scenery. */
.chart-plot {
  position: absolute; left: var(--gutter); right: 0; top: 0; height: var(--plot);
  z-index: 1;
  pointer-events: none;
}
/* Each rule is a zero-height box pinned by its `bottom`, so the border lands
   exactly on the value it stands for; the figure hangs in the gutter beside it. */
.chart-rule { position: absolute; left: 0; right: 0; height: 0; border-top: 1px solid var(--line); }
.chart-rule.is-baseline { bottom: 0; border-top-color: var(--line-strong); }
.chart-rule b {
  position: absolute; right: calc(100% + 8px); top: -0.75em;
  font-size: 0.68rem; font-weight: 500; color: var(--ink-faint);
  font-variant-numeric: tabular-nums;
}

/* The line itself, and the wash under it. The wash is a tenth-opacity fill
   rather than a paler colour of its own, so it stays the line's own hue however
   the brand colour is changed. */
.chart-line { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
/* Four series on one plot. The colours are set once here and read by both the
   line and its dot through `data-series`, so the key, the strokes and the
   markers cannot drift apart - and the template names no colour at all.

   Deliberately not four shades of the brand: these are four different
   quantities, not four sizes of one, and a reader has to tell them apart at a
   glance in a 200px-tall plot. Deleted is the only one that is red, because it
   is the only one that is a loss. */
.chart {
  --series-generated: var(--viz-cat-1);
  --series-saved: var(--viz-cat-2);
  --series-deleted: var(--viz-cat-3);
  --series-downloaded: var(--viz-cat-4);
}

.chart-stroke {
  fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round;
}
.chart-stroke[data-series="generated"]  { stroke: var(--series-generated); }
.chart-stroke[data-series="saved"]      { stroke: var(--series-saved); }
.chart-stroke[data-series="deleted"]    { stroke: var(--series-deleted); }
.chart-stroke[data-series="downloaded"] { stroke: var(--series-downloaded); }

/* Kept as a rule rather than deleted with the markup: "saved" is a subset of
   "generated" and the two lines sit on top of one another whenever nothing has
   been deleted. Dashed, the one underneath still reads. */
.chart-stroke[data-series="saved"] { stroke-dasharray: 5 3; }

/* --- The key ----------------------------------------------------------------
   Above the plot: four lines need naming before they are read, not after. */
/* A grid, not a row. Each entry is now a name over a line of explanation, and
   four of those side by side in a flex row either overflowed the card or
   collapsed to one entry per line on a laptop. `auto-fit` puts all four across a
   wide card, two on a narrow one and one on a phone, without a breakpoint. */
.chart-key {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(210px, 100%), 1fr));
  gap: 10px 20px;
  list-style: none; margin: 0 0 16px; padding: 0;
  font-size: 0.8rem; color: var(--ink-muted);
}
/* The mark sits on the *name's* line rather than centred against a two-line
   block, which would leave it floating between the name and the gloss. */
.chart-key-item { display: flex; align-items: flex-start; gap: 8px; min-width: 0; }
.chart-key-text { display: grid; gap: 1px; min-width: 0; }
.chart-key-name { display: flex; align-items: baseline; gap: 7px; color: var(--ink); }
.chart-key-name b { color: var(--ink); font-variant-numeric: tabular-nums; }
/* The gloss. A step down and in the faint ink, because it is read once to learn
   what the line is and never again - the name and the figure are what the eye
   comes back to. */
.chart-key-hint {
  font-size: 0.72rem; line-height: 1.35; color: var(--ink-faint);
}
.chart-key-mark { width: 14px; height: 3px; border-radius: 2px; flex: none; margin-top: 0.55em; }
.chart-key-item[data-series="generated"] .chart-key-mark  { background: var(--series-generated); }
.chart-key-item[data-series="saved"] .chart-key-mark      { background: var(--series-saved); }
.chart-key-item[data-series="deleted"] .chart-key-mark    { background: var(--series-deleted); }
.chart-key-item[data-series="downloaded"] .chart-key-mark { background: var(--series-downloaded); }
/* The key sits outside `.chart`, so it carries the palette itself. */
.chart-key {
  --series-generated: var(--viz-cat-1);
  --series-saved: var(--viz-cat-2);
  --series-deleted: var(--viz-cat-3);
  --series-downloaded: var(--viz-cat-4);
}

/* No gap between the columns: they are hover targets sitting under the line,
   and a gap would shift each column's centre away from the point the line was
   drawn through. */
.chart-cols { display: flex; align-items: flex-end; list-style: none; margin: 0; padding: 0; }
/* `position: relative` so the month's readout can be placed against its own
   column rather than against the plot. */
.chart-col { flex: 1; min-width: 0; text-align: center; position: relative; }
/* `display: block` is load-bearing: the slot is a <span>, and height does
   nothing to an inline box - left inline it collapses, the months ride up over
   the plot and the line spills out of the card. */
.chart-slot { display: block; position: relative; height: var(--plot); }
/* The hit target is the whole month, not the marker - a marker is seven pixels
   across and seven pixels is not something to ask anyone to aim at. The band is
   what makes the target visible once the pointer is inside it.

   `--brand-light`, not `--brand-tint`. The tint measures 1.07 against the card
   in the light theme, which over a 150px block is not a highlight, it is a
   rumour; this is the same step the active sidebar entry wears. It can afford
   to be that strong now only because it sits *behind* the plot - before that it
   was covering the lines, and the fix for a band that hides the chart is not a
   fainter band. */
.chart-col:hover .chart-slot { background: var(--brand-light); }
.chart-col:hover .chart-dot { transform: translate(-50%, 50%) scale(1.35); }
/* The month under the band, so the highlight and its label read as one thing
   rather than as a stripe that happens to be above a word. */
.chart-col:hover .chart-tick { color: var(--brand); font-weight: 600; }
/* Pinned by its `bottom` in the same percentages the line was drawn from, then
   pulled back by half its own size so the point - not its corner - lands on the
   value. The ring is the surface colour: it keeps the marker legible where the
   line runs under it, and is what stops twelve dots reading as beads on a
   string. */
/* Smaller than they were: four markers can land on the same column, and eight
   pixels each turned a busy month into one blob. The ring in the surface colour
   is what keeps two that overlap readable as two. */
.chart-dot {
  position: absolute; left: 50%; width: 7px; height: 7px;
  transform: translate(-50%, 50%);
  border-radius: 50%; box-shadow: 0 0 0 2px var(--surface);
  transition: transform 0.12s ease;
  /* Above the plot, which is itself above the hover band. A marker sits *on* its
     line, and the ring that keeps two overlapping markers readable as two only
     works if the line is not drawn across it. */
  z-index: 2;
}
.chart-dot[data-series="generated"]  { background: var(--series-generated); }
.chart-dot[data-series="saved"]      { background: var(--series-saved); }
.chart-dot[data-series="deleted"]    { background: var(--series-deleted); }
.chart-dot[data-series="downloaded"] { background: var(--series-downloaded); }
.chart-tick {
  padding-top: 6px; font-size: 0.68rem; color: var(--ink-faint);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

/* --- What a month actually says ----------------------------------------------

   Every series for the month under the pointer, at once - the same reading the
   invoices project's chart gives, built in CSS rather than by a script. That is
   not a shortcut: this chart is server-rendered so that a dashboard's main
   picture does not vanish when a script fails, and putting the *numbers* behind
   a script would reintroduce that failure one layer down.

   It replaces a `title` attribute, which is where these four figures used to
   live and is the worst available place for them: about a second of delay
   before it appears, no styling, no theming, the wrong font, and four values
   run together on one line.

   Faded rather than hidden - `opacity`, not `visibility` or `display` - so the
   figures stay in the accessibility tree. A chart whose only text alternative
   is removed from the tree is a chart with no text alternative. */
.chart-tip {
  position: absolute;
  bottom: calc(100% - var(--plot) + 10px);
  z-index: 5;
  display: grid;
  grid-template-columns: auto auto 1fr;
  align-items: center;
  gap: 3px 8px;
  width: max-content;
  max-width: 15rem;
  padding: 9px 11px;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-sm);
  background: var(--surface);
  box-shadow: 0 8px 22px rgb(var(--shadow-rgb) / 0.18);
  text-align: left;
  font-size: 0.78rem;
  /* Never a hit target of its own: the column is what is being hovered, and a
     tooltip that can itself be hovered flickers at its own edge. */
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.12s ease;
}
.chart-col:hover .chart-tip { opacity: 1; }

/* Centred on its column, and pulled inside the card at the two ends - a
   centred readout on January hangs off the left of the chart, and on the newest
   month off the right, which is the one everybody looks at first. This is the
   CSS version of the flip the invoices tooltip does against the cursor. */
.chart-tip { left: 50%; transform: translateX(-50%); }
.chart-col:nth-child(-n+3) .chart-tip { left: 0; transform: none; }
.chart-col:nth-last-child(-n+3) .chart-tip { left: auto; right: 0; transform: none; }

.chart-tip-month {
  grid-column: 1 / -1;
  font-weight: 650; color: var(--ink);
  padding-bottom: 4px; margin-bottom: 2px;
  border-bottom: 1px solid var(--line);
}
/* Each row is three cells of the parent grid, so the labels and the figures
   line up in columns instead of each row setting its own widths. */
.chart-tip-row { display: contents; }
.chart-tip-mark { width: 12px; height: 3px; border-radius: 2px; }
.chart-tip-label { color: var(--ink-muted); white-space: nowrap; }
.chart-tip-value {
  justify-self: end; color: var(--ink);
  font-variant-numeric: tabular-nums; font-weight: 650;
}
/* The same `data-series` mechanism the key and the strokes use, so the swatch
   in the readout cannot drift from the line it names. */
.chart-tip-row[data-series="generated"] .chart-tip-mark  { background: var(--series-generated); }
.chart-tip-row[data-series="saved"] .chart-tip-mark      { background: var(--series-saved); }
.chart-tip-row[data-series="deleted"] .chart-tip-mark    { background: var(--series-deleted); }
.chart-tip-row[data-series="downloaded"] .chart-tip-mark { background: var(--series-downloaded); }

/* A ranked list: label, bar, count. The bar is scaled against the largest row,
   so the longest one always fills its track and the rest are read against it. */
.rank { list-style: none; margin: 0; padding: 0; }
.rank-row {
  display: grid; grid-template-columns: minmax(90px, 8rem) minmax(40px, 1fr) auto;
  align-items: center; gap: 12px; padding: 7px 0;
}
.rank-row + .rank-row { border-top: 1px solid var(--line); }
.rank-label {
  display: flex; align-items: center; gap: 8px; min-width: 0;
  font-size: 0.88rem; color: var(--ink-muted);
}
.rank-label .icon { flex: none; opacity: 0.55; }
.rank-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* A lighter step of the same colour as the bar, so an empty track still reads
   as part of the same scale rather than as a separate grey object. */
/* The same treatment on the ranked bars, so a bar is a bar wherever it appears:
   an inset channel, a fill lit along its length, and a cap that keeps the
   smallest row visible. */
.rank-track {
  display: block; height: 10px; border-radius: 999px;
  background: color-mix(in srgb, var(--cat) var(--light-mix), var(--surface));
  overflow: hidden;
  box-shadow: inset 0 1px 2px rgb(var(--shadow-rgb) / 0.10);
}
/* The bar is its type's own colour, not the brand's - a ranking of types is a
   ranking by category, and matching a word in the left column against a bar in
   the middle one is work the colour can do instead. `--cat` comes from
   `data-family` on the row and falls back to the brand for a ranking of
   something that is not a code. */
.rank-row { --cat: var(--brand); }
.rank-label .icon { color: var(--cat); opacity: 0.85; }
.rank-bar {
  display: block; height: 100%; min-width: 10px; border-radius: 999px;
  background: linear-gradient(90deg, color-mix(in srgb, var(--cat) 66%, white), var(--cat));
  box-shadow: 0 1px 2px rgb(var(--shadow-rgb) / 0.16);
}
/* The longest row is the scale everything else is read against, so it gets the
   full colour - the rows under it are the same ramp, only shorter. */
.rank-row:first-child .rank-bar {
  background: linear-gradient(90deg, var(--cat), color-mix(in srgb, var(--cat) 80%, black));
}
.rank-value { font-size: 0.88rem; font-variant-numeric: tabular-nums; }

/* --- Tables ----------------------------------------------------------------- */
.table-wrap { overflow-x: auto; }
table.data { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
table.data th .icon { opacity: 0.5; margin-right: 6px; vertical-align: -0.15em; }
table.data th {
  text-align: left; font-size: 0.7rem; letter-spacing: 0.06em; text-transform: uppercase;
  color: var(--brand); padding: 11px 14px; border-bottom: 1px solid var(--line);
  background: linear-gradient(180deg, var(--brand-light), var(--brand-tint));
}
table.data td { padding: 12px 14px; border-bottom: 1px solid var(--line-soft); }
/* Banded, so the eye can carry a value across a wide row without losing the
   line it is on. The band is very slight on purpose - a stripe strong enough to
   read as a colour turns the table into a pattern, and the hover state has to
   stay visible on top of it. */
/* The band is a translucent *image*, not a colour, and that is load-bearing.

   A row also carries a wash of its own state - see "Row state" - and the two
   have to be able to sit on one row. Painted as `background-color` the band
   would replace that wash on every second row, which is to say the table would
   report a menu as published or parked depending on where it happened to land
   in the list. As an image it lies over the colour, and both read. */
table.data tbody tr:nth-child(even) {
  background-image: linear-gradient(var(--row-band), var(--row-band));
}
/* A row lights up under the pointer, which is also what tells anyone the row is
   a thing rather than five separate cells. Stronger than the band, and it wins
   on both kinds of row - `background-color`, so it does not knock the band out
   from under itself. */
table.data tbody tr { transition: background-color 0.12s ease; }
table.data tbody tr:hover { background-color: var(--brand-tint); }
table.data tbody tr:last-child td { border-bottom: none; }
table.data .shrink { width: 1%; white-space: nowrap; }

/* --- What a cell is, said in the type ----------------------------------------
   Every column used to be the same 0.9rem grey, so a table read as a wall and
   the one value somebody came for had to be hunted. Three weights now:

   the subject   - the name of the thing, the only link in the row, in ink
   the figures   - counts and money, tabular and heavier, so a column of them
                   lines up digit under digit and reads as a column
   the rest      - type, style, dates: context, in muted ink, and dates a size
                   down because they are the same shape in every row anyway
*/
table.data td .strong,
table.data td a.strong { font-weight: 650; color: var(--ink); }
table.data tbody tr:hover td a.strong { color: var(--brand); }
table.data .num {
  text-align: right; font-variant-numeric: tabular-nums;
  font-weight: 600; color: var(--ink);
}
/* A zero is not news. It stays in the column but steps back, so the rows that
   do have a number are the ones the eye lands on. */
table.data .num.is-zero { font-weight: 500; color: var(--ink-faint); }
table.data td.nowrap { font-variant-numeric: tabular-nums; font-size: 0.85rem; }

/* An empty state is the first thing a new account sees, so it gets a ground of
   its own rather than being a grey glyph on white. The icon sits in the same
   tinted chip the stat tiles use. */
.empty {
  text-align: center; padding: 44px 20px;
  background: radial-gradient(120% 90% at 50% 0, var(--brand-tint), transparent 70%);
}
.empty-icon {
  box-sizing: content-box; width: 30px; height: 30px; padding: 13px; margin-bottom: 12px;
  border-radius: 50%; background: var(--brand-light); color: var(--brand);
}

/* --- Forms ------------------------------------------------------------------ */
.field { margin-bottom: 14px; }
/* The field's own label, and only that one. Left as `.field label` this reached
   every label *inside* a control as well - the flag picker is built from radios
   with a label each, and each of them was picking up the 5px bottom margin
   meant to separate a caption from its input. That margin sat inside the
   picker's border, under the row and not over it, which is what left the
   control looking bottom-heavy. Both `.field` markups put the caption directly
   inside the wrapper, so the child combinator costs nothing. */
.field > label {
  display: flex; align-items: center; gap: 6px;
  font-size: 0.82rem; font-weight: 600; color: var(--ink-muted); margin-bottom: 5px;
}
.field .req { color: var(--danger); margin-left: -2px; }
.field-icon { opacity: 0.55; }
.control, .field input, .field select, .field textarea {
  width: 100%; padding: 9px 11px; font: inherit; font-size: 0.92rem;
  border: 1px solid var(--line-strong); border-radius: var(--radius-sm);
  background: var(--surface); color: var(--ink);
}
/* --- Colour controls ---------------------------------------------------------
   A swatch, the hex beside it, and the palette under both. The native input is
   still the control - it is the only picker that needs no script - but a
   full-width bar of colour reads as a banner rather than as a value, and gives
   no way to type a brand colour in. */
.colour-control {
  display: flex; align-items: stretch; gap: 0;
  border: 1px solid var(--line-strong); border-radius: var(--radius-sm);
  background: var(--surface); overflow: hidden;
}
.colour-control:focus-within { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-light); }
/* `appearance: none` is what removes the browser's own frame; without it the
   swatch keeps a chrome border and inset that no padding here can reach. */
.field .colour-swatch {
  appearance: none; -webkit-appearance: none;
  width: 46px; height: 38px; flex: none; padding: 0;
  border: none; border-right: 1px solid var(--line); border-radius: 0;
  background: none; cursor: pointer;
}
.colour-swatch::-webkit-color-swatch-wrapper { padding: 5px; }
.colour-swatch::-webkit-color-swatch { border: 1px solid rgb(var(--shadow-rgb) / 0.18); border-radius: 5px; }
.colour-swatch::-moz-color-swatch { border: 1px solid rgb(var(--shadow-rgb) / 0.18); border-radius: 5px; }
.field .colour-hex {
  flex: 1; min-width: 0; width: auto; height: 38px; padding: 0 10px;
  border: none; border-radius: 0; background: none;
  font: inherit; font-family: var(--mono); font-size: 0.84rem; color: var(--ink);
  text-transform: lowercase;
}
.field .colour-hex:focus { outline: none; box-shadow: none; }
.field .colour-hex.is-invalid { color: var(--danger); }
.field .colour-swatch[disabled], .field .colour-hex[disabled] { cursor: not-allowed; opacity: 0.55; }

/* The quick picks. Small, round and quiet: they are shortcuts, and a row of
   large blocks would outweigh the value they set. */
.colour-dots { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 7px; }
.colour-dot {
  width: 17px; height: 17px; padding: 0; border-radius: 50%; cursor: pointer;
  border: 1px solid rgb(var(--shadow-rgb) / 0.18);
  transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.colour-dot:hover { transform: scale(1.18); box-shadow: 0 1px 4px rgb(var(--shadow-rgb) / 0.25); }
.colour-dot:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
/* A checkbox stretched to the width of the column is a horizontal rule with a
   tick at one end. */
.field input[type="checkbox"] { width: auto; height: 16px; accent-color: var(--brand); }
.field input:focus, .field select:focus, .field textarea:focus {
  outline: none; border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-light);
}
.field input[disabled], .field select[disabled] { background: var(--canvas); cursor: not-allowed; }
.field-help { font-size: 0.78rem; color: var(--ink-muted); margin: 4px 0 0; }
/* One line shared by a pair of fields - see `parameter_groups`. It belongs to
   the row above it, so it sits close under the pair and keeps the full gap
   before whatever comes next. Written after `.field-help` on purpose: the two
   have the same weight, and this one has to win. */
.field-row.is-tight .field { margin-bottom: 5px; }
.field-help.field-help-row { margin: 0 0 14px; }
.field-error {
  display: flex; align-items: center; gap: 5px;
  font-size: 0.78rem; color: var(--danger); margin: 4px 0 0;
}
.field.has-error .control { border-color: var(--danger); }
.field-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.form-actions { display: flex; gap: 8px; align-items: center; margin-top: 16px; }

.alert {
  display: flex; align-items: flex-start; gap: 9px;
  padding: 12px 14px; border-radius: var(--radius-sm); margin-top: 16px;
}
.alert-error { background: var(--danger-bg); color: var(--danger); }
/* Amber text on amber: `--warn` itself only reaches 4.2:1 there, which is why
   `--warn-ink` exists and is used here rather than the border colour. */
.alert-warning { background: var(--warn-bg); color: var(--warn-ink); }
.alert-warning .icon { color: var(--warn); }
/* Neither a failure nor a warning: a statement of where something stands. */
.alert-info { background: var(--brand-tint); color: var(--ink); border: 1px solid var(--brand-light); }
.alert-info .icon { color: var(--brand); }
.alert .icon { margin-top: 0.15em; }

/* --- Public builder --------------------------------------------------------- */
/* The public bar stays put.

   It carries the two controls a visitor is most likely to want *while reading*
   - the language and the theme - and both were at the top of a page that runs
   to several screens of pricing, use cases and FAQ. Somebody four sections down
   who wanted the page in Bosnian had to scroll back to the top for it, which is
   the same as not having it.

   A translucent ground with a blur behind it, so the page reads as continuing
   underneath rather than as being cut off - with an opaque fallback declared
   first, because without one the bar is simply see-through on a browser that
   does not do `backdrop-filter` and the page runs through the words in it.

   Wraps rather than overflows. On a phone the mark, three flags, three theme
   buttons and a button come to well over the width of the screen, and a header
   that does not wrap does not get narrower, it takes the whole page sideways
   with it. */
.public-head {
  position: sticky; top: 0; z-index: 40;
  display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap;
  gap: 12px 16px; padding: 14px 28px; background: var(--surface); border-bottom: 1px solid var(--line);
}
@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
  .public-head {
    background: color-mix(in srgb, var(--surface) 86%, transparent);
    -webkit-backdrop-filter: blur(10px);
    backdrop-filter: blur(10px);
  }
}

/* Anything else on a public page that sticks has to start below the bar - the
   builder's preview pane is the one that does, and without this it slid up
   behind the bar and the code, the one thing on that page worth looking at, was
   the part that went under it.

   The four numbers are measured off the rendered bar at each breakpoint, not
   worked out from the padding - the bar wraps, and what is tallest inside it
   changes when the lockup goes. `:has()` is what scopes them to pages that
   actually carry the bar; the signed-in shell sets `--sticky-top` from its own
   bar on `.shell` instead. */
:root:has(.public-head) { --sticky-top: 69px; }

.public-brand { display: inline-flex; align-items: center; gap: 9px; font-weight: 700; color: var(--ink); }
.public-actions { display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
@media (max-width: 720px) { :root:has(.public-head) { --sticky-top: 61px; } }
@media (max-width: 560px) {
  .public-head { padding: 12px 16px; }
  :root:has(.public-head) { --sticky-top: 57px; }
  /* The two calls to action stay; the second one loses its words rather than
     the row losing the button. */
  .public-actions .btn { padding: 8px 11px; }
}

/* Under 500px the lockup goes.

   It is 240x54 of artwork whose only job on this bar is to be the way back to
   the front page, and at that width it was taking the whole first line to
   itself and pushing the language and theme controls onto a second - which
   doubled the height of a bar that is now sticky, so the cost was paid on every
   screen of the page rather than once at the top.

   The link is hidden rather than removed, so the markup is the same one bar in
   every language and at every width; the footer carries the mark and the way
   home on the pages this bar is on. */
@media (max-width: 499px) {
  .public-head .public-brand { display: none; }
  /* With the brand gone the bar has one child, and `space-between` would leave
     the controls hard against the left. */
  .public-head { justify-content: flex-end; padding: 10px 14px; }
  :root:has(.public-head) { --sticky-top: 52px; }
}

.hero { text-align: center; padding: 40px 20px 24px; }
.hero h1 { font-size: 2.1rem; }
.hero-sub { color: var(--ink-muted); max-width: 620px; margin: 0 auto; }

.builder { max-width: 1120px; margin: 0 auto 60px; padding: 0 24px; }
/* The public page has no shell, so the form carries its own gutter, width cap
   and bottom margin. Inside the signed-in shell `.content` already provides all
   three, and the second gutter was what set the builder's controls a step in
   from the page heading above them - the heading is not inside the form. */
.content .builder { max-width: none; margin: 0; padding: 0; }
.builder-panes { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(300px, 0.6fr); gap: var(--gap); align-items: start; }
/* `minmax(0, 1fr)`, not the implicit `auto`: an auto track is sized to its
   widest content, so the moment something inside scrolls sideways - the looks
   picker - the whole column grows to that content's width and takes the page
   with it. The same reason the dashboard grids are written this way. */
.builder-controls { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--gap); }

/* --- Folding steps -----------------------------------------------------------
   Steps 2-5 are <details>. The first step is not: it is the one thing that has
   to be filled in, and a required field behind a fold is a form people submit
   empty.

   A <summary> carries the heading and, once JavaScript has run, a line of what
   is currently chosen - so the fold can be read without being opened, which is
   the only thing that makes folding better than scrolling. */
.step-fold > summary {
  display: flex; align-items: center; justify-content: space-between;
  gap: 12px; cursor: pointer; list-style: none;
}
.step-fold > summary::-webkit-details-marker { display: none; }
.step-fold > summary h2 { margin-bottom: 0; }
.step-fold[open] > summary { margin-bottom: 12px; }
.step-fold > summary:focus-visible { outline: 2px solid var(--brand); outline-offset: 3px; border-radius: 4px; }
.step-fold > summary:hover .step-choice { color: var(--brand); }

/* The chevron, drawn rather than an icon so it can rotate on open. */
.step-fold > summary::after {
  content: ""; flex: none;
  width: 8px; height: 8px; margin-left: 2px;
  border-right: 2px solid var(--ink-faint); border-bottom: 2px solid var(--ink-faint);
  transform: rotate(45deg) translate(-2px, -2px);
  transition: transform 0.15s ease;
}
.step-fold[open] > summary::after { transform: rotate(-135deg) translate(-2px, -2px); }

.step-choice {
  margin-left: auto;
  display: inline-flex; align-items: center; gap: 7px;
  font-size: 0.8rem; color: var(--ink-muted);
  overflow: hidden; white-space: nowrap; text-overflow: ellipsis; max-width: 45%;
}
.step-choice .swatch-dot {
  width: 13px; height: 13px; flex: none;
  border-radius: 3px; border: 1px solid var(--line-strong);
}
.step-fold[open] .step-choice { display: none; }

.step { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 18px; box-shadow: var(--shadow); }
.step h2 { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.step-num {
  display: inline-flex; align-items: center; justify-content: center;
  width: 24px; height: 24px; border-radius: 50%;
  background: var(--brand); color: var(--on-brand); font-size: 0.8rem; font-weight: 700;
}

/* The content type picker. Eighteen tiles, so they are small and the glyph does
   the work - the label under it confirms the choice rather than making it. */
.type-grid {
  display: grid; grid-template-columns: repeat(auto-fill, minmax(82px, 1fr));
  gap: 8px; margin: 10px 0 18px;
}
.type-card {
  position: relative; cursor: pointer; text-align: center;
  display: flex; flex-direction: column; align-items: center; gap: 6px;
  padding: 11px 5px; border: 1px solid var(--line);
  border-radius: var(--radius-sm); background: var(--surface);
}
.type-card input { position: absolute; opacity: 0; pointer-events: none; }
.type-card:hover { border-color: var(--brand); background: var(--brand-tint); }
.type-card .type-mark { font-size: 1.3rem; line-height: 1; color: var(--ink-muted); }
.type-card .type-name { font-size: 0.73rem; line-height: 1.2; color: var(--ink-muted); }
.type-card.is-selected { border-color: var(--brand); background: var(--brand-light); }
.type-card.is-selected .type-mark,
.type-card.is-selected .type-name { color: var(--brand); font-weight: 600; }
/* Focus lands on the radio, which is invisible, so the ring is drawn on the
   card around it - otherwise the picker cannot be used from the keyboard. */
.type-card:focus-within { outline: 2px solid var(--brand); outline-offset: 2px; }

/* One type's extra fields. Hidden unless that type is selected; see the
   <noscript> rule in builder.html, which shows every group when there is no
   JavaScript to switch between them. */
.param-group { display: none; border-left: 2px solid var(--line); padding-left: 12px; margin-bottom: 14px; }
.param-group.is-active { display: block; }
.param-group-head {
  display: flex; align-items: center; gap: 7px; margin-bottom: 9px;
  font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
  letter-spacing: 0.06em; color: var(--ink-faint);
}

/* --- Module shape picker -----------------------------------------------------
   Nine little modules per sample, drawn in CSS. The shape of the sample is the
   shape the code will use, which is the only honest way to offer "gapped". */
.picker-label {
  display: flex; align-items: center; gap: 6px;
  font-size: 0.82rem; font-weight: 600; color: var(--ink-muted); margin-bottom: 5px;
}
.picker-label .req { color: var(--danger); }
/* One row that scrolls, rather than a row that wraps. There are seven corner
   shapes and seven centres now, and in a narrow column the last of them dropped
   onto a second line - which reads as a second group of choices rather than as
   the same one continued. `grid-auto-flow: column` fixes the count of rows at
   one and lets the length be the free dimension, as in the look and frame
   pickers below; the scrollbar only appears when they do not fit. */
.shape-grid {
  display: grid; gap: 10px; margin-bottom: 14px;
  /* Without this the grid takes its content's width rather than its box's, and
     there is nothing left for `overflow-x` to do. */
  min-width: 0;
  grid-auto-flow: column; grid-auto-columns: minmax(96px, 1fr);
  overflow-x: auto; overscroll-behavior-x: contain;
  padding-bottom: 4px;
  scroll-snap-type: x proximity;
  scrollbar-width: thin; scrollbar-color: var(--line-strong) transparent;
}
.shape-grid::-webkit-scrollbar { height: 8px; }
.shape-grid::-webkit-scrollbar-track { background: var(--canvas); border-radius: 999px; }
.shape-grid::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 999px; }
.shape-grid::-webkit-scrollbar-thumb:hover { background: var(--ink-faint); }
/* `position: relative` keeps the hidden radio inside its card; see the note on
   `.template-card`, where the same input gave the page a phantom scrollbar. */
.shape-card { display: block; position: relative; text-align: center; cursor: pointer; scroll-snap-align: start; }
.shape-card input { position: absolute; opacity: 0; pointer-events: none; }
.shape-sample {
  display: flex; align-items: center; justify-content: center;
  height: 68px;
  border: 2px solid var(--line); border-radius: var(--radius-sm);
  background: var(--paper);
}
/* Matched to the template swatch beside it - same height, same border, same
   radius - so the two pickers read as one row of choices rather than as a light
   one and a heavy one. The sample is a rendered code, not a glyph, so it is
   *not* pixelated: at this size a nearest-neighbour circle is a lumpy octagon,
   and the whole point of the sample is to show the shape. The rule against
   smoothing applies to codes that get scanned. */
.shape-sample img { width: 46px; height: 46px; image-rendering: auto; }
.shape-card:hover .shape-sample { border-color: var(--brand); }
.shape-card.is-selected .shape-sample { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-light); }
.shape-card:focus-within .shape-sample { outline: 2px solid var(--brand); outline-offset: 2px; }
.shape-name { display: block; font-size: 0.74rem; color: var(--ink-muted); margin-top: 5px; }

/* --- Logo gallery -------------------------------------------------------------
   Small dense tiles, because there are nearly forty of them and the mark is
   what identifies each one - the name under it only confirms the choice. The
   grid scrolls rather than pushing the rest of the step down the page. */
.logo-grid {
  display: grid; grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
  gap: 8px; margin: 6px 0 14px;
  max-height: 224px; overflow-y: auto;
  padding: 4px 4px 2px;
  border: 1px solid var(--line); border-radius: var(--radius-sm);
  background: var(--canvas);
}
.logo-card { display: block; text-align: center; cursor: pointer; }
.logo-card input { position: absolute; opacity: 0; pointer-events: none; }
.logo-sample {
  display: flex; align-items: center; justify-content: center;
  height: 48px; border-radius: var(--radius-sm);
  border: 2px solid transparent; background: var(--paper);
}
.logo-sample img { width: 34px; height: 34px; }
.logo-none { color: var(--ink-faint); font-size: 1.1rem; }
.logo-card:hover .logo-sample { border-color: var(--brand); }
.logo-card.is-selected .logo-sample { border-color: var(--brand); box-shadow: 0 0 0 2px var(--brand-light); }
.logo-card:focus-within .logo-sample { outline: 2px solid var(--brand); outline-offset: 2px; }
.logo-name {
  display: block; font-size: 0.68rem; color: var(--ink-muted); margin-top: 3px;
  overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
}
/* A tier without logos gets the gallery greyed rather than removed - a control
   that vanishes teaches nothing about what the paid tiers are for. */
.logo-grid:has(input:disabled) { opacity: 0.5; pointer-events: none; }

/* --- Frame picker ------------------------------------------------------------
   Each tile is a real framed code, rendered by the generator - "bubble" and
   "label above" mean nothing until you have seen them, and a CSS drawing of a
   frame is a second implementation that would drift from the first. */
/* Twenty of them, laid out the way the looks are: two rows filled column by
   column, scrolling sideways. See the note above `.template-grid` - everything
   said there applies here for the same reasons, and the two pickers sit one
   above the other, so they had better behave the same. */
.frame-grid {
  display: grid; gap: 10px; margin: 10px 0 14px;
  min-width: 0;
  grid-auto-flow: column; grid-template-rows: repeat(2, auto); grid-auto-columns: 92px;
  overflow-x: auto; overscroll-behavior-x: contain;
  padding-bottom: 8px;
  scroll-snap-type: x proximity;
  scrollbar-width: thin; scrollbar-color: var(--line-strong) transparent;
}
.frame-grid::-webkit-scrollbar { height: 8px; }
.frame-grid::-webkit-scrollbar-track { background: var(--canvas); border-radius: 999px; }
.frame-grid::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 999px; }
.frame-grid::-webkit-scrollbar-thumb:hover { background: var(--ink-faint); }
/* `position: relative` for the same reason the look cards need it: the radio
   inside is absolutely positioned, and without a positioned ancestor it lands
   at its static position past the right edge of the scroller and gives the page
   a phantom horizontal scrollbar. */
.frame-card { display: block; position: relative; text-align: center; cursor: pointer; scroll-snap-align: start; }
.frame-card input { position: absolute; opacity: 0; pointer-events: none; }
.frame-sample {
  display: flex; align-items: center; justify-content: center;
  height: 84px; padding: 7px;
  border: 2px solid var(--line); border-radius: var(--radius-sm);
  background: var(--paper);
}
/* Fit whichever way the frame is taller or wider; a captioned frame is not
   square and must not be squashed to fit a square tile. */
.frame-sample img { max-width: 100%; max-height: 100%; width: auto; height: auto; image-rendering: auto; }
.frame-card:hover .frame-sample { border-color: var(--brand); }
.frame-card.is-selected .frame-sample { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-light); }
.frame-card:focus-within .frame-sample { outline: 2px solid var(--brand); outline-offset: 2px; }
.frame-name { display: block; font-size: 0.74rem; color: var(--ink-muted); margin-top: 5px; }

/* Twenty presets do not belong in a wall that pushes the rest of the builder
   off the screen. Two rows, filled column by column, and the row scrolls
   sideways - `grid-auto-flow: column` is what makes the pair of rows the fixed
   dimension and the length the free one. */
.template-grid {
  display: grid; gap: 10px; margin-top: 10px;
  /* Without this the grid takes its content's width rather than its box's, and
     there is nothing left for `overflow-x` to do. */
  min-width: 0;
  grid-auto-flow: column; grid-template-rows: repeat(2, auto); grid-auto-columns: 92px;
  overflow-x: auto; overscroll-behavior-x: contain;
  padding-bottom: 8px;
  scroll-snap-type: x proximity;
  scrollbar-width: thin; scrollbar-color: var(--line-strong) transparent;
}
.template-card { scroll-snap-align: start; }
/* WebKit needs the bar drawn by hand, and the default is a grey slab that
   looks like part of the form rather than part of the scrolling. */
.template-grid::-webkit-scrollbar { height: 8px; }
.template-grid::-webkit-scrollbar-track { background: var(--canvas); border-radius: 999px; }
.template-grid::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 999px; }
.template-grid::-webkit-scrollbar-thumb:hover { background: var(--ink-faint); }
/* `position: relative` is what keeps the hidden radio inside the card. The
   input is absolutely positioned, and with no positioned ancestor its
   containing block is the page itself - so once the looks scrolled sideways,
   twenty invisible radios sat at their static positions past the right edge,
   outside the scroller's clipping, and gave the whole page a phantom
   horizontal scrollbar with nothing in it. */
.template-card { display: block; position: relative; text-align: center; cursor: pointer; }
.template-card input { position: absolute; opacity: 0; pointer-events: none; }
/* The tile is the code itself now, not a glyph tinted twice, so the box only
   has to hold it: the ground is the look's own background and the render sits
   in the middle with a little air around it. */
.template-card .swatch {
  display: flex; align-items: center; justify-content: center;
  height: 68px; padding: 5px; border-radius: var(--radius-sm); border: 2px solid var(--line);
  overflow: hidden;
  /* The tile is a picture of a printed code, so it sits on paper rather than on
     the card - see the paper tokens. The dark theme leaves it white on purpose. */
  background: var(--paper);
}
.template-card .swatch img { display: block; width: auto; height: 100%; border-radius: 3px; }
.template-card.is-selected .swatch { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-light); }
.template-name { display: block; font-size: 0.78rem; color: var(--ink-muted); margin-top: 5px; }

.builder-preview {
  /* Clear of whatever is fixed above: 20px on the public builder, which has
     nothing, and 20px below the bar inside the shell. */
  position: sticky; top: calc(var(--sticky-top) + 20px);
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); padding: 18px; box-shadow: var(--shadow);
}
/* The mat behind the preview takes the code's own background colour, which is
   what `--preview-mat` carries. The box is square and a framed code is not - the
   caption bar hangs below the square - so the image is fitted by height and
   there is spare width either side of it. Against the brand tint that spare
   width read as two pale strips down the sides of the picture, as if the render
   had edges on it. Matched to what the render's own margin is drawn in - white
   around a frame, the code's background without one - there is nothing to see.
   The tint is the fallback, and it is what the empty state still shows. */
.preview-frame {
  display: flex; align-items: center; justify-content: center;
  aspect-ratio: 1; background: var(--preview-mat, var(--brand-tint));
  border-radius: var(--radius-sm); margin-bottom: 14px; overflow: hidden;
}
/* Height as well as width. A framed code is taller than it is wide - the
   caption bar is outside the square - and the preview box is square with
   `overflow: hidden`, so constraining only the width crops the caption off and
   the frame looks like it did nothing.

   Smoothed, not pixelated. `image-rendering: pixelated` only helps when an
   image is drawn *larger* than it was made; the preview box is 278px and the
   render is 320px or more, so it is always drawn smaller - and a nearest
   downscale throws pixels away instead of averaging them, which unevens the
   modules and turns the logo's curves to gravel. This is the on-screen
   picture, not the file: the PNG itself is still resampled with NEAREST where
   that matters. */
.preview-frame img {
  max-width: 100%; max-height: 100%;
  width: auto; height: auto;
  object-fit: contain;
  image-rendering: auto;
}
.preview-empty { text-align: center; padding: 20px; }
.preview-placeholder { width: 46px; height: 46px; opacity: 0.25; }
/* --- Scannability meter ------------------------------------------------------
   The band drives the colour, not the number: green at a glance means "print
   it", amber means "read the line under it". The notes below are the actual
   product - a score with no reason attached cannot be acted on. */
/* A read-out, not a rule across the panel, and three versions got here: a 6px
   line with a caption reads as a section divider, a large number beside a thin
   bar reads as two unrelated things, and the number set inside the bar had to be
   drawn twice to stay legible where the fill ended under it.

   So there is no number. The track is the whole scale - red at nought through
   amber to green at a hundred - and a marker sits at the reading, the way a
   slider shows where it has been set. The scale is the same picture every time,
   which is what makes a mark two thirds along it mean something at a glance;
   the word beside the label says which band that is. */
.scan-meter {
  margin: 14px 0; padding: 12px 14px 11px;
  background: var(--canvas); border: 1px solid var(--line); border-radius: var(--radius-sm);
}
.scan-meter[data-empty="1"] { opacity: 0.55; }
.scan-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.scan-label {
  display: inline-flex; align-items: center; gap: 6px;
  font-size: 0.76rem; font-weight: 600; letter-spacing: 0.04em;
  text-transform: uppercase; color: var(--ink-faint);
}
.scan-gauge { margin-top: 12px; }
/* The verdict wears its own state as a chip. This is the one place a status
   colour is the subject rather than a warning about the page, and it always
   arrives with the word beside it - never colour alone. */
.scan-band {
  padding: 2px 8px; border-radius: 999px;
  background: var(--canvas); color: var(--ink-muted); font-size: 0.78rem; font-weight: 600;
}
.scan-meter[data-band="excellent"] .scan-band,
.scan-meter[data-band="good"] .scan-band { background: var(--ok-bg); color: var(--ok); }
.scan-meter[data-band="fair"] .scan-band { background: var(--warn-bg); color: var(--warn-ink); }
.scan-meter[data-band="poor"] .scan-band { background: var(--danger-bg); color: var(--danger); }

/* `--score` is the reading, and the marker's position is the only thing that
   uses it: the track never changes. The stops are where the bands change hands,
   so the colour under the marker is the colour of the verdict beside it. */
.scan-bar {
  --score: 0;
  position: relative; height: 14px; border-radius: 999px;
  background: linear-gradient(
    to right,
    var(--danger) 0%, var(--danger) 12%,
    var(--warn) 46%,
    color-mix(in srgb, var(--ok) 70%, white) 74%,
    var(--ok) 100%
  );
  box-shadow: inset 0 1px 2px rgb(var(--shadow-rgb) / 0.18);
}
/* The quarter marks. White at low opacity reads on every part of the ramp,
   which a single ink colour does not. */
.scan-bar::after {
  content: ""; position: absolute; inset: 0; pointer-events: none; border-radius: inherit;
  background:
    linear-gradient(to right, transparent calc(25% - 1px), rgb(255 255 255 / 0.45) calc(25% - 1px), rgb(255 255 255 / 0.45) 25%, transparent 25%),
    linear-gradient(to right, transparent calc(50% - 1px), rgb(255 255 255 / 0.45) calc(50% - 1px), rgb(255 255 255 / 0.45) 50%, transparent 50%),
    linear-gradient(to right, transparent calc(75% - 1px), rgb(255 255 255 / 0.45) calc(75% - 1px), rgb(255 255 255 / 0.45) 75%, transparent 75%);
}
/* The marker. White with a ring in the band's colour, so it is visible against
   every part of the ramp it can land on - a solid coloured dot disappears into
   the stretch of track that shares its colour, which is exactly the stretch it
   sits on. `clamp` keeps it on the track at nought and at a hundred instead of
   hanging half off each end. */
.scan-marker {
  position: absolute; top: 50%; z-index: 1;
  left: clamp(11px, calc(var(--score) * 1%), calc(100% - 11px));
  transform: translate(-50%, -50%);
  width: 22px; height: 22px; border-radius: 50%;
  background: var(--surface); border: 3px solid var(--ink-muted);
  box-shadow: 0 1px 4px rgb(var(--shadow-rgb) / 0.28);
  transition: left 0.25s ease;
}
.scan-meter[data-band="excellent"] .scan-marker,
.scan-meter[data-band="good"] .scan-marker { border-color: var(--ok); }
.scan-meter[data-band="fair"] .scan-marker { border-color: var(--warn); }
.scan-meter[data-band="poor"] .scan-marker { border-color: var(--danger); }
/* The scale the marker is placed on. Numerals only - they read the same in all
   three languages and need no catalogue entry. */
.scan-scale {
  display: flex; justify-content: space-between;
  margin-top: 7px; font-size: 0.68rem; color: var(--ink-faint);
  font-variant-numeric: tabular-nums;
}
.scan-notes { list-style: none; margin: 10px 0 0; padding: 0; display: grid; gap: 5px; }
.scan-notes li {
  display: flex; align-items: flex-start; gap: 7px;
  font-size: 0.78rem; color: var(--ink-muted); line-height: 1.35;
}
/* `display: flex` above beats the browser's own `[hidden] { display: none }`,
   so without this every note in the table shows at once and the panel reads as
   a list of everything wrong with a code nobody has built yet. */
.scan-notes li[hidden] { display: none; }
/* Always shown, whatever the score. The number grades the settings; this is the
   half of the answer the settings cannot reach. */
.scan-caveat { color: var(--ink-faint); margin: 8px 0 0; line-height: 1.35; }
.scan-notes li .icon { flex: none; margin-top: 0.15em; color: var(--warn); }

.preview-actions { display: grid; gap: 8px; }
/* The same reasoning, more so: a 1024px download shown at 280px is a four-fold
   reduction, and doing that by nearest neighbour drops three pixels in four. */
.code-image { max-width: 280px; width: 100%; height: auto; image-rendering: auto; }
.download-row { display: flex; gap: 8px; justify-content: center; margin-top: 16px; }

.detail-meta .row { display: flex; justify-content: space-between; gap: 14px; padding: 7px 0; border-bottom: 1px solid var(--line); }
.detail-meta .row:last-child { border-bottom: none; }
.detail-meta .row > span:first-child {
  display: inline-flex; align-items: center; gap: 8px;
  color: var(--ink-muted); font-size: 0.85rem; white-space: nowrap;
}
.detail-meta .row > span:first-child .icon { opacity: 0.5; }

/* The heading and the period switch are centred over the four cards - there is
   nothing else on this page to align them to the left of. The column itself is
   left where `.content-wide` puts it on every other page; only what sits above
   the grid moves. */
.plan-page .page-head { justify-content: center; text-align: center; }
.plan-page .page-head h1 { justify-content: center; }
/* Column flex rather than `text-align`, which would centre every feature row
   inside all four cards. The grid takes its full width back, since a column
   flex would otherwise shrink it to its content. */
.plan-page .plan-periods { display: flex; flex-direction: column; align-items: center; }
.plan-page .plan-periods > .grid { width: 100%; }

/* A column, so the action can be pinned to the bottom of whichever card is
   tallest - see .plan-features below. Without it the four buttons sit wherever
   their own feature list happens to end, which is what made the row ragged. */
.plan-card {
  display: flex; flex-direction: column; position: relative; overflow: hidden;
  transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
}
.plan-card:hover { transform: translateY(-3px); box-shadow: 0 10px 24px rgb(var(--shadow-rgb) / 0.09); }
.plan-card .card-body { display: flex; flex-direction: column; flex: 1; }
.plan-card .card-head { position: relative; }

/* A quiet checkerboard behind the tier name - the product is QR codes, and a
   pricing table for it may as well look like it knows that. Kept under 6%
   opacity: it should register as texture, not as a pattern to read. */
.plan-card .card-head::after {
  content: ""; position: absolute; inset: 0 0 0 auto; width: 150px;
  background-image: repeating-conic-gradient(color-mix(in srgb, var(--ink) 6%, transparent) 0% 25%, transparent 0% 50%);
  background-size: 10px 10px;
  /* Faded from the top-right corner outwards rather than sideways. A linear
     fade leaves the pattern with a hard edge along the top, the right and the
     header's own bottom border, which reads as a pasted rectangle instead of
     texture. The radius is set so it has reached zero by the time it meets
     that border. */
  -webkit-mask-image: radial-gradient(105px 105px at 100% 0, #000 8%, transparent 66%);
  mask-image: radial-gradient(105px 105px at 100% 0, #000 8%, transparent 66%);
  pointer-events: none;
}

.plan-card.is-current { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-light); }
.plan-card.is-current:hover { box-shadow: 0 0 0 3px var(--brand-light), 0 10px 24px rgb(var(--shadow-rgb) / 0.09); }

/* The one tier the table argues for. A rule across the top, a warmer ground
   behind the name, and a solid button - three small signals rather than one
   loud one. */
.plan-card.is-recommended { border-color: var(--line-strong); }
.plan-card.is-recommended::before {
  content: ""; position: absolute; inset: 0 0 auto 0; height: 3px; z-index: 1;
  background: linear-gradient(90deg, var(--brand), var(--logo-blue) 55%, var(--brand-dark));
}
.plan-card.is-recommended .card-head { background: var(--brand-tint); }
.plan-card.is-recommended .card-head h2 { color: var(--brand); }
.plan-card.is-recommended .card-head::after {
  background-image: repeating-conic-gradient(color-mix(in srgb, var(--brand) 13%, transparent) 0% 25%, transparent 0% 50%);
}
.plan-flag {
  display: inline-flex; align-items: center; gap: 5px; position: relative; z-index: 1;
  padding: 3px 9px; border-radius: 999px; white-space: nowrap;
  background: linear-gradient(135deg, var(--brand), var(--brand-dark));
  box-shadow: 0 1px 3px color-mix(in srgb, var(--brand-dark) 35%, transparent);
  color: var(--on-brand);
  font-size: 0.68rem; font-weight: 650; letter-spacing: 0.03em; text-transform: uppercase;
}
.plan-flag .icon { width: 12px; height: 12px; }

.plan-price { font-size: 1.6rem; font-weight: 650; margin-bottom: 10px; }
.plan-per { font-size: 0.8rem; font-weight: 500; color: var(--ink-muted); margin-left: 4px; }
/* Beside the price, not under it. On its own line the badge pushes the feature
   list of the two tiers that have one out of line with the two that do not, and
   the four cards stop reading as a row. */
.plan-save {
  display: inline-flex; align-items: center; margin-left: 8px;
  padding: 3px 9px; border-radius: 999px; white-space: nowrap;
  background: var(--brand-light); color: var(--brand);
  font-size: 0.72rem; font-weight: 600; letter-spacing: 0.01em;
  vertical-align: 3px;
}
.plan-save .icon { width: 12px; height: 12px; margin-right: 4px; vertical-align: -1px; }

/* The monthly/yearly switch. Every rule below hangs off `:checked ~`, so the
   two inputs have to remain siblings of the switch and the grid - see the
   comment in plan.html. Hidden with clip rather than display:none, because a
   radio that is not rendered is a radio the keyboard cannot reach. */
.plan-period-input {
  position: absolute; width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0; overflow: hidden;
  clip: rect(0 0 0 0); clip-path: inset(50%);
}
.plan-switch {
  display: inline-flex; gap: 4px; margin-bottom: 18px; padding: 4px;
  border: 1px solid var(--line); border-radius: 999px; background: var(--canvas);
}
.plan-switch label {
  display: inline-flex; align-items: center; gap: 7px;
  padding: 7px 16px; border-radius: 999px;
  font-size: 0.85rem; font-weight: 600; color: var(--ink-muted);
  cursor: pointer; user-select: none;
  transition: background 0.15s ease, color 0.15s ease;
}
.plan-switch label:hover { color: var(--ink); }
.plan-switch label .icon { width: 14px; height: 14px; opacity: 0.6; }
#plan-period-monthly:checked ~ .plan-switch label[for="plan-period-monthly"],
#plan-period-yearly:checked ~ .plan-switch label[for="plan-period-yearly"] {
  background: var(--surface); color: var(--brand); box-shadow: 0 1px 2px rgb(var(--shadow-rgb) / 0.08);
}
#plan-period-monthly:checked ~ .plan-switch label[for="plan-period-monthly"] .icon,
#plan-period-yearly:checked ~ .plan-switch label[for="plan-period-yearly"] .icon { opacity: 1; }
/* The group takes the focus ring, since the radio itself is clipped away. */
.plan-period-input:focus-visible ~ .plan-switch { outline: 2px solid var(--brand); outline-offset: 2px; }

/* The euro restatement, under the figure rather than beside it: the mark price
   is what is charged and has to stay the largest thing in the card. The two
   spans inside it are caught by the same `:checked ~ .grid` rules below, which
   are descendant selectors and so reach a nested one. */
.plan-price-eur {
  font-size: 0.78rem; font-weight: 500; color: var(--ink-faint);
  /* Held open on the two tiers that have no euro figure, or their feature lists
     start a line above the other two and the row of four comes out ragged. */
  margin-top: -4px; min-height: 1.15em;
}

/* One price is shown, the other is removed. `.plan-save` belongs to yearly.

   Two containers, because the switch drives the plan page's `.grid` and the
   landing page's `.pricing-grid`, which are different layouts showing the same
   table. The ids are shared on purpose - the two pages are never on screen
   together, and a second set of names would mean a second set of these rules to
   keep in step. */
#plan-period-monthly:checked ~ .grid .price-yearly,
#plan-period-monthly:checked ~ .grid .plan-save,
#plan-period-yearly:checked ~ .grid .price-monthly,
#plan-period-monthly:checked ~ .pricing-grid .price-yearly,
#plan-period-monthly:checked ~ .pricing-grid .plan-save,
#plan-period-yearly:checked ~ .pricing-grid .price-monthly { display: none; }
/* flex:1 so the list absorbs the slack in a short card and the action below it
   lands on the same line in all four. */
.plan-features { list-style: none; margin: 0 0 16px; padding: 0; font-size: 0.88rem; flex: 1; }
.plan-features li {
  display: flex; align-items: center; gap: 8px;
  padding: 5px 0; border-bottom: 1px solid var(--line);
}
/* The tick or dash, in its own slot so every label starts on the same column
   whichever of the two it is - the glyphs are not the same width. */
.plan-feature-mark { display: inline-flex; flex: none; width: 16px; justify-content: center; }
.plan-features li .icon { flex: none; color: var(--ok); }
.plan-feature-label { min-width: 0; }
.plan-features li.is-excluded { color: var(--ink-faint); }
.plan-features li.is-excluded .icon { color: var(--ink-faint); }
.plan-features li.is-excluded strong { font-weight: 550; color: inherit; }

/* --- What paying here means, under a price ----------------------------------
   Shared by the plan page and the landing page's price block - see
   core/_pay_terms.html.

   A card, not a centred column of small print. The first version of this was
   the marks and two sentences centred under the grid with no ground of their
   own, which read as loose text somebody had left there rather than as the
   payment terms: nothing bounded it, the long currency sentence set its own
   ragged measure, and the policy links wrapped into a third centred line. */
/* Full width of whatever column it is in, so its edges line up with the price
   cards above it. Capped and centred it read as a separate narrower thing
   floating under the grid rather than as the last row of it. */
.pay-terms { margin-top: 26px; }
.pay-terms.is-flush { border: none; background: none; box-shadow: none; }
.pay-terms .legal-callout { margin: 0; }

/* The marks at one end and the policy links at the other, stacking when there
   is no room for both. */
.pay-terms-row {
  display: flex; flex-wrap: wrap;
  align-items: flex-start; justify-content: space-between;
  gap: 14px 24px; margin-top: 14px;
}

.pay-terms-links {
  display: flex; flex-wrap: wrap; gap: 6px 18px;
  margin: 0; padding: 0; list-style: none; font-size: 0.86rem;
}
.pay-terms-links li { display: inline-flex; align-items: center; gap: 7px; }
.pay-terms-links .icon { color: var(--ink-faint); flex: none; }

/* The landing page's arrangement of the same facts: no card, stacked and
   centred under the price grid, the policies as one sentence. It sits at the
   foot of a centred section rather than inside a column of content, and a
   bordered box there reads as a fifth card in a row of four. */
.pay-terms.is-centred {
  display: flex; flex-direction: column; align-items: center;
  gap: 14px; text-align: center;
}
.pay-terms.is-centred .legal-callout { max-width: 78ch; text-align: left; }
.pay-terms.is-centred .card-marks { align-items: center; }
.pay-terms-read { margin: 0; max-width: 86ch; }
/* A policy name is one thing to click, so it wraps as one - the line broke
   between "Payment" and "security" and left half a link on its own. */
.pay-terms-read a { white-space: nowrap; }

/* Directly under the block above, and to the same edges. */
.pay-terms-note { margin-top: 12px; }
.plan-features li.is-excluded .icon { color: var(--ink-faint); }

/* The current-plan marker is sized as a button, not as a badge: it stands in
   the same slot as one, and matching its box is what keeps the bottom of the
   four cards on one line. Borrowed metrics from .btn - padding, radius, weight
   - so the two stay the same height if either is ever restyled. */
.plan-action { margin-top: auto; }
.plan-current {
  display: flex; align-items: center; justify-content: center; gap: 7px;
  width: 100%; padding: 9px 16px;
  border: 1px dashed var(--brand); border-radius: var(--radius-sm);
  background: var(--brand-light); color: var(--brand);
  font-size: 0.89rem; font-weight: 600;
}
/* A badge always has a ground. Without one the plain variant was bare text in a
   column of pills, which reads as a missing value rather than a state. */
/* A badge always has a ground, and the plain one is the stateless case - a
   draft, an unset value. Grey rather than a hue, because it is the absence of a
   state rather than one of them; without a ground it was bare text in a column
   of pills, which reads as a missing value. */
.badge {
  display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px;
  border-radius: 999px; font-size: 0.75rem; font-weight: 600;
  background: var(--neutral-bg); color: var(--ink-muted);
  border: 1px solid var(--neutral-line);
}
/* One chip, one hairline and one ink per state, all derived from the hue in the
   tokens - so a badge on a row already tinted its own colour still reads as a
   chip on a ground, which is what the `-line` edge is for. */
.badge-ok, .badge-current {
  background: var(--ok-bg); color: var(--ok); border-color: var(--ok-line);
}
.badge-warn { background: var(--warn-bg); color: var(--warn); border-color: var(--warn-line); }
.badge-danger { background: var(--danger-bg); color: var(--danger); border-color: var(--danger-line); }
.badge-info { background: var(--info-bg); color: var(--brand);
  border-color: color-mix(in srgb, var(--brand) var(--hairline-mix), var(--surface)); }
.badge-alt { background: var(--alt-bg); color: var(--alt); border-color: var(--alt-line); }

/* --- The logo on the menu settings ------------------------------------------
   The file that is on the menu now, and the way to take it off. Shown as the
   thing itself rather than as "Currently: menu-logos/a8f3.png", which is a path
   nobody chose and cannot recognise. */
.logo-current {
  display: flex; align-items: center; gap: 14px;
  margin: 2px 0 10px; padding: 10px 12px;
  background: var(--canvas);
  border: 1px solid var(--line); border-radius: var(--radius-sm);
}
.logo-current img {
  flex: none; width: 64px; height: 64px; object-fit: contain;
  background: var(--surface);
  border: 1px solid var(--line); border-radius: var(--radius-sm);
  padding: 5px;
}
.logo-current-actions { display: flex; flex-direction: column; align-items: flex-start; gap: 7px; }

/* --- The picture on a line, an offer or an event -----------------------------
   Built on `.logo-current` above, which is the same idea one field over: show
   the thing itself rather than the path it is stored at, and put the way to
   remove it beside it. Square here rather than `contain`, because every picture
   on a menu is a square by construction - see `menus/services/images.py`. */
.shot-current img {
  flex: none; width: 72px; height: 72px; object-fit: cover;
  border: 1px solid var(--line); border-radius: var(--radius-sm);
  background: var(--surface);
}
.shot-current {
  display: flex; align-items: center; gap: 14px;
  margin: 2px 0 10px; padding: 10px 12px;
  background: var(--canvas);
  border: 1px solid var(--line); border-radius: var(--radius-sm);
}
/* The square that has just been cropped and is waiting for Save. Tinted, and
   the one difference that matters: a control that looked unchanged after
   somebody spent twenty seconds framing a photograph is a control they will use
   twice and then post twice. */
.shot-picked {
  background: var(--brand-tint);
  border-color: color-mix(in srgb, var(--brand) var(--hairline-mix), var(--surface));
}
/* The `hidden` attribute has to be said again here, and this is not belt and
   braces. `hidden` works through a `display: none` rule in the *browser's* own
   stylesheet, and any author rule that sets `display` beats it whatever its
   specificity - so `.shot-current { display: flex }` above quietly un-hid this
   panel, and every row's page opened with an empty "just cropped" card on it.
   Anything carrying both a `display` and a `hidden` attribute needs this pair. */
.shot-picked[hidden] { display: none; }

/* --- The cropper -------------------------------------------------------------
   One per page, opened by whichever picture field was used. A <dialog>, like
   the confirmation below it, so the focus trap, Esc and the backdrop are the
   browser's job.

   Wider than the confirmation dialog because its subject is a picture rather
   than a sentence, and capped so the frame is never bigger than a phone's
   screen is tall - the zoom slider and the two buttons have to be reachable
   without scrolling a dialog. */
.crop {
  border: none; padding: 0;
  width: calc(100% - 24px); max-width: 420px;
  border-radius: var(--radius);
  background: var(--surface); color: var(--ink);
  box-shadow: 0 18px 44px rgb(var(--shadow-rgb) / 0.22), 0 2px 8px rgb(var(--shadow-rgb) / 0.10);
}
.crop::backdrop { background: rgb(var(--shadow-rgb) / 0.6); }

.crop-head { display: flex; align-items: center; gap: 11px; padding: 16px 18px 10px; }
.crop-head h3 { margin: 0; font-size: 1.02rem; }
.crop-mark {
  flex: none; width: 34px; height: 34px;
  display: flex; align-items: center; justify-content: center;
  border-radius: 50%; background: var(--brand-light); color: var(--brand);
}

/* The frame *is* the canvas: what is inside it is exactly what will be saved.
   No dashed rectangle floating over a larger picture - "the part outside the
   box is being thrown away" is something to be read, and "this is the picture"
   is something to be seen.

   The chequer underneath is what shows through where a zoomed-out picture has
   nothing: the crop pads with transparency rather than with a colour, and this
   is the one convention everybody already reads as "there is nothing here". */
.crop-stage { padding: 0 18px; }
.crop-canvas {
  display: block; width: 100%; aspect-ratio: 1;
  border-radius: var(--radius-sm); border: 1px solid var(--line);
  cursor: grab;
  /* A drag on it moves the picture; without this the browser scrolls the page
     instead and the frame cannot be panned on a phone at all. */
  touch-action: none;
  background-color: var(--canvas);
  background-image:
    linear-gradient(45deg, var(--line-soft) 25%, transparent 25%),
    linear-gradient(-45deg, var(--line-soft) 25%, transparent 25%),
    linear-gradient(45deg, transparent 75%, var(--line-soft) 75%),
    linear-gradient(-45deg, transparent 75%, var(--line-soft) 75%);
  background-size: 16px 16px;
  background-position: 0 0, 0 8px, 8px -8px, -8px 0;
}
.crop-canvas:active { cursor: grabbing; }
.crop-canvas:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }

.crop-help {
  display: flex; align-items: center; justify-content: center; gap: 7px;
  margin: 10px 18px 0; color: var(--ink-muted); font-size: 0.82rem;
}
.crop-help .icon { flex: none; width: 14px; height: 14px; }

.crop-zoom { display: flex; align-items: center; gap: 10px; padding: 10px 18px 4px; }
.crop-range { flex: 1; min-width: 0; accent-color: var(--brand); }

.crop-foot {
  display: flex; align-items: center; justify-content: flex-end; gap: 8px;
  margin-top: 12px; padding: 12px 18px;
  border-top: 1px solid var(--line); background: var(--brand-tint);
  border-radius: 0 0 var(--radius) var(--radius);
}
/* What will actually be stored, in pixels. Pushed to the left so it reads as a
   fact about the frame rather than as a third button. Zooming into a small
   picture makes a smaller square - nothing here invents pixels that were not
   photographed - and this is where that becomes visible rather than
   surprising. */
.crop-size {
  margin-right: auto; color: var(--ink-muted);
  font-size: 0.78rem; font-weight: 600; font-variant-numeric: tabular-nums;
}

/* --- The confirmation dialog -------------------------------------------------
   One per page, filled in by whichever trigger opened it. A <dialog>, so the
   focus trap, Esc and the backdrop are the browser's job rather than ours. */
.modal {
  border: none; padding: 0;
  max-width: 440px; width: calc(100% - 32px);
  border-radius: var(--radius);
  background: var(--surface); color: var(--ink);
  box-shadow: 0 18px 44px rgb(var(--shadow-rgb) / 0.22), 0 2px 8px rgb(var(--shadow-rgb) / 0.10);
}
.modal::backdrop { background: rgb(var(--shadow-rgb) / 0.55); }

.modal-head { display: flex; align-items: center; gap: 11px; padding: 18px 20px 0; }
.modal-head h3 { margin: 0; font-size: 1.05rem; }

.modal-icon {
  flex: none; width: 34px; height: 34px;
  display: flex; align-items: center; justify-content: center;
  border-radius: 50%; background: var(--brand-light); color: var(--brand);
}
/* Red only when the thing being confirmed cannot be undone. A warning triangle
   on every question teaches people to click through it. */
.modal.is-danger .modal-icon { background: var(--danger-bg); color: var(--danger); }

.modal-body {
  margin: 0; padding: 12px 20px 20px;
  color: var(--ink-muted); font-size: 0.93rem; line-height: 1.5;
}

.modal-foot {
  display: flex; justify-content: flex-end; gap: 8px;
  padding: 14px 20px; border-top: 1px solid var(--line);
  background: var(--brand-tint);
  border-radius: 0 0 var(--radius) var(--radius);
}
.modal.is-danger .modal-foot { background: var(--danger-bg); }

/* The accept button is filled here, unlike `.btn-danger` elsewhere in the app.
   Outlined, it sits on a foot of the same tint and reads as the quieter of the
   two - and the quieter of the two is Cancel, which is already the one with
   focus. The dialog asks a question; the button that answers it has to look
   like the answer. */
.modal-foot .btn-danger {
  background: var(--danger); border-color: var(--danger); color: var(--on-brand);
}
.modal-foot .btn-danger:hover { background: color-mix(in srgb, var(--danger) 82%, var(--ink)); border-color: color-mix(in srgb, var(--danger) 82%, var(--ink)); }
.modal-foot .btn-danger .icon { color: currentColor; }

/* --- The signed-out shell ----------------------------------------------------
   One centred column on the canvas, shared by sign-in, sign-up and the four
   screens of the forgotten-password flow - see core/base_auth.html.

   `min-height: 100dvh` rather than `100vh`: on a phone the browser's own bars
   are counted into `vh`, so a card centred in it sits low enough that the
   footer link falls under the address bar.

   The column used to be a fixed 460px cap with a 60px top margin, which put the
   card near the top of a tall window and gave the wider screens - the sign-up
   form and the two-box reset form - no way to ask for more room. The width is a
   block each page overrides now. */
.auth-shell {
  min-height: 100dvh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 32px 20px;
  background: var(--canvas);
}

.auth-column { width: 100%; }

/* The mark and the language, with the switcher under the logo rather than
   beside it - the column is 520px at its widest and the two do not share a line
   comfortably in every language. */
.auth-brand {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
  margin-bottom: 22px;
}
.auth-brand .brand-logo { margin: 0 auto; max-width: 210px; height: auto; }
/* The two controls under the mark, on one row. They wrap on a narrow screen
   rather than squeezing, because a flag squeezed to nothing is not a flag. */
.auth-switches { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; }

/* The gap under the heading belongs to the heading, because three of these
   screens have no lede at all - sign in, and the two that only report a result.
   With the gap on the lede instead, "Sign in" sat directly on the first label. */
.auth-title { font-size: 1.32rem; margin: 0 0 16px; }
/* The lede then pulls back up against it. A negative margin under a *heading* is
   safe in a way one under a field is not: a heading's height is its own line and
   does not grow, whereas a field grows by however many errors it has. */
.auth-lede { color: var(--ink-muted); font-size: 0.88rem; margin: -12px 0 16px; }

/* The two screens that only report a result have no form to align to, so the
   heading is centred over the text rather than left above it. */
.card-body.is-centred { text-align: center; }

.auth-foot {
  margin-top: 18px;
  display: flex; justify-content: center; align-items: center;
  gap: 6px; flex-wrap: wrap;
}
/* The icon and its words are one label. Without this the arrow on "Back to sign
   in" is welded to the B. */
.auth-foot a { display: inline-flex; align-items: center; gap: 6px; }

/* The way out of a refused password, at the end of the row it belongs to. */
.login-forgot { margin: -2px 0 0; text-align: right; }

/* The two screens that only report: a link is on its way, and the password has
   been changed. No form on either, so the icon carries the state and the text
   is centred under it rather than set as a paragraph. */
.reset-sent { text-align: center; }
.reset-sent p { margin-bottom: 8px; }
.reset-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 52px;
  height: 52px;
  margin-bottom: 14px;
  border-radius: 50%;
  background: var(--brand-light);
  color: var(--brand);
  font-size: 1.3rem;
}
.reset-icon.is-done { background: var(--ok-bg); color: var(--ok); }

@media (max-width: 480px) {
  /* Top-aligned rather than centred, and a tighter gutter. Vertically centred
     on a short screen the card starts below the fold, so the first thing on
     screen is empty canvas. */
  .auth-shell { align-items: flex-start; padding: 24px 14px 40px; }
}

/* --- Password requirements ---------------------------------------------------
   Stated once under both password boxes rather than as help text on one of
   them, which made the two columns of the field row different heights - see
   core/_password_rules.html and QuietHelpMixin. */
.password-rules {
  /* Positive top margin, deliberately. A negative one tucks the block under the
     field row until a password is rejected: the validator messages render
     inside the field above, and the block would be pulled up over them. Nothing
     that sits below a field may use negative margin - the field's height is not
     fixed, it grows by however many errors it has. */
  margin: 10px 0 16px;
  padding: 12px 14px;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--brand-tint);
}

.password-rules-title {
  display: block;
  font-size: 0.78rem;
  font-weight: 650;
  color: var(--ink-muted);
  margin-bottom: 7px;
}

/* Two columns where there is room: four stacked lines read as a wall of
   conditions, which is the thing being avoided. */
.password-rules ul {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 4px 16px;
  margin: 0;
  padding: 0;
  list-style: none;
}

@media (max-width: 520px) {
  .password-rules ul { grid-template-columns: 1fr; }
}

.password-rules li {
  display: flex;
  align-items: flex-start;
  gap: 7px;
  font-size: 0.8rem;
  line-height: 1.45;
  color: var(--ink-muted);
}

.password-rules li .icon { color: var(--brand); margin-top: 0.22em; font-size: 0.85em; }

/* --- Card acceptance marks, and the callouts on the policy pages -------------
   In main.css rather than landing.css because the plan page carries them too,
   and that one is inside the signed-in shell. */
.card-marks { display: flex; flex-direction: column; gap: 8px; }
.card-mark-list {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.card-mark-list img { display: block; width: 42px; height: 28px; }
.card-marks-note { margin: 0; font-size: 0.78rem; color: var(--ink-faint); }

/* The one sentence in a section that answers the requirement the section exists
   for - the currency, the refund route, the fact that no card number arrives
   here. Set apart so a reviewer working down a checklist finds it without
   reading the paragraph around it. */
.legal-callout {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  margin: 14px 0;
  padding: 12px 14px;
  border-left: 3px solid var(--brand);
  border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
  background: var(--brand-tint);
  color: var(--ink);
  line-height: 1.55;
}
.legal-callout .icon { flex: none; margin-top: 0.15em; color: var(--brand); }
/* --- Theme switcher ----------------------------------------------------------
   Three buttons in a track - System, Light, Dark - built to the same shape as
   the language switcher beside it, because they are the same kind of control:
   a small set of exclusive choices, all of them on screen, the current one
   raised out of the track. Nothing here is theme-specific; every colour is a
   token, so the control themes itself along with the page it changes. */
.theme-switch { display: inline-flex; align-items: center; }
.theme-options {
  display: inline-flex; align-items: center; gap: 2px;
  padding: 2px; background: var(--pick-track);
  border: 1px solid var(--line); border-radius: var(--radius-sm);
}
.theme-option {
  display: inline-flex; align-items: center; justify-content: center;
  width: 27px; height: 25px; padding: 0;
  border: none; border-radius: 5px;
  background: transparent; color: var(--ink-muted);
  font: inherit; cursor: pointer;
  transition: background 0.12s ease, color 0.12s ease;
}
.theme-option .icon { width: 14px; height: 14px; }
.theme-option:hover { background: var(--pick-hover); color: var(--ink); }
.theme-option:focus-visible { outline: 2px solid var(--brand); outline-offset: 1px; }
.theme-option.is-active {
  background: var(--pick-bg); color: var(--pick-ink);
  /* The edge is an inset shadow rather than a border: these options declare
     `border: none`, and giving one of three a real border would move the other
     two by a pixel every time the choice changed. */
  box-shadow: inset 0 0 0 1px var(--pick-edge), 0 1px 2px rgb(var(--shadow-rgb) / 0.16);
}

/* --- Language switcher ------------------------------------------------------
   Three flag buttons. The flag is 20x14, not square like an .icon glyph, so it
   gets its own rule rather than borrowing that one. */
.lang-switch { display: inline-flex; align-items: center; }
.lang-options {
  display: inline-flex; align-items: center; gap: 2px;
  padding: 2px; background: var(--pick-track);
  border: 1px solid var(--line); border-radius: var(--radius-sm);
}
.lang-option {
  display: inline-flex; align-items: center; gap: 5px;
  padding: 4px 5px; border: none; border-radius: 5px;
  background: transparent; color: var(--ink-muted);
  font: inherit; font-size: 0.72rem; font-weight: 600; letter-spacing: 0.04em;
  cursor: pointer;
}
.lang-option:hover { background: var(--pick-hover); color: var(--ink); }
.lang-option:focus-visible { outline: 2px solid var(--brand); outline-offset: 1px; }
.lang-option.is-active {
  background: var(--pick-bg); color: var(--pick-ink); font-weight: 700;
  box-shadow: inset 0 0 0 1px var(--pick-edge), 0 1px 2px rgb(var(--shadow-rgb) / 0.16);
}
/* Inside a form the picker is a control like any other, so it takes the whole
   width and the three options share it. `inline-flex` is right in the toolbar,
   where it sits beside other things, but in a column of full-width inputs it
   left a short box floating at one end. It also sat on the parent's baseline as
   an inline box, which put a sliver of line-box descender under it that the top
   did not have - the gap under the row read as tighter than the gap over it for
   that reason rather than any padding. `flex` is block-level and has no
   baseline to sit on. */
.field .lang-options { display: flex; width: 100%; }
.field .lang-option { flex: 1; justify-content: center; padding: 8px 7px; }

/* The same control built from radios, for the copy on the profile form - see
   core/_language_field.html. The input is clipped, so the label carries both
   the appearance and the focus ring. */
.lang-radio:checked + .lang-option { background: var(--surface); color: var(--brand); box-shadow: var(--shadow); }
.lang-radio:focus-visible + .lang-option { outline: 2px solid var(--brand); outline-offset: 1px; }
/* Room to spell the language out, since this one is not squeezed into a
   toolbar and "BS" is not a word anybody has to guess at. */
.lang-options .lang-name { font-weight: 500; letter-spacing: 0; text-transform: none; }
/* The compact form: flag and code, like the toolbar. For a picker that shares a
   row with another field, where three spelled-out names would wrap. The name is
   still in the markup for a screen reader, which has no flag to read. */
.lang-options.is-compact .lang-name {
  position: absolute; width: 1px; height: 1px;
  margin: -1px; padding: 0; overflow: hidden; white-space: nowrap;
  clip-path: inset(50%);
}

.lang-option .flag {
  width: 17px; height: 12px; display: block; flex: none;
  border-radius: 2px;
  /* The flags are flat colour to the edge, and several are pale at one corner;
     without this they dissolve into a light toolbar. */
  box-shadow: 0 0 0 1px rgb(var(--shadow-rgb) / 0.15);
}

/* Present for screen readers and for nothing else - the flag and the two-letter
   code are the visible label, and neither says "language" out loud. */
/* Off the screen but still in the accessibility tree. The second selector is
   not a duplicate: `.field input` sets `width: 100%` and outweighs a single
   class, so a clipped radio inside a form was 390px wide and pushed the whole
   page sideways on a phone - the language picker on the profile did exactly
   that. A utility whose one job is to take up no room has to win. */
.visually-hidden,
.field .visually-hidden {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}

/* What the month's allowance has taken, under the Generate button. Amber the
   moment the next code would be refused, because the button above it is about
   to send somebody to the plans page and this is the only warning before it. */
.quota-line { color: var(--ink-muted); margin: 0; }
.quota-figure { font-weight: 650; font-variant-numeric: tabular-nums; color: var(--ink); }
.quota-line.is-full { color: var(--warn-ink); }
.quota-line.is-full .quota-figure { color: var(--warn-ink); }

/* --- The menu editor --------------------------------------------------------
   A tree of small forms: tabs, the headings inside them, the lines inside those.
   Indented rather than boxed, because four nested cards is a set of frames with
   a menu somewhere inside it. */
/* A tab, and everything under it - closed until it is opened. A ground a shade
   off the card rather than an outline on white: outlined, four tabs on one
   white card read as one long form with some rules drawn on it, and the eye has
   to follow a 1px line to work out where one tab ends. A filled block is
   spotted rather than traced.

   `overflow: hidden` so the strip's corners are the block's corners; the
   padding that used to be here is on `.tree-body`, because a strip that stops
   12px short of the edge is not a strip. */
.tree-section {
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--canvas);
  margin-bottom: 12px;
  overflow: hidden;
}

/* The headings inside it go back to white, so the nesting reads as depth: the
   card is white, the tab is grey, the heading inside it is white again. Three
   levels, two colours, alternating - which is the one place alternation earns
   its keep, because it is saying "inside", not "next". */
.tree-category {
  margin: 10px 0 10px 14px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-left: 2px solid var(--line-strong);
  border-radius: var(--radius-sm);
  overflow: hidden;
}
.tree-section > .tree-body { padding: 12px; }
.tree-category > .tree-body { padding: 8px 12px 10px; }

/* --- The strip you click ----------------------------------------------------
   Drawn as the cards on the right of this page are drawn - a shallow wash of
   the brand fading out before the edge, with the name in it - because that is
   what it now is: a labelled head over a panel, at one more level of nesting.

   It carries a name, a count and a caret and nothing else. Everything you can
   *do* to the tab is in the panel below, and that is forced as much as chosen:
   `<summary>` takes phrasing content, a `<form>` is not phrasing content, and a
   text box on the strip that opens the tab is a text box nobody can click
   into. */
.tree-head {
  display: flex; align-items: center; gap: 9px;
  padding: 11px 13px; cursor: pointer;
  background: linear-gradient(180deg, var(--brand-tint), var(--surface));
  /* The browser's own disclosure triangle, removed in both dialects - the
     caret below replaces it, at the end of the strip where the eye finishes
     reading rather than where it starts. */
  list-style: none;
  transition: background 0.15s ease;
}
.tree-head::-webkit-details-marker { display: none; }
.tree-head:hover { background: linear-gradient(180deg, var(--brand-light), var(--brand-tint)); }
.tree-head:focus-visible { outline: 2px solid var(--brand); outline-offset: -2px; }
.tree-section[open] > .tree-head, .tree-category[open] > .tree-head {
  border-bottom: 1px solid var(--line);
}
/* A heading's strip is quieter than a tab's: it is one level in, and two heads
   of the same weight stacked read as two tabs rather than as one inside the
   other. */
.tree-category > .tree-head { padding: 8px 11px; background: var(--surface); }
.tree-category > .tree-head:hover { background: var(--brand-tint); }

.tree-name {
  flex: 1 1 auto; min-width: 0; font-weight: 650; font-size: 0.95rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.tree-category > .tree-head .tree-name { font-size: 0.9rem; font-weight: 600; }
/* A heading's note, on the strip beside its name - it is the one thing about a
   closed heading that is not its name, and it is already typed in. */
.tree-subnote {
  flex: 0 1 auto; min-width: 0; color: var(--ink-faint); font-size: 0.8rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* How much is inside, so a closed strip answers rather than asks. */
.tree-count {
  flex: none; min-width: 22px; padding: 1px 7px; border-radius: 999px;
  background: var(--neutral-bg); color: var(--ink-muted);
  border: 1px solid var(--neutral-line);
  font-size: 0.72rem; font-weight: 700; text-align: center;
  font-variant-numeric: tabular-nums;
}
.tree-caret {
  flex: none; width: 14px; height: 14px; color: var(--ink-faint);
  transition: transform 0.18s ease;
}
.tree-section[open] > .tree-head .tree-caret,
.tree-category[open] > .tree-head .tree-caret { transform: rotate(180deg); }
@media (prefers-reduced-motion: reduce) {
  .tree-caret, .tree-head { transition: none; }
}

/* The one-line add forms sit on the tab's own ground, so they take the card
   colour to stay legible as inputs rather than as part of the block. */
.tree-section > .tree-body > .tree-add,
.tree-category > .tree-body > .tree-add { background: var(--surface); }

/* Renaming the row, and moving or deleting it. On its own line at the top of
   the panel now that the strip above carries only the name. */
.tree-tools {
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  margin-bottom: 8px;
}
.tree-rename { display: flex; align-items: center; gap: 6px; flex: 1 1 240px; margin: 0; }
.tree-rename input {
  flex: 1 1 auto; min-width: 0; padding: 6px 9px;
  border: 1px solid var(--line); border-radius: var(--radius-sm);
  background: var(--surface); color: var(--ink); font: inherit; font-size: 0.9rem;
}
/* The lone icon buttons on this page take a real edge for the same reason the
   groups above do - the save beside a rename box, and the two at the top of the
   page. A tint as well as an outline: on the tab's own grey ground an outlined
   white box is a box, and a wash of the brand is a button. */
.tree-rename .btn-icon,
.menu-head .page-actions .btn-icon {
  border-color: var(--line-strong);
  background: var(--brand-tint);
  color: var(--brand);
}
.menu-head .page-actions .btn-icon-danger { background: var(--danger-bg); color: var(--danger); }
.tree-section > .tree-body > .tree-tools .tree-rename input { font-weight: 650; }
/* --- The buttons on a row ---------------------------------------------------
   Edit, up, down, delete - drawn as one segmented control rather than as four
   bare glyphs in a row.

   `.btn-icon` is transparent with no edge everywhere else in the product, and
   that is right where there is a column of them in a table: forty outlined
   boxes down a page is a grid nobody asked for. It is wrong here. These sit at
   the end of a line of text, on a tinted ground, next to other glyphs that are
   *not* buttons - the kind mark on a tab, the thumbnail on a row - and a glyph
   with nothing around it reads as a label rather than as something to press.

   Every rule below is scoped to the editor's own containers. Nothing outside
   this page changes.

   The frame is on the group and the dividers are between the buttons, so four
   controls cost one outline instead of four - and the set reads as one thing
   you do to this row, which is what it is. */
.tree-actions, .rate-actions {
  display: inline-flex; align-items: center; gap: 0;
  background: var(--surface);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-sm);
  /* The dividers and the buttons' own hover fill stop at the rounded corners. */
  overflow: hidden;
}
.tree-actions form, .rate-actions form { margin: 0; }
.tree-actions .btn, .rate-actions .btn {
  border: none; border-radius: 0; background: transparent;
  width: 30px; height: 30px;
}
/* A hairline between one button and the next. Two selectors because the group's
   children are a mix: the edit link is a `.btn` itself, and each of the other
   three is a one-button `<form>` - a POST that changes data cannot share a form
   with two others, so they cannot be flattened into one. */
.tree-actions > * + * .btn, .tree-actions > * + .btn,
.rate-actions > * + * .btn, .rate-actions > * + .btn {
  border-left: 1px solid var(--line);
}

.tree-items { list-style: none; margin: 8px 0; padding: 0; }
.tree-item {
  display: flex; align-items: center; gap: 10px;
  padding: 6px 0; border-top: 1px solid var(--line-soft);
}
.tree-item:first-child { border-top: none; }
/* Truncated rather than allowed to run on: `min-width: 0` lets the box shrink,
   but without this the text itself spills straight over the price beside it -
   "Ćevapi€9.50" on a phone, and on any name long enough at any width. */
.tree-item-name {
  flex: 1 1 auto; min-width: 0; font-weight: 600;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.tree-item-price { font-variant-numeric: tabular-nums; color: var(--ink-muted); }
/* Sold out, in the editor as on the menu: still in its place, plainly off. */
.tree-item.is-out .tree-item-name { text-decoration: line-through; color: var(--ink-faint); }
/* An offer or event a guest cannot see is dimmed rather than struck through:
   it is not withdrawn, it is out of its window or switched off, and the reason
   is spelled out in the column beside it. */
.tree-item.is-out { opacity: 0.62; }
.tree-item.is-out .tree-item-price { font-style: italic; }

/* The picture a row already has, in front of its name. Small on purpose - it is
   an answer to "which of these forty lines has a photograph", not a preview.
   Without it the only way to find out is to open each of them in turn. */
.tree-shot {
  flex: none; width: 26px; height: 26px; object-fit: cover;
  border-radius: 6px; border: 1px solid var(--line);
  background: var(--canvas);
}

/* The glyph in front of a tab's name, saying what it holds. Not editable and
   not a control - the kind is fixed once the tab exists. */
.tree-kind {
  display: inline-flex; flex: none; color: var(--ink-faint);
  padding: 0 2px;
}
/* The venue's own chip - "1+1", "-30%" - beside an offer's name in the tree, so
   the list reads the way the public page does. */
.tree-chip {
  display: inline-block; margin-left: 7px; padding: 1px 7px;
  border-radius: 999px; background: var(--brand-light); color: var(--brand);
  font-size: 0.68rem; font-weight: 700; letter-spacing: 0.03em;
  text-transform: uppercase; vertical-align: 1px;
}
.tree-empty { padding: 6px 0; font-size: 0.85rem; }

/* An input group: the fields run together and the button is attached at the
   right-hand end of them, so the row reads as one control. Enter submits it,
   because it is a form with a single submit button - the button is there for
   the people who reach for a button. */
.tree-add {
  display: flex; align-items: stretch; margin: 8px 0 0;
  border: 1px solid var(--line); border-radius: var(--radius-sm);
  background: var(--surface); overflow: hidden;
}
.tree-add > input, .tree-add > select { border: none !important; border-radius: 0 !important; }
.tree-add > input + input, .tree-add > select + input { border-left: 1px solid var(--line) !important; }
/* The end of an add bar is where the row is actually created, and it was the
   same white as the three inputs leading up to it - a strip of boxes with the
   word "Add" at the end of it. Tinted, the bar reads as what it is: type into
   the fields, press the coloured end. */
.tree-add > .btn {
  border-radius: 0; border: none; border-left: 1px solid var(--line);
  white-space: nowrap;
  background: var(--brand-tint); color: var(--brand); font-weight: 600;
}
.tree-add > .btn:hover { background: var(--brand-light); }
.tree-add > .btn .icon { color: currentColor; }
.tree-add input, .tree-add select {
  padding: 6px 9px; border: 1px solid var(--line); border-radius: var(--radius-sm);
  background: var(--surface); color: var(--ink); font: inherit; font-size: 0.88rem;
}
.tree-add input[type="text"] { flex: 1 1 140px; min-width: 0; }
.tree-add input[type="number"] { width: 110px; }

/* A date field inside the group. `datepicker.js` wraps its input in a
   positioning div, and that wrapper - not the input - becomes the flex child,
   which breaks the group two ways and broke the events row both of them:

   - the `> input` rules above stop matching, so the field kept its own border
     and rounded corners and sat inside the group as a second box;
   - the calendar is drawn *under* the field, and `overflow: hidden` - which is
     what rounds the group's ends - cut all of it off. The picker opened on
     every click and was never visible, which reads as a control that does
     nothing.

   So the wrapper takes over the flex sizing and the seam. Lifting the clip is
   the other half, and it is the same rule the network dropdown already needed
   - see `.tree-add:has(.net-picker)` further down, where both are written
   once. */
.tree-add > .datepicker-wrap { display: flex; flex: 1 1 150px; min-width: 0; }
.tree-add > .datepicker-wrap > input {
  flex: 1 1 auto; min-width: 0; padding-right: 34px;
  border: none !important; border-radius: 0 !important;
}
.tree-add > input + .datepicker-wrap > input,
.tree-add > select + .datepicker-wrap > input { border-left: 1px solid var(--line) !important; }
.tree-add > .datepicker-wrap + input, .tree-add > .datepicker-wrap + select {
  border-left: 1px solid var(--line) !important;
}
.tree-add-section { margin-top: 4px; }

/* The five looks, drawn in their own colours: a swatch of the page it makes. */
/* Twenty looks. As a wrapping grid they were a wall six rows deep that pushed
   every other setting on the card off the screen, so they are two fixed rows
   that scroll sideways instead - the shape a picker of many similar things
   wants, and the one that keeps the card the same height whatever the count.

   `grid-auto-flow: column` fills down then across, so reading order matches the
   order in `styles.TEMPLATES`. The columns are a fixed width rather than a
   fraction: `1fr` in a scrolling track resolves against the *visible* width and
   would squeeze all twenty into it. */
.menu-templates {
  display: grid;
  grid-auto-flow: column;
  grid-template-rows: repeat(2, auto);
  grid-auto-columns: 92px;
  gap: 12px 10px;
  margin-bottom: 14px;
  overflow-x: auto;
  /* Room for the focus ring and the selected preset's outline, which would
     otherwise be clipped by the scroll container on the first and last column. */
  padding: 3px 3px 10px;
  scroll-snap-type: x proximity;
  overscroll-behavior-x: contain;
}
.menu-template { scroll-snap-align: start; }

/* A thin track rather than the platform's default, which on a desktop is a
   16px bar across a card that is only 190px tall. */
.menu-templates::-webkit-scrollbar { height: 8px; }
.menu-templates::-webkit-scrollbar-track { background: var(--canvas); border-radius: 999px; }
.menu-templates::-webkit-scrollbar-thumb {
  background: var(--line-strong); border-radius: 999px;
}
.menu-templates::-webkit-scrollbar-thumb:hover { background: var(--ink-faint); }
/* Firefox, which takes no pseudo-elements for this. */
.menu-templates { scrollbar-width: thin; scrollbar-color: var(--line-strong) var(--canvas); }
/* Each preset is a submit button now, not a label with a radio in it: the
   choice saves itself, so there is nothing for a radio to hold. */
.menu-template {
  display: block; position: relative; text-align: center; cursor: pointer;
  padding: 0; border: none; background: none; font: inherit; color: inherit;
}
.menu-presets { padding-bottom: 4px; }
/* A heading inside a card, for a card that answers two questions. Small and in
   the label's own colour, so it separates without competing with the card's
   own title above it. */
/* --- Row state ---------------------------------------------------------------

   A table row is coloured by what the row *is*, not by where it landed in the
   list. The wash goes under the band rather than over it - `--row-band` is
   translucent and laid over the row as an image for exactly this reason - so a
   published menu reads as published on the odd rows and on the even ones alike.

   Strengths come from the `--tint-*` ladder in the tokens, so the dark theme
   lifts every one of them together: the same percentage of a hue reads far
   weaker against a dark ground.

   Three states here, and only two of them take a hue. A draft is the ordinary
   case and takes none - if every row is coloured, nothing is - and the ladder
   is kept shallow because these tables are read as lists of names, not as a
   picture. The badge in the status column is what *says* the state; the wash
   only makes the rows findable without reading it. */
table.data tbody tr.is-published { background-color: color-mix(in srgb, var(--ok) var(--tint-1), transparent); }
table.data tbody tr.is-published:hover {
  background-color: color-mix(in srgb, var(--ok) var(--tint-1-hover), transparent);
}

/* A menu the tier no longer carries: still listed, plainly inactive.

   This was `opacity: 0.6` on every cell, which is not a state, it is a way of
   making a row harder to read - and on a dark ground it takes the text with it,
   because there is no white underneath to fade towards. A quiet grey wash and
   faint ink say the same thing and stay legible in either theme. */
table.data tbody tr.is-parked { background-color: color-mix(in srgb, var(--ink) var(--tint-quiet), transparent); }
table.data tbody tr.is-parked:hover {
  background-color: color-mix(in srgb, var(--ink) var(--tint-quiet-hover), transparent);
}
table.data tbody tr.is-parked td { color: var(--ink-faint); }
table.data tbody tr.is-parked td .strong { color: var(--ink-muted); font-weight: 600; }

/* A band across the whole card, not a line of small grey text.

   The settings card asks four separate questions - what the place is called,
   what its menu looks like, how to reach it, what it charges in - and as four
   faint captions between forty inputs, none of them registered: the card read
   as one long column of fields. Full-bleed, so the band is the width of the
   card and the eye reads it as a divider rather than as another label.

   The negative inline margin is the card body's own padding, given back as
   padding on the band. Any change to `.card-body` has to be made here too;
   there is no way to say "ignore my parent's padding" in CSS. */
/* The band is a `<summary>` in the menu editor and an `<h3>` everywhere else,
   and both want the same look - so the rules stay on the class and only the
   handful that are about *clicking* are scoped to the element below. */
.group-title {
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
  margin: 22px -18px 14px;
  padding: 9px 18px;
  font-size: 0.72rem; font-weight: 700;
  letter-spacing: 0.07em; text-transform: uppercase;
  color: var(--brand);
  /* Left to right, strong to nothing. A flat fill reads as a table header and
     would compete with the card's own head above it; a gradient that runs out
     reads as a rule with a label on it. */
  background: linear-gradient(
    90deg,
    var(--brand-light) 0%,
    var(--brand-tint) 42%,
    transparent 100%
  );
  border-top: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
  /* The mark at the start of the band, in the brand proper - the one saturated
     thing on it, so the row has somewhere to begin. */
  box-shadow: inset 3px 0 0 var(--brand);
}
/* The first band sits flush against the top of the body it opens, so it needs
   no rule above it and no gap - see `.pt-0`, which is on that body. */
.card-body > .group-title:first-child { margin-top: 0; border-top: none; }
/* A `<details>` wrapping the band takes the margin the band used to carry, or
   the negative inline margin would be measured against the wrapper and the band
   would stop short of the card's edge. */
.group { margin: 22px 0 14px; }
.group > .group-title {
  margin: 0 -18px 14px; cursor: pointer; list-style: none;
  transition: filter 0.15s ease;
}
.group > .group-title::-webkit-details-marker { display: none; }
.group > .group-title:hover { filter: brightness(0.97); }
.group > .group-title:focus-visible { outline: 2px solid var(--brand); outline-offset: -2px; }
/* Closed, the band has nothing under it, so the space below it is the space
   between it and the next band rather than a gap over an empty panel. */
.group:not([open]) > .group-title { margin-bottom: 0; }
.group-caret {
  flex: none; width: 13px; height: 13px; color: var(--brand);
  transition: transform 0.18s ease;
}
.group[open] > .group-title .group-caret { transform: rotate(180deg); }
.pt-0 { padding-top: 0 !important; }
.menu-swatch {
  display: flex; flex-direction: column; justify-content: flex-end; gap: 4px;
  height: 62px; padding: 8px; border-radius: var(--radius-sm);
  border: 2px solid var(--line); overflow: hidden;
}
.menu-swatch-bar { height: 8px; width: 46%; border-radius: 999px; }
.menu-swatch-line { height: 5px; width: 100%; border-radius: 999px; opacity: 0.85; }
.menu-swatch-line.short { width: 62%; }
/* `:has` rather than a class from the server: the swatch has to answer the
   click, not the next page load. The server class stays for the first render. */
.menu-template.is-selected .menu-swatch,
.menu-template:has(input:checked) .menu-swatch { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-light); }
.menu-template:focus-visible .menu-swatch { outline: 2px solid var(--brand); outline-offset: 2px; }
.menu-template:hover .menu-swatch { border-color: var(--brand); }
/* The radio was `pointer-events: none` and off in the corner of the page; kept
   inside its own label, so the whole tile is the target. */
.menu-template input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.menu-template-name { display: block; font-size: 0.76rem; color: var(--ink-muted); margin-top: 5px; }

/* A menu's header: name, address, whether it is live, and every action, on one
   line. Two blocks - a heading with buttons, then a card with the switch and
   more buttons - was two rows of chrome above a page whose subject is the menu
   underneath. It wraps rather than shrinking, so a narrow window gets two tidy
   rows instead of a cramped one. */
.menu-head { align-items: center; gap: 14px; flex-wrap: wrap; }
.menu-head-title { flex: 1 1 240px; min-width: 0; }
.menu-head-title h1 { margin: 0; }
.menu-head-url {
  display: inline-block; margin-top: 2px; font-size: 0.85rem;
  color: var(--ink-muted); word-break: break-all;
}
.menu-head-url:hover { color: var(--brand); }
.menu-head .page-actions { align-items: center; }
.menu-head .page-actions form { margin: 0; }

/* A switch that is a submit button: one bit, saved the instant it is flipped,
   with no checkbox and no Save beside it. The button carries the state, so it
   works with no JavaScript - which a checkbox that saves on change would not. */
.switch-form { margin: 0; }
.switch {
  display: inline-flex; align-items: center; gap: 10px;
  padding: 4px; border: none; background: none; cursor: pointer;
  font: inherit; color: var(--ink-muted);
}
.switch-track {
  position: relative; display: block; width: 46px; height: 26px;
  border-radius: 999px; background: var(--line-strong);
  transition: background 0.18s ease;
}
.switch-knob {
  position: absolute; top: 3px; left: 3px; width: 20px; height: 20px;
  border-radius: 50%; background: var(--surface);
  box-shadow: 0 1px 3px rgb(var(--shadow-rgb) / 0.3);
  transition: transform 0.18s ease;
}
.switch-label { font-size: 0.85rem; font-weight: 650; }
.switch.is-on .switch-track { background: var(--ok); }
.switch.is-on .switch-knob { transform: translateX(20px); }
.switch.is-on .switch-label { color: var(--ok); }
.switch:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; border-radius: 999px; }

/* The four colours of a menu, on one line: they are one palette. */
/* Two across, not four. Four columns leave each field 114px, and twelve quick
   picks need 213px at a size anybody can hit - so four columns means either a
   scroller or dots too small to aim at. Two columns give each palette its own
   line at full size, and the four boxes still read as one row of a palette
   rather than as four separate questions. */
.colour-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 16px; }
.colour-row .field { margin-bottom: 10px; }
/* Four colour boxes side by side leave a quarter of the width each, and the
   twelve quick picks under one of them do not fit at the size they are in a
   full-width field. They are shrunk to fit rather than made to scroll: a
   shortcut somebody has to drag sideways is not a shortcut, and all twelve have
   to be one click away. */
.colour-row .colour-dots { flex-wrap: nowrap; gap: 3px; }
.colour-row .colour-dot { flex: none; width: 16px; height: 16px; }
@media (max-width: 640px) { .colour-row { grid-template-columns: minmax(0, 1fr); } }

.rate-list { list-style: none; margin: 0 0 10px; padding: 0; }
.rate-list li { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--line-soft); }
.rate-list form { margin: 0; }
/* A card's footer: the row a form's own button sits in. It had no rules at all,
   so the button sat flush against the card's right and bottom edges. */
.card-foot {
  padding: 14px 18px; border-top: 1px solid var(--line);
  background: var(--canvas); border-radius: 0 0 var(--radius) var(--radius);
}
.card-foot-end { display: flex; justify-content: flex-end; }
.rate-actions { display: flex; align-items: center; gap: 6px; }
/* Where to find today's rate: a dropdown and a link into a search, in a row. */
.rate-lookup { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
.rate-lookup label { font-size: 0.8rem; color: var(--ink-muted); }
.rate-lookup select {
  padding: 6px 9px; border: 1px solid var(--line); border-radius: var(--radius-sm);
  background: var(--surface); color: var(--ink); font: inherit; font-size: 0.88rem;
}
.rate-actions form { margin: 0; }

/* A date and time is not a headline number; it keeps the tile's shape without
   pretending to be a count. */
.stat-value.tiny-value { font-size: 1.05rem; }

/* --- Pager -------------------------------------------------------------------
   Under every list, and the same one under each: the size control on the left,
   what is on screen in the middle, the way through the pages on the right. It
   sits inside the card's own padding rather than under it, so the table and its
   controls read as one object. */
.pager {
  display: flex; align-items: center; justify-content: space-between;
  flex-wrap: wrap; gap: 10px 16px;
  padding: 12px 16px; border-top: 1px solid var(--line);
  font-size: 0.85rem; color: var(--ink-muted);
}
.pager-size { display: flex; align-items: center; gap: 10px; }
.pager-size-label { font-size: 0.78rem; letter-spacing: 0.04em; text-transform: uppercase; color: var(--ink-faint); }
/* A segmented control: one track, four numbers, the current one raised out of
   it. The set is small and fixed, so showing all of it costs a few pixels and
   saves opening anything - and the raised tile is the state, which a select
   only shows once it is closed again. */
.segmented {
  display: inline-flex; padding: 3px; gap: 2px;
  background: var(--pick-track); border: 1px solid var(--line); border-radius: 999px;
}
.segmented-option {
  min-width: 34px; padding: 4px 10px; border-radius: 999px;
  color: var(--ink-muted); font-size: 0.82rem; font-weight: 600;
  font-variant-numeric: tabular-nums; text-align: center; text-decoration: none;
  transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
}
.segmented-option:hover { background: var(--pick-hover); color: var(--ink); text-decoration: none; }
.segmented-option.is-active {
  background: var(--pick-bg); color: var(--pick-ink); font-weight: 700;
  box-shadow: inset 0 0 0 1px var(--pick-edge), 0 1px 2px rgb(var(--shadow-rgb) / 0.16);
}
.segmented-option:focus-visible { outline: 2px solid var(--brand); outline-offset: 1px; }
.pager-range { font-variant-numeric: tabular-nums; }
.pager-links { display: flex; align-items: center; gap: 4px; }
/* Round, because they are one glyph each and a rounded rectangle round a single
   chevron reads as a button with something missing from it. */
.pager-links .btn-icon {
  width: 30px; height: 30px; padding: 0; border-radius: 50%;
}
.pager-page {
  padding: 0 10px; font-size: 0.85rem; font-weight: 600; color: var(--ink);
  font-variant-numeric: tabular-nums; white-space: nowrap;
}
/* The ends of the list keep their buttons in place rather than removing them,
   so the row does not shuffle sideways as somebody pages through it. */
.pager-links .is-disabled { opacity: 0.35; pointer-events: none; }

/* --- Toasts ----------------------------------------------------------------- */
/* In the flow, directly under the page's navigation - not fixed to a corner of
   the window. Three arrangements were tried: the top right corner is beside
   whatever somebody was looking at rather than in front of it, and on a wide
   screen it is the better part of a metre from the button that produced it;
   floated over the content it covered the first thing they were about to read.
   Taking up room pushes the page down instead, which is what makes a message
   impossible to miss and impossible to be in the way of.

   Each page includes the block itself, under its own header - see
   core/messages.html and the note in core/base.html. */
/* A public page's content is centred under a 1120px cap with 24px of padding,
   and this matches it, so a message shares the cards' own edges. */
.toasts {
  display: grid; gap: 8px;
  width: 100%; max-width: 1120px; margin: 16px auto 0; padding: 0 24px;
}
/* In the signed-in shell the content column is capped at 1200 and left aligned
   under it, with the same padding. */
.shell .toasts { max-width: 1200px; margin: 16px 0 0; }
/* The auth pages are a single narrow column that already has its own padding. */
.auth-shell .toasts { max-width: none; margin: 0 0 16px; padding: 0; }
/* The wash carries the same colour as the border and the glyph, fading out
   before it reaches the words - so the toast is tinted by its state without any
   text ever sitting on colour. */
.toast {
  display: flex; align-items: center; gap: 12px;
  /* The whole strip dismisses it, so it has to look like something to click. */
  cursor: pointer;
  border: 1px solid var(--line); border-left: 3px solid var(--brand);
  border-radius: var(--radius-sm); padding: 11px 14px;
  box-shadow: 0 4px 14px rgb(var(--shadow-rgb) / 0.1);
  /* Colour underneath, wash on top of it - two properties rather than one
     gradient ending in `--surface`. The wash is translucent at its coloured
     end, and now that these sit over the page rather than in an empty corner,
     that let the heading underneath show through the left of the toast. */
  background-color: var(--surface);
  background-image: linear-gradient(90deg, var(--brand-light), transparent 52%);
}
.toast-success { border-left-color: var(--ok); background-image: linear-gradient(90deg, var(--ok-bg), transparent 52%); }
.toast-warning { border-left-color: var(--warn); background-image: linear-gradient(90deg, var(--warn-bg), transparent 52%); }
.toast-error { border-left-color: var(--danger); background-image: linear-gradient(90deg, var(--danger-bg), transparent 52%); }
.toast-close {
  display: inline-flex; border: none; background: none; padding: 2px;
  font-size: 0.8rem; cursor: pointer; color: var(--ink-faint);
}
.toast-close:hover { color: var(--ink); }
/* Leaving, rather than blinking out: 200ms, which is what `shell.js` waits for
   before it takes the element out of the page. */
.toast { transition: opacity 0.2s ease, transform 0.2s ease; }
.toast.is-leaving { opacity: 0; transform: translateY(-6px); }
/* The glyph repeats the border colour, which is the point - the border
   alone is the kind of signal a red-green colour blindness erases. */
.toast-icon { flex: none; font-size: 1.05rem; color: var(--brand); }
.toast-success .toast-icon { color: var(--ok); }
.toast-warning .toast-icon { color: var(--warn); }
.toast-error .toast-icon { color: var(--danger); }
.toast span { flex: 1; }

/* --- Narrow screens ---------------------------------------------------------
   Every multi-column grid collapses to one. minmax(0, 1fr) throughout, for the
   reason given at .grid-1: fixed columns cannot shrink, .table-wrap's
   overflow-x never engages, and the whole page scrolls sideways instead of the
   table. */
@media (max-width: 980px) {
  .shell { grid-template-columns: minmax(0, 1fr); }
  .sidebar { border-right: none; border-bottom: 1px solid var(--line); }
  .grid-2, .grid-4, .grid-stats { grid-template-columns: minmax(0, 1fr); }
  .builder-panes { grid-template-columns: minmax(0, 1fr); }
  .builder-preview { position: static; }
  .field-row { grid-template-columns: minmax(0, 1fr); }
}

/* --- On a phone --------------------------------------------------------------
   Three things that only go wrong at this width, all of them a row of controls
   that has more in it than fits. */
@media (max-width: 640px) {
  /* A crowded action row drops its words and keeps its icons. Only crowded
     ones: a page whose header holds a single "New code" has room for the label,
     and a lone icon there would be a guess. Three or more controls is what does
     not fit, which is why this hangs off :has() rather than being blanket.
     The label stays in the markup - the button is still named for a screen
     reader, and `title` names it for a pointer. */
  .page-actions:has(> :nth-child(3)) .btn-text {
    position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
    overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
  }
  .page-actions:has(> :nth-child(3)) .btn { padding-left: 10px; padding-right: 10px; }
  .page-actions:has(> :nth-child(3)) .btn .icon { margin: 0; }

  /* The add-a-row forms stack instead of sharing one line. Four controls across
     390px left the two text boxes about eight pixels wide each - present, and
     impossible to type in. The group keeps its single border; the children lose
     the vertical rules that only make sense side by side. */
  .tree-add, .tree-rename { flex-wrap: wrap; }
  .tree-add > input, .tree-add > select, .tree-add > .net-picker,
  .tree-rename > input {
    flex: 1 1 100%; min-width: 0;
    border-left: none !important;
    border-bottom: 1px solid var(--line) !important;
  }
  .tree-add > .btn { flex: 1 1 100%; border-left: none; }
  /* The rename form's Save is a lone icon; full width it sits marooned in the
     middle of a row of its own. It keeps its size and goes to the end. */
  .tree-rename > .btn { flex: 0 0 auto; margin-left: auto; border-left: none; }
  .tree-add > :last-child, .tree-rename > :last-child { border-bottom: none !important; }

  /* A tap target is a finger, not a mouse pointer. The move, delete and edit
     buttons came out at 30px squares, the small buttons at 30px tall, and the
     colour swatches at 16px - all of them fine under a cursor and a guess under
     a thumb. */
  .btn-icon { min-width: 38px; min-height: 38px; }
  .btn-sm { min-height: 38px; }
  .lang-option { padding: 4px 5px; }
  .colour-dot { width: 24px; height: 24px; }
  /* The quick-pick row keeps all twelve on one line and shrinks them to 16px to
     do it - which is a cursor-sized target. Twelve 24px dots still fit across a
     390px card, so on a phone the row gets the bigger ones. */
  .colour-row .colour-dot { width: 24px; height: 24px; }
}

/* --- The type of a saved code ------------------------------------------------
   One presentation for the list, the dashboard and the detail header, so a code
   looks like the same kind of thing everywhere it is mentioned. */
/* A pill rather than an icon beside a word: the type is the one thing every row
   of every list is scanned for, and a chip is what makes it findable without
   reading. */
/* The chip takes its family's hue through one local variable, so the ground,
   the edge and the text are three stops on one colour rather than three rules
   per family. `--cat` defaults to the brand, which is what every chip used to
   be - so a type whose family is unset, or a chip drawn without `data-family`
   at all, is drawn the old way rather than as an unstyled gap. */
.type-tag {
  --cat: var(--brand);
  display: inline-flex; align-items: center; gap: 6px;
  padding: 3px 9px; border-radius: 999px; white-space: nowrap;
  background: color-mix(in srgb, var(--cat) var(--tint-mix), var(--surface));
  border: 1px solid color-mix(in srgb, var(--cat) var(--hairline-mix), var(--surface));
  color: var(--cat); font-size: 0.78rem; font-weight: 550;
}
.type-tag .icon { opacity: 0.9; }

/* The seven families. One rule each, and nothing else in the file needs to know
   the list - a family is set on the element as data and read here. See the
   category palette in the tokens for why there are seven of them and not
   eighteen, and why the colour is never the only channel. */
[data-family="link"]    { --cat: var(--cat-link); }
[data-family="text"]    { --cat: var(--cat-text); }
[data-family="contact"] { --cat: var(--cat-contact); }
[data-family="social"]  { --cat: var(--cat-social); }
[data-family="place"]   { --cat: var(--cat-place); }
[data-family="access"]  { --cat: var(--cat-access); }
[data-family="pay"]     { --cat: var(--cat-pay); }
.card-head .head-warn { color: var(--warn); margin-right: 8px; }

/* The same reading on a saved code, where nothing is live and it is rendered
   server-side rather than assembled from headers. */
.scan-static { margin-top: 14px; }

.landing-footer .brand-logo { height: 34px; }

/* --- Picking a network -------------------------------------------------------
   A dropdown that can carry a mark, which a native <select> cannot. The real
   <select> is still in the markup and is what submits; the script hides it and
   shows this instead, so the page works either way.

   Each mark is drawn in the network's own colour, pulled 30% toward the app's
   ink for the same reason the public menu does it: WhatsApp's green measures
   1.9:1 against a white card and is a logo you have to hunt for. */
/* The input group clips its children so its corners stay rounded, which also
   clips away anything that opens *below* the row - this dropdown to nothing,
   and the date calendar in the offers and events rows to nothing. Both are the
   same bug and take the same rule, so it is written once: a row carrying a
   popup stops clipping and rounds its two end children itself.

   `overflow-x: clip` is not a shortcut past this. It keeps the corners and
   still cuts the calendar off at the row's right-hand edge. */
.tree-add:has(.net-picker), .tree-add:has(.datepicker-wrap) { overflow: visible; }
.tree-add:has(.net-picker) > :first-child,
.tree-add:has(.datepicker-wrap) > :first-child {
  border-top-left-radius: var(--radius-sm); border-bottom-left-radius: var(--radius-sm);
}
.tree-add:has(.net-picker) > :last-child,
.tree-add:has(.datepicker-wrap) > :last-child {
  border-top-right-radius: var(--radius-sm); border-bottom-right-radius: var(--radius-sm);
}

.net-mark {
  display: inline-flex; align-items: center; justify-content: center;
  width: 20px; flex: none;
  color: color-mix(in oklab, var(--net, currentColor) 70%, var(--ink));
}
.net-mark .icon { width: 15px; height: 15px; }
.net-row { display: inline-flex; align-items: center; gap: 8px; }

.net-picker { position: relative; display: flex; min-width: 168px; }
.net-picker select { flex: 1 1 auto; min-width: 0; }
.net-toggle {
  display: flex; align-items: center; gap: 8px; width: 100%;
  padding: 6px 9px; border: none; background: var(--surface);
  color: var(--ink); font: inherit; font-size: 0.88rem;
  text-align: left; cursor: pointer;
}
.net-toggle:hover { background: var(--brand-light); }
.net-name { flex: 1 1 auto; }
.net-caret { width: 12px; height: 12px; color: var(--ink-faint); }
.net-menu {
  position: absolute; z-index: 30; top: calc(100% + 4px); left: 0;
  min-width: 100%; margin: 0; padding: 4px; list-style: none;
  max-height: 280px; overflow-y: auto;
  border: 1px solid var(--line); border-radius: var(--radius-sm);
  background: var(--surface); box-shadow: 0 10px 24px rgb(var(--shadow-rgb) / 0.14);
}
.net-menu li {
  display: flex; align-items: center; gap: 8px;
  padding: 7px 10px; border-radius: var(--radius-sm);
  font-size: 0.88rem; cursor: pointer; white-space: nowrap;
}
.net-menu li:hover, .net-menu li.is-active { background: var(--brand-light); }
.net-menu li[aria-selected="true"] { font-weight: 600; }

/* --- Dark theme -------------------------------------------------------------

   Three states, and only two palettes. "System" is the absence of a choice: no
   `data-theme` attribute is written, and the media query below decides. Light
   and Dark write the attribute and win over the device either way, which is what
   makes the switch worth having for somebody whose laptop is dark and whose eyes
   are not. See `apps/core/theme.py`.

   Only the *roots* are redeclared. Everything else in this file derives from
   them through `color-mix`, so one block of a few dozen values re-colours the
   whole product - which is the only reason a second theme was a day's work
   rather than a rewrite.

   Not redeclared, and deliberately: `--paper`. A look tile, a frame tile and a
   logo tile are pictures of a code that will be printed, and a printed code is
   on white in either theme - the same reason a PDF viewer keeps its sheet white
   on a dark chrome. Inverting the ground under a QR tile would not be a dark
   mode, it would be a claim about the code. `--logo-blue` stays too: it is the
   mark.

   The two blocks below are identical by necessity and are maintained as such.
   CSS has no way to say "these two selectors share a body", and a dark theme
   that is only right when the device agrees with the toggle is the kind of bug
   nobody reports. `apps/core/tests/test_theme.py` fails if they ever drift.
   --------------------------------------------------------------------------- */

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
  color-scheme: dark;

  --brand: #5c9ded;
  /* In the light theme this is a *darker* brand, for emphasis. Emphasis against
     a dark ground means brighter, so the two are mirror images rather than the
     same colour. */
  --brand-dark: #8fbdf5;
  --on-brand: #0d1620;

  --ink: #e4ebf2;
  --ink-muted: #9aabbd;
  --ink-faint: #93a4b6;

  --surface: #171d26;
  --canvas: #10151c;

  /* The picked/hovered ladder, reached the other way round - see the note in
     the light palette. A raised white pill is the light theme's idiom and
     cannot be this one's: `--surface` is 1.08x of `--canvas` and a drop shadow
     is invisible on a dark ground, so the selected state is a brand wash with a
     brand edge instead. The track needs no deepening because it is already the
     darkest thing here. Picked is 1.82x of the track and 1.48x of hover, and
     the ink on it reads 8.37:1. */
  --pick-track: var(--canvas);
  --pick-hover: color-mix(in srgb, var(--brand) 14%, var(--pick-track));
  --pick-bg: color-mix(in srgb, var(--brand) 30%, var(--surface));
  --pick-edge: color-mix(in srgb, var(--brand) 65%, var(--surface));
  /* Not the brand: brand on a 30% brand wash is 3.60:1 and fails AA. */
  --pick-ink: var(--ink);

  /* The four state hues, lifted rather than inverted: the same four meanings, at
     the lightness a dark ground needs. Each was checked the way the light set
     was - as text on the card, as text on its own chip, and as text on the
     strongest row tint it can ever sit on. */
  --ok: #4cb782;
  --warn: #d99a4e;
  --danger: #f28880;
  --alt: #a892e8;

  /* Every mix goes up. The same percentage of a hue reads far weaker against a
     dark ground than against white - 12% of a pale red over #171d26 is very
     nearly #171d26 - so a dark theme built by swapping only the hues comes out
     flat and grey. These are the numbers that keep a chip looking like a chip. */
  --chip-mix: 16%;
  --hairline-mix: 34%;
  --tint-mix: 7%;
  --light-mix: 15%;

  /* And so do the tint stops, up to the point where the ink printed *on* a
     tinted row stops clearing AA. */
  --tint-1: 6%;      --tint-1-hover: 10%;
  --tint-2: 11%;
  --tint-quiet: 7%;  --tint-quiet-hover: 11%;

  /* The seven category hues, re-picked on the same principle and checked the
     same way: each is the text of its own chip, so each has to clear 4.5:1
     against a 7% wash of itself over #171d26. The families keep their
     identities - blue is still the link, magenta is still the wifi - because a
     visitor who switches themes has learned them once. */
  --cat-link: #6ea8e8;
  --cat-text: #a2b1c2;
  --cat-contact: #4fc0bd;
  --cat-social: #b79ae8;
  --cat-place: #e0a95e;
  --cat-access: #ef86ba;
  --cat-pay: #55c48c;

  /* The chart palette is re-picked, not reused: its four hues were chosen dark
     enough to read on a white card, which is exactly the wrong constraint here.
     Searched the same way and held to the same floors by the same test - dE2000
     10 between every pair under normal vision and the three dichromacies, 3:1
     on the card - so a reader who switches themes gets the same guarantee and
     the same four meanings, not the same four numbers. */
  --viz-cat-1: #409bfa;
  --viz-cat-2: #65c38c;
  --viz-cat-3: #e85a4e;
  --viz-cat-4: #c1b0e7;

  /* A shadow on a dark ground cannot be a darker grey - there is nothing darker
     to go to - so it is black, at alphas the light theme would drown in, and
     the card is separated by its own lighter fill as much as by the shadow. */
  --shadow-rgb: 0 0 0;
  --shadow: 0 1px 2px rgb(var(--shadow-rgb) / 0.40), 0 1px 3px rgb(var(--shadow-rgb) / 0.30);
  }
}

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

  --brand: #5c9ded;
  /* In the light theme this is a *darker* brand, for emphasis. Emphasis against
     a dark ground means brighter, so the two are mirror images rather than the
     same colour. */
  --brand-dark: #8fbdf5;
  --on-brand: #0d1620;

  --ink: #e4ebf2;
  --ink-muted: #9aabbd;
  --ink-faint: #93a4b6;

  --surface: #171d26;
  --canvas: #10151c;

  /* The picked/hovered ladder, reached the other way round - see the note in
     the light palette. A raised white pill is the light theme's idiom and
     cannot be this one's: `--surface` is 1.08x of `--canvas` and a drop shadow
     is invisible on a dark ground, so the selected state is a brand wash with a
     brand edge instead. The track needs no deepening because it is already the
     darkest thing here. Picked is 1.82x of the track and 1.48x of hover, and
     the ink on it reads 8.37:1. */
  --pick-track: var(--canvas);
  --pick-hover: color-mix(in srgb, var(--brand) 14%, var(--pick-track));
  --pick-bg: color-mix(in srgb, var(--brand) 30%, var(--surface));
  --pick-edge: color-mix(in srgb, var(--brand) 65%, var(--surface));
  /* Not the brand: brand on a 30% brand wash is 3.60:1 and fails AA. */
  --pick-ink: var(--ink);

  /* The four state hues, lifted rather than inverted: the same four meanings, at
     the lightness a dark ground needs. Each was checked the way the light set
     was - as text on the card, as text on its own chip, and as text on the
     strongest row tint it can ever sit on. */
  --ok: #4cb782;
  --warn: #d99a4e;
  --danger: #f28880;
  --alt: #a892e8;

  /* Every mix goes up. The same percentage of a hue reads far weaker against a
     dark ground than against white - 12% of a pale red over #171d26 is very
     nearly #171d26 - so a dark theme built by swapping only the hues comes out
     flat and grey. These are the numbers that keep a chip looking like a chip. */
  --chip-mix: 16%;
  --hairline-mix: 34%;
  --tint-mix: 7%;
  --light-mix: 15%;

  /* And so do the tint stops, up to the point where the ink printed *on* a
     tinted row stops clearing AA. */
  --tint-1: 6%;      --tint-1-hover: 10%;
  --tint-2: 11%;
  --tint-quiet: 7%;  --tint-quiet-hover: 11%;

  /* The seven category hues, re-picked on the same principle and checked the
     same way: each is the text of its own chip, so each has to clear 4.5:1
     against a 7% wash of itself over #171d26. The families keep their
     identities - blue is still the link, magenta is still the wifi - because a
     visitor who switches themes has learned them once. */
  --cat-link: #6ea8e8;
  --cat-text: #a2b1c2;
  --cat-contact: #4fc0bd;
  --cat-social: #b79ae8;
  --cat-place: #e0a95e;
  --cat-access: #ef86ba;
  --cat-pay: #55c48c;

  /* The chart palette is re-picked, not reused: its four hues were chosen dark
     enough to read on a white card, which is exactly the wrong constraint here.
     Searched the same way and held to the same floors by the same test - dE2000
     10 between every pair under normal vision and the three dichromacies, 3:1
     on the card - so a reader who switches themes gets the same guarantee and
     the same four meanings, not the same four numbers. */
  --viz-cat-1: #409bfa;
  --viz-cat-2: #65c38c;
  --viz-cat-3: #e85a4e;
  --viz-cat-4: #c1b0e7;

  /* A shadow on a dark ground cannot be a darker grey - there is nothing darker
     to go to - so it is black, at alphas the light theme would drown in, and
     the card is separated by its own lighter fill as much as by the shadow. */
  --shadow-rgb: 0 0 0;
  --shadow: 0 1px 2px rgb(var(--shadow-rgb) / 0.40), 0 1px 3px rgb(var(--shadow-rgb) / 0.30);
}

/* The one place the dark theme needs more than a root.

   A rendered code sits on paper, and paper is not themed - so the empty preview
   mat, whose fallback is the brand tint, would be the one dark box with a white
   picture dropped into it the moment a code was built. Its fallback goes to
   paper too. Once a code exists `--preview-mat` is set inline by builder.js
   from the code's own background colour and neither theme has a say, which is
   right: that is what the code will be printed on. */
:root[data-theme="dark"] .preview-frame { background: var(--preview-mat, var(--paper)); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .preview-frame { background: var(--preview-mat, var(--paper)); }
}

/* --- The staff area ----------------------------------------------------------
   Superuser-only pages, and the one place in the product where a screen is full
   of other people's names and email addresses. The styling is deliberately the
   product's own - these are lists like any other - with exactly one thing added
   that the rest of the product does not have: a banner saying whose data this
   is. A staff page that is indistinguishable from your own account's pages is a
   page somebody screen-shares without thinking about it. */
.staff-notice {
  display: flex; align-items: center; gap: 9px;
  margin-bottom: 20px; padding: 10px 14px;
  background: var(--warn-bg); border: 1px solid var(--warn-line); border-radius: var(--radius);
  color: var(--warn); font-size: 0.84rem;
}
.staff-notice .icon { flex: none; }

/* The section label in the sidebar carries a glyph, which no other section does.
   It is the only group in that column that is not shown to every account, and
   the shield is what says so without a second line of text. */
.nav-section-staff { display: flex; align-items: center; gap: 7px; }
.nav-section-staff .icon { width: 12px; height: 12px; }

/* Three across, to sit beside the existing .grid-2 and .grid-4. The overview
   groups its figures in threes: this month, all time, and engagement. */
.grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }

/* A short list of label/value pairs inside a card. A <dl>, because that is what
   it is, laid out as rows rather than as the browser's default indent. */
.fact-list { display: grid; gap: 10px; margin: 0; }
.fact-list > div {
  display: flex; align-items: baseline; justify-content: space-between; gap: 14px;
}
.fact-list dt { color: var(--ink-muted); font-size: 0.87rem; }
.fact-list dd { margin: 0; font-weight: 650; font-variant-numeric: tabular-nums; }
.fact-heading { margin: 22px 0 8px; font-size: 0.82rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-muted); }

/* What a code encodes. Wraps rather than scrolls: a vCard is a dozen short
   lines and a WIFI string is one long one, and the point of showing it is to
   read it, not to page through it sideways. */
.staff-payload {
  margin: 0; padding: 12px 14px;
  background: var(--canvas); border: 1px solid var(--line); border-radius: var(--radius);
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.8rem;
  color: var(--ink); white-space: pre-wrap; word-break: break-all;
}

/* The code itself, centred on its own ground. `--paper` and not a themed
   surface: a QR code is thresholded into light and dark before anything reads
   it, so its quiet zone stays light in both themes - the same rule the look
   tiles and the landing page's code follow. */
.staff-code-figure { display: flex; justify-content: center; }
.staff-code-figure img {
  width: 320px; height: 320px; max-width: 100%;
  background: var(--paper); border: 1px solid var(--line); border-radius: var(--radius);
}

@media (max-width: 900px) {
  .grid-3 { grid-template-columns: minmax(0, 1fr); }
}

/* The cron jobs page. Its tiles say whether something is wrong, which the
   quota's `.is-high` and `.is-full` are not for - only the quota tile takes
   those. Here the chip and the figure carry it, and the card stays plain. */
.stat.is-attention .stat-icon { background: var(--danger-bg); color: var(--danger); }
.stat.is-attention .stat-value { color: var(--danger); }
.stat.is-settled .stat-icon { background: var(--ok-bg); color: var(--ok); }
/* "Every job is there", which no other page has needed to say. */
.alert-success { background: var(--ok-bg); color: var(--ok); }
/* The state of the crontab leads the page, straight under the heading. */
.page-head + .alert { margin-top: 0; }

/* A crontab line. Wrapped anywhere rather than scrolled: the lines are two
   hundred characters of paths, and the one that matters is the line to paste
   into `crontab -e`, which has to be selectable whole. */
.cron-line {
  display: block; margin-top: 6px; padding: 6px 10px;
  background: var(--canvas); border: 1px solid var(--line); border-radius: var(--radius-sm);
  font-family: var(--mono); font-size: 0.78rem; color: var(--ink);
  white-space: pre-wrap; word-break: break-all;
}
.cron-note { margin-top: 4px; color: var(--warn-ink); }
/* A job's line sits on its own row under the job, and reads as part of it. */
table.data tr.cron-has-line > td { border-bottom: 0; padding-bottom: 4px; }
table.data tr.cron-detail > td { padding-top: 0; }
.cron-detail summary, .cron-elsewhere > summary { cursor: pointer; }

/* The honeypot on the registration and password-reset forms - see
   core/_antispam_fields.html. Off the edge of the page rather than
   `display: none`: it has to stay *rendered*, because a form-filling script
   that skips unrendered boxes is the script it is there to catch, and it has
   to stay out of the accessibility tree, which `aria-hidden` on the wrapper
   does. `!important` so no later rule on wrappers can move it back. */
.hp-field {
  position: absolute !important;
  left: -9999px !important;
  top: 0;
  width: 1px;
  height: 1px;
  overflow: hidden;
}
