/* ===========================================================================
   Forever Imprinted — shared styles for the website and inventory dashboard.

   The `--fi-*` block below is the design system verbatim — same names, same
   hexes, so a value can be checked against the document without translating
   it first. The document is in the repository, at
   Design/forever-imprinted-design-studio-prompt.md, and the § references
   through this file point into it. Everything the app uses is an alias onto
   that block; no rule further down this file should carry its own hex.

   Chart colors are the one deliberate exception, and §5.6 says so: they are
   validated categorical slots, not brand colours, because four warm browns
   cannot be told apart in a bar chart.
   =========================================================================== */

/* --- Self-hosted type ------------------------------------------------------
   §6 requires all three faces be self-hosted: the system script stack the
   studio used before resolved to a different typeface on every OS, and the
   CSP here is `font-src 'self'` anyway, so a CDN would be blocked.
   Source Sans 3 ships as one variable file per subset covering 400–600.
   Petit Formal Script is not here on purpose — it is the public tagline face
   only, and §3 forbids it in the studio. */
@font-face {
  font-family: "Cormorant Garamond";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("/fonts/cormorant-garamond-600-normal-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
                 U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
                 U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Cormorant Garamond";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("/fonts/cormorant-garamond-600-normal-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
                 U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
                 U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: "Source Sans 3";
  font-style: normal;
  font-weight: 400 600;
  font-display: swap;
  src: url("/fonts/source-sans-3-400600-normal-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
                 U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
                 U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Source Sans 3";
  font-style: normal;
  font-weight: 400 600;
  font-display: swap;
  src: url("/fonts/source-sans-3-400600-normal-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
                 U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
                 U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

:root {
  color-scheme: light;

  /* ===== The design system, verbatim (§5) ================================ */
  --fi-cream: #f6efe6;           /* page background                          */
  --fi-ink: #4a382b;             /* primary text, headings — 9.73 on cream   */
  --fi-bark: #6e5b48;            /* secondary text, icons  — 5.66 on cream   */
  --fi-script: #8b7460;          /* tagline and pull quotes ≥19px. Not text  */
  --fi-gold: #b08655;            /* decoration and the focus ring. NEVER text*/
  --fi-frame: #c6a57f;           /* hairlines at large sizes. NEVER text     */

  --fi-surface: #fbf8f4;         /* raised card on cream                     */
  --fi-surface-sunken: #ece5db;  /* wells, table header, inactive tab        */
  --fi-line: #d8cec3;            /* hairline divider, input border           */
  --fi-line-strong: #c0b4a7;     /* table rules, card border, hover border   */
  --fi-ink-deep: #2e241c;        /* footers, deepest chrome                  */

  /* Status (§5.4) — studio only, desaturated and warmed so they sit inside
     the palette instead of shouting over it. Foreground on its own tint:
     ok 6.14 · attention 5.11 · blocked 5.14 · info 6.29. */
  --fi-ok: #3f5a40;              --fi-ok-tint: #e4e8dc;
  --fi-attention: #8a5127;       --fi-attention-tint: #f5e3d2;
  --fi-blocked: #8c4a3a;         --fi-blocked-tint: #f3ded6;
  --fi-info: #3a5566;            --fi-info-tint: #e0e7eb;

  /* Shape and depth (§7). Depth goes inward: cards and inputs are pressed
     into the page, and --fi-raise is kept for things that genuinely float. */
  --fi-r-sm: 3px;
  --fi-r-md: 6px;
  --fi-r-lg: 10px;
  --fi-press: inset 0 1px 2px rgba(74, 56, 43, 0.07), 0 1px 0 rgba(255, 255, 255, 0.6);
  --fi-raise: 0 1px 2px rgba(74, 56, 43, 0.06), 0 4px 12px rgba(74, 56, 43, 0.06);
  --fi-focus: 0 0 0 3px rgba(176, 134, 85, 0.45);
  /* The focus ring's colour (§15). Not --fi-focus above, which is gold at 45%
     and measured 1.54:1 on this page — that one is kept for the states that are
     not focus: a drop target, a tile being dragged, a search hit. Measured
     against the page (--fi-cream) and --surface-1 (--fi-surface), which is what
     a ring sits on: here #8a6a3f is 4.37:1 and 4.71:1. On dark the gold itself
     clears the 3:1 of 1.4.11 without darkening — #b08655 is 5.69:1 on #17110c
     and 5.23:1 on #211a13 — so both dark blocks point it back at --fi-gold. */
  --focus-ring: #8a6a3f;

  /* ===== App aliases ===================================================== */
  --page: var(--fi-cream);
  --surface-1: var(--fi-surface);
  --surface-2: var(--fi-surface-sunken);
  --text-primary: var(--fi-ink);
  --text-secondary: var(--fi-bark);
  /*
    The lightest text tier is bark, not the taupe.

    §5.1 makes --fi-script a display colour rather than a text one: it is for
    taglines and pull quotes at 19px and up. Everything this token is used for
    — table headers, hints, timestamps, captions — is 11 to 13px, where the
    taupe measures 3.86 on cream and 3.53 against the sunken table header, and
    fails the floor in §15. Nothing between bark and script survives that
    background either. So the tier collapses onto bark, and hierarchy is
    carried by size and weight instead of by a third colour.
  */
  --text-muted: var(--fi-bark);
  --gridline: var(--fi-line);
  --baseline: var(--fi-line-strong);
  --border: var(--fi-line);
  /* Part 6 (1.4.11): was --fi-line-strong (#c0b4a7), 1.92:1 against
     --surface-1 — under the 3:1 floor for a UI-component border (an input
     well, the bench-step tick box, and the icon-button border once it reads
     this token below). #8f8173 measures 3.57:1 against --surface-1.
     tests/a11y-contrast.test.js pins the number rather than the comment. */
  --border-strong: #8f8173;

  /* Categorical slots — fixed order, never cycled. Deliberately NOT brand
     colours: this is a colourblind-safe set, re-validated against the cream
     surface above. Warm browns would collide with each other in a chart. */
  --series-1: #2a78d6;           /* blue    — materials / received */
  --series-2: #eb6834;           /* orange  — components / consumed */
  /* Part 6: darkened one step each, aqua 2.66:1 -> 3.15:1 and yellow
     2.05:1 -> 3.17:1 against --surface-1 (both still the same hue family;
     dark already cleared 3:1 and is unchanged). */
  --series-3: #19a06f;           /* aqua    — finished */
  --series-4: #bd8000;           /* yellow  — fourth slot, the lead-time stages */

  /* The calendar's four kinds, in the order a day draws them — the categorical
     slots rather than colours of their own, so an order on the calendar is the
     blue it is in a chart, and the dark blocks re-value them already. The two
     grounds below are the calendar's own and are re-valued in both dark blocks:
     a day outside the month, and the day a card is being dragged over. */
  --cal-order: var(--series-1);
  --cal-card: var(--series-3);
  --cal-delivery: var(--series-4);
  --cal-material: var(--series-2);
  --cal-outside: #f1ebe3;
  --cal-target: #efe2cf;

  /* Sequential blue ramp (magnitude encoding). */
  --seq-250: #86b6ef;
  --seq-400: #3987e5;
  --seq-550: #1c5cab;

  /* Status aliases. The old names stay because the dashboard reaches for them
     directly; the values are now the §5.4 palette. */
  --status-good: var(--fi-ok);
  --status-warning: var(--fi-attention);
  --status-critical: var(--fi-blocked);
  --status-info: var(--fi-info);
  --success-text: var(--fi-ok);
  /* Spelled without the -ing in two places in the dashboard; aliased rather
     than left undefined, which is how it rendered before. */
  --status-warn: var(--fi-attention);

  /* ===== BRAND ===========================================================
     --brand-primary      ink: primary button, active tab, toast (§10)
     --brand-on-primary   cream sitting on that ink — 9.73:1
     --brand-accent       the monogram gold — rules, frames, focus ring only
     --brand-accent-text  gold darkened to carry text. Public collection
                          accent (In Memoriam); the studio uses none of the
                          collection accents (§5.2), so nothing here should
                          reach for it. */
  --brand-primary: var(--fi-ink);
  --brand-on-primary: var(--fi-cream);
  --brand-accent: var(--fi-gold);
  --brand-accent-text: #7a5c35;

  --radius: var(--fi-r-md);
  --radius-lg: var(--fi-r-lg);
  --shadow: var(--fi-press);
  --sans: "Source Sans 3", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  --display: "Cormorant Garamond", "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
}

@media (prefers-color-scheme: dark) {
  :root:where(:not([data-theme="light"])) {
    color-scheme: dark;
    /*
      Warm dark, stepped from the same brown family — not a neutral grey, which
      would read as a different brand.

      The design document does not describe a dark studio (§18 leaves it open),
      so this shell is the app's own: it re-derives the same tokens rather than
      inventing a second system, and every pairing below is measured. The one
      place it departs from §10 on purpose is the primary button, which keeps the
      frame tan — bark on this page measures 2.90 and loses its edge.
    */
    --fi-cream: #17110c;
    --fi-surface: #211a13;
    --fi-surface-sunken: #2c231a;
    --fi-ink: #f6efe6;             /* the logo cream becomes the ink   — 15.07 */
    --fi-bark: #d9c9b4;            /* secondary                        — 10.61 */
    --fi-script: #a89482;          /* tertiary; legal here, see below         */
    --fi-line: #33291f;
    --fi-line-strong: #4a3b2c;
    --fi-ok: #8fae86;              --fi-ok-tint: #2a3024;        /* 5.54 */
    --fi-attention: #d9a06a;       --fi-attention-tint: #33261a; /* 6.41 */
    --fi-blocked: #e0917c;         --fi-blocked-tint: #33241f;   /* 6.02 */
    --fi-info: #8fb6cd;            --fi-info-tint: #212c33;      /* 6.61 */
    --fi-press: inset 0 1px 2px rgba(0, 0, 0, 0.45), 0 1px 0 rgba(246, 239, 230, 0.05);
    --fi-raise: 0 1px 2px rgba(0, 0, 0, 0.5), 0 8px 24px rgba(0, 0, 0, 0.38);
    --focus-ring: var(--fi-gold);  /* 5.69 on the page, 5.23 on the surface */
    /*
      The third text tier comes back on dark: #a89482 measures 5.30 against the
      sunken table header, where the light theme's taupe managed 3.53. So the
      alias is re-pointed here rather than collapsed onto bark.
    */
    --text-muted: var(--fi-script);
    --border: rgba(246, 239, 230, 0.12);
    /* Part 6: 0.22 measured 1.94:1 against --surface-1 (#211a13), under the
       3:1 floor. 0.45 measures 4.06:1. */
    --border-strong: rgba(246, 239, 230, 0.45);
    --series-1: #3987e5;
    --series-2: #d95926;
    --series-3: #199e70;
    --series-4: #c98500;
    --cal-outside: #1c1610;
    --cal-target: #3a2d20;
    --seq-250: #184f95;
    --seq-400: #3987e5;
    --seq-550: #86b6ef;
    --brand-primary: var(--fi-frame);  /* the frame tan carries on dark — 8.10 */
    --brand-on-primary: var(--fi-cream);
    --brand-accent: var(--fi-frame);
    /* Gold is never a text colour; on dark there is no darkened gold that could
       be one, so this falls back to the secondary tier. Nothing uses it. */
    --brand-accent-text: var(--fi-bark);
    --shadow: var(--fi-press);
  }
}

:root[data-theme="dark"] {
  color-scheme: dark;
  /*
    Warm dark, stepped from the same brown family — not a neutral grey, which
    would read as a different brand.

    The design document does not describe a dark studio (§18 leaves it open),
    so this shell is the app's own: it re-derives the same tokens rather than
    inventing a second system, and every pairing below is measured. The one
    place it departs from §10 on purpose is the primary button, which keeps the
    frame tan — bark on this page measures 2.90 and loses its edge.
  */
  --fi-cream: #17110c;
  --fi-surface: #211a13;
  --fi-surface-sunken: #2c231a;
  --fi-ink: #f6efe6;             /* the logo cream becomes the ink   — 15.07 */
  --fi-bark: #d9c9b4;            /* secondary                        — 10.61 */
  --fi-script: #a89482;          /* tertiary; legal here, see below         */
  --fi-line: #33291f;
  --fi-line-strong: #4a3b2c;
  --fi-ok: #8fae86;              --fi-ok-tint: #2a3024;        /* 5.54 */
  --fi-attention: #d9a06a;       --fi-attention-tint: #33261a; /* 6.41 */
  --fi-blocked: #e0917c;         --fi-blocked-tint: #33241f;   /* 6.02 */
  --fi-info: #8fb6cd;            --fi-info-tint: #212c33;      /* 6.61 */
  --fi-press: inset 0 1px 2px rgba(0, 0, 0, 0.45), 0 1px 0 rgba(246, 239, 230, 0.05);
  --fi-raise: 0 1px 2px rgba(0, 0, 0, 0.5), 0 8px 24px rgba(0, 0, 0, 0.38);
  --focus-ring: var(--fi-gold);  /* 5.69 on the page, 5.23 on the surface */
  /*
    The third text tier comes back on dark: #a89482 measures 5.30 against the
    sunken table header, where the light theme's taupe managed 3.53 — the
    contrast runs the other way here. So the alias is re-pointed rather than
    collapsed onto bark, and each theme is measured against its own ground.
  */
  --text-muted: var(--fi-script);
  --border: rgba(246, 239, 230, 0.12);
  /* Part 6: 0.22 measured 1.94:1 against --surface-1 (#211a13), under the
     3:1 floor. 0.45 measures 4.06:1. */
  --border-strong: rgba(246, 239, 230, 0.45);
  --series-1: #3987e5;
  --series-2: #d95926;
  --series-3: #199e70;
  --series-4: #c98500;
  --cal-outside: #1c1610;
  --cal-target: #3a2d20;
  --seq-250: #184f95;
  --seq-400: #3987e5;
  --seq-550: #86b6ef;
  --brand-primary: var(--fi-frame);  /* the frame tan carries on dark — 8.10 */
  --brand-on-primary: var(--fi-cream);
  --brand-accent: var(--fi-frame);
  /* Gold is never a text colour; on dark there is no darkened gold that could
     be one, so this falls back to the secondary tier. Nothing uses it. */
  --brand-accent-text: var(--fi-bark);
  --shadow: var(--fi-press);
}

/* The dark shell swaps the colour lockup for the mono one — see .brand-lockup. */
@media (prefers-color-scheme: dark) {
  :root:where(:not([data-theme="light"])) .brand-lockup { display: none; }
  :root:where(:not([data-theme="light"])) .brand-lockup-mono { display: block; }
}
:root[data-theme="dark"] .brand-lockup { display: none; }
:root[data-theme="dark"] .brand-lockup-mono { display: block; }
/* …and back again when the toggle overrides a dark system preference. */
:root[data-theme="light"] .brand-lockup { display: block; }
:root[data-theme="light"] .brand-lockup-mono { display: none; }

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

/*
  A jump inside a page is animated, so it reads as a journey rather than as a
  page that changed underneath you.

  The four stat tiles on the overview are links to sections of the same page
  (finding 6 of docs/ux-scan-2026-09.md), and the furthest of them is about
  3,500px away. An instant jump over that distance is indistinguishable from a
  different screen having loaded: nothing on it moved, so nothing tells the
  reader that what they are now looking at is further down the thing they were
  already looking at. The animation is the only part of the interaction that
  says so.

  **It is on `html`, and what that does and does not reach was checked rather
  than assumed.** `scroll-behavior` is not an inherited property, so it
  describes the document's own scrolling box and no other. Three of the four
  programmatic scrolls in public/js therefore stay instant, because each moves a
  scrollport of its own and never the page: the global search dropdown
  (search.js, `max-height` plus `overflow-y: auto`) and the import verdict
  inside a `<dialog>` (inventory.js, twice). The fourth is `keepInView` in
  public/js/dashboard/tabs.js, which brings the selected tab along the strip —
  and `scrollIntoView` walks every ancestor scrollport including the viewport,
  so that one *can* move the document and does now animate when it does. That is
  the right outcome and the note on it in tabs.js is the longer half of this
  story: the case where it moved the page at all turned out to be a fault of its
  own, and it is now called only when the selection actually changes.

  §15 requires prefers-reduced-motion be respected and says what that means:
  reveals become instant. A scroll animation is exactly the vestibular case the
  setting exists for, so it is switched off rather than shortened.
*/
html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
}

/*
  How far down the window a jump has to stop, because the top of it is not the
  top of the page.

  `.topbar` is `position: sticky` at `top: 0` on every page that loads this file
  — six of them — so a section scrolled flush to the top of the window has its
  heading, the one word that says the jump worked, underneath the header. Only
  two of the six anchor anything today; the rule is on the root because the
  header is, not because of who happens to use it. `scroll-padding` on
  the scrolling box is the property for exactly that, and it is here rather than
  as `scroll-margin` on each target so that it covers everything: the four stat
  tiles on the overview, the website's own Collections and Behind-the-counter
  nav links, which have been landing under the header since they were written
  and which the smooth scroll above now makes it possible to watch happen, and
  anything either page anchors next.

  **The numbers are ceilings, not heights, and the breakpoints are the two this
  file already keys off.** The header is `height: 60px` until 860, and below
  that it wraps to as many lines as its contents need — so its height is
  content, not a constant, and it was measured in Chromium rather than reasoned
  about. On the dashboard: 60 above 860, 108 from 650 to 860, 119 from 470 to
  640, and 170 below 470, where the search field takes a line of its own. On the
  website, which has no search field and three nav items: 60, 62, 116 and 128
  across the same spans. Each rule below takes the tallest case in its band and
  adds 16, which is why 640-and-below reserves 186 for a header that is 119 at
  the top of that band. Over-reserving costs a band of quiet above the card;
  under-reserving costs the heading, and a heading nobody can see is a jump that
  looks like it failed. A third breakpoint at 470 would buy that band back and
  would be pinned to the width at which a nav item happens to wrap — which is a
  number that changes the next time anybody edits the nav, silently, in the
  direction that hides things.
*/
html { scroll-padding-top: 76px; }
@media (max-width: 860px) { html { scroll-padding-top: 124px; } }
@media (max-width: 640px) { html { scroll-padding-top: 186px; } }

/* An explicit `display` on a component beats the UA's [hidden] rule, so make
   the attribute win — .btn and .panel both set display and would leak through. */
[hidden] { display: none !important; }

body {
  margin: 0;
  font-family: var(--sans);
  background: var(--page);
  color: var(--text-primary);
  line-height: 1.5;
  /* §6: every quantity, price, gram and timestamp lines up. Set once here
     rather than remembered at each of the several hundred call sites. */
  font-variant-numeric: tabular-nums;
  -webkit-font-smoothing: antialiased;
}

/*
  Headings.

  §6 gives Cormorant the display role and then forbids it below 20px, where its
  thins disappear on cream — so the page titles get it and the card heads, which
  are 15px, keep Source Sans. Tracking runs positive: the wordmark is spaced
  Didone caps, and tightening a serif away from it was the sans-serif habit.
*/
h1, h2, h3, h4 { line-height: 1.3; margin: 0; font-weight: 600; }
h1, h2 { font-family: var(--display); line-height: 1.25; letter-spacing: 0.03em; }
a { color: inherit; }
button { font: inherit; }

/*
  §15: a visible focus ring on everything, in gold, never removed. Declared
  once at the top so a component has to opt out on purpose rather than by
  forgetting — and so the ring is the same object everywhere it appears.

  An outline and not a `box-shadow`, since the September audit. As a shadow it
  was replaced by any component that drew a shadow of its own (the bench's
  active step, every `.card`), and Windows forced colours drops shadows
  outright, so the ring vanished there too. An outline competes with nothing
  and forced colours repaints it. Its colour and its measured contrast are at
  `--focus-ring` in the tokens.
*/
:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}
/*
  There used to be a `.card:focus-visible` here restating the ring beside
  `--fi-press`, because `.card`'s own `box-shadow` replaced the shadow ring and
  the overview's stat tiles, links since #132, had no focus state at all. With
  the ring an outline the two no longer touch, and the override would only have
  drawn a second, fainter ring inside the first.
*/

/*
  Text for a screen reader only — row context inside a repeated button
  ("Open" on every row, "Open the order for Priya"), or a label a sighted
  user does not need because the layout already says it. Clipped rather than
  hidden, so it is still in the accessibility tree.
*/
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* Part 6 (2.4.1): the first focusable thing on the page, invisible until it
   is what the keyboard is on. `.sr-only`'s own clip trick is not reused here
   because a skip link needs to become visible on :focus, which .sr-only has
   no rule for; everything else about it is the same idea. */
.skip-link {
  position: absolute;
  top: -100%;
  left: 12px;
  z-index: 100;
  padding: 10px 16px;
  border-radius: var(--fi-r-sm);
  background: var(--brand-primary);
  color: var(--brand-on-primary);
  font-weight: 600;
  text-decoration: none;
}
.skip-link:focus-visible {
  top: 12px;
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}

/* --- Site chrome ---------------------------------------------------------- */

.topbar {
  position: sticky;
  top: 0;
  z-index: 20;
  display: flex;
  align-items: center;
  gap: 24px;
  padding: 0 24px;
  height: 60px;
  background: color-mix(in srgb, var(--surface-1) 88%, transparent);
  backdrop-filter: blur(12px);
  border-bottom: 1px solid var(--border);
}

/* ---------------------------------------------------------------------------
   The header lockup.

   This is the artwork, not a heading: §4 forbids re-typesetting the wordmark,
   and what stood here was the mark beside "Forever Imprinted" set in the UI
   face — a second wordmark, in a second typeface, on every page.

   The compact horizontal lockup is the variant §4 specifies below ~180px
   displayed width, and 144px sits mid-range. Its clear space is the height of
   the heart in the monogram, which measures 17.29 units in a 347-wide artwork
   — 4.98%, so 7.2px at this size. The 12px gaps and the 9px above and below
   inside a 60px bar all clear it.
   --------------------------------------------------------------------------- */
.brand {
  display: flex;
  align-items: center;
  gap: 12px;
  text-decoration: none;
  white-space: nowrap;
}
.brand-lockup {
  width: 144px;
  height: auto;
  display: block;
  flex: none;
}
/*
  The same lockup for the dark shell.

  The colour artwork draws its wordmark in --fi-ink, which on the dark page
  measures 1.6 and effectively disappears, leaving a gold ring beside nothing.
  §4 keeps the mono variant for exactly this — a dark background — and it
  paints through `currentColor`, which an <img> cannot reach. A mask can: the
  glyph shapes come from the file, the colour from the page.
*/
.brand-lockup-mono {
  display: none;
  width: 144px;
  height: 41.5px;                /* the artwork is 347 × 100 */
  flex: none;
  background-color: var(--text-primary);
  -webkit-mask: url("/img/lockup-horizontal-mono.svg") no-repeat center / contain;
  mask: url("/img/lockup-horizontal-mono.svg") no-repeat center / contain;
}
/* Which of the two rooms this is. A scored rule keeps it clearly outside the
   lockup rather than reading as part of the wordmark. */
.brand-rule {
  width: 1px;
  height: 24px;
  flex: none;
  background: var(--border);
}
.brand-label {
  font-size: 11px;
  line-height: 1.3;
  font-weight: 600;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--text-muted);
}
@media (max-width: 720px) {
  .brand-rule, .brand-label { display: none; }
}

/* --- Global search ---------------------------------------------------------
   One box in the header across orders, items, products, moodboards and
   notes. `margin-left: auto` moves here from .nav so the field takes the slack
   between the brand and the navigation rather than being pinned to either. */
.global-search {
  position: relative;
  margin-left: auto;
  flex: 0 1 340px;
  min-width: 0;
}
.global-search-icon {
  position: absolute;
  left: 11px;
  top: 50%;
  transform: translateY(-50%);
  color: var(--text-muted);
  pointer-events: none;
}
/* The :not() pair is not decoration — it matches the shape of the generic well
   rule in §Forms, which is written `input:not([type="checkbox"]):not([type="radio"])`
   and sits further down the file. A plain `.global-search input` scores lower
   than that, so the well's own 11px of padding won and left the placeholder
   sitting under the magnifier. This has to outrank it for the text to start
   clear of the icon.

   appearance is dropped for the same reason from the other side: Safari's
   native search field ignores padding outright. Its clear button is hidden
   below anyway, so there is nothing to lose by opting out of the widget. */
.global-search input:not([type="checkbox"]):not([type="radio"]) {
  appearance: none;
  -webkit-appearance: none;
  width: 100%;
  height: 36px;
  padding: 0 32px 0 33px;
  border-radius: var(--fi-r-sm);
  border: 1px solid var(--border);
  background: var(--surface-1);
  color: var(--text-primary);
  font-family: inherit;
  /* Size comes from the well rule (14px) so the 16px bump at the phone
     breakpoint — the one that stops iOS zooming the page — still lands. */
}
.global-search input::placeholder { color: var(--text-muted); }
/* Same guard again: the rest state above now sets the border outright, so the
   focused state has to outrank it as well as the well rule. */
.global-search input:not([type="checkbox"]):not([type="radio"]):focus {
  border-color: var(--border-strong);
  background: var(--surface-1);
}
/* The browser's own clear button on type="search" sits where the hint is. */
.global-search input::-webkit-search-cancel-button { display: none; }

/* The "/" hint, hidden the moment the field is in use — it is a thing to
   discover once, not a thing to read while typing. */
.global-search-hint {
  position: absolute;
  right: 9px;
  top: 50%;
  transform: translateY(-50%);
  padding: 1px 6px;
  border: 1px solid var(--border);
  border-radius: var(--fi-r-sm);
  font-family: inherit;
  font-size: 11.5px;
  color: var(--text-muted);
  pointer-events: none;
}
.global-search input:focus ~ .global-search-hint,
.global-search input:not(:placeholder-shown) ~ .global-search-hint { display: none; }

.global-search-results {
  position: absolute;
  top: calc(100% + 6px);
  left: 0;
  right: 0;
  max-height: min(70vh, 520px);
  overflow-y: auto;
  z-index: 40;
  background: var(--surface-1);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  box-shadow: var(--fi-raise);
  padding: 6px;
}
.global-search-results .gs-group {
  padding: 9px 10px 5px;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-muted);
}
.global-search-results .gs-hit {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 2px 9px;
  width: 100%;
  padding: 8px 10px;
  border: 0;
  border-radius: var(--fi-r-sm);
  background: none;
  font: inherit;
  color: var(--text-primary);
  text-align: left;
  cursor: pointer;
}
.global-search-results .gs-hit:hover,
.global-search-results .gs-hit[aria-selected="true"] { background: var(--surface-2); }
/* The title at its own width, up to three fifths of the row, and the line
   beside it in whatever is left, cut with an ellipsis. Both used to start from
   their own width and give way in proportion to it, which was fine while every
   line beside a title was a SKU or a customer's name — and which, the day a
   long line arrived, gave way on the title's side down to one word a line.
   The three fifths is so a long title still leaves its neighbour something to
   say. With the line beside it starting from nothing, a row wraps only where
   something asks for a line of its own: a note's words, below. */
.global-search-results .gs-hit .gs-title { font-weight: 600; flex: 0 1 auto; max-width: 60%; }
.global-search-results .gs-hit .gs-sub,
.global-search-results .gs-hit .gs-words {
  color: var(--text-secondary);
  font-size: 13px;
  flex: 1 1 0;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* A note hit's second line: the words around the match, out of the note
   itself (#222). A line of its own and the whole width of the row, because it
   is the one line here that is prose — beside the title it had room for a few
   words at the width this box is. `searchHitHtml` in search.js. */
.global-search-results .gs-hit .gs-words { flex-basis: 100%; }
.global-search-results .gs-empty { padding: 16px 12px; color: var(--text-muted); font-size: 13.5px; }
.global-search-results .gs-note {
  padding: 8px 12px 4px;
  color: var(--text-muted);
  font-size: 12px;
  border-top: 1px solid var(--border);
  margin-top: 4px;
}

/* Below the tab strip's breakpoint the header is already tight; the box drops
   to its own line rather than squeezing the brand out. */
@media (max-width: 860px) {
  .topbar { flex-wrap: wrap; height: auto; padding: 10px 16px; gap: 10px; }
  .global-search { order: 3; flex: 1 1 100%; margin-left: 0; }
  .nav { margin-left: auto; }
}

.nav { display: flex; gap: 4px; align-items: center; }
.nav a, .nav button.linklike {
  padding: 7px 12px;
  border-radius: var(--fi-r-sm);
  font-size: 14px;
  color: var(--text-secondary);
  text-decoration: none;
  background: none;
  border: 0;
  cursor: pointer;
}
.nav a:hover, .nav button.linklike:hover { background: var(--surface-2); color: var(--text-primary); }
.nav a.active { color: var(--text-primary); font-weight: 600; background: var(--surface-2); }

.icon-btn {
  width: 34px; height: 34px;
  display: grid; place-items: center;
  border-radius: var(--fi-r-sm);
  /* Part 6 (1.4.11): was --border, 1.47:1 light / 1.40:1 dark — under 3:1.
     --border-strong clears 3:1 in both (see its own comment in the tokens). */
  border: 1px solid var(--border-strong);
  background: var(--surface-1);
  color: var(--text-secondary);
  cursor: pointer;
}
.icon-btn:hover { background: var(--surface-2); color: var(--text-primary); }

/* --- Buttons -------------------------------------------------------------- */

/* §10: primary is an ink fill with a cream label; secondary is a 1px rule and
   nothing else. 3px radius throughout — nearly square, so a button reads as
   pressed into the page rather than as a bubble sitting on it. */
.btn {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 9px 16px;
  border-radius: var(--fi-r-sm);
  border: 1px solid var(--border-strong);
  background: var(--surface-1);
  color: var(--text-primary);
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  text-decoration: none;
  white-space: nowrap;
  transition: background 120ms ease, border-color 120ms ease;
}
.btn:hover { background: var(--surface-2); }
.btn-primary {
  background: var(--brand-primary);
  border-color: var(--brand-primary);
  color: var(--brand-on-primary);
}
.btn-primary:hover { filter: brightness(1.15); background: var(--brand-primary); }
.btn-sm { padding: 5px 10px; font-size: 13px; }
.btn-danger { color: var(--status-critical); border-color: color-mix(in srgb, var(--status-critical) 40%, transparent); }
/* `aria-disabled` is the busy state: a button that must keep the keyboard
   while its request runs (Part 4 of the September audit), so its handler
   guards on the attribute instead of the browser refusing the press. */
.btn:disabled, .btn[aria-disabled="true"] { opacity: 0.5; cursor: not-allowed; }

/*
  The plus comes off the action that makes nothing.

  `#addItemBtn` beside the page heading is a plus and a noun — New item, New
  order, New product — because on nine of the ten panels the primary action
  creates something. Shipping's does not: what that screen wants pressed is
  "Check every parcel now", which asks the carrier about parcels that already
  exist. §8 wants a primary action in the title row on every panel, always, so
  the button stays; a plus in front of a verb that creates nothing would be a
  smaller version of the same lie the map in public/js/dashboard/tabs.js was
  written to stop telling, so the sign comes off instead. `syncPrimaryAction`
  puts this class on and takes it off.
*/
.btn.no-plus .plus-icon { display: none; }

/*
  The refresh button, working.

  The arrow turns while the fetch is in flight, because a refresh that reaches
  the same numbers is indistinguishable from a button that did nothing — and a
  studio that cannot tell the two apart presses it again instead of trusting it.
  Held still for anyone who has asked their machine for less movement.
*/
.btn.is-busy .refresh-icon { animation: spin 900ms linear infinite; transform-origin: 50% 50%; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  .btn.is-busy .refresh-icon { animation: none; opacity: 0.6; }
}

/* --- Layout --------------------------------------------------------------- */

.wrap { max-width: 1280px; margin: 0 auto; padding: 28px 24px 64px; }
.section-head {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 16px;
  flex-wrap: wrap;
  margin-bottom: 16px;
}
.section-head p { margin: 4px 0 0; color: var(--text-secondary); font-size: 14px; }
/*
  Figures old enough that somebody should press refresh before trusting them.

  Marked with a dot rather than by colouring the sentence: amber text on this
  background is the one thing on the page nobody could read, and the warning is
  wasted on whoever needs it most.
*/
.section-head p.stale { color: var(--text-primary); font-weight: 550; }
.section-head p.stale::before {
  content: ''; display: inline-block; vertical-align: middle;
  width: 8px; height: 8px; margin-right: 7px; border-radius: 50%;
  background: var(--status-warning);
}
/* Micro (§6): 11 / 1.3, Source Sans 600, +0.09em, uppercase. The one place
   the studio sets type in capitals. */
.eyebrow {
  font-size: 11px;
  line-height: 1.3;
  font-weight: 600;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--text-muted);
}

/* §2: depth goes inward. A card is set into the cream — inset top shadow, a
   1px light bottom edge, one hairline border — not floated above it. Drop
   shadows are kept for the things that genuinely float: modals, menus, toasts,
   tooltips. */
.card {
  background: var(--surface-1);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--fi-press);
}
/*
  A card's head wraps rather than pushing the page sideways.

  Left on one line it cannot fit, a `.card-head` is a flex row of items that
  will not shrink past their own content — a heading and its sub-line on the
  left, and on the right whatever the card puts there. The Production load card
  puts a button and a nowrap badge there, and at 390px "Every step" plus
  "14.9h hands-on · 30d 14h waiting" plus the heading came to more than the
  342px inside `.wrap`, so the overview scrolled sideways by 16px. Wrapping
  costs nothing at any width where the row does fit — a flex line only breaks
  when the alternative is to overflow — and this is the §8 promise at the foot
  of the file again: the studio has to be usable at 640px.
*/
.card-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 12px;
  padding: 16px 20px;
  border-bottom: 1px solid var(--border);
}
/* Part 6: the page's 35 panel/card titles were relevelled from h3 to h2
   (2.4.1/1.3.1 — they used to follow the page's one h1 directly, with no h2
   anywhere on the page). `h1, h2 { font-family: var(--display) }` above
   would otherwise put an h2 title in the serif display face and 0.03em
   tracking; that is cancelled below so the look is exactly what h3 rendered
   before. The size rule stays on h3 too — a card-head title is still h3 in
   46 dialogs, in config.js's Configuration cards and on status.html, none of
   which this part relevels, and this was the only font-size any of them had. */
.card-head h2, .card-head h3 { font-size: 15px; }
.card-head h2 { font-family: var(--sans); line-height: 1.3; letter-spacing: normal; }
.card-head .sub { font-size: 12.5px; color: var(--text-muted); margin: 2px 0 0; }
.card-body { padding: 20px; }
.card-body.flush { padding: 0; }

.grid { display: grid; gap: 16px; }
/*
 * A card in a grid is sized by its track, not by the widest table inside it.
 *
 * `min-width` on a grid item defaults to `auto`, which means "never narrower
 * than your own content" — and a `.card` holding a `.table-scroll` has the
 * whole table's min-content inside it, because `overflow-x: auto` makes a box
 * scrollable without making it narrow. So on the overview at 390px the two
 * cards in the lower `.grid-2` came out 504px wide inside a 342px column and
 * the *document* scrolled sideways to fit them: 528 against a 390 viewport,
 * measured with the tab strip taken out of the way. The scan never saw this
 * one — finding 1's 912px strip was wider than it and hid it — and the whole
 * point of `.table-scroll` is that the table scrolls instead of the page.
 *
 * On `.grid` rather than on `.grid-2`, which is where it was measured: the
 * modifiers only choose the tracks, the trap belongs to any grid item that
 * grows a table, and the tracks that have a floor keep it — `minmax(210px,
 * 1fr)` is still 210px wide whatever its item says.
 */
.grid > * { min-width: 0; }
.grid-kpi { grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); }
.grid-2 { grid-template-columns: 1.35fr 1fr; }
/*
 * One column below 1060, and it used to be 940.
 *
 * 940 reads like "a little under a small laptop" and is about a hundred pixels
 * short of what these two columns actually need. An `fr` track will not shrink
 * past its own min-content, so between 941 and roughly 1040 the grid held two
 * columns whose combined minimum was wider than the page and the *document*
 * scrolled sideways to make room: the September 2026 scan measured
 * `documentElement.scrollWidth` at 1042 against a 1024x768 viewport — an 18px
 * sideways drag on one of the commonest laptop widths there is. Finding 8 of
 * docs/ux-scan-2026-09.md.
 *
 * 1060 is that measurement with a little over it rather than a round number
 * picked for reading like one. A breakpoint at exactly 1042 would put the fault
 * straight back the first time a figure in one of these cards grows a digit.
 *
 * It is deliberately not one of §8's 640 / 900 / 1200 stops, and 1200 was the
 * tempting alternative. It would be on-system and it would certainly be wide
 * enough — but it would also stack these cards on every screen between 1060 and
 * 1200 that has the room to sit them side by side, and side by side is the
 * arrangement the overview and the status page are laid out around. Nothing
 * else in this file keyed off 940; it appeared here and nowhere else.
 */
@media (max-width: 1060px) { .grid-2 { grid-template-columns: 1fr; } }

/* --- Stat tiles ----------------------------------------------------------- */

.stat { padding: 18px 20px; }
.stat .label { font-size: 13px; color: var(--text-secondary); }
/* A figure, not a title: Source Sans with tabular digits, so a column of them
   lines up and none of Cormorant's thins land on a number that has to be read
   at a glance. */
.stat .value {
  margin-top: 6px;
  font-size: 30px;
  font-weight: 600;
  line-height: 1.1;
}
.stat .value.hero { font-size: 48px; }
.stat .meta { margin-top: 6px; font-size: 12.5px; color: var(--text-muted); }
.stat .meta strong { color: var(--text-secondary); font-weight: 600; }
.stat.alert .value { color: var(--status-critical); }
.stat .meta.good { color: var(--success-text); }

/*
  A tile that is the way to its own answer.

  All four overview tiles are anchors now — see the long note above them in
  public/dashboard.html — so this is what an `a` has to be given back before it
  can look like the `div` it replaced, and what it has to be given so that it
  reads as pressable. `color` is already handled: `a { color: inherit }` sits at
  the top of this file, which is why only the underline is taken off here.

  `display: block` matters more than it looks. An inline `a` wrapping three
  block children generates a box per line and the tile's border would be drawn
  round each fragment; as a block it is one rectangle, and the whole rectangle
  is the hit target rather than the text inside it.

  The hover pair is the one this file already uses for "this row is the one you
  are pointing at" — `--surface-2` behind it, `--border-strong` round it, the
  same two the picker results and the table rows reach for. §15 asks for a new
  pairing to be measured, and a hover that changes the ground under three tiers
  of text is three new pairings. Read off the running page in Chromium, on the
  resolved colours rather than off the tokens: the figure lands at 8.88:1 in
  light and 13.51:1 in dark, and the 12.5px meta line — the smallest text
  involved and the one at risk — at 5.16:1 and 5.30:1. All four clear the 4.5
  floor; the meta line loses about a point of headroom in light against the
  6.10:1 it has at rest, which is the whole of what this hover costs.

  `position: relative` is here for `.stat-jump` below, which is absolutely
  positioned and would otherwise be laid out against whichever distant ancestor
  happens to be positioned.

  The 120ms fade is not switched off under `prefers-reduced-motion`, and that is
  a decision rather than an oversight. The one other transition-like thing in
  this file that is switched off is the refresh spinner, which rotates; this
  moves nothing, and the setting is about motion — a colour arriving over an
  eighth of a second cannot cause the vestibular trouble it exists to prevent.
  The scroll it leads to is a different matter, and is switched off, at the top
  of this file.
*/
a.stat {
  display: block;
  position: relative;
  text-decoration: none;
  transition: background-color 120ms ease, border-color 120ms ease;
}
a.stat:hover { background: var(--surface-2); border-color: var(--border-strong); }

/*
  Where the tile goes, for anyone who cannot see the cursor change.

  Taken out of the page rather than hidden: `display: none` and
  `visibility: hidden` are both removed from the accessibility tree as well as
  from the screen, which would leave the sentence doing nothing for the one
  reader it is written for. The 1px box with `clip-path` is the arrangement
  that keeps it in the tree, and `white-space: nowrap` stops it being wrapped
  into a tall column inside that box by a browser that lays it out before
  clipping it.
*/
.stat-jump {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/*
  The section a tile just sent somebody to.

  The overview is nine cards that all look alike, and a jump that ends with the
  right one at the top of the screen still leaves it looking exactly like the
  two below it. This is the answer to "which of these did I ask for?" — a rule
  down the left edge of the card the address is naming, which survives until the
  address changes.

  **Ink and not gold**, which is the second choice here and the right one. Gold
  is §5.2's decoration colour and this is decoration, so it was the obvious
  reach — but `--brand-accent` in the light theme resolves to exactly the colour
  of the focus ring declared at the top of this file, and a mark that outlives
  the keypress must not be the same colour as the one that does not.
  `--brand-primary` is already what this page means by "this one": it is the
  underline under the selected tab, eight lines of markup above. On dark the
  question does not arise — both tokens re-derive to `--fi-frame` there, so the
  swap changes nothing but the light theme, where it roughly triples the
  contrast against the card.

  An inset shadow rather than a border, because a border would move the card by
  a pixel on arrival. `--fi-press` is repeated because a `box-shadow` list
  replaces rather than adds, and dropping it would flatten the card out of §2's
  pressed-into-the-page depth for as long as the hash pointed at it.
*/
.jump-target:target { box-shadow: var(--fi-press), inset 3px 0 0 var(--brand-primary); }

/* --- Tables --------------------------------------------------------------- */

.table-scroll { overflow-x: auto; }

/*
 * The line under a paged table: how many are showing, and the way to see more.
 *
 * P6 in PLAN.md. Outside `.table-scroll`, so the button stays put when a wide
 * table is scrolled sideways, and `hidden` until there is something behind the
 * page — an empty strip under every table is chrome these screens do not need.
 */
.table-more {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 10px 16px;
  border-top: 1px solid var(--border);
}
.table-more[hidden] { display: none; }
table.data {
  width: 100%;
  border-collapse: collapse;
  font-size: 14px;
}
table.data th {
  text-align: left;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--text-muted);
  padding: 10px 14px;
  background: var(--surface-2);
  border-bottom: 1px solid var(--border);
  white-space: nowrap;
  position: sticky;
  top: 0;
}
table.data th.sortable { cursor: pointer; user-select: none; }
table.data th.sortable:hover { color: var(--text-primary); }
/* The header's words are a button so the keyboard can sort (see `sortHeader`
   in dom.js); it wears the header's type and colour and nothing of its own. */
table.data th.sortable .sort-btn {
  font: inherit; letter-spacing: inherit; text-transform: inherit; color: inherit;
  background: none; border: 0; padding: 0; text-align: inherit; cursor: pointer;
}
table.data td {
  padding: 11px 14px;
  border-bottom: 1px solid var(--gridline);
  vertical-align: middle;
}
table.data tbody tr:hover { background: var(--surface-2); }
table.data tbody tr:last-child td { border-bottom: 0; }
/* Give the name column room so titles don't wrap into narrow towers, and
   weight it: §8 makes the first column the one you find the row by. */
table.data td.name-cell { min-width: 190px; font-weight: 600; }
table.data th.name-col { min-width: 190px; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.muted { color: var(--text-muted); }
.secondary { color: var(--text-secondary); }
.nowrap { white-space: nowrap; }
.strong { font-weight: 600; }
.mono {
  font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
  font-size: 12.5px;
  color: var(--text-secondary);
}

.empty {
  padding: 44px 20px;
  text-align: center;
  color: var(--text-muted);
  font-size: 14px;
}

/* --- A row's other actions ------------------------------------------------ */

/*
 * The `⋯` at the end of a row, and the menu it opens.
 *
 * The Inventory table carried five outlined buttons on each of 32 rows — 160 of
 * them on one screen, all the same shape, competing with the SKU and the
 * quantity for the eye. It also did not fit: the table needs 1,279px and the
 * scroller around it is 1,230px at *every* viewport width, because `.wrap`
 * caps at 1,280px, so Delete was cut off by 49px at 1280, at 1440 and at 1920
 * alike. Findings 2 and 7 of docs/ux-scan-2026-09.md. One action stays out and
 * four come in here; the long note in public/js/dashboard/inventory.js says why
 * the one that stays is Stock and why there is a single menu rather than 32.
 */
.btn-more {
  /* Square, because the label is one glyph. The generic `.btn-sm` padding is
     written for a word and would leave the ellipsis adrift in the middle of a
     box the width of "Reduce". */
  padding-inline: 9px;
  font-size: 16px;
  line-height: 1;
}
.btn-more[aria-expanded="true"] { background: var(--surface-2); border-color: var(--text-secondary); }

/*
 * `position: fixed`, in the body, and both halves of that are forced.
 *
 * `.table-scroll` is `overflow-x: auto`, and the overflow specification does
 * not let the two axes disagree in that direction: an unset `overflow-y`
 * beside a scrolling `overflow-x` computes to `auto` as well. So a menu drawn
 * inside the scroller is clipped at the bottom row and drags the table sideways
 * from the last column — which is the fault this whole change exists to remove,
 * reappearing one level down. Fixed positioning is the way out of a scrollport,
 * and it only stays true where no ancestor has a transform or a filter to
 * become its containing block, which on this page means the body.
 *
 * `--fi-raise` and not `--fi-press`: §2 sets depth *inward* here and cards are
 * pressed into the cream for that reason, but this is the exception the token
 * block already names — "kept for things that genuinely float". A menu is over
 * the table, not in it, and a pressed menu would read as part of the row it is
 * covering.
 */
.row-menu {
  position: fixed;
  z-index: 70;
  min-width: 236px;
  max-width: 320px;
  /* Four entries do not reach this at any window a browser will hand you — the
     clamp in `openRowMenu` is what keeps the box on screen, and this on its own
     fixes nothing, measured. It is here for the fifth entry: a fixed menu taller
     than the window cannot be reached by scrolling the page, so the scroller has
     to be its own. `closeOnScroll` ignores scrolls that start inside it, or this
     would be a bar that closes what it is scrolling. */
  max-height: calc(100vh - 16px);
  overflow-y: auto;
  padding: 5px;
  border: 1px solid var(--border-strong);
  border-radius: var(--fi-r-md);
  background: var(--surface-1);
  box-shadow: var(--fi-raise);
}
.row-menu [role="menuitem"] {
  display: block;
  width: 100%;
  box-sizing: border-box;
  padding: 8px 10px;
  border: 0;
  border-radius: var(--fi-r-sm);
  background: none;
  color: var(--text-primary);
  font-size: 13.5px;
  font-weight: 600;
  text-align: left;
  cursor: pointer;
}
.row-menu [role="menuitem"]:hover { background: var(--surface-2); }
/* The keyboard's equivalent of the hover, and it has to be said separately: the
   entries are reached with the arrow keys rather than with Tab, and a browser
   does not call that a `:focus-visible`. Without this the arrow keys would move
   a gold ring around a menu that never changed which row was lit. */
.row-menu [role="menuitem"]:focus { background: var(--surface-2); }
/* The second line: what the entry does, or why it will not. Regular weight and
   secondary colour so the verb above stays the thing being read. */
.row-menu .menu-note {
  display: block;
  margin-top: 2px;
  font-size: 12px;
  font-weight: 400;
  line-height: 1.45;
  color: var(--text-secondary);
}
.row-menu .menu-sep {
  height: 1px;
  margin: 5px 4px;
  background: var(--gridline);
}

/*
 * Delete, told apart from the other three by four things, three of which are
 * not colour.
 *
 * §15 is explicit that status is never carried by colour alone, and the one
 * irreversible action in a menu is exactly the case it is written for. So:
 * it is **last**, it is **under a rule** that nothing else in the menu is under,
 * and it **says what it costs** on its second line — "For good — the stock
 * history goes with it", or, where something still points at the item, the
 * sentence naming what. The critical colour is the fourth signal rather than
 * the only one, and it is the same pairing `.btn-danger` already puts on this
 * surface, so it introduces nothing the contrast sweep has not already measured.
 *
 * Archive is deliberately *not* red here, and it used to be — it was
 * `.btn btn-sm btn-danger` in the row. Two red entries beside each other is
 * neither of them reading as the destructive one, and archiving is the
 * recoverable half of the pair: the confirmation on it says so in its own words,
 * "It stays in the database and in stock history, just hidden from the list."
 */
.row-menu .menu-danger { color: var(--status-critical); }
.row-menu .menu-danger .menu-note { color: var(--status-critical); }
/*
 * The refusing Delete — see `deleteMenuItem`. It keeps the colour and the
 * position, because it is still the entry somebody came to the menu for and
 * hiding it would read as the feature not existing, and it loses the pointer
 * and the hover, because it is an answer rather than a control.
 * `aria-disabled` and not `disabled`: the attribute would take it out of the
 * focus order, and the arrow keys would then walk past the only entry with a
 * reason to give.
 *
 * **It had `opacity: 0.72` here and that was a §15 failure, measured.** Fading
 * a whole entry is the ordinary way to say "unavailable", and it works by
 * compositing every pixel of it towards the background — including the text.
 * Sampling the painted glyphs on the live page put this entry at **3.47:1 in
 * light and 4.23:1 in dark**, both under the 4.5:1 floor, and the line it was
 * costing most is the sentence saying *why* the item cannot be deleted. The
 * one entry in this menu that is nothing but an explanation was the one hardest
 * to read. It is at full strength now, and what says it is not a control is
 * that it does not light up under the pointer and does not move when pressed —
 * which is what "not a control" actually looks like, rather than a colour
 * washed halfway out.
 */
.row-menu [aria-disabled="true"] { cursor: default; }
/* `:not(:focus)` so the hover cannot cancel the keyboard's own highlight: the
   arrow keys can land here while the pointer happens to be resting on it. */
.row-menu [aria-disabled="true"]:hover:not(:focus) { background: none; }

/* --- Badges & pills ------------------------------------------------------- */

/* §10: tint background, status foreground, 3px radius, Micro type, a 6px
   leading dot — and the word always present. The pill shape went: §7 keeps
   fully-round for the logo's oval and ring and nothing else. */
.badge {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 2px 8px;
  border-radius: var(--fi-r-sm);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.04em;
  white-space: nowrap;
  border: 1px solid var(--border);
  background: var(--surface-2);
  color: var(--text-secondary);
}
.badge .dot { width: 6px; height: 6px; border-radius: 50%; flex: none; }

/*
  A badge that is a paragraph is a sentence, and a sentence wraps.

  `white-space: nowrap` above is right for what a badge usually is — a two-word
  chip in a table cell that must not break across lines. It is wrong for the
  other thing this class is used for, which is a full-width banner inside a
  card: "No shipping provider is enabled. Turn one on in Configuration →
  Shipping." is 409px of unbreakable text, and on the Shipping tab at 390px it
  was what made the document scroll sideways — 454 against a 390 viewport, with
  the tab strip taken out of the way. There are two dozen of these across
  config.js, orders.js, custody.js and shipping.js, and mapping.js had already
  met the same wall and worked around it with `white-space:normal` in a `style`
  attribute on its own one.

  `p.badge` rather than a new class, because the `<p>` is already the difference
  between the two uses and every one of those two dozen is a paragraph.
*/
p.badge { white-space: normal; }

/*
  A badge that is a link, which today is one thing: the moodboard chip on the
  face of a board card, pointing at `#moodboards?open=12`.

  An `<a>` and not a button, because that address is a real one — the Back
  button, a middle-click and a pasted link all have to work, and a handler
  cannot give them. What the anchor brings with it is the underline, which on a
  chip this size reads as a line through the number rather than as a link, so
  it is taken off and the hover does the work instead — the same pair `a.stat`
  above makes for the overview's tiles, and the focus ring is untouched.
*/
a.badge { text-decoration: none; }
/* Part 6 (2.5.8): a `.badge` that is a link is the one that is a target —
   ~20px tall before this; a 24px minimum block height gets there without
   touching the plain (non-link) badge's smaller footprint in a table cell. */
a.badge { min-height: 24px; }
a.badge:hover { border-color: var(--border-strong); color: var(--text-primary); }


/* Etsy-sourced orders. Tinted, never a status colour — where an order came
   from is not a state it is in. */
.badge.etsy {
  background: var(--surface-2);
  border-color: var(--border-strong);
  color: var(--text-primary);
}

/* Status colors always ship with a label, never color alone. Foreground on its
   own tint measures 5.11–6.29 in both themes. */
.badge.ok       { color: var(--fi-ok);       background: var(--fi-ok-tint);        border-color: color-mix(in srgb, var(--fi-ok) 35%, transparent); }
.badge.warn     { color: var(--fi-attention); background: var(--fi-attention-tint); border-color: color-mix(in srgb, var(--fi-attention) 35%, transparent); }
.badge.critical { color: var(--fi-blocked);  background: var(--fi-blocked-tint);   border-color: color-mix(in srgb, var(--fi-blocked) 35%, transparent); }
.badge.info     { color: var(--fi-info);     background: var(--fi-info-tint);      border-color: color-mix(in srgb, var(--fi-info) 35%, transparent); }

/* --- Meter (stock level vs reorder point) --------------------------------- */

.meter {
  display: block;          /* the span is inline by default — height needs a block box */
  position: relative;
  height: 6px;
  min-width: 72px;
  border-radius: var(--fi-r-sm);
  background: var(--gridline);
  overflow: hidden;
}
.meter > span {
  display: block;
  height: 100%;
  border-radius: var(--fi-r-sm);
  background: var(--seq-400);
}
.meter.low > span { background: var(--status-warning); }
.meter.out > span { background: var(--status-critical); }

/* --- Forms ---------------------------------------------------------------- */

.field { display: flex; flex-direction: column; gap: 5px; }
.field label { font-size: 12.5px; font-weight: 600; color: var(--text-secondary); }
/* Part 6 (3.3.2): the 38 required fields carried `required` and nothing a
   sighted user could see before submitting. `aria-hidden` because the
   control's own `required` is what a screen reader announces already. */
.required-mark { color: var(--status-critical); margin-left: 2px; }
/* An input is a well: pressed in, 3px radius, hairline border. Boxes and radios
   are left to the platform — they are controls, not wells. */
input:not([type="checkbox"]):not([type="radio"]), select, textarea {
  font: inherit;
  font-size: 14px;
  padding: 8px 11px;
  border-radius: var(--fi-r-sm);
  border: 1px solid var(--border-strong);
  background: var(--surface-1);
  box-shadow: var(--fi-press);
  color: var(--text-primary);
  width: 100%;
}
/* The ring is gold, in both themes, and it is the same ring as everywhere else
   — the blue outline that used to be here came from the chart ramp, which is a
   magnitude encoding and has no business marking focus.

   The `:not()` pair is the well rule's above, repeated, and not decoration:
   `:not()` lends its argument's specificity, so the well is (0,2,1) and this
   rule, written as a plain `input:focus` (0,1,1), lost to it for everything but
   `outline: none`. No text input in the application showed focus. Matching the
   shape and adding `:focus` makes it (0,3,1). No `!important`, which would also
   have won and would have flattened every deliberate override below it —
   `.board-setup-colour` among them. The well's `--fi-press` is left to stand:
   the ring is an outline now, so the input keeps its depth while focused. */
input:not([type="checkbox"]):not([type="radio"]):focus, select:focus, textarea:focus {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
  border-color: var(--fi-gold);
}
textarea { resize: vertical; min-height: 68px; }
.field .hint { font-size: 12px; color: var(--text-muted); }
/* A field `markInvalid` (dashboard/api.js) has said something about. The same
   (0,3,1) shape as the focus rule and after it, so a focused invalid field
   keeps the red border inside the gold ring rather than losing it to focus.
   The sentence is beside it in words, so the colour is not the only signal. */
input:not([type="checkbox"]):not([type="radio"])[aria-invalid="true"],
select[aria-invalid="true"], textarea[aria-invalid="true"] {
  border-color: var(--status-critical);
}
.field-error { font-size: 12px; font-weight: 600; color: var(--status-critical); }

/* A link that sits inside a sentence but does something on this page rather
   than going anywhere — "create a new customer" in the order form. */
.linkish {
  font: inherit;
  color: var(--text-primary);
  background: none;
  border: 0;
  padding: 0;
  width: auto;
  text-decoration: underline;
  text-underline-offset: 2px;
  cursor: pointer;
}
.linkish:hover { color: var(--brand-primary, var(--text-primary)); }

/* --- Search-and-pick field ------------------------------------------------ */

/* A search box whose matches drop over what follows it, so choosing somebody
   already on file never pushes the rest of the form down the screen. */
.picker { position: relative; }
.picker-results {
  position: absolute;
  z-index: 5;
  top: calc(100% + 4px);
  left: 0;
  right: 0;
  max-height: 232px;
  overflow-y: auto;
  padding: 4px;
  border: 1px solid var(--border-strong);
  border-radius: var(--fi-r-md);
  background: var(--surface-1);
  box-shadow: var(--fi-raise);
}
.picker-results button {
  display: block;
  width: 100%;
  text-align: left;
  font: inherit;
  padding: 7px 9px;
  border: 0;
  border-radius: var(--fi-r-sm);
  background: none;
  color: var(--text-primary);
  cursor: pointer;
}
.picker-results button:hover, .picker-results button:focus-visible,
.picker-results button[aria-selected="true"] { background: var(--surface-2); }
.picker-results .line2 { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
.picker-results .none { padding: 8px 9px; font-size: 12.5px; color: var(--text-muted); }

/* The person picked, sitting under the search box until they are cleared. */
.picked {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  padding: 9px 11px;
  border: 1px solid color-mix(in srgb, var(--status-good) 35%, transparent);
  border-radius: var(--fi-r-sm);
  background: color-mix(in srgb, var(--status-good) 8%, var(--surface-1));
  font-size: 13px;
}
.picked .who { font-weight: 600; }
.picked .spacer { flex: 1; }

.toolbar {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
  align-items: center;
  padding: 14px 20px;
  border-bottom: 1px solid var(--border);
}
.toolbar .search { flex: 1 1 240px; max-width: 340px; }
.toolbar select { width: auto; min-width: 130px; }
.toolbar .spacer { flex: 1; }

.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 14px; }
.form-grid .span-2 { grid-column: span 2; }
/* Full width whatever the column count works out to — for a block of
   checkboxes, which reads badly squeezed into half a row. */
.form-grid .span-all { grid-column: 1 / -1; }
@media (max-width: 560px) { .form-grid .span-2 { grid-column: span 1; } }

/* --- Modal ---------------------------------------------------------------- */

dialog.modal {
  width: min(680px, calc(100vw - 32px));
  padding: 0;
  border: 1px solid var(--border);
  border-radius: var(--fi-r-lg);
  background: var(--surface-1);
  color: var(--text-primary);
  box-shadow: var(--fi-raise);
}
dialog.modal::backdrop { background: rgba(0, 0, 0, 0.45); backdrop-filter: blur(2px); }
dialog.modal .card-head { padding: 18px 22px; }
dialog.modal .modal-body { padding: 22px; max-height: 68vh; overflow-y: auto; }
dialog.modal .modal-foot {
  display: flex;
  justify-content: flex-end;
  gap: 10px;
  padding: 16px 22px;
  border-top: 1px solid var(--border);
  background: var(--surface-2);
  border-radius: 0 0 var(--fi-r-lg) var(--fi-r-lg);
}

/* --- Charts --------------------------------------------------------------- */

.chart { width: 100%; display: block; overflow: visible; }
.chart text { font-family: var(--sans); }
.chart .axis-label { font-size: 11px; fill: var(--text-muted); }
.chart .value-label { font-size: 11.5px; fill: var(--text-secondary); font-weight: 600; }
.chart .gridline { stroke: var(--gridline); stroke-width: 1; }
/* The figures a chart plots, as a table behind a disclosure under it — the
   keyboard's and the screen reader's way to the numbers the tooltip shows a
   mouse. See `numbersHtml` in public/js/charts.js. */
.chart-numbers { margin-top: 10px; }
.chart-numbers summary { cursor: pointer; font-size: 13px; color: var(--text-secondary); width: fit-content; }
.chart-numbers table { margin-top: 8px; }
.chart .baseline { stroke: var(--baseline); stroke-width: 1; }
.chart .hit { fill: transparent; cursor: pointer; }
.chart .hit:hover + .mark, .chart .mark.active { filter: brightness(1.12); }

.legend {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 18px;
  align-items: center;
  font-size: 12.5px;
  color: var(--text-secondary);
}
.legend .key { display: inline-flex; align-items: center; gap: 6px; }
.legend .swatch { width: 10px; height: 10px; border-radius: 3px; flex: none; }
.legend .line-key { width: 14px; height: 2px; border-radius: 2px; flex: none; }

.tooltip {
  position: fixed;
  z-index: 60;
  pointer-events: none;
  padding: 8px 11px;
  border-radius: var(--fi-r-sm);
  background: var(--surface-1);
  border: 1px solid var(--border-strong);
  box-shadow: var(--fi-raise);
  font-size: 12.5px;
  color: var(--text-primary);
  max-width: 240px;
  opacity: 0;
  transition: opacity 90ms ease;
}
.tooltip.show { opacity: 1; }
.tooltip .t-title { font-weight: 650; margin-bottom: 3px; }
.tooltip .t-row { display: flex; align-items: center; gap: 7px; color: var(--text-secondary); }
.tooltip .t-row .swatch { width: 8px; height: 8px; border-radius: 2px; flex: none; }
.tooltip .t-row b { color: var(--text-primary); font-variant-numeric: tabular-nums; margin-left: auto; }

/* Category bar rows — direct labels satisfy the light-mode contrast relief. */
.barlist { display: flex; flex-direction: column; gap: 12px; }
/* The list's gap already spaces its numbers table; the margin would double it. */
.barlist > .chart-numbers { margin-top: 0; }
.barrow { display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; align-items: baseline; }
.barrow .name { font-size: 13.5px; }
.barrow .amount { font-size: 13.5px; font-weight: 600; font-variant-numeric: tabular-nums; }
.barrow .track {
  display: block;
  grid-column: 1 / -1;
  height: 8px;
  background: var(--surface-2);
  border-radius: var(--fi-r-sm);
  overflow: hidden;
}
/* Block box so the height resolves — an inline span would collapse to nothing. */
.barrow .fill { display: block; height: 100%; border-radius: 0 var(--fi-r-sm) var(--fi-r-sm) 0; }

/* --- Banner --------------------------------------------------------------- */

.banner {
  display: none;
  gap: 12px;
  align-items: flex-start;
  padding: 14px 18px;
  margin-bottom: 20px;
  border-radius: var(--radius);
  border: 1px solid color-mix(in srgb, var(--status-critical) 35%, transparent);
  background: color-mix(in srgb, var(--status-critical) 8%, var(--surface-1));
  font-size: 14px;
}
.banner.show { display: flex; }
.banner strong { display: block; margin-bottom: 3px; }
.banner code {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 12.5px;
  padding: 1px 5px;
  border-radius: var(--fi-r-sm);
  background: var(--surface-2);
}

.toast {
  position: fixed;
  left: 50%;
  bottom: 28px;
  transform: translate(-50%, 12px);
  z-index: 80;
  padding: 11px 18px;
  border-radius: var(--fi-r-sm);
  background: var(--brand-primary);
  color: var(--brand-on-primary);
  font-size: 13.5px;
  font-weight: 550;
  box-shadow: var(--fi-raise);
  opacity: 0;
  pointer-events: none;
  transition: opacity 160ms ease, transform 160ms ease;
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
.toast.error { background: var(--fi-blocked); color: var(--fi-cream); }  /* 5.83 */
/* Part 6 (2.3.3): the 12px slide is motion nobody asked for; held still and
   left to the opacity fade for anyone who has asked for less. */
@media (prefers-reduced-motion: reduce) {
  .toast { transition: opacity 160ms ease; transform: translate(-50%, 0); }
}
/* An error stays until it is dismissed, so it carries the way to dismiss it —
   and only that button takes the pointer, so a click on whatever the toast
   sits over still lands. The box stays rendered (see `.toast`: transparent,
   not `display:none`) because the alert inside it has to exist before it
   speaks; the button is what is hidden, so an empty toast is not a Tab stop. */
#toastError {
  display: flex;
  align-items: center;
  gap: 10px;
  padding-right: 8px;
}
/* A fixed box at `left: 50%` is sized into the half of the screen to its right,
   so on a phone a sentence wrapped to four lines in 200px. `max-content`, capped
   at the viewport less the gutters, gives it the whole width to wrap in. */
#toast, #toastError {
  width: max-content;
  max-width: calc(100vw - 32px);
}
#toastError:not(.show) .toast-dismiss { visibility: hidden; }
.toast-dismiss {
  pointer-events: auto;
  flex: none;
  width: 28px;
  height: 28px;
  border: 0;
  border-radius: var(--fi-r-sm);
  background: transparent;
  color: inherit;
  font-size: 18px;
  line-height: 1;
  cursor: pointer;
}
.toast-dismiss:hover { background: color-mix(in srgb, currentColor 16%, transparent); }

/* Filter chips: a row of toggles that also carry their counts. */
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chips button {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 5px 11px; border-radius: var(--fi-r-sm);
  border: 1px solid var(--border-strong);
  background: var(--surface-1); color: var(--text-secondary);
  font-size: 12.5px; font-weight: 550; cursor: pointer;
}
.chips button:hover { background: var(--surface-2); color: var(--text-primary); }
.chips button[aria-pressed="true"] {
  background: var(--brand-primary); border-color: var(--brand-primary); color: var(--brand-on-primary);
}
/*
 * The count inside a chip, set back from the word — by weight, because there is
 * nothing else to set it back with.
 *
 * This was `opacity: 0.75`, which is the ordinary way to say "quieter" and is
 * the one way that is not available here. Opacity composites the glyph towards
 * whatever is behind it, so on an unpressed chip it took the count from 5.9:1
 * to **3.5:1** against `--surface-1` — under §15's 4.5 floor, on a number
 * somebody is reading to decide which filter to press. (A *pressed* chip was
 * never in danger: cream on the ink fill measures 6.3 even faded, because it
 * has 9.7 to spend. The failure was only ever on the eleven chips that are not
 * the one you chose, which is most of them, most of the time.)
 *
 * **There is no colour answer, and that is a fact about the palette rather than
 * an oversight.** §5.1 is explicit: under 19px there are two text colours, ink
 * and bark, and nothing survives in between — it measured `#7A6553` at 4.40 on
 * the sunken fill and ruled the gap out on purpose. This file says the same
 * thing twice over: `--text-secondary` and `--text-muted` are *both*
 * `--fi-bark` in the light theme, so "one step quieter than the label" is a
 * colour that does not exist to be asked for.
 *
 * So the hierarchy comes off the type scale, which is what §5.1 says to do with
 * it — "the §6 scale carries the rest of the hierarchy" — and what §6 already
 * does for the same shape of problem: a unit beside a quantity ("5.200 g") is
 * set back by size in a legal colour, never by fading it. The chip's own label
 * is 550; the count is 400 and keeps the colour it always had.
 */
.chips button .n { font-variant-numeric: tabular-nums; font-weight: 400; }

/* Status timeline on an order. */
.timeline { list-style: none; padding: 0; margin: 0; display: grid; gap: 0; }
.timeline li { display: grid; grid-template-columns: 14px 1fr; gap: 12px; padding-bottom: 14px; position: relative; }
.timeline li::before {
  content: ''; grid-column: 1; width: 9px; height: 9px; border-radius: 50%;
  background: var(--brand-accent); margin-top: 5px; z-index: 1;
}
.timeline li:not(:last-child)::after {
  content: ''; position: absolute; left: 4px; top: 14px; bottom: 0;
  width: 1px; background: var(--gridline);
}
.timeline .when { font-size: 12px; color: var(--text-muted); }
.timeline .what { font-size: 13.5px; }

/* Notes thread. */
.note { padding: 12px 0; border-bottom: 1px solid var(--gridline); }
.note:last-child { border-bottom: 0; }
.note .meta { font-size: 12px; color: var(--text-muted); margin-bottom: 3px; }
.note .body { font-size: 13.5px; white-space: pre-wrap; }

/* --- Tabs ----------------------------------------------------------------- */

/*
 * The strip scrolls sideways; the page does not.
 *
 * This was a `nowrap` flex row with the default `overflow-x: visible`, which is
 * right exactly while the buttons fit and a trap the moment they stop. The ten
 * main tabs come to 888px of content, so below about 936px the strip no longer
 * fitted — and because nothing scrolled it, *the document* widened instead. The
 * September 2026 scan measured `documentElement.scrollWidth` at 912 against a
 * 390px viewport, with the last six tabs — Products, Orders, Customers,
 * Shipping, Statistics, Configuration — off the right-hand edge and no way to
 * reach them but dragging the whole page sideways; a scripted click on
 * `#tab-orders` at 390px timed out. Finding 1 of docs/ux-scan-2026-09.md.
 *
 * That made the block at the foot of this file a promise the page could not
 * keep. It heads `@media (max-width: 640px)` with "§8: the studio has to be
 * usable at 640px, one-handed, by somebody with clay on their hands" and then
 * gives `.tabs button` a 44px minimum — and a 44px tap target on a tab that
 * cannot be brought on screen is not a tap target at all. This rule is what
 * makes those 44px reachable; the two are one intention read from two ends.
 *
 * Snapping is `proximity` rather than `mandatory`: ten tabs is a strip somebody
 * flicks along to read, and mandatory snapping fights that by hauling the
 * nearest button to the edge every time the finger leaves the glass.
 * `scroll-padding-inline` is what leaves the neighbouring tab peeking past the
 * edge when `switchTab` scrolls the selected one into view — on a strip with no
 * visible scrollbar, that peek is the only thing on screen saying there is
 * more. 24px is the gutter `.wrap` already uses.
 *
 * The scrollbar is hidden rather than the scrolling given up: a permanent
 * horizontal bar under a 40px strip is chrome on every desktop screen in the
 * studio, and this page carries six of these strips. Both properties are
 * needed and neither is redundant — `scrollbar-width` is what Firefox reads,
 * the pseudo-element is what Chromium and WebKit read.
 *
 * **The hairline moved from a border to an inset shadow, and it had to.** It
 * was `border-bottom: 1px solid var(--border)` here with `margin-bottom: -1px`
 * on the buttons, so the selected tab's 2px accent sat *over* the strip's own
 * line rather than stacked above it. A scroll container clips at its padding
 * box, and that negative margin is exactly 1px of button hanging outside it: as
 * soon as `overflow-x: auto` arrived, the accent on the tab that is showing
 * came out 1px instead of 2, with the grey hairline showing beneath it. An
 * inset shadow is painted inside the padding box, on the same pixel the border
 * occupied, and the buttons paint over it — so the strip looks precisely as it
 * did, and nothing overflows for the scrollport to clip.
 */
.tabs {
  display: flex;
  gap: 4px;
  box-shadow: inset 0 -1px 0 var(--border);
  margin-bottom: 22px;
  overflow-x: auto;
  scroll-snap-type: x proximity;
  scroll-padding-inline: 24px;
  scrollbar-width: none;
}
.tabs::-webkit-scrollbar { display: none; }
.tabs button {
  padding: 10px 14px;
  border: 0;
  background: none;
  color: var(--text-secondary);
  font-size: 14px;
  font-weight: 550;
  cursor: pointer;
  border-bottom: 2px solid transparent;
  /* A row that scrolls must not let its buttons shrink. `flex-shrink` defaults
     to 1, so in an overflowing row the multi-word labels on the Configuration
     strip — "Shops & marketplaces", "Email & alerts" — would squeeze down to
     their min-content and wrap onto two lines rather than run off the end and
     be scrolled to, which is the behaviour this whole block exists to give
     them. The main tabs are all one word, which is why the fault the scan
     measured was an overflowing document rather than a wrapped strip. */
  flex: none;
  scroll-snap-align: start;
}
.tabs button:hover { color: var(--text-primary); }
.tabs button[aria-selected="true"] { color: var(--text-primary); border-bottom-color: var(--brand-primary); }

.panel[hidden] { display: none; }


/* --- Shipping ------------------------------------------------------------ */

/* A checkbox that reads as one line rather than a label stacked on a control. */
.check {
  display: flex; align-items: center; gap: 8px;
  font-size: 13.5px; font-weight: 500; color: var(--text-secondary);
}
/* Radios too: a control that inherits the full-width input rule stops looking
   like something you pick and starts looking like something you drag. */
.check input[type="checkbox"],
.check input[type="radio"] { width: auto; margin: 0; flex: none; }
/* A choice that carries an explanation under it lines its control up with the
   first line of text rather than with the middle of the paragraph. */
.check input[type="radio"] { align-self: flex-start; margin-top: 2px; }
.check + .check { margin-top: 8px; }

/* Where the parcel is going, shown while choosing how to send it. */
.addr-card {
  border: 1px solid var(--border);
  border-radius: var(--fi-r-md);
  padding: 12px 14px;
  margin-bottom: 16px;
  background: var(--surface-2);
  font-size: 13.5px;
  line-height: 1.5;
}
.addr-card .to { font-size: 11.5px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-muted); }

/* Label, packing slip and the attachments that follow. Links, not buttons:
   these open a PDF, and a middle click should work the way it looks. */
.doc-list { display: flex; flex-wrap: wrap; gap: 8px; }
.doc-list a {
  display: inline-flex; align-items: center; gap: 7px;
  padding: 7px 11px;
  border: 1px solid var(--border);
  border-radius: var(--fi-r-sm);
  font-size: 13px;
  color: var(--text-secondary);
  text-decoration: none;
  background: var(--surface-1);
}
.doc-list a:hover { background: var(--surface-2); color: var(--text-primary); }
.doc-list a .size { color: var(--text-muted); font-variant-numeric: tabular-nums; }

/* The answers on an order line, kept as answers rather than as one sentence:
   the question the buyer read, what they typed, and the state of the check on
   that one value.

   It sits inside a cell of `table.data`, whose own th and td rules reach every
   nested table — so each of them is answered here rather than inherited: no
   small capitals, no sticky heading, no second grid of rules inside one row.

   The column it lives in is given room first, or a table of three columns
   arrives as three columns of single words. */
table.data th.pers-col { min-width: 300px; }

table.pfields { border-collapse: collapse; width: 100%; font-size: 12.5px; }
table.pfields th {
  position: static;
  text-align: left;
  font-size: 12.5px;
  font-weight: 600;
  letter-spacing: normal;
  text-transform: none;
  color: var(--text-muted);
  background: none;
  border-bottom: 0;
  padding: 3px 14px 3px 0;
  vertical-align: top;
  white-space: normal;
  min-width: 110px;
  max-width: 200px;
}
table.pfields td {
  padding: 3px 14px 3px 0;
  border-bottom: 0;
  vertical-align: top;
}
table.pfields td.state { padding-right: 0; white-space: nowrap; text-align: right; }
table.pfields .req { color: var(--status-critical); }

/* The shipping facts on an order: label on the left, value on the right. */
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0; font-size: 13.5px; }
.kv dt { color: var(--text-muted); }
.kv dd { margin: 0; color: var(--text-primary); }
.kv dd a { color: inherit; }

/* --- Order attachments ---------------------------------------------------- */

.drop {
  border: 1.5px dashed var(--border);
  border-radius: var(--fi-r-md);
  padding: 16px;
  text-align: center;
  color: var(--text-muted);
  font-size: 13px;
  cursor: pointer;
  transition: border-color 0.12s, background 0.12s, color 0.12s;
}
.drop:hover { border-color: var(--brand-primary); color: var(--text-secondary); }
/* Held while a file is over the zone, so it is obvious where it will land. */
.drop.over {
  border-color: var(--brand-primary);
  background: color-mix(in srgb, var(--brand-primary) 8%, var(--surface-1));
  color: var(--text-primary);
}
.drop strong { display: block; color: var(--text-secondary); font-size: 13.5px; margin-bottom: 2px; }
.drop.busy { cursor: progress; opacity: 0.7; }

/* Photos of the finished piece. Square tiles so a mixed set of portrait and
   landscape shots still reads as a grid. */
.photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(86px, 1fr)); gap: 8px; }
.photo-tile { position: relative; aspect-ratio: 1; border-radius: var(--fi-r-sm); overflow: hidden; border: 1px solid var(--border); }
.photo-tile img { width: 100%; height: 100%; object-fit: cover; display: block; background: var(--surface-2); }
/* Part 6 (2.5.8): was 22x22 — 24x24 is the minimum target size. Review L3:
   kept at 4px from the corner (not shrunk to 2px to make room for the extra
   2px of growth) — the plan's "no overlap" is better served by staying as
   far from the tile's edge as the smaller button did, not by moving closer
   to it while growing. */
.photo-tile .kill {
  position: absolute; top: 4px; right: 4px;
  width: 24px; height: 24px; border-radius: 50%;
  border: 0; cursor: pointer;
  background: rgba(0, 0, 0, 0.6); color: #fff;
  font-size: 14px; line-height: 1;
  opacity: 0; transition: opacity 0.12s;
}
.photo-tile:hover .kill, .photo-tile:focus-within .kill { opacity: 1; }

/* Print files. A row each, because the name and size are what you pick by. */
.file-row {
  display: flex; align-items: center; gap: 10px;
  padding: 8px 10px;
  border: 1px solid var(--border);
  border-radius: var(--fi-r-sm);
  font-size: 13.5px;
}
.file-row + .file-row { margin-top: 6px; }
.file-row .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-row .size { color: var(--text-muted); font-variant-numeric: tabular-nums; flex: none; }
.file-row a { color: var(--text-secondary); text-decoration: none; }
.file-row a:hover { color: var(--text-primary); text-decoration: underline; }

/* A button that reads as a link — for a table cell where the identifier itself
   is the thing you click, rather than an "Open" button beside it. */
.linkbtn {
  border: 0; background: none; padding: 0;
  color: var(--text-primary); font: inherit; cursor: pointer;
  text-align: left; text-decoration: underline;
  text-decoration-color: var(--baseline); text-underline-offset: 3px;
}
.linkbtn:hover { text-decoration-color: currentColor; }

/* --- Bulk upload ---------------------------------------------------------- */

/* Numbered steps, because the order matters: the template has to be downloaded
   before there is anything to upload. */
.import-step {
  margin-top: 18px;
  padding-top: 18px;
  border-top: 1px solid var(--gridline);
}
.import-step .step-head { display: flex; gap: 11px; align-items: flex-start; }
.import-step .step-head strong { font-size: 14px; }
.import-step .step-head p { margin: 3px 0 0; font-size: 12.5px; }
.import-step .step-no {
  flex: none;
  width: 22px; height: 22px;
  border-radius: var(--fi-r-sm);
  display: grid; place-items: center;
  background: var(--surface-2);
  border: 1px solid var(--border);
  font-size: 12px; font-weight: 650;
  color: var(--text-secondary);
}
.import-step .step-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0 0 33px; }
.import-step .drop { margin: 12px 0 0 33px; }

/* The column reference — one row per column, so a hint can run long without
   squeezing the name. */
.import-columns {
  margin: 12px 0 0 33px;
  border: 1px solid var(--border);
  border-radius: var(--fi-r-md);
  overflow: hidden;
}
.import-columns .col-row {
  display: grid;
  grid-template-columns: minmax(120px, 170px) 1fr;
  gap: 4px 14px;
  padding: 9px 12px;
  font-size: 12.5px;
}
.import-columns .col-row + .col-row { border-top: 1px solid var(--gridline); }
.import-columns .col-name {
  font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
  font-size: 12px;
  color: var(--text-primary);
}
.import-columns .col-name .req { color: var(--status-critical); margin-left: 3px; }
.import-columns .col-hint { color: var(--text-secondary); }
.import-columns .col-example { color: var(--text-muted); }
@media (max-width: 560px) {
  .import-columns .col-row { grid-template-columns: 1fr; }
}

.import-paste { margin: 12px 0 0 33px; font-size: 12.5px; color: var(--text-muted); }
.import-paste summary { cursor: pointer; }
.import-paste textarea { margin-top: 8px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
.import-paste .btn { margin-top: 8px; }

/* The verdict on a checked file. */
.import-result {
  margin-top: 18px;
  padding: 14px 16px;
  border-radius: var(--fi-r-md);
  border: 1px solid var(--border);
  background: var(--surface-2);
  font-size: 13.5px;
}
.import-result.bad { border-color: color-mix(in srgb, var(--status-critical) 45%, transparent); }
.import-result.good { border-color: color-mix(in srgb, var(--status-good) 45%, transparent); }
.import-result h4 { font-size: 13.5px; margin: 0 0 6px; }
.import-result .counts { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.import-result ul { margin: 10px 0 0; padding-left: 18px; color: var(--text-secondary); }
.import-result li { margin-top: 3px; }
.import-result li b { color: var(--text-primary); font-variant-numeric: tabular-nums; }

/* --- The studio board ----------------------------------------------------- */

/* The one screen in this application that is not a table, a form or a chart.
   Written rather than assembled out of `.card` and `.grid` for one reason: a
   board scrolls sideways and its lanes keep their width, where `.grid` wraps
   them onto a second row — and a column of a kanban that has wrapped under the
   one before it is no longer a column of a kanban.

   Everything inside a lane is the existing vocabulary. A card is `.badge`s and
   `.fine` type on a bordered box; a category chip is a `.badge` with an
   inline-coloured `.dot`, which is how stats.js already draws a chip carrying
   an arbitrary colour. The colour is a six-pixel disc and never a background:
   it is picked from a colour well by whoever is adding "Repairs", and a scheme
   that let that choice decide a text contrast is a screen somebody can
   accidentally make unreadable. */
.board {
  display: flex;
  gap: 14px;
  align-items: flex-start;
  overflow-x: auto;
  padding-bottom: 10px;
}
.board-col {
  flex: 0 0 296px;
  background: var(--surface-1);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--fi-press);
  padding: 12px;
}
.board-col-head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
/* Part 6: relevelled from h3 to h2, same reasoning as .card-head h2 above. */
.board-col-head h2 { font-size: 14px; margin: 0; flex: 1; font-family: var(--sans); line-height: 1.3; letter-spacing: normal; }
.board-col-count {
  font-size: 12px;
  font-variant-numeric: tabular-nums;
  color: var(--text-muted);
  white-space: nowrap;
}
/* Over its limit. The number is drawn attention to and never enforced — see the
   note in the Columns & categories dialog. */
.board-col-count.over { color: var(--fi-blocked); font-weight: 600; }

/* Quick add. The select is the category, and it is beside the box rather than
   guessed at, so what a card will be filed under is visible before it exists.

   The button is the third thing in the row and it is not decoration. Enter has
   always submitted this form, and a box with a placeholder and nothing beside
   it reads as a search box: the studio looked at four columns of these and
   reported that a card could not be added from a browser at all. A control
   somebody cannot see is a control they do not have. */
.board-add { display: flex; gap: 6px; margin-bottom: 10px; }
.board-add input { flex: 1 1 auto; min-width: 0; }
.board-add select { flex: 0 0 88px; width: auto; font-size: 12.5px; padding: 8px 6px; }
.board-add button { flex: 0 0 auto; padding-left: 10px; padding-right: 10px; }

.board-stack { display: flex; flex-direction: column; gap: 8px; min-height: 48px; }
.board-stack-empty { margin: 0; padding: 12px 2px; }

.board-card {
  border: 1px solid var(--border);
  border-radius: var(--fi-r-md);
  background: var(--surface-1);
  padding: 10px;
  cursor: pointer;
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.board-card:hover { border-color: var(--border-strong); background: var(--surface-2); }
.board-card:focus-within { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
.board-card-top { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
/* The title is the card's real button — see cardHtml in board.js for why the
   card itself is not one. Stripped back to reading as a title: the ring belongs
   to the whole card, which is what `:focus-within` above is for. */
.board-card-title {
  font: inherit;
  font-size: 13.5px;
  font-weight: 600;
  line-height: 1.35;
  text-align: left;
  width: 100%;
  padding: 0;
  border: 0;
  background: none;
  color: var(--text-primary);
  cursor: pointer;
}
/* Opting out of the ring in §15 on purpose — `outline`, which is what that
   rule at the top of the file sets. A gold ring around
   one line of text inside a bordered card reads as a second box; `:focus-within`
   above puts the same ring, the same object, around the card that is about to
   open. */
.board-card-title:focus-visible { outline: none; }
.board-card-link { line-height: 1.35; }
.board-card-foot { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.board-card-spacer { flex: 1; }
/* Who the card is for, beside the category and the due date it shares a row
   with. Not a status colour and deliberately not a tint: whose card it is, is
   not a state the card is in, which is the same argument `.badge.etsy` above
   makes about where an order came from. So it is the quietest badge on the
   card — the outline and nothing filling it — and what tells it apart from the
   category beside it is the shape rather than the colour: a category always
   carries a coloured dot and a name never does, which still reads in the one
   place a colour cannot. The tracking goes back to nothing because this is
   somebody's name and not a label. */
.board-card .board-who {
  background: transparent;
  letter-spacing: 0;
  font-weight: 500;
}
/* An archived card is set back with a border and a ground, not with opacity.
   Opacity composites the whole subtree, so 0.62 took the title from 9.7:1 to
   about 3.6:1 against the card and the card number below the floor in §15.
   The "Archived" badge is the label; this is the shape beside it.

   This used to end "and it is the one thing in this file that would have put
   readable content there". That was wrong four times over, and it is worth
   correcting rather than deleting, because the reasoning in front of it was
   right and only the tally was wrong.

   `.chips button .n` at 0.75 and `.version-bar-stats` at 0.85 were both doing
   it already — 3.5:1 and 4.1:1 measured — and both are gone; see the notes on
   each. And two are still here, three lines below this one: `.is-moving` at
   0.55 takes a card's own text from 5.16:1 to **2.22:1** in light and 9.52 to
   3.91 in dark, and `.is-dragging` at 0.4 takes it to **1.75:1** and 2.73.
   They are left standing on purpose rather than by omission. Deleting those two
   fades removes the only thing on screen saying a card is in flight, so they
   need something to replace them, and what a board card should look like while
   it waits on the server is a decision about the board rather than a contrast
   fix — the studio has it. Both are momentary, and `.is-dragging` in particular
   is a ghost the browser paints its own drag image over rather than text
   anybody is reading.

   None of the four was found by reading. The sweep that reported zero failures
   composited the alpha on `color` and ignored `opacity` entirely, so every one
   of them was invisible to the only instrument that was looking — which is the
   part of this worth remembering: a fade is the one device in this file that
   can put readable content under the floor without changing a colour anybody
   wrote down. */
.board-card.is-archived {
  background: var(--surface-2);
  border-style: dashed;
}
.board-card.is-dragging { opacity: 0.4; }
/* Waiting on the server's answer. The card has NOT moved — see the header of
   public/js/dashboard/board.js — so this says "asked", not "done". */
.board-card.is-moving { opacity: 0.55; pointer-events: none; }

/* Where a dropped card would land. A line rather than a gap the card is slotted
   into, because slotting it in is the lie this panel refuses to tell. */
.board-drop {
  height: 3px;
  border-radius: 2px;
  background: var(--brand-primary);
}

/* The category dot in the filter row. The rule that draws one is scoped to
   `.badge .dot`, and these are `.chips` toggles. */
.chips button .dot { width: 6px; height: 6px; border-radius: 50%; flex: none; }

/* "Load more", under the board rather than inside a card. `.table-more` is the
   last strip of a `.card-body.flush.table-scroll` everywhere else on this page
   — its top border is the rule under a table and its 16px lines up with the
   card's — and the board has no card around it to line up with. */
.board-more { display: flex; align-items: center; gap: 10px; padding: 12px 2px 0; }

/* A board with no columns on it yet. `.empty` was written for a `<td colspan>`
   filling a table row; here it is a flex item of `.board`, so without a basis
   it shrinks to its own text and its centring centres nothing. */
.board-empty { flex: 1 1 100%; }

/* One column, or one category, in the Columns & categories dialog. Flex rather
   than a grid so the two add-rows at the bottom, which have fewer controls than
   the rows above them, still line up. */
.board-setup-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
.board-setup-row .board-setup-name { flex: 1 1 150px; min-width: 0; }
.board-setup-row .board-setup-wip { flex: 0 0 96px; }
/* `input.` and not just the class: the base well rule is
   `input:not([type="checkbox"]):not([type="radio"])`, and `:not()` lends the
   specificity of its argument, so it is (0,2,1) and a bare class pair loses to
   it. A swatch drawn with 8px of padding inside it is a 24x16 colour in a 48x34
   box. */
.board-setup-row input.board-setup-colour { flex: 0 0 48px; padding: 2px; height: 34px; }
.board-setup-row input[type="number"] { width: auto; }

/* The comment box under a card's timeline, and a comment being corrected. */
.board-comment-form { display: flex; gap: 8px; align-items: flex-start; margin-top: 12px; }
.board-comment-form textarea { flex: 1; }
.board-comment { white-space: pre-wrap; }
.board-comment-edit { display: grid; gap: 6px; }
.board-comment-actions { display: flex; justify-content: flex-end; gap: 8px; }

/* The three groups of files on a card, side by side while there is room for
   them and stacked when there is not. The tiles, the file rows and the drop
   zones inside are the order screen's own (`.photo-grid`, `.file-row`,
   `.drop`); this is only how the three sit together. */
.board-files { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 18px; }

/* A card's clip, in the same square tile its photographs use — so the strip
   reads as one row of things rather than as a gallery with a video beside it.
   `controls` is what makes it playable in place, which is the whole ask: there
   is no viewer for a print file and there does not need to be one for fifteen
   seconds of a jig turning. */
.photo-tile video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  background: var(--surface-2);
}

/* The "or point at one in the library" line under the print files. */
.board-file-link { display: flex; gap: 8px; align-items: center; }
.board-file-link select { flex: 1; min-width: 0; }

/* Taking stock off the shelf for a card: the picker, the amount, what it is
   for, and the two buttons, on one line where there is room and stacked on the
   bench iPad where there is not. `auto-fit` rather than fixed columns for the
   reason the file strip above uses it — this dialog is opened at 1400px on a
   desk and at 768px in portrait on a tablet, and neither is the special case. */
.board-draw-form {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  gap: 10px;
  align-items: end;
  margin-bottom: 12px;
}
.board-draw-actions { display: flex; gap: 8px; align-items: end; }

/* One line per movement, and the give-backs read as what they are rather than
   as a draw with a minus sign somebody has to notice. */
.board-draw-row { display: flex; gap: 10px; align-items: baseline; padding: 4px 0; }
.board-draw-row .grow { flex: 1; min-width: 0; }
.board-draw-row .qty { font-variant-numeric: tabular-nums; white-space: nowrap; }
.board-draw-row.back .qty { color: var(--success-text); }
.board-draw-total {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  border-top: 1px solid var(--gridline);
  margin-top: 6px;
  padding-top: 6px;
  font-variant-numeric: tabular-nums;
}

/* Both versions of a card's notes, as the history stored them. Folded away
   because they can run to a page, and present because "the notes changed" with
   no way to see what they said is an entry nobody can act on. */
.board-notes-diff { margin-top: 6px; }
.board-note-body {
  white-space: pre-wrap;
  font-size: 13px;
  color: var(--text-secondary);
  margin: 2px 0 8px;
  padding-left: 10px;
  border-left: 2px solid var(--gridline);
}

/* The card dialog has a destructive action in its foot, and it belongs at the
   far end from Save rather than beside it.

   Keyed on this dialog rather than written as `dialog.modal .modal-foot
   .spacer`, which is what it wants to be. FOUR OTHER DIALOGS ALREADY CARRY AN
   INERT `.spacer` IN THEIR FOOT — the photo lock, the photo crop, the listing
   editor and the drift dialog — because `.spacer` is only given a flex by
   `.toolbar` and `.picked`. The general rule would make all four suddenly split
   their buttons apart, which is a visible change to four screens inside a
   commit about a fifth. Worth doing; worth doing where somebody can find it. */
#boardCardModal .modal-foot .spacer { flex: 1; }

/* --- Notes ----------------------------------------------------------------
   The other pane under the Board tab: what the studio writes down rather than
   what it promises itself. Two columns — a list of one-line previews, and the
   note that is open.

   NO NEW VOCABULARY, which is the point: a row is the `.board-card` box a size
   smaller, the pane it sits in is the `.card` every other panel uses, and the
   footer is a `.toolbar` row. What is actually decided here is the heights.
   The one new thing, `.notes-view` — a note as it reads, with its links — is
   the textarea's own well written out again for a div, and says why at its
   rule.

   A note is typed into for minutes at a time, so the box has to hold a
   paragraph without scrolling and has to stay still while the list beside it
   scrolls. `68vh` rather than a figure in pixels, so a laptop and the monitor
   over the wheel both give the textarea most of what they have; `resize:
   vertical` so somebody pasting a glaze recipe can take more.

   Below 760px the two panes stack, the list first — picking the note comes
   before reading it — and the list gives up most of its height, because a
   column of previews above the box is a column somebody scrolls past on every
   keystroke. That width is where the 300px column plus a textarea worth typing
   into stops fitting side by side, not a device.

   THE TOOLBAR IS IN THE GRID, spanning it, rather than a card above one — and
   that is the address rather than the layout talking. `#notes` is this whole
   screen, because `#notes?id=12` is a link somebody is sent and `landOn` in
   public/js/dashboard/tabs.js scrolls to whatever the hash names: with the
   search box and New note outside that element, following the link would put
   the panel's own controls under the sticky header before anybody had looked
   at the note. */
.notes {
  display: grid;
  grid-template-columns: minmax(0, 300px) minmax(0, 1fr);
  gap: 14px;
  align-items: start;
}
.notes > .card { grid-column: 1 / -1; }

.notes-list {
  display: flex;
  flex-direction: column;
  gap: 6px;
  max-height: 68vh;
  overflow-y: auto;
  padding-right: 4px;
}
.notes-empty { margin: 0; padding: 12px 2px; }

/* A row is a button, so it is stripped back to reading as one line of text with
   a quieter one under it — the border and the ground are what make it a row. */
.notes-row {
  font: inherit;
  text-align: left;
  width: 100%;
  display: flex;
  flex-direction: column;
  gap: 3px;
  padding: 9px 10px;
  border: 1px solid var(--border);
  border-radius: var(--fi-r-md);
  background: var(--surface-1);
  color: var(--text-primary);
  cursor: pointer;
}
.notes-row:hover { border-color: var(--border-strong); background: var(--surface-2); }
/* The note being typed into. A left edge rather than a fill: the row is beside
   a box somebody is reading, and a block of colour there pulls the eye off it. */
.notes-row.is-open {
  border-color: var(--border-strong);
  background: var(--surface-2);
  box-shadow: inset 3px 0 0 var(--fi-gold);
}
.notes-row.is-archived .notes-row-title { color: var(--text-secondary); }
.notes-row-title {
  font-size: 13.5px;
  font-weight: 600;
  line-height: 1.35;
  /* One line, cut with an ellipsis. The whole first line is in the note itself
     and a row that wrapped to four of them would make the list a page. */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* A private note's lock sits beside the pin and at the same size, one rule for
   both; "only you" in the line under the title is the words for it, so the
   lock is never the only signal. */
.notes-pin, .notes-lock { margin-right: 5px; font-size: 11px; }
.notes-row-who { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* The category on a row: the board's own chip — a `.badge` with its `.dot` —
   set ahead of who touched the note last, as a card's face sets it, and never
   a background, for the reason `categoryDot` in board/filters.js gives: the
   colour was picked in a hurry and must not get to decide a contrast. The lock
   and "only you" are the privacy marker and carry no dot, so the board's
   *Private* category and a private note cannot be mistaken for each other. */
.notes-row-who .badge { margin-right: 6px; }

.notes-editor {
  display: flex;
  flex-direction: column;
  min-height: 68vh;
  background: var(--surface-1);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--fi-press);
  padding: 14px;
}
.notes-blank { margin: auto; text-align: center; }
/* `min-height: 0`, or the textarea's own content height wins the flex
   negotiation and the footer is pushed off the bottom of a long note. The wrap
   round the textarea, the one flex item between this and it, does without on
   purpose — see `.notes-body-wrap` below. */
.notes-open { display: flex; flex-direction: column; gap: 8px; flex: 1; min-height: 0; }
.notes-status { display: flex; align-items: center; gap: 8px; min-height: 28px; }
/* Two facts about the note, stated in lines over the box — who can read it,
   and which card it became. The footer is for things to press, and a fact
   drawn there reads as one more button. No margin of their own: they sit in
   the editor's column and the column's gap already spaces them. */
.notes-private-line, .notes-card-line { margin: 0; }
/* The card's number in the face every other card number on the dashboard is
   set in. `.linkish` resets the font with `font: inherit` and comes later in
   this file, so `.mono` on the same button would otherwise lose its family. */
.notes-card-line .linkish.mono { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; }
/* `flex: 1 0 auto` rather than `flex: 1`, so that the grip does something.
   `flex: 1` is a basis of 0%, which the column resolves against its own
   height, and then the height the grip writes on the element is never read —
   measured in Chromium before this changed, the drag set `height: 612px` and
   the box stayed 459px tall, at 1280px and at 390px alike. With `auto` the
   basis is that height, so a drag down grows the box and the editor with it;
   the grow still fills the editor when nobody has dragged anything, and a drag
   back up stops at the editor's own floor. */
.notes-body {
  flex: 1 0 auto;
  min-height: 220px;
  resize: vertical;
  line-height: 1.5;
  font-family: inherit;
}
/* The body is two elements in one place: the box it is typed into and, for a
   note with a web address in it and nobody typing, the note as it reads
   (issue #225; `drawBody` in notes.js).

   THE BOX SETS THE SIZE AND THE VIEW LIES OVER IT, rather than the two taking
   turns in the flow. A textarea is as tall as the layout makes it whatever it
   holds; a div is as tall as its text, and in this column that grows the
   editor to the height of the note — so a swap on blur would move the footer
   between mousedown and mouseup on Pin, and the click would land on nothing.
   Laid over the box, the view is the box's size by construction, including
   after somebody has dragged the grip. `visibility`, not `display`, so the box
   keeps the space the view lies in; the view comes straight before the box in
   the markup so its own `hidden` is the one switch.

   The wrap is now the flex child that fills the editor — the textarea's flex
   means nothing inside a box that is not a flex container — so it is one, a
   column of one, with the `flex: 1` the textarea had in the column before.
   The textarea keeps its own flex, its grip and its 220px floor inside it.
   And no `min-height: 0` here, though `.notes-open` has one: the wrap's own
   minimum is then the textarea — its floor, or the height its grip was
   dragged to — so the wrap can never be squeezed shorter than the box. The
   box does not shrink (`flex: 1 0 auto` above); in a shorter wrap it would
   hang out of the bottom, and the view, laid over the wrap, would stop short
   of the box it is meant to cover exactly. */
.notes-body-wrap { position: relative; display: flex; flex-direction: column; flex: 1; }
.notes-view:not([hidden]) + .notes-body { visibility: hidden; }

/* The box's twin: the well rule for every textarea near the top of this file
   and `.notes-body` above, written out for a div. They have to agree to the
   pixel — a click puts the caret on the character under the pointer and the
   box then appears in exactly the view's place, so a difference in font,
   padding or wrapping is text jumping under the hand that clicked it.
   `pre-wrap` and `break-word` are what the browser gives a textarea itself,
   and they keep line breaks without a <br> — see note-text.js for why the
   view's characters have to be the note's. */
.notes-view {
  position: absolute;
  inset: 0;
  overflow-y: auto;
  font: inherit;
  font-size: 14px;
  line-height: 1.5;
  padding: 8px 11px;
  border: 1px solid var(--border-strong);
  border-radius: var(--fi-r-sm);
  background: var(--surface-1);
  box-shadow: var(--fi-press);
  color: var(--text-primary);
  white-space: pre-wrap;
  overflow-wrap: break-word;
  cursor: text;
}
/* `.linkish`'s underline, because colour alone does not say "link" (1.4.1).

   Isolated, and left to right, so that the text in front of a link cannot
   turn it round. A direction override typed before an address — U+202E, which
   reverses everything after it — would otherwise draw `https://evil.example/`
   backwards, as something else, over an href that goes to evil.example. An
   isolate starts the link's own text afresh whatever came before it, which is
   the half of that note-text.js cannot answer: it stops an address at a
   direction control inside it, and this is what stops one outside it. */
.notes-view a {
  text-decoration: underline;
  text-underline-offset: 2px;
  cursor: pointer;
  unicode-bidi: isolate;
  direction: ltr;
}
/* A Markdown note, drawn (#221; `markdownHtml` in note-text.js). The view is
   still `pre-wrap` and the renderer writes no <p> and no <br>, so a note keeps
   the lines it was typed in only while nothing here adds space of its own:
   every block is margin-free, and the text between blocks carries its own
   newlines. That is also what makes a Markdown note with no Markdown in it
   read exactly as a plain one.

   The headings are the body's face at a larger size rather than the page's
   display face — `h1, h2` near the top of this file set Cormorant for the
   titles of whole screens, and a heading inside a note is a line of the note,
   not a title over the dashboard. A list keeps its markers and gives them room
   rather than hanging them in the margin, and code is set as `.banner code`
   sets it, on the sunken surface.

   A block of code wraps, as the rest of the note does, rather than scrolling
   sideways. A block that scrolls is a scroller with nothing focusable in it,
   and Chromium and Firefox now make such a thing a Tab stop of its own — an
   unnamed one, between the view and the footer, that the view's own keys do
   not scroll — and the box underneath wraps the same line anyway, so a
   scrolling block would be the one place the view and the box did not keep
   the same lines. `pre-wrap` has to be said, because a browser's own `pre`
   is `pre`; the `break-word` it wraps a long token with is the view's,
   inherited. */
.notes-view h2, .notes-view h3, .notes-view h4, .notes-view h5, .notes-view h6 {
  margin: 0;
  font-family: var(--sans);
  font-weight: 600;
  letter-spacing: normal;
  line-height: 1.35;
}
.notes-view h2 { font-size: 1.3em; }
.notes-view h3 { font-size: 1.15em; }
.notes-view h4 { font-size: 1.05em; }
.notes-view h5, .notes-view h6 { font-size: 1em; }
.notes-view ul, .notes-view ol { margin: 0; padding-left: 1.4em; }
.notes-view code {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.92em;
  padding: 0 4px;
  border-radius: var(--fi-r-sm);
  background: var(--surface-2);
}
.notes-view pre {
  margin: 0;
  padding: 8px 10px;
  white-space: pre-wrap;
  border: 1px solid var(--border);
  border-radius: var(--fi-r-md);
  background: var(--surface-2);
}
.notes-view pre code { padding: 0; background: none; }
.notes-foot { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
/* The category picker in the footer: as wide as the names in it and no wider
   — the global well rule makes a select `width: 100%`, which here would push
   every button after it onto a line of its own — and the height of the
   `.btn-sm` buttons it sits between. */
.notes-category { width: auto; max-width: 14em; padding: 5px 8px; font-size: 13px; }
/* "Filed under" and the picker as one item of the footer's row, the words on
   the same line as the select they name, so that it is wrapped as a pair on a
   narrow screen rather than leaving the words at the end of one line. */
.notes-category-label { display: inline-flex; align-items: center; gap: 4px; }

@media (max-width: 760px) {
  .notes { grid-template-columns: minmax(0, 1fr); }
  .notes-list { max-height: 38vh; }
  .notes-editor { min-height: 0; }
}

/* --- Calendar -------------------------------------------------------------
   The fourth pane under the Board tab: what falls on which day, as a month of
   six rows or a week of taller days. See public/js/dashboard/calendar.js.

   A TABLE, NOT A GRID OF DIVS, because a calendar is tabular: the weekday is a
   column header a screen reader carries down the column. `table-layout: fixed`
   is what keeps seven equal columns whatever one busy Thursday holds — an
   item's text is cut with an ellipsis in the month and wraps in the week, where
   there is room for it.

   THE KIND IS SAID TWICE, the coloured left edge and the word in front of the
   item, so colour is never the only signal (§15). The colours are the chart
   slots (see --cal-order on :root).

   AT PHONE WIDTH THE GRID SCROLLS SIDEWAYS inside its card rather than
   squashing seven columns into 360px, which is a month of days one word wide.
   The board's columns do the same thing for the same reason. */
.cal-toolbar .cal-range {
  margin: 0 4px; font-family: var(--sans); font-size: 16px; line-height: 1.3;
  letter-spacing: normal; min-width: 12ch;
}
.cal-step { display: flex; gap: 4px; }
.cal-views {
  display: inline-flex; border: 1px solid var(--border-strong);
  border-radius: var(--fi-r-sm); overflow: hidden;
}
.cal-views button {
  padding: 5px 12px; border: 0; background: var(--surface-1); color: var(--text-secondary);
  font: inherit; font-size: 12.5px; font-weight: 550; cursor: pointer;
}
.cal-views button + button { border-left: 1px solid var(--border-strong); }
.cal-views button:hover { background: var(--surface-2); color: var(--text-primary); }
.cal-views button[aria-checked="true"] { background: var(--brand-primary); color: var(--brand-on-primary); }
.cal-views button:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -2px; }
.toolbar.cal-filters { border-bottom: 0; padding-top: 10px; }
.cal-swatch { width: 10px; height: 10px; border-radius: 2px; flex: none; }
.cal-swatch--order { background: var(--cal-order); }
.cal-swatch--card { background: var(--cal-card); }
.cal-swatch--delivery { background: var(--cal-delivery); }
.cal-swatch--material { background: var(--cal-material); }
.cal-undated, .cal-status { margin: 0 2px 8px; }

.cal-scroll { overflow-x: auto; }
.cal { width: 100%; min-width: 700px; border-collapse: collapse; table-layout: fixed; }
.cal th {
  padding: 8px 10px; text-align: left; font-size: 12px; font-weight: 600;
  color: var(--text-secondary); border-bottom: 1px solid var(--border);
}
.cal th abbr { text-decoration: none; }
.cal-day { vertical-align: top; padding: 6px; height: 118px; border: 1px solid var(--border); }
.cal tr > .cal-day:first-child { border-left: 0; }
.cal tr > .cal-day:last-child { border-right: 0; }
.cal--week .cal-day { height: 380px; }
/* The agenda: no columns to line a weekday header up over, so the header row
   goes away and the table collapses to the width of the one column each row
   spans — the plain list a phone at the kiln can read without scrolling
   sideways, which `.cal`'s own `min-width` (sized for seven columns) would
   otherwise force. */
/* Two classes, to outrank `.cal { min-width: 640px }` inside the 760px media
   query below — the phone-width rule this override exists for. */
.cal.cal--agenda { min-width: 0; }
.cal--agenda thead { display: none; }
.cal--agenda .cal-day { height: auto; padding: 10px 4px; border-left: 0; border-right: 0; }
.cal--agenda .cal-item { white-space: normal; }
.cal-day.is-outside { background: var(--cal-outside); }
.cal-day.is-outside .cal-daynum { color: var(--text-muted); font-weight: 400; }
.cal-day.is-target { background: var(--cal-target); outline: 2px dashed var(--brand-accent); outline-offset: -3px; }
.cal-daynum {
  display: inline-block; margin: 0 0 4px; padding: 0 4px; border-radius: var(--fi-r-sm);
  font-family: var(--sans); font-size: 12.5px; font-weight: 600; line-height: 1.6; letter-spacing: normal;
}
.cal-daynum:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
.cal-day.is-today .cal-daynum { background: var(--brand-primary); color: var(--brand-on-primary); }

.cal-items { list-style: none; margin: 0; padding: 0; display: grid; gap: 3px; }
.cal-item {
  display: block; width: 100%; padding: 3px 6px; text-align: left;
  font: inherit; font-size: 12px; line-height: 1.35; color: var(--text-primary);
  background: var(--surface-1); border: 1px solid var(--border);
  border-left: 3px solid var(--cal-order); border-radius: var(--fi-r-sm);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer;
}
.cal--week .cal-item { white-space: normal; }
.cal-item:hover { background: var(--surface-2); border-color: var(--border-strong); }
.cal-item:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
.cal-item--order { border-left-color: var(--cal-order); }
.cal-item--card { border-left-color: var(--cal-card); cursor: grab; }
.cal-item--delivery { border-left-color: var(--cal-delivery); }
.cal-item--material { border-left-color: var(--cal-material); }
.cal-item .dot { display: inline-block; width: 6px; height: 6px; margin-right: 4px; border-radius: 50%; vertical-align: middle; }
.cal-item-kind { font-weight: 600; color: var(--text-secondary); }
.cal-item-num { font-variant-numeric: tabular-nums; color: var(--text-secondary); }
.cal--month .cal-item-sub { display: none; }
.cal-item-sub { color: var(--text-secondary); }
.cal-item-late { font-weight: 600; color: var(--status-critical); }
.cal-item.is-overdue { background: var(--fi-blocked-tint); }
/* The board's two states — a card being dragged, and a card whose drop has
   not been answered — dimmed WITHOUT opacity. The board's own two fades are on
   notice in tests/text-opacity.test.js for putting the card's title under 4.5:1,
   so these say "waiting" with a dashed edge and the sunken ground instead, in
   the secondary ink that clears the floor on it in both themes. */
.cal-item.is-dragging,
.cal-item.is-moving {
  border-style: dashed; border-left-style: solid;
  background: var(--surface-2); color: var(--text-secondary);
}
.cal-item.is-moving { pointer-events: none; cursor: progress; }
.cal-more {
  padding: 2px 6px; border: 0; background: none; cursor: pointer;
  font: inherit; font-size: 12px; color: var(--text-secondary); text-decoration: underline;
}
.cal-more:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }

@media (max-width: 760px) {
  .cal { min-width: 640px; }
  .cal-day { height: 96px; }
  .cal--week .cal-day { height: auto; min-height: 260px; }
}

/* --- Moodboards ------------------------------------------------------------
   The third pane under the Board tab: a free canvas per product collection,
   for the thinking that comes before a card. Two screens — a list of cards,
   and the canvas that replaces it when one is opened.

   THE ONE RULE THAT MATTERS IN THIS BLOCK is `touch-action: none` on
   .moodboard-viewport and on nothing wider (trap 2 of the moodboards plan). It
   is what lets a pinch on the bench iPad mean zoom-the-canvas rather than
   zoom-the-page, and what lets a finger drag a tile rather than scroll the
   panel — and put on anything wider it would take browser zoom off the whole
   dashboard, which is an accessibility regression nobody reports as one. The
   viewport is the only element that receives the pointer events, so it is the
   only element that needs it.

   The canvas is one element moved and scaled as a whole —
   `transform: translate(px, py) scale(s)`, written by moodboards.js — and every
   tile is `position: absolute` inside it in canvas units, so a tile's own style
   is exactly its row: left = x, top = y, width = w, height = h, z-index = z.
   `transform-origin: 0 0` is load-bearing: the arithmetic in
   moodboard-canvas.js assumes the scale is about the canvas origin, and the
   default of 50% 50% would put every tile somewhere else.

   No new colour. A tile is the `.board-card` box over the canvas's own ground,
   and its kind is said by a small label in the head rather than by a colour
   scheme, for the reason the board's category dots are dots. */
.moodboards { display: block; }

.moodboard-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
  gap: 14px;
}
.moodboard-grid-empty { grid-column: 1 / -1; margin: 0; padding: 12px 2px; }

/* A moodboard in the list: a button, so it is one tap and one Tab stop. */
.moodboard-card {
  font: inherit;
  text-align: left;
  width: 100%;
  display: flex;
  flex-direction: column;
  gap: 0;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface-1);
  color: var(--text-primary);
  box-shadow: var(--fi-press);
  cursor: pointer;
  overflow: hidden;
}
.moodboard-card:hover { border-color: var(--border-strong); background: var(--surface-2); }
.moodboard-card.is-archived .moodboard-card-title { color: var(--text-secondary); }
/* The cover: the first photograph on the canvas, or the collection's code on
   a plain tile. Both the same box, so the list reads as one row of covers. */
.moodboard-cover {
  aspect-ratio: 4 / 3;
  background: var(--surface-2);
  border-bottom: 1px solid var(--border);
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}
.moodboard-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.moodboard-cover-code {
  font-family: var(--display);
  font-size: 30px;
  letter-spacing: 0.06em;
  color: var(--text-secondary);
}
.moodboard-card-body { display: flex; flex-direction: column; gap: 4px; padding: 10px 12px 12px; }
.moodboard-card-title {
  font-size: 13.5px;
  font-weight: 600;
  line-height: 1.35;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.moodboard-card-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }

/* The open moodboard's head: the way back, the name, the zoom, the drawer. */
.moodboard-toolbar { align-items: center; }
.moodboard-name { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-width: 0; }
.moodboard-name strong { font-size: 15px; }
.moodboard-zoom { display: flex; align-items: center; gap: 4px; }
.moodboard-zoom span { min-width: 44px; text-align: center; }
.moodboard-adds { margin-top: 10px; }

/* The stage: the viewport, and the drawer beside it when it is open. */
.moodboard-stage { display: flex; gap: 14px; align-items: stretch; }
.moodboard-viewport {
  position: relative;
  flex: 1 1 auto;
  min-width: 0;
  height: max(420px, calc(100vh - 300px));
  overflow: hidden;
  /* THIS ELEMENT ONLY — see the head of this block. */
  touch-action: none;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--fi-press);
  /* A quiet grid, so panning is visible on a canvas with one tile on it. */
  background-color: var(--surface-2);
  background-image:
    linear-gradient(var(--gridline) 1px, transparent 1px),
    linear-gradient(90deg, var(--gridline) 1px, transparent 1px);
  background-size: 40px 40px;
  cursor: grab;
  user-select: none;
  -webkit-user-select: none;
}
.moodboard-viewport.is-panning { cursor: grabbing; }
/* Files being dragged over the canvas. A ring rather than a wash, for the
   reason the tiles do not fade: the canvas is covered in text and photographs,
   and tinting the lot to say "you may let go here" makes all of it harder to
   read at the moment somebody is looking hardest. */
.moodboard-viewport.is-over { border-color: var(--fi-gold); box-shadow: var(--fi-focus); }
.moodboard-canvas {
  position: absolute;
  left: 0;
  top: 0;
  width: 0;
  height: 0;
  transform-origin: 0 0;
  will-change: transform;
}
/* Two layers inside the canvas, sharing its transform: the frames, and then
   everything else. Decision 5 of the second moodboards plan — a frame is
   ALWAYS under every other tile whatever its z, and z orders frames only among
   themselves. Each layer is its own stacking context (position + z-index), so a
   frame's z-index of 40 never argues with a note's z-index of 3; without the
   two contexts the frame would sit over the note and the note would be a tile
   nobody can reach. A "bring forward" button was the alternative and this rule
   removes the case instead of adding a button to fix it. Both are 0×0 like the
   canvas, so neither takes a pointer event of its own: only their children do. */
.moodboard-frames, .moodboard-tiles {
  position: absolute;
  left: 0;
  top: 0;
  width: 0;
  height: 0;
}
.moodboard-frames { z-index: 0; }
.moodboard-tiles { z-index: 1; }
.moodboard-empty {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  margin: 0;
  max-width: 360px;
  text-align: center;
  pointer-events: none;
}

/* A tile. The row's numbers are its style; nothing here sets a size. */
.moodboard-tile {
  position: absolute;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  background: var(--surface-1);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  box-shadow: var(--fi-raise);
  overflow: hidden;
  cursor: grab;
  user-select: none;
  -webkit-user-select: none;
}
/* Neither state fades the tile. A tile carries text — a note's words, a link's
   label — and `opacity` composites that text towards the ground, which is
   exactly the fault tests/text-opacity.test.js exists to catch and the reason
   the board's two card fades are on notice there. So a tile being dragged is
   lifted (the focus ring and the raise, in gold) rather than dimmed, and one
   the server is still deciding about — a drag released, the answer not back;
   see `commitPlacement` in moodboards.js — is dashed and shows a progress
   cursor. Both say what they mean with the text left readable. */
.moodboard-tile.is-dragging {
  cursor: grabbing;
  border-color: var(--fi-gold);
  box-shadow: var(--fi-focus), var(--fi-raise);
}
.moodboard-tile.is-placing { border-style: dashed; border-color: var(--fi-gold); cursor: progress; }
/* The tile a search landed on, for one second — `showFoundTile` in
   moodboards.js. A ring rather than a flash: the same reasoning as the two
   states above, plus the one this adds, which is that a pulse would be motion
   nobody asked for on a screen somebody has just been dropped onto. It is
   removed on a timer, so nothing about the canvas depends on it being there. */
.moodboard-tile.is-found {
  border-color: var(--fi-gold);
  box-shadow: var(--fi-focus), var(--fi-raise);
}
.moodboard-tile-head {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 5px 8px;
  border-bottom: 1px solid var(--border);
  background: var(--surface-2);
  font-size: 11px;
  color: var(--text-muted);
  flex: none;
}
.moodboard-tile-kind { text-transform: uppercase; letter-spacing: 0.06em; font-weight: 600; }
.moodboard-tile-caption { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-secondary); }
.moodboard-tile-head .icon-btn { width: 24px; height: 24px; font-size: 13px; } /* Part 6 (2.5.8): 22px -> 24px minimum target size */
.moodboard-tile-body {
  flex: 1;
  min-height: 0;
  padding: 8px 10px;
  overflow: auto;
  font-size: 13px;
  line-height: 1.45;
}
/* The note's words are typed straight into the tile. The box has no chrome of
   its own, so the tile is the box. */
.moodboard-tile-note {
  width: 100%;
  height: 100%;
  min-height: 0;
  resize: none;
  border: 0;
  padding: 0;
  background: transparent;
  font: inherit;
  line-height: inherit;
  color: inherit;
  box-shadow: none;
  cursor: text;
  user-select: text;
  -webkit-user-select: text;
}
.moodboard-tile-note:focus { box-shadow: none; }
/* A photograph fills its tile. The body's padding and scroll are off for this
   one kind: the tile is already the picture's shape (the upload set w and h
   from it), so there is nothing to scroll to and a margin would be a frame
   nobody asked for. `contain` rather than `cover` is the case that is not the
   ordinary one — a tile resized with Shift held is no longer the picture's
   shape, and a photograph with a margin is better than one with its edges cut
   off without being told. */
.moodboard-tile.has-photo .moodboard-tile-body { padding: 0; overflow: hidden; }
.moodboard-photo { position: relative; margin: 0; width: 100%; height: 100%; }
.moodboard-photo img { display: block; width: 100%; height: 100%; object-fit: contain; background: var(--surface-2); }
/* `display: block` above would otherwise beat the browser's own `[hidden]`
   rule, and a preview that failed to load would sit as a broken glyph on top
   of the words explaining it. See `onPhotoError`. */
.moodboard-photo img[hidden] { display: none; }
/* The words a preview that would not load is replaced by — see onPhotoError.
   Not italic-and-muted like the retired-pointer tiles: this is a fault to be
   read rather than a fact to be noted. */
.moodboard-photo-gone { padding: 8px 10px; color: var(--text-secondary); }
/* On the head beside Place… and Remove, so a finger can reach it: there is no
   hover on the bench iPad. */
.moodboard-tile-original { flex: none; white-space: nowrap; }
/* A link: the picture the server found at its address over its words. The
   body's padding is off for this kind, as it is for a photograph, so the
   picture runs edge to edge; the words get their own padding back. */
.moodboard-tile.is-link .moodboard-tile-body { padding: 0; overflow: hidden; }
.moodboard-tile-link { display: flex; flex-direction: column; height: 100%; min-height: 0; }
.moodboard-tile-link a { font-weight: 600; word-break: break-word; cursor: pointer; }
/* The picture's box: the picture, or the host in words when there is none or
   it would not load (onPhotoError). Two-fifths of the tile, so a link resized
   short still shows its words. */
.moodboard-link-picture {
  flex: 0 0 40%;
  min-height: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--surface-2);
  overflow: hidden;
}
.moodboard-link-picture img { display: block; width: 100%; height: 100%; object-fit: cover; }
.moodboard-link-picture img[hidden] { display: none; }
.moodboard-link-host { padding: 8px 10px; font-weight: 600; color: var(--text-muted); overflow-wrap: anywhere; text-align: center; }
.moodboard-link-words { display: flex; flex-direction: column; gap: 2px; padding: 8px 10px; min-height: 0; overflow: auto; }
.moodboard-link-title { color: var(--text-secondary); }
/* The page's own description, clipped to two lines: a tile is a fixed box and
   a description is whatever length the page liked. */
.moodboard-link-desc {
  margin: 0;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  color: var(--text-secondary);
}
/* A fetch that failed says why, in the same tone a photograph that would not
   load uses, and offers the button. */
.moodboard-link-failed { margin: 4px 0 0; font-size: 12px; color: var(--text-secondary); }
.moodboard-link-failed .linkish { font-size: 12px; }
.moodboard-tile-thing { display: flex; flex-direction: column; gap: 4px; }
.moodboard-tile-thing .mono { font-size: 12px; }
.moodboard-tile-thing .linkish { text-align: left; font-weight: 600; }
/* A pointer the database has taken away: the tile stays and says so, in words. */
.moodboard-tile.is-gone .moodboard-tile-body { color: var(--text-muted); font-style: italic; }
/* A frame: the tile's box with the inside taken out. A dashed edge and a head
   along the top with the title in it, and NO fill — decision 8 declined colour,
   and a tinted frame is the same thing wearing a rectangle. The grid shows
   through, and the tiles in it are in the layer above and draw over it. The
   head is the only part with a ground of its own, so the title reads over
   whatever is under the frame. */
.moodboard-tile.moodboard-frame {
  background: transparent;
  border: 2px dashed var(--border-strong);
  box-shadow: none;
  overflow: visible;
}
.moodboard-tile.moodboard-frame.is-dragging,
.moodboard-tile.moodboard-frame.is-found { box-shadow: var(--fi-focus); }
/* The frame a dragged tile will land in — `frameContaining` in
   moodboard-canvas.js, applied on every pointermove. A solid gold edge rather
   than a fill, for the reason above and one more: the tile under the hand is
   what the eye is on, and a wash under it would move the eye off it. */
.moodboard-tile.moodboard-frame.is-target { border-style: solid; border-color: var(--fi-gold); }
.moodboard-frame-head {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 4px 8px;
  background: var(--surface-2);
  border-bottom: 1px dashed var(--border-strong);
  font-size: 11px;
  color: var(--text-muted);
  flex: none;
}
.moodboard-frame-head .icon-btn { width: 24px; height: 24px; font-size: 13px; } /* Part 6 (2.5.8): 22px -> 24px minimum target size */
/* The title is typed straight into the edge, like a note's words into its
   tile: no chrome of its own until it has the focus. */
.moodboard-frame-title {
  flex: 1;
  min-width: 0;
  border: 0;
  padding: 2px 4px;
  background: transparent;
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  color: var(--text-primary);
  box-shadow: none;
  cursor: text;
  user-select: text;
  -webkit-user-select: text;
}
.moodboard-frame-title:focus { background: var(--surface-1); }

/* Arrows — phase 12, part four. The SVG sits above both layers inside the
   canvas, sharing its transform, and takes NO pointer events of its own: only
   the wide invisible stroke and the label on each arrow do (trap 1), so a tile
   under an arrow is still clickable everywhere but along the stroke. The
   stroke is ARROW_HIT_WIDTH in moodboard-canvas.js — twelve units at 1:1,
   written as an attribute by arrowSvgHtml — and scales with the canvas because
   it is drawn inside the transform. `overflow: visible` because the SVG is
   sized to the arrows' bounding box with a margin (moveArrows), and a head or
   a label at the very edge must not be clipped by it. */
.moodboard-arrows {
  position: absolute;
  z-index: 2;
  overflow: visible;
  pointer-events: none;
}
.moodboard-arrow-line {
  stroke: var(--text-secondary);
  stroke-width: 2;
  stroke-linecap: round;
}
.moodboard-arrow-hit {
  pointer-events: auto;
  cursor: pointer;
}
/* The stroke is what the keyboard focuses; the ring goes on the line beside
   it, and on hover, so the arrow under the pointer or the focus reads as lit
   without a fill anywhere. */
.moodboard-arrow-hit:focus { outline: none; }
.moodboard-arrow:hover .moodboard-arrow-line,
.moodboard-arrow:focus-within .moodboard-arrow-line { stroke: var(--fi-gold); stroke-width: 3; }
/* The head takes the line's own colour where the browser can say so
   (`context-stroke`, every engine this dashboard supports), so a lit line has
   a lit head; the plain colour is the fallback for one that cannot. */
.moodboard-arrowhead path { fill: var(--text-secondary); fill: context-stroke; }
.moodboard-arrow-label { pointer-events: auto; cursor: pointer; }
.moodboard-arrow-label rect { fill: var(--surface-1); stroke: var(--border-strong); stroke-width: 1; }
.moodboard-arrow-label text {
  font: inherit;
  font-size: 12px;
  font-weight: 600;
  fill: var(--text-primary);
  user-select: none;
  -webkit-user-select: none;
}
/* A connect pending: the from tile is marked, the tile under the pointer says
   it is the target, and the cursor says what a press will do. Edges rather than
   fills, for the reason every other state on a tile is an edge. */
.moodboard-viewport.is-connecting { cursor: crosshair; }
.moodboard-viewport.is-connecting .moodboard-tile { cursor: crosshair; }
/* And while one is pending the arrows take no pointer events at all. The hit
   stroke is twelve units wide and sits above the tile layer, so an arrow
   crossing the tile somebody is pressing would answer the press instead of it —
   and a press that is not on a tile cancels, so the arrow would cancel the
   connect rather than complete it. That is trap 1 again, inside the one gesture
   where being wrong costs the whole of it. */
.moodboard-viewport.is-connecting .moodboard-arrow-hit,
.moodboard-viewport.is-connecting .moodboard-arrow-label { pointer-events: none; }
.moodboard-tile.is-connect-from { border-style: dashed; border-color: var(--fi-gold); }
.moodboard-tile.is-connect-target { border-style: solid; border-color: var(--fi-gold); box-shadow: var(--fi-focus), var(--fi-raise); }
.moodboard-connect-say {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin: 8px 0 0;
  color: var(--text-secondary);
}
/* The popover on an arrow: a small card inside the viewport, over the arrow's
   midpoint, at the page's scale. */
.moodboard-arrow-pop {
  position: absolute;
  z-index: 5;
  width: 260px;
  max-width: calc(100% - 16px);
  display: grid;
  gap: 6px;
  padding: 10px;
  background: var(--surface-1);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  box-shadow: var(--fi-raise);
}
.moodboard-arrow-pop p { margin: 0; }
.moodboard-arrow-pop-row { display: flex; align-items: center; gap: 6px; }
.moodboard-arrow-pop-row select { min-width: 0; }

/* The resize handle: a corner big enough for a finger. */
.moodboard-handle {
  position: absolute;
  right: 0;
  bottom: 0;
  width: 22px;
  height: 22px;
  cursor: nwse-resize;
  background:
    linear-gradient(135deg, transparent 0 50%, var(--border-strong) 50% 60%, transparent 60% 75%, var(--border-strong) 75% 85%, transparent 85%);
  border-bottom-right-radius: var(--radius);
}

/* The drawer: the moodboard's own words, its history and its comments. */
.moodboard-drawer {
  flex: 0 0 340px;
  max-width: 100%;
  max-height: max(420px, calc(100vh - 300px));
  overflow-y: auto;
  background: var(--surface-1);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--fi-press);
  padding: 14px;
}
.moodboard-details { display: grid; gap: 10px; margin-bottom: 8px; }
.moodboard-details-foot, .moodboard-drawer-foot { display: flex; align-items: center; gap: 8px; }
.moodboard-drawer-foot { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border); }

/* What the thinking on a board turned into: the cards about it, in the drawer.
   A row rather than a list bullet, because each row is three things of
   different weights — the number, the title as a button, and where the card
   stands — and the number has to line up down the column for the list to be
   scannable at all. `list-style: none` with no padding is what makes that
   possible inside an `<aside>` that is otherwise prose. */
.moodboard-cards { list-style: none; margin: 0 0 8px; padding: 0; }
.moodboard-cards li {
  display: flex;
  align-items: baseline;
  gap: 8px;
  padding: 4px 0;
  border-bottom: 1px solid var(--border);
}
.moodboard-cards li:last-child { border-bottom: 0; }
.moodboard-cards .linkish { text-align: left; flex: 1; }
.moodboard-cards-archived summary { cursor: pointer; padding: 4px 0; }

@media (max-width: 860px) {
  /* The drawer goes under the canvas rather than beside it, and the canvas
     gives up some height so the drawer is reachable without a long scroll. */
  .moodboard-stage { flex-direction: column; }
  .moodboard-viewport { height: max(320px, calc(100vh - 340px)); }
  .moodboard-drawer { flex: none; max-height: none; }
}

/* ---------------------------------------------------------------------------
   Version footer — appended to every page by /js/version-footer.js.
   Quiet by design: it is a fact to look up, not something to read.
   --------------------------------------------------------------------------- */
.version-bar {
  margin-top: 40px;
  padding: 18px 20px 24px;
  border-top: 1px solid var(--border);
  text-align: center;
  font-size: 12.5px;
  color: var(--text-muted);
}
/* Part 6 (1.4.1): colour was the only signal this is a link (no underline,
   same colour as the plain text beside it) — now underlined at rest. */
.version-bar a { color: var(--text-muted); text-decoration: underline; }
.version-bar a:hover { color: var(--text-secondary); }
.version-bar-num { font-variant-numeric: tabular-nums; }
/*
 * What this page cost — a second line under the version. Smaller and set apart
 * on purpose: it is a measurement of the screen rather than anything on it, and
 * it must not compete with the page for attention. See public/js/page-stats.js
 * for where the three numbers come from.
 *
 * **"Smaller and dimmer" is what this said, and the dimmer half had to go.**
 * `opacity: 0.85` put the whole line — and the "Server status" link inside it,
 * which inherits — at **4.1:1** on the page, under §15's 4.5 floor. 0.85 is a
 * very light touch and it was still enough, because `--text-muted` starts at
 * 5.7 in the light theme and there is only 1.2 of headroom between that and the
 * floor. Any fade at all on bark spends more than there is.
 *
 * **What replaces it is size, because size is the only lever there is.** The
 * line above is `--text-muted` as well, and in the light theme `--text-muted`
 * and `--text-secondary` are *both* `--fi-bark` — §5.1 allows two text colours
 * under 19px and this is already the quieter of them, so there is no third to
 * step down to. Taking the fade off and changing nothing else was tried and
 * looked wrong: at 12px against the version line's 12.5 the two lines read as
 * equals, and this one is longer and denser, so the diagnostic ended up
 * shouting louder than the version it sits under.
 *
 * 11px is the step, and it is the §6 scale's own — Micro, the size this file
 * already sets table headers and eyebrows in. §5.1 says how to spend it in so
 * many words: "the §6 scale carries the rest of the hierarchy". Bark measures
 * 5.66 on cream at every size, so the step costs nothing at the floor; WCAG
 * sets no minimum size, and the line is three numbers and a link, not prose.
 * The fade was buying a hierarchy the palette does not offer and charging §15
 * for it; this buys the same hierarchy from the scale, where it is in stock.
 */
.version-bar-stats { margin-top: 4px; font-size: 11px; color: var(--text-muted); }

/* ---------------------------------------------------------------------------
   Version history — Configuration › Version.
   --------------------------------------------------------------------------- */
.release { display: grid; grid-template-columns: 92px 1fr; gap: 16px; padding: 16px 0; border-top: 1px solid var(--border); }
.release:first-child { border-top: 0; padding-top: 4px; }
.release .rel-mark { text-align: right; }
.release .rel-num { font-size: 15px; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--text-primary); }
.release .rel-date { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
.release h4 { font-size: 14.5px; margin: 0 0 5px; }
.release p { font-size: 13.5px; margin: 0; color: var(--text-secondary); line-height: 1.6; }
.release .rel-pr { font-size: 12.5px; margin-top: 6px; display: inline-block; color: var(--text-muted); }
/* What one merge shipped, when it shipped more than one thing. */
.release .rel-changes { margin: 8px 0 0; padding: 0 0 0 14px; list-style: none; border-left: 2px solid var(--border); }
.release .rel-changes li { margin-top: 10px; }
.release .rel-changes li:first-child { margin-top: 0; }
.release .rel-changes strong { display: block; font-size: 13.5px; color: var(--text-primary); }
.release .rel-changes span { display: block; font-size: 13px; color: var(--text-secondary); line-height: 1.6; margin-top: 2px; }
@media (max-width: 560px) {
  .release { grid-template-columns: 1fr; gap: 4px; }
  .release .rel-mark { text-align: left; display: flex; gap: 8px; align-items: baseline; }
  .release .rel-date { margin-top: 0; }
}

/* ---------------------------------------------------------------------------
   The workshop floor.

   §8: the studio has to be usable at 640px, one-handed, by somebody with clay
   on their hands — so below that width every control that can be tapped is at
   least 44px tall, and the 16px input text stops iOS zooming the page on
   focus. Above it nothing changes; the dense rows are the point.
   --------------------------------------------------------------------------- */
@media (max-width: 640px) {
  .btn, .icon-btn, .chips button, .tabs button, .nav a, .nav button.linklike {
    min-height: 44px;
  }
  .btn, .chips button { padding-block: 11px; }
  .icon-btn { width: 44px; }
  /* The `⋯` is a `.btn`, so the rule above already gives it 44px of height. Its
     width comes from one glyph and nine pixels of padding, which is 34 — and a
     tap target is a rectangle, not a height. The menu's own entries clear 44
     from their two lines of text everywhere but the shortest of them, and this
     says so rather than leaving it to the copy. */
  .btn-more { min-width: 44px; justify-content: center; }
  .row-menu [role="menuitem"] { min-height: 44px; }
  .nav a, .nav button.linklike { display: inline-flex; align-items: center; }
  input:not([type="checkbox"]):not([type="radio"]), select, textarea {
    min-height: 44px;
    font-size: 16px;
  }
  .check input[type="checkbox"], .check input[type="radio"] { min-height: 0; width: 20px; height: 20px; }
  table.data td, table.data th { padding-block: 14px; }
  /* A board lane that took 296px of a 360px screen would leave the next one
     invisible, and a kanban you cannot see the edge of is one nobody drags
     across. Narrower, so the column beside it shows and says which way to
     scroll — and this is the width where the "Move…" button matters most,
     because iOS gives native drag-and-drop nothing at all. */
  .board-col { flex-basis: 252px; }
  /* The font-size as well as the width: `.board-add select` is (0,1,1) and the
     rule above it in this block matches a `<select>` through the bare type
     selector at (0,0,1), so without this line the quick-add category picker
     keeps its 12.5px and iOS zooms the page when somebody taps it — which is
     the one thing the 16px in this block is for. */
  .board-add select { flex-basis: 80px; font-size: 16px; }
  /* The notes' category picker, for the same reason: `.notes-category` is
     (0,1,0) and sets 13px to sit beside the footer's small buttons. */
  .notes-category { font-size: 16px; }
  /* The note as it reads, because the box under it gets its 16px from the
     rule for every textarea above and the two have to be one size: at 14px
     over a 16px box the lines break in different places, and a click puts the
     box back with the text reflowed under the finger that tapped it. */
  .notes-view { font-size: 16px; }
}

/* ---------------------------------------------------------------------------
   Utilities.

   Last in the file on purpose. These replaced style="..." attributes, which
   outrank every selector; a utility that lost a specificity tie to a component
   rule further down would silently change what it was meant to preserve.
   --------------------------------------------------------------------------- */
/*
  The sizes the dashboard actually sets, named.

  Four steps below body text carry almost everything the screen says quietly:
  the note under a figure, the second line under a name, a field label, a
  timestamp. They were written as `style="font-size:12px"` and its neighbours
  in a hundred and eleven places, which is a hundred and ten more chances to
  pick 12.5 where the row above used 12.

  The values are exactly the ones that were inline — this renames, it does not
  restyle. That the scale has both 12 and 12.5 in it is worth settling one day;
  doing it here would have changed how the screen looks in a commit that says
  it does not.
*/
.fine { font-size: 12px; }
.fine-xs { font-size: 11px; }
.fine-sm { font-size: 12.5px; }
.fine-md { font-size: 13px; }

/* Right-aligned without the tabular figures `.num` also brings. */
.right { text-align: right; }

/*
  A run-in heading inside a pane — the small capitals that separate one group
  of fields from the next. Two of them, because half the uses set a top margin
  and half leave the element's own; collapsing that would move things.
*/
.subhead {
  font-size: 13px;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--text-muted);
  margin: 24px 0 10px;
}
.subhead-b {
  font-size: 13px;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--text-muted);
  margin-bottom: 10px;
}

/* The buttons at the foot of a form grid, pushed to the right across it. */
.form-actions { grid-column: 1/-1; display: flex; justify-content: flex-end; }

/* One bordered block in a stack of them. */
.boxed {
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 12px;
  margin-bottom: 8px;
}

/* The spacings that repeat. Named for what they set, so a glance at the markup
   still says the number. */
.m0 { margin: 0; }
.mt-3 { margin-top: 3px; }
.mt-10 { margin-top: 10px; }
.mt-14 { margin-top: 14px; }
.mb-8 { margin-bottom: 8px; }
.mb-14 { margin-bottom: 14px; }
.mb-16 { margin-bottom: 16px; }
.m0-b10 { margin: 0 0 10px; }
.m0-b14 { margin: 0 0 14px; }

/* A table column that should be no wider than its content. `width: 1%` is the
   old trick for it: the browser honours the declared width only as far as the
   content allows, so the column collapses onto its buttons. */
.shrink { width: 1%; }

.mt-8 { margin-top: 8px; }
.mt-16 { margin-top: 16px; }
.mt-22-rule { margin-top: 22px; border-top: 1px solid var(--border); padding-top: 16px; }
.mb-18 { margin-bottom: 18px; }
.w-full { width: 100%; }
.w-auto { width: auto; margin: 0; }
/* The small capitals of `.subhead`, where the margins come from elsewhere. */
.subhead-plain {
  font-size: 13px;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--text-muted);
}
.framed { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
