/* Dark is the default palette (the app shipped dark-only for its first ~48 rounds, so absence of
   a data-theme stamp must keep looking exactly like it always did). A pre-paint script in
   index.html resolves the workspace's saved mode - system/light/dark - to an explicit
   data-theme="light|dark" stamp on <html>, so this file never needs a prefers-color-scheme
   media block: JS owns the resolution, CSS just carries the two palettes. */
:root {
	color-scheme: dark;
	/* Base font: Settings → Appearance swaps the leading family via applyThemeFont() */
	--font-stack: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
	--bg: #0f1117;
	--bg2: #1a1d27;
	--bg3: #242736;
	--border: #2e3247;
	/* A CONTROL's boundary, as opposed to --border's grouping edge. Same "one token, two roles"
	   split as --accent/--accent-text: a card outline wants to be quiet (1.33:1 is right - the
	   surface change is doing the work) and an input's edge is the only thing telling a reader where
	   to type, which WCAG 1.4.11 puts at 3:1. Sharing one token meant light mode rendered a
	   near-white field on a white card behind a 1.38:1 line, which is the most visible thing wrong
	   with light mode and was invisible in dark, where --bg/--bg2 differ enough to draw the box by
	   themselves. */
	--border-strong: #707381;
	--text: #e0e0e8;
	--text2: #9498b0;
	--accent: #6c5ce7;
	--accent-hover: #5b4dc2;
	/* The accent in a TEXT role (links, chips, the active tab) - a separate token because the two
	   roles want opposite things from one hue: a fill wants to be saturated enough to carry a white
	   label, and text on --bg3 wants 4.5:1. The bare --accent is 3.05:1 on --bg3 here, so every link
	   in the app was under AA in the theme it was developed in. ⚠ Both of these, and --on-accent
	   below, are RE-DERIVED in JS by applyThemeColor() whenever the owner picks a custom accent -
	   these literals are the default accent's answers and the pre-paint fallback, nothing more. */
	--accent-text: #8c80ec;
	/* The gradient's SECOND stop (2026-08-03), and ⚠ it is derived rather than picked. The owner can
	   set a custom accent (applyThemeColor re-derives --accent-text and --on-accent from it), so a
	   literal pink here would be a fixed hue sitting next to a hue the owner chose - the one pairing
	   nobody reviewed. Mixing the accent toward --success keeps the two stops a fixed distance apart
	   in the same space, so the sheen reads as one colour moving rather than as two colours meeting,
	   whatever accent is live. It is declared once: custom properties resolve lazily, so this line
	   answers differently in each theme without being restated in the light block. */
	--accent-2: color-mix(in oklch, var(--accent) 62%, var(--success));
	--success: #00b894;
	--warning: #fdcb6e;
	/* ── The --on-X family: text sitting ON a solid fill of --X. ────────────────────────────────
	   The dark theme's soft amber needs dark text and the light theme's deep amber needs white - a
	   literal #333 there would go illegible the moment the fill darkens. ⚠ That argument was made
	   for --warning alone and never asked of its siblings, so #fff sat on --success at 2.54:1 (the
	   Approve / Send-all button) and on --danger at 3.16:1 for ~100 rounds.

	   ⚠ Why this has to be a TOKEN and cannot be a light-mode override in the component that draws
	   the chip: inside a shadow root there is no `:root[data-theme="light"]` to select - the host
	   document's attribute is not reachable across the boundary (`:host-context` is the only reach
	   and is not portable). Custom properties DO inherit through it. So in this app a colour that
	   varies by theme has exactly one available spelling, and it is this one.

	   The pairing is checked by NAME in `tests/theme.test.ts`: every --on-X must clear AA against
	   --X in both palettes, so adding a fill token and its label is one step and forgetting the
	   label is a failure rather than a guess. */
	--on-accent: #fff;
	--on-warning: #11131e;
	--on-success: #11131e;
	--on-danger: #11131e;
	--on-text2: #11131e;
	--on-viz-1: #11131e;
	--on-viz-3: #11131e;
	--on-viz-dim: #fff;
	--on-viz-ord-2: #11131e;
	--danger: #e17055;
	/* The toast-error surface: a red-tinted panel, not a solid --danger fill */
	--danger-surface: #2a1a1a;
	/* The barely-there "raised surface" tint (locale cards, accordions): white-at-2% reads as
	   lift on dark but is invisible on white, so the tint must flip with the theme */
	--lift: rgba(255, 255, 255, 0.02);
	/* Shadows carry most of the depth on dark; on light the borders do, so these soften there */
	--shadow-pop: 0 8px 24px rgba(0, 0, 0, 0.4);
	--shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.45);
	--radius: 8px;
	/* ⚠ ONE HEIGHT FOR EVERY CONTROL IN THE APP, and it is a token because the controls are spread
	   across a global sheet, six style fragments and a dozen shadow roots - which is exactly how they
	   drifted (owner, 2026-08-11, of the press kit's appearance strip: "different heights across the
	   UI components... same on other pages like Smart links"). Nothing was WRONG in any single file:
	   a button derived its floor from its own padding and line-height (40.4px), the three input types
	   that had ever been reported pinned 37px each, a plain text input took whatever the UA gave it,
	   and a color swatch was not styled at all. Four correct rules, four heights, one row.
	   40px is the number the app's BUTTONS already were, so the most visible control does not move.
	   A custom property is also the only kind of rule that reaches a shadow root, which is what lets
	   the select trigger and the multiselect share it (round 148's rule, once more).
	   ⚠ It is a floor, never a fixed height: text scaled to 150% has to be able to grow its box. */
	--control-h: 40px;
	/* The app-wide dot texture's strength; see body::before. Zero in the high-contrast palettes. */
	--dot-grid-opacity: 9%;
	/* The SAME texture on a page that is ONE card on an empty field - the sign-in gate. Stronger on
	   purpose (there are no cards for the grid to fill the gaps between, so it carries the page), and
	   a TOKEN rather than a literal for the reason NOTE 5 on `body::before` gives: the four
	   high-contrast palettes take both of these to 0%, and a percentage written into the rule would be
	   a decision `prefers-contrast` could not reach. ⚠ It shipped as a literal `11%` and did exactly
	   that - a high-contrast reader got a clean app and a still-textured sign-in page (§AC.4), which
	   `tests/theme.test.ts` could not see, because the gate catches a hardcoded COLOR and this was a
	   hardcoded PERCENTAGE over a correct token. */
	--dot-grid-opacity-solo: 11%;
	/* THE ACCENT WASH BEHIND THE SIGN-IN CARD (2026-08-09, owner: Crew's sign-in *"looks cooler than
	   the app one, and I think it's because the crew one has a glow or something around the login
	   container"*). Right, and the reason is written down in `src/crew/views.ts`: that glow was in the
	   mock and shipped only in Crew, so the app's own gate - the door every customer comes through -
	   kept the flat version. A wide, soft wash lifts the middle of the field so the dot grid sits IN
	   something rather than on flat black, and the card lands on the lit part.
	   A TOKEN for `--dot-grid-opacity-solo`'s exact reason, and it is the same decision one surface
	   over: this is a full-surface layer whose whole job is to compete with the words a little, so the
	   four high-contrast palettes take it to 0% and a literal here would be a strength
	   `prefers-contrast` could not reach. `tests/theme.test.ts` enforces both halves.
	   ⚠ **15%, where Crew's identical rule says 9%, and copying its number is what NOT to do.** A
	   percentage in a `color-mix` is an amount of PAINT, not an amount of light: Crew's page sits on a
	   lighter ground than this app's `--bg`, so the same 9% lifted its field twice as far as it lifted
	   ours. Both were shipped, screenshotted and sampled at the same two spots - the far corner and the
	   field beside the card - and 15% here is what puts our lift on Crew's (+7 luminance, measured, in
	   the dark palette). **When you carry a visual value across surfaces, carry the RESULT and re-derive
	   the number**; the value that travels is "the field lifts by about this much", and the literal that
	   produces it is a fact about whatever it is painted on. */
	--signin-glow-opacity: 15%;
	/* How far anything docked in the bottom-right corner must sit from the bottom edge (2026-08-07).
	   It is 24px - the plain inset - until `<gh-help-bubble>` mounts and raises it to clear its own
	   48px launcher, which is why it is a TOKEN and not a literal in two places: the corner is shared
	   now, and a second surface that ever docks there reads this instead of guessing a clearance.
	   ⚠ Declared HERE rather than left to a `var(…, 24px)` fallback, so it is discoverable in the
	   palette and `tests/theme.test.ts` can see that it resolves. */
	--gh-corner-dock: 24px;

	/* ── Text scale (§R) ──────────────────────────────────────────────────────────────────────────
	   The multiplier Settings → Accessibility sets, and the reason every font-size in this app is a
	   `rem`. Read `html { font-size }` below for what it multiplies and why nothing else scales. */
	--gh-text-scale: 1;

	/* ── The focus ring (WCAG 2.4.7) ──────────────────────────────────────────────────────────────
	   ⚠ Tokens for the same reason the motion durations are: a `:focus-visible` rule written once at
	   document scope reaches nothing inside a shadow root, and every island and component has been
	   shadow since round 100. So the RULE is duplicated (here for the light chrome, `focusStyles` in
	   `sharedStyles.ts` for the shadow side) and the VALUES are custom properties, which do cross -
	   otherwise the two copies drift and only one of them is the one a keyboard user meets.

	   `--accent-text` rather than `--accent`: this is the accent in its READABLE role (the token's
	   own comment above), and a ring is a thin line that has to be seen against three surfaces. The
	   2px offset is what makes a ring on a filled button visible at all - drawn flush it sits on the
	   fill and disappears into it. */
	--focus-ring-color: var(--accent-text);
	--focus-ring-width: 2px;
	--focus-ring-offset: 2px;
	/* Data-viz series slots - validated (CVD separation + 3:1 contrast) against --bg2, in BOTH
	   palettes: same hues, stepped per surface (see the light block) */
	--viz-1: #3987e5;
	--viz-2: #199e70;
	--viz-3: #c98500;
	/* The chorus's echo in the Sections timeline - a subordinate relative of --viz-3, and a real
	   token rather than the color-mix() it used to be so that it can HAVE an --on- label. A derived
	   fill cannot be paired with one by name, and this one lands mid-tone in both palettes, which is
	   exactly where the label flips. Same rendered colour as the mix it replaces. */
	--viz-3-soft: #7f5b18;
	--on-viz-3-soft: #fff;
	--viz-dim: #565b78;
	--viz-grid: #232738;
	/* Ordinal blue ramp for ordered stages (funnel) - validated monotone, light end clears the
	   surface. The 250→600 steps clear BOTH surfaces (2.11:1 on white, 2.07:1 on dark), so the
	   ramp is deliberately theme-invariant */
	--viz-ord-1: #86b6ef;
	--viz-ord-2: #5598e7;
	--viz-ord-3: #2a78d6;
	--viz-ord-4: #184f95;

	/* ── Motion (round 149, BACKLOG B.4) ──────────────────────────────────────────────────────────
	   Three durations and one easing, because the app only makes three kinds of promise: something
	   responded to me (fast), something changed (base), something arrived (slow). A fourth would be
	   a decision nobody could restate.

	   ⚠ These are TOKENS for the same reason the colours are, and the reason is worth knowing before
	   reaching for the obvious implementation: the usual reduced-motion reset
	   (`@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none } }`)
	   **cannot cross a shadow boundary**, and every island and component in this app has been shadow
	   since round 100 - so a global reset would silently protect nothing. Custom properties DO
	   inherit through the boundary, which makes flipping them at :root the only spelling that
	   actually reaches the elements that animate. */
	--motion-fast: 0.15s;
	--motion-base: 0.25s;
	--motion-slow: 0.4s;
	/* Decelerating: leaves immediately, settles gently. One easing, so nothing has to be chosen. */
	--motion-ease: cubic-bezier(0.2, 0, 0, 1);
	/* Loops that DECORATE (the skeleton shimmer): a period and an iteration count. Only the count
	   changes under reduced motion - one pass, then a plain grey block, which is all the shimmer was
	   ever adding to. Status loops are a different token below. */
	--motion-decor-loop: 1.5s;
	--motion-decor-iterations: infinite;
	/* Loops that carry INFORMATION - a spinner, the connection check's pulse. */
	--motion-status-loop: 0.7s;
	/* The SHEEN: the slow gradient drift across the wordmark, added 2026-08-03 when the owner asked
	   for the landing page's look in the app. Its own token because it is an order of magnitude
	   slower than the shimmer and for the opposite reason: the shimmer says "something is loading"
	   and this says nothing at all, which is what lets it collapse to a single pass with no loss. */
	--motion-sheen-loop: 9s;
	/* The page's entrance on a route change. One rise, never a loop. */
	--motion-enter: 0.42s;
}

/* ⚠ Not "no motion": WCAG 2.3.3 is about NON-ESSENTIAL animation, and the distinction has teeth
   here. A transition is decoration over a state the DOM already carries, so it collapses. A skeleton
   shimmer decorates a wait, so it stops after one pass and leaves a plain grey block. **A spinner
   does not stop**, because it is the only evidence that work is in flight - a still spinner is a
   spinner that lies, and "did my click register?" is a worse experience than a rotating ring for
   someone who asked for less motion. It slows down instead. */
/* ⚠ `:root:not([data-motion="full"])`, not a bare `:root` (§R). Round 149 honored the platform
   preference and gave the owner no way to say anything about it, which is the half §R was filed for:
   an accessibility SETTING has to be able to disagree with the OS in both directions, or it is a
   readout. `data-motion` is stamped on <html> by `frontend/a11y.ts` (and pre-paint by index.html):
   absent or "system" = whatever the platform says, "reduced" = the block below, "full" = this one
   stands down. ⚠ The two token blocks are DUPLICATED on purpose - CSS has no way to name a set of
   declarations and apply it from two selectors - so `tests/motion.test.ts` asserts they are
   character-identical. A drift here is a preference that works from the OS and not from the app. */
@media (prefers-reduced-motion: reduce) {
	:root:not([data-motion="full"]) {
		/* Not 0s: a zero-length transition never fires `transitionend`, and anything waiting on one
		   would hang. 0.01ms is instant to a reader and still a real transition to the engine. */
		--motion-fast: 0.01ms;
		--motion-base: 0.01ms;
		--motion-slow: 0.01ms;
		/* Both halves, and the gate is what noticed: capping the shimmer at one iteration still
		   leaves one full 1.5s sweep across the screen, which is exactly the movement the preference
		   asked not to see. The period collapses too, so the skeleton is simply a grey block. */
		--motion-decor-loop: 0.01ms;
		--motion-decor-iterations: 1;
		--motion-status-loop: 1.6s;
		/* Both of the 2026-08-03 additions are pure decoration over a state the DOM already has, so
		   both collapse outright - a sheen that finishes one 9s pass is still 9s of movement nobody
		   asked for, which is the mistake the shimmer's own comment above records. */
		--motion-sheen-loop: 0.01ms;
		--motion-enter: 0.01ms;
	}
}

/* The same block, reached from the app's own setting instead of the platform's. Someone on a shared
   or managed laptop cannot always change the OS preference, and someone who wants less motion in
   THIS app and not in every other one has no OS spelling for that at all. */
:root[data-motion="reduced"] {
	/* Not 0s: a zero-length transition never fires `transitionend`, and anything waiting on one
	   would hang. 0.01ms is instant to a reader and still a real transition to the engine. */
	--motion-fast: 0.01ms;
	--motion-base: 0.01ms;
	--motion-slow: 0.01ms;
	/* Both halves, and the gate is what noticed: capping the shimmer at one iteration still
	   leaves one full 1.5s sweep across the screen, which is exactly the movement the preference
	   asked not to see. The period collapses too, so the skeleton is simply a grey block. */
	--motion-decor-loop: 0.01ms;
	--motion-decor-iterations: 1;
	--motion-status-loop: 1.6s;
	/* Both of the 2026-08-03 additions are pure decoration over a state the DOM already has, so
	   both collapse outright - a sheen that finishes one 9s pass is still 9s of movement nobody
	   asked for, which is the mistake the shimmer's own comment above records. */
	--motion-sheen-loop: 0.01ms;
	--motion-enter: 0.01ms;
}

/* The light palette. Every text/surface pairing here is contrast-checked to ≥4.5:1 (AA) - and as of
   round 148 that is a TEST (`tests/theme.test.ts`) rather than this sentence, because the sentence
   was true of the pairing its author had in mind (white, --bg2) and false against --bg3: --warning
   read 4.09:1 and --danger 4.42:1 on the surface half the app's cards use. The status colors are
   darker steps of the same hues because on white they double as text, and #00b894-style brights sit
   under 3:1 there. --viz-2/--viz-3 sit below 3:1 on white by design (the validated reference steps
   for these hues) - legal because every chart pairs them with a legend and printed totals, never
   color alone, and the test carries that exemption with its reason instead of a silent pass. */
:root[data-theme="light"] {
	color-scheme: light;
	--bg: #f2f3f8;
	--bg2: #ffffff;
	--bg3: #e9ebf4;
	--border: #d8dbe8;
	--border-strong: #85878f;
	--text: #21253a;
	--text2: #5c6280;
	/* --accent-hover is the same value in both palettes and that is not an oversight: a hover moves
	   away from the LABEL, which is a fact about the accent, so the surface has no vote. It is
	   restated here only because the light block would otherwise inherit the dark one silently, and
	   a token that happens to agree should say so. --accent-text is the opposite case - its whole
	   job is to be legible ON a surface, so it does differ, which is why applyThemeColor() has to
	   re-run on a MODE flip and not only on a colour change. */
	--accent-hover: #5b4dc2;
	--accent-text: #6656d9;
	--success: #00795c;
	--warning: #916100;
	--on-accent: #fff;
	--on-warning: #fff;
	--on-success: #fff;
	--on-danger: #fff;
	--on-text2: #fff;
	/* ⚠ Pure black, not the app's ink, and it is the one place in the palette that differs: this
	   fill sits in the narrow luminance band where #11131e reads 4.05:1 and white 4.42:1 - neither
	   clears AA, and only #000 does (4.76:1). `onColor()` in shared/contrast.ts encodes the same
	   fallback for the accent an owner picks, for the same reason. */
	--on-viz-1: #000;
	--on-viz-3: #11131e;
	--on-viz-dim: #11131e;
	--on-viz-ord-2: #11131e;
	--danger: #ba4325;
	--danger-surface: #fdeee9;
	--lift: rgba(20, 26, 60, 0.04);
	--shadow-pop: 0 8px 24px rgba(15, 20, 40, 0.14);
	--shadow-modal: 0 20px 60px rgba(15, 20, 40, 0.18);
	--viz-1: #2a78d6;
	--viz-2: #1baf7a;
	--viz-3: #eda100;
	--viz-3-soft: #ebc26e;
	--on-viz-3-soft: #11131e;
	--viz-dim: #9ba2bd;
	--viz-grid: #e7e9f2;
	/* ⚠ THE DOT TEXTURE IS STRONGER HERE THAN IN THE DARK PALETTE, AND ONE NUMBER COULD NOT SERVE BOTH
	   (owner, 2026-08-13: *"the dot pattern needs to be a tad stronger in light mode, where they're
	   almost invisible. They're just right in dark mode"*).

	   `--dot-grid-opacity` lived only on `:root` - the dark palette - so light inherited it, and the
	   arithmetic explains why that looked like a fair deal and was not. The dots are the TEXT color at
	   N% over the page, so at 9% both palettes land about 19/255 away from their own background: the
	   same delta, which is what makes a shared token so tempting. But an increment of 19 read against
	   near-white is far weaker to the eye than the same 19 read against near-black, so identical
	   numbers buy visibly different textures. **A token shared across two palettes is only shared if
	   the thing it controls is perceived the same way in both** - a color pairing gets checked for
	   this (`tests/theme.test.ts` does the contrast math); a strength never was.

	   Dark keeps 9%/11% untouched, because the owner says dark is right and the only bug is here.
	   ⚠ These are deliberately NOT restated in the two light high-contrast blocks: those take both to
	   0% and out-specify this one, so the texture still disappears where `prefers-contrast` asks. */
	--dot-grid-opacity: 16%;
	--dot-grid-opacity-solo: 19%;
}

/* ── HIGH CONTRAST (§R) - a THIRD and FOURTH palette, not a filter ────────────────────────────────
   Settings → Accessibility, and `@media (prefers-contrast: more)` when the owner leaves it on
   "system". `data-contrast="high"` is stamped on <html> by `frontend/a11y.ts`, exactly like
   `data-motion`, and for the same reason: the attribute is only a selector hook, and what actually
   reaches the shadow-rooted islands is the token values these blocks set.

   ⚠ **A palette, and deliberately not `filter: contrast(1.4)` or a blanket "text goes white".** A
   filter is applied to PIXELS, so it hits the cover art, the waveform and every chart at the same
   time - a photograph the owner uploaded is not a contrast problem to be fixed - and it cannot be
   checked by the gate that makes all of this real. Two more sets of token values can be: both blocks
   below are in `tests/theme.test.ts`'s `PALETTES`, so every pairing the app draws is measured in
   FOUR palettes now, and a high-contrast block that is high contrast everywhere except on raised
   rows fails the same way the light palette did in round 148.

   ⚠ **Neither block touches `--accent`, `--accent-hover` or `--on-accent`, and that is required
   rather than an omission.** Those three are DERIVED at runtime from the accent the owner picked
   (`theme.ts`'s `deriveAccentTokens`) and written as INLINE styles on <html>, which beat any selector
   here - so a literal in these blocks would be dead on every install that has ever opened the color
   picker. `a11y.ts` re-derives them when the contrast changes instead, which is also more correct:
   they are computed against whatever surfaces are live, so they follow this palette automatically.
   `--accent-text` IS declared, because it is what the pre-derive first paint uses and what the gate
   measures. */
:root[data-contrast="high"] {
	--bg: #000000;
	--bg2: #0c0e15;
	--bg3: #1a1d29;
	/* A grouping edge stops being decoration here: at this contrast the surfaces are close enough
	   together that the border is what separates a card from the page. */
	--border: #5c6178;
	--border-strong: #b0b5c8;
	--text: #ffffff;
	--text2: #d6dae8;
	/* ⚠ The gate picked this value, not a designer: `tests/theme.test.ts` requires the literal to be
	   exactly what `applyThemeColor()` derives for the DEFAULT accent against THESE surfaces, or the
	   app repaints the accent a shade different the moment /api/settings/theme resolves. A "more
	   contrasty" hand-picked purple here would be a flash on every boot of a palette chosen by
	   someone who is sensitive to exactly that. */
	--accent-text: #8476eb;
	--success: #4fdba9;
	--warning: #ffd77a;
	--danger: #ff9d85;
	--danger-surface: #3a1512;
	/* The status fills got brighter, so their labels flip - the round-148 rule that this family is
	   checked BY NAME and that adding a fill without its label is a failure rather than a guess. */
	--on-warning: #000000;
	--on-success: #000000;
	--on-danger: #000000;
	--on-text2: #000000;
	--viz-grid: #3a3f55;
	/* The dot grid is a texture at 9% of the text colour - at this contrast it reads as noise over
	   the one palette whose whole point is that nothing competes with the words. */
	--dot-grid-opacity: 0%;
	--dot-grid-opacity-solo: 0%;
	--signin-glow-opacity: 0%;
}

:root[data-theme="light"][data-contrast="high"] {
	--bg: #ffffff;
	--bg2: #ffffff;
	/* ⚠ The one value the gate moved: `--bg3` started at #e8eaf2 and put `--accent-text` at 4.36:1 on
	   a raised row - under AA, in the palette built to be readable. It is the light theme's own
	   lesson (round 148) arriving in the palette that exists because of it: a surface a designer
	   reads as "barely tinted" is a real background to the text on top of it. */
	--bg3: #e4e7f1;
	--border: #6f7488;
	--border-strong: #2b2e3d;
	--text: #000000;
	--text2: #33374a;
	/* As the dark block above: derived, not picked. */
	--accent-text: #6153d0;
	--success: #0b6b4f;
	--warning: #7a4a00;
	--danger: #a52f14;
	--danger-surface: #ffe4de;
	--on-warning: #ffffff;
	--on-success: #ffffff;
	--on-danger: #ffffff;
	--on-text2: #ffffff;
	--viz-grid: #cbd0e0;
	--dot-grid-opacity: 0%;
	--dot-grid-opacity-solo: 0%;
	--signin-glow-opacity: 0%;
}

/* "System" on the contrast control means this: the platform's own answer, which is what an install
   that never opens the setting gets. ⚠ `:not([data-contrast])` rather than a bare `:root` - someone
   who has explicitly chosen "standard" has said they do NOT want this, and a media query that
   overrules an explicit choice is the readout problem the motion block above is written against. */
@media (prefers-contrast: more) {
	:root:not([data-contrast]) {
		--bg: #000000;
		--bg2: #0c0e15;
		--bg3: #1a1d29;
		--border: #5c6178;
		--border-strong: #b0b5c8;
		--text: #ffffff;
		--text2: #d6dae8;
		--accent-text: #8476eb;
		--success: #4fdba9;
		--warning: #ffd77a;
		--danger: #ff9d85;
		--danger-surface: #3a1512;
		--on-warning: #000000;
		--on-success: #000000;
		--on-danger: #000000;
		--on-text2: #000000;
		--viz-grid: #3a3f55;
		--dot-grid-opacity: 0%;
		--dot-grid-opacity-solo: 0%;
		--signin-glow-opacity: 0%;
	}
	:root[data-theme="light"]:not([data-contrast]) {
		--bg: #ffffff;
		--bg2: #ffffff;
		--bg3: #e4e7f1;
		--border: #6f7488;
		--border-strong: #2b2e3d;
		--text: #000000;
		--text2: #33374a;
		--accent-text: #6153d0;
		--success: #0b6b4f;
		--warning: #7a4a00;
		--danger: #a52f14;
		--danger-surface: #ffe4de;
		--on-warning: #ffffff;
		--on-success: #ffffff;
		--on-danger: #ffffff;
		--on-text2: #ffffff;
		--viz-grid: #cbd0e0;
		--dot-grid-opacity: 0%;
		--dot-grid-opacity-solo: 0%;
		--signin-glow-opacity: 0%;
	}
}

* {
	margin: 0;
	padding: 0;
	box-sizing: border-box;
}

/* THE DOT GRID, app-wide (owner, 2026-08-05). One dot every 22px, in the text colour at 9%.
   ## What this was, and why it is not that any more
   It shipped as a STEP SEQUENCER - the same grid with an accent-coloured dot on every fourth column,
   four steps to the bar with the downbeat lit - chosen from six candidates the owner looked at on the
   real pages (dot grid, sequencer, vinyl grooves, grooves+dots, waveform horizon, staff lines). Two
   things retired the beat, in this order, and both are worth keeping because they generalise:
   NOTE 1 - **the accent is a knob the USER owns** (Settings -> Appearance), so an accent-coloured dot
   on every screen makes the whole app loud the day somebody picks neon pink, and nothing here can
   reach that. A texture keyed to a user-chosen colour has a character we do not control.
   NOTE 2 - the beat survived one round as a monochrome dot and then went too: at app-wide density the
   emphasis reads as busy rather than as rhythm. **A pattern that is right for one card on an empty
   field is not automatically right behind a working screen** - the same texture wants different
   strengths, and sometimes fewer ideas, as the density goes up.
   NOTE 3 - painted on a FIXED layer rather than on `body`, so it does not scroll with the content: a
   texture sliding under a long table is motion nobody asked for, and it would tile visibly at the
   seam of every page-height.
   NOTE 4 - it is visible only in the GUTTERS, because every surface above it is opaque. That is the
   whole effect, and it is why `tableStyles` had to give the bare tables a surface first - without
   one, the dots ran straight through the rows of the Outlets table. */
body::before {
	content: "";
	position: fixed;
	inset: 0;
	z-index: 0;
	pointer-events: none;
	/* NOTE 5 - the strength is a TOKEN (§R), because the high-contrast palettes take it to 0%. That
	   palette exists so nothing competes with the words, and a texture behind them is the one thing
	   in the app whose entire job is to compete a little. A literal here would be a decision
	   `prefers-contrast` could not reach - the motion tokens' argument, in a second lane. */
	background-image: radial-gradient(color-mix(in srgb, var(--text) var(--dot-grid-opacity), transparent) 1px, transparent 1px);
	background-size: 22px 22px;
}
/* Everything the app draws sits above the layer. */
body > * {
	position: relative;
	z-index: 1;
}

/* ── The text-size control's one point of contact with the app (§R) ───────────────────────────────
   Every `font-size` in `src/frontend` and in this file is a `rem`, and `rem` resolves against THIS
   declaration. That is the whole mechanism, and each half of it was chosen against an alternative
   that looked simpler:

   ⚠ **`rem`, not a class or a wrapper scale.** A `rem` resolves against the document root from
   anywhere, including inside a shadow root - and every island and component in this app has been
   shadow since round 100, so a `:root.text-large` class or an `em` cascade from `.layout` reaches
   none of them. It is round 148's rule once more: an ambient condition reaches a shadow-rooted
   element only through something that inherits, and a length that resolves against the root is the
   font-size-shaped version of that.

   ⚠ **`100%`, not `16px`.** A percentage here resolves against the browser's own default text size,
   so someone who has already raised it in their browser keeps that and the app's multiplier stacks
   on top. Writing `16px` would silently overrule a setting they made once for every site.

   ⚠ **Only text scales, and that is the feature rather than a limitation** (§R's design note, which
   asked what this buys over ⌘+). Browser zoom already scales everything and every browser has one -
   building a worse copy of it would have built nothing. What no browser offers is *text bigger,
   layout the same*, which is WCAG 1.4.4 / 1.4.12 and what someone who can read this app's layout
   but not its 13px hints actually needs. So spacing, control boxes, icons and the sidebar stay in
   `px` ON PURPOSE: a padding in `rem` would grow the chrome back around the bigger text and undo
   the gain. If you add a `rem` to anything that is not a font-size, you have quietly turned this
   into zoom - `tests/a11y.test.ts` fails that. */
html {
	font-size: calc(100% * var(--gh-text-scale, 1));
}

body {
	font-family: var(--font-stack);
	/* Set by applyThemeFont when a custom base font carries its own tracking; else `normal` */
	letter-spacing: var(--font-tracking, normal);
	background: var(--bg);
	color: var(--text);
	line-height: 1.6;
}

/* ── Focus (WCAG 2.4.7) ───────────────────────────────────────────────────────────────────────────
   ⚠ Before §R there were TWO `:focus-visible` rules in the whole app, which means a keyboard user
   could not see where they were - and that is not one bug among many, it is the bug that makes every
   other keyboard fix impossible to check by hand. This rule and its shadow twin (`focusStyles` in
   `components/sharedStyles.ts`) are the app's answer.

   `:focus-visible`, not `:focus`: the browser's own heuristic already knows a mouse click on a button
   should not draw a ring while a Tab onto the same button should. Writing `:focus` would put a ring
   on everything the owner clicks, which is the reason rings get deleted from apps in the first place.

   ⚠ `:focus-visible` on a shadow HOST is not the same question as on its contents, and both matter:
   an element that delegates focus (`<gh-select>`) matches on the host, and one that renders a plain
   `<button>` matches inside its own root. That is why the twin exists rather than this rule being
   "enough for the whole app". */
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
summary:focus-visible,
[tabindex]:focus-visible {
	outline: var(--focus-ring-width) solid var(--focus-ring-color);
	outline-offset: var(--focus-ring-offset);
	/* The ring is drawn OUTSIDE the box, so a control flush against a scroll container's edge would
	   clip it. A radius costs nothing and keeps the ring following the control it belongs to. */
	border-radius: var(--radius);
}

/* ── The skip link (WCAG 2.4.1) ───────────────────────────────────────────────────────────────────
   The app's sidebar is fourteen nav rows, and without this every keyboard user walks all fourteen on
   every single page they open. ⚠ It is visually hidden rather than `display: none`: a hidden element
   is not focusable, so the "hide it until it is focused" spellings that actually work are a clip or
   an off-screen position, never the app's `.hidden`. It reveals itself on focus, which is the only
   moment anyone wants to see it. */
.skip-link {
	position: absolute;
	left: 8px;
	top: -100px;
	z-index: 1000;
	padding: 10px 16px;
	background: var(--bg2);
	color: var(--text);
	border: 1px solid var(--border-strong);
	border-radius: var(--radius);
	font-size: 0.875rem;
	text-decoration: none;
	transition: top var(--motion-fast) var(--motion-ease);
}
.skip-link:focus {
	top: 8px;
}
/* The landing target takes focus programmatically (`tabindex="-1"`), and a ring on a whole page
   region is noise rather than orientation - the page changing under you is the feedback. */
main.content:focus {
	outline: none;
}

/* Without this, any link outside the few themed contexts falls back to the UA's default
   blue, which is close to unreadable on the dark surfaces */
a {
	color: var(--accent-text);
	text-decoration: none;
}
a:hover {
	text-decoration: underline;
}

.layout {
	display: flex;
	min-height: 100vh;
}

/* Sidebar */
.sidebar {
	width: 220px;
	background: var(--bg2);
	border-right: 1px solid var(--border);
	padding: 20px 0;
	flex-shrink: 0;
	position: fixed;
	top: 0;
	left: 0;
	bottom: 0;
	overflow-y: auto;
}
.sidebar-header {
	padding: 0 20px 20px;
	border-bottom: 1px solid var(--border);
	display: flex;
	align-items: center;
	gap: 10px;
	/* It became an <a> in 2026-08-07's round (it navigates to the first nav item). These undo what a
	   link brings with it: the underline, the link color the <h1> would inherit, and the text cursor.
	   The gradient wordmark below already provides the only color this carries.
	   ⚠ The `:hover` half is not redundant - the global `a:hover { text-decoration: underline }` two
	   rules up is (0,1,1) to this selector's (0,1,0), so it WINS on hover and the wordmark grew an
	   underline nobody asked for. The owner reported it. **Turning off a link's decoration means
	   turning it off in the states that turn it back on**, which is one selector, not one property. */
	text-decoration: none;
	color: inherit;
	cursor: pointer;
}
.sidebar-header:hover {
	text-decoration: none;
}
.sidebar-logo {
	width: 32px;
	height: 32px;
	border-radius: 6px;
}

/* ⚠ The wordmark is gradient TEXT, which means `color` no longer paints it - the fill comes from
   `background-clip: text` over a transparent colour. The `color` below is not dead: it is what a
   browser without `background-clip: text` renders, and what a forced-colours mode falls back to. */
@keyframes wordmark-sheen {
	from {
		background-position: 0% 50%;
	}
	to {
		background-position: 100% 50%;
	}
}
/* ⚠ The wordmark reads as the page's own text, NOT as the accent (owner, 2026-08-04: the landing
   page's mark is contrast text and "looks really awesome", the app's was tinted). The landing's
   `nav .mark` is plain --fg, and the hero's gradient is the shape borrowed here: it STARTS in the
   text colour and only reaches the accent at the tail, so the name reads light-on-dark (or dark-on-
   light) and the accent is a highlight rather than the fill. Starting the ramp at the accent is what
   made this look like a tinted logo instead of the product's name. */
/* ⚠ TWO elements, ONE rule, and that is the point (owner, 2026-08-10: "include the name with text
   gradient like on wide"). The phone's bar carries the same wordmark as the drawer, so a second copy
   of these declarations would be a gradient that drifts the first time either is tuned - and the
   drift would be invisible to whoever tuned it, because the two never appear at the same width. The
   sheen keyframes are global (this sheet, not a shadow root), so they reach both. */
.sidebar-header h1,
.topbar-name {
	font-size: 1.125rem;
	color: var(--text);
	background-image: linear-gradient(100deg, var(--text) 0%, var(--text) 46%, var(--accent-text) 88%, var(--accent-2) 100%);
	background-size: 220% 100%;
	-webkit-background-clip: text;
	background-clip: text;
	-webkit-text-fill-color: transparent;
	animation: wordmark-sheen var(--motion-sheen-loop) var(--motion-ease) var(--motion-decor-iterations) alternate;
	/* The wordmark is set in TWO weights, matching the logo artwork: "Get" light, "Heard" heavy. So
	   the element's own weight is the light one and <strong> supplies the contrast - if this stayed
	   700 the <strong> would be indistinguishable from it and the emphasis would silently vanish. */
	font-weight: 500;
}
.sidebar-header h1 strong,
.topbar-name strong {
	font-weight: 800;
}
.nav-links {
	list-style: none;
	padding: 10px 0;
}
.nav-links a {
	/* Flex, not block, since the label became icon + span. ⚠ The 3px active border is drawn INSIDE
	   the box (border-left changes the content width on a block), so the padding compensates on the
	   active state below - a row that shifts 3px as you click it is the one motion nobody asked for. */
	display: flex;
	align-items: center;
	gap: 10px;
	padding: 10px 20px;
	color: var(--text2);
	text-decoration: none;
	font-size: 0.875rem;
	transition: all var(--motion-fast) var(--motion-ease);
}
/* The mark is quieter than its label until the row is current or hovered: a column of fourteen
   full-strength icons competes with the one piece of text the reader is actually scanning for. */
.nav-links a gh-icon {
	font-size: 1.0625rem;
	opacity: 0.7;
	transition: opacity var(--motion-fast) var(--motion-ease);
}
.nav-links a:hover,
.nav-links a.active {
	color: var(--text);
	background: var(--bg3);
	border-left: 3px solid var(--accent);
	padding-left: 17px;
}
.nav-links a:hover gh-icon,
.nav-links a.active gh-icon {
	opacity: 1;
}
.nav-links a.active gh-icon {
	color: var(--accent-text);
}
/* The attention badge (round 160). It sits OVER the nav link rather than inside it (see the
   comment in index.html), so the row has to become its positioning context - and the badge must
   not eat the click, or the one thing it is pointing at becomes harder to reach. The warning
   pair is the token answer to "a fill needs a label that survives both palettes" (round 148). */
.nav-row {
	position: relative;
}
.nav-badge {
	position: absolute;
	right: 14px;
	top: 50%;
	transform: translateY(-50%);
	min-width: 18px;
	padding: 0 5px;
	border-radius: 9px;
	background: var(--warning);
	color: var(--on-warning);
	font-size: 0.6875rem;
	font-weight: 700;
	line-height: 18px;
	text-align: center;
	pointer-events: none;
}

/* Content */
.content {
	flex: 1;
	margin-left: 220px;
	padding: 30px;
	max-width: 1200px;
}

/* ─────────────────────────────────────────────────────────────────────────────
   THE PHONE (owner, 2026-08-10: "the app doesn't work well on mobile", with a
   screenshot of the sidebar eating the screen and the content squeezed into what
   was left).

   ⚠ **This app had NO width media query anywhere** until now - two in this file,
   both about preferences (reduced motion, contrast), and seven inside shadow
   roots for individual panels. The shell was 220px of fixed sidebar plus a
   220px margin, so at a 390px viewport the content box was 110px wide: not
   "cramped", arithmetically unusable.

   The breakpoint is 900px rather than a phone width on purpose - the drawer is
   the right shape for a narrow WINDOW too, and the app's own content columns
   (the 170px subnav plus a subpage) stop fitting long before 390px.

   ⚠ **A shadow root's media query matches the VIEWPORT, not its host box**, so
   every panel query inside the islands was already mis-calibrated by the 220px
   margin + 60px padding this rule removes: at 390px they fired against 390
   while their box was 110. Collapsing the shell is what makes those honest.
   ───────────────────────────────────────────────────────────────────────────── */
/* The bar and the scrim are markup at every width and only the phone shows them.
   ⚠ Hidden by DISPLAY rather than by the `.hidden` class, and driven by ONE state
   (`body.nav-open`) whose every effect is inside the query below - so a window
   widened while the drawer is open needs no cleanup: the transform, the drawer
   width and the scrim all simply stop applying. A `.hidden` toggle here would be
   a second mechanism on the same element, which is how a drawer gets stuck.
   ⚠ **This block sits ABOVE the query on purpose.** It was below it for one build,
   where it won on source order and the hamburger was permanently invisible - a
   `display: none` base and a `display: flex` override are the same specificity, so
   the LAST one wins. The phone had no nav at all and every gate was green. */
.topbar,
.nav-scrim {
	display: none;
}

@media (max-width: 900px) {
	/* The drawer. It is the SAME fixed element - only its transform changes - so no
	   layout is duplicated and nothing about the desktop shell moves. */
	.sidebar {
		width: min(280px, 82vw);
		z-index: 10200;
		transform: translateX(-100%);
		transition: transform var(--motion-fast) var(--motion-ease);
		box-shadow: 0 0 40px rgb(0 0 0 / 0.25);
	}
	body.nav-open .sidebar {
		transform: translateX(0);
	}
	/* ⚠ Above `.modal` (10000) deliberately: the drawer is the way OUT of a screen, and a
	   modal that covered it would trap someone on a page they cannot leave on a phone. */
	body.nav-open .nav-scrim {
		display: block;
		position: fixed;
		inset: 0;
		z-index: 10100;
		background: rgb(0 0 0 / 0.45);
		border: 0;
		padding: 0;
	}
	.content {
		margin-left: 0;
		/* Room for the bar below, which is fixed so the nav is reachable without scrolling up */
		padding: 64px 14px 24px;
		max-width: none;
		/* ⚠ **THE one declaration that makes any of this work.** A flex item's default `min-width: auto`
		   means it never shrinks below its own content's min-content width - so `flex: 1` here shrank
		   nothing, the content box stayed 580px inside a 390px window, and the whole document scrolled
		   sideways. Measured: with this line the box is 390 and the overflow moves INSIDE, where the
		   grids and tables now handle it. **A shrink that does not happen is a min-width, not a
		   width.** */
		min-width: 0;
	}
	/* ⚠ A THREE-COLUMN GRID rather than a flex row, and the third column is empty on purpose (owner,
	   2026-08-10: "maybe center that horizontally along with the icon?"). The brand has to be centred
	   against the BAR, and the only thing a flex row can offer is `margin: auto`, which centres it in
	   the space the hamburger LEAVES - off by half the button's width, which is exactly the amount a
	   person notices without being able to name. Two equal `1fr` tracks make the middle one land on
	   the bar's own centre with no absolute positioning and no transform (a transform here would make
	   the bar a containing block for anything `position: fixed` inside it - see the .page.active
	   comment below for what that costs). */
	.topbar {
		display: grid;
		grid-template-columns: 1fr auto 1fr;
		position: fixed;
		top: 0;
		left: 0;
		right: 0;
		z-index: 10000;
		align-items: center;
		gap: 10px;
		padding: 8px 12px;
		background: var(--bg2);
		border-bottom: 1px solid var(--border);
	}
	.topbar .nav-toggle {
		justify-self: start;
	}
	/* ⚠ `min-width: 0` because a grid item's default `min-width: auto` refuses to shrink below its
	   content - the same declaration the `.content` column needs three rules up, for the same reason.
	   Without it a long wordmark would push the bar wider than the screen instead of ellipsing. */
	.topbar-brand {
		display: flex;
		align-items: center;
		gap: 8px;
		min-width: 0;
	}
	.topbar-name {
		white-space: nowrap;
		overflow: hidden;
		text-overflow: ellipsis;
	}
	/* ⚠ 44px, the platform floor for a thing a finger has to hit. The desktop nav rows are
	   40px and stay 40px - a mouse is not a thumb - which is why this lives in here. */
	.nav-links a {
		min-height: 44px;
		padding-top: 12px;
		padding-bottom: 12px;
	}
	.nav-toggle {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		width: 40px;
		height: 40px;
		border-radius: 10px;
		border: 1px solid var(--border);
		background: var(--bg3);
		color: var(--text);
		cursor: pointer;
	}
}

.page {
	display: none;
}
/* ⚠ **A ROUTE CHANGE IS NOT ANIMATED, and it was for one day.** A `page-enter` opacity fade shipped
   here on 2026-08-03 and the owner had it removed the same day: navigation in this app is a person
   looking for something, and a fade puts a delay between the click and the answer on every single
   move through the product. An entrance animation earns its place when something ARRIVES (the
   attention strip's staggered rows, a modal); a page the reader asked for by name is not an arrival.

   Two things are worth keeping from the day it existed, because both would bite a round that tries
   again. ⚠ **It has to live on `.page.active` in the SHELL, never inside the islands** - every page
   has been a shadow-rooted island since round 100, so one rule here reaches all thirty and the only
   alternative is thirty copies. And ⚠ **OPACITY ONLY, NEVER A TRANSFORM: it shipped as
   `translateY(8px)` and broke every modal in the app.** A transform makes the element the containing
   block for its `position: fixed` descendants, so a centred modal stopped being positioned against
   the VIEWPORT and moved somewhere off-screen no scrolling could reach - and with
   `animation-fill-mode: both` the element never leaves its filling state, so it stayed unreachable
   for the whole session. `filter` and `perspective` do the same thing. The only symptom was the UI
   suite's "element is outside of the viewport" after a click retried for 31 seconds: nothing throws,
   nothing logs, and the modal is visibly THERE in a screenshot. */
.page.active {
	display: block;
}

h2 {
	font-size: 1.5rem;
	margin-bottom: 6px;
}
h3 {
	font-size: 1.125rem;
	margin-bottom: 10px;
}
.subtitle {
	color: var(--text2);
	margin-bottom: 20px;
	font-size: 0.875rem;
}

/* Cards */
/* ⚠ NO :hover ON .card, AND THAT IS A DESIGN CALL RATHER THAN AN OVERSIGHT (2026-08-03). The landing
   page lifts and lights its cards on hover and it is right to: those are small tiles a reader's eye
   picks between. THIS `.card` is the app's page PANEL - full width, 20px padding, one per section -
   and lighting up a whole panel because the pointer crossed it is noise, not affordance. The same
   word, two different objects. `tests/ui/sectionContrast.test.ts` also says so from the other
   direction: a translucent accent shadow on a panel composites into the section-timeline blocks
   inside it and drops their measured label contrast below AA. */
.card {
	background: var(--bg2);
	border: 1px solid var(--border);
	border-radius: var(--radius);
	padding: 20px;
	margin-bottom: 20px;
}

/* Stats grid */
.stats-grid {
	display: grid;
	grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
	gap: 16px;
	margin-top: 20px;
}
.stat-card {
	background: var(--bg2);
	border: 1px solid var(--border);
	border-radius: var(--radius);
	padding: 20px;
	text-align: center;
}
.stat-card .stat-value {
	font-size: 2rem;
	font-weight: 700;
	color: var(--accent-text);
}
.stat-card .stat-label {
	color: var(--text2);
	font-size: 0.8125rem;
	margin-top: 4px;
}

/* A heading that carries an icon is a ROW - the shadow twin in sharedStyles.ts carries the WHY: an
   inline icon hangs off the baseline, which rides high beside 1.125rem capitals. Scoped with
   :has(svg) so a heading with no icon is untouched. */
h2:has(svg),
h3:has(svg),
h4:has(svg) {
	display: flex;
	align-items: center;
	gap: 8px;
	flex-wrap: wrap;
}

/* Buttons */
/* The shadow twin of this rule lives in sharedStyles.ts's buttonStyles - a qualifier inside a
   button keeps the button's own colour, because the muted grey of .hint is unreadable on a filled
   primary (owner, 2026-08-04). */
.btn .hint {
	color: inherit;
	opacity: 0.75;
	font-size: inherit;
}
.btn {
	/* Tokens because min-height is derived from both - see the shadow twin for the WHY. */
	--btn-pad-y: 8px;
	--btn-line: 1.6;
	padding: var(--btn-pad-y) 16px;
	border: 1px solid var(--border);
	border-radius: var(--radius);
	background: var(--bg3);
	color: var(--text);
	font-family: inherit;
	font-size: 0.875rem;
	/* <a class="btn"> inherits the body line-height while <button> defaults to
	   "normal" - pin it so both render at the same height next to each other */
	line-height: var(--btn-line);
	/* The app-wide control height, so a button lines up with the field beside it - see the token's own
	   note at :root. The derived floor it replaced lives on in .btn-small, which must stay small. */
	min-height: var(--control-h);
	cursor: pointer;
	transition: all var(--motion-fast) var(--motion-ease);
	text-decoration: none;
	/* The shadow twin in components/sharedStyles.ts carries the WHY: a flex row with a gap is what
	   separates an icon from its label, because the markup space is only a word space wide and a
	   margin on the icon would also apply to an icon-only button. Keep the two in step. */
	display: inline-flex;
	align-items: center;
	justify-content: center;
	gap: 6px;
	flex-wrap: wrap;
	vertical-align: middle;
}
.btn:hover {
	background: var(--border);
}
/* ⚠ The gradient is a BACKGROUND-IMAGE over the flat --accent background, not instead of it, and
   that ordering is load-bearing in two directions. `applyThemeColor()` re-derives --on-accent from
   --accent, so the label's contrast is computed against the flat colour - the gradient's second stop
   is a mix toward --success, which stays in the same lightness band, so the label keeps the ratio
   that was actually measured. And a browser that drops the image still gets a filled accent button
   rather than a transparent one. Hover moves the flat colour underneath, which the image then
   re-tints, so the existing hover token still does its job. */
.btn-primary {
	background-color: var(--accent);
	background-image: linear-gradient(100deg, var(--accent) 0%, var(--accent-2) 165%);
	border-color: var(--accent);
	color: var(--on-accent);
}
.btn-primary:hover {
	background-color: var(--accent-hover);
	/* Leaf elements only. A lift on a CARD would make it the containing block for any
	   position: fixed descendant - see the .page.active note above, which cost a modal. */
	transform: translateY(-1px);
	box-shadow: 0 4px 14px color-mix(in srgb, var(--accent) 34%, transparent);
}
.btn-primary:active {
	transform: none;
}
.btn-success {
	background: var(--success);
	border-color: var(--success);
	color: var(--on-success);
}
.btn-success:hover {
	opacity: 0.9;
}
.btn-danger {
	background: var(--danger);
	border-color: var(--danger);
	color: var(--on-danger);
}
.btn-danger:hover {
	opacity: 0.9;
}
/* Destructive, but repeated on every row of a list - a solid red per row turns the whole
   table into a wall of alarm. It only commits to red on hover, where the intent is real. */
.btn-ghost-danger {
	background: none;
	border-color: var(--border);
	color: var(--text2);
}
.btn-ghost-danger:hover {
	background: var(--danger);
	border-color: var(--danger);
	color: var(--on-danger);
}
/* The small variant keeps the DERIVED floor - see the shadow twin: a full-size button takes the
   app-wide control height so it lines up with fields, and a small one forced to that would not be
   small. The floor is still the icon-only fix (a lone glyph makes no line box). */
.btn-small {
	--btn-pad-y: 4px;
	padding: var(--btn-pad-y) 10px;
	font-size: 0.75rem;
	min-height: calc(var(--btn-line) * 1em + 2 * var(--btn-pad-y) + 2px);
}
.btn + .btn {
	margin-left: 8px;
}
/* Flex button groups space with gap - neutralize the inline-flow sibling margin */
.queue-actions .btn + .btn,
.modal-actions .btn + .btn,
.table-actions .btn + .btn,
.blog-actions .btn + .btn,
.btn-row .btn + .btn {
	margin-left: 0;
}
/* Generic inline row of buttons/status text (audio uploads, AI analysis, …) */
.btn-row {
	display: flex;
	align-items: center;
	gap: 8px;
	flex-wrap: wrap;
}
/* .audio-upload-row moved into <gh-releases-page>'s shadow (round 98). */
.btn:disabled,
.disabled,
#blog-disabled-reason-group:has([disabled]) {
	opacity: 0.5;
	cursor: not-allowed;
}

/* Forms */
.form-group {
	margin-bottom: 14px;
}

/* The localeCard.ts family (.locale-card / .label-inline / .label-actions / .locale-overrides /
   .locale-selector-container / .locale-add-select / .locale-group / .btn-locale-remove /
   .btn-locale-translate), the details.accordion disclosure, and the .tooltip-icon "?" trigger moved
   into gh-settings-page's shadow (round 99) - settings was their last LIGHT consumer (releases +
   artists carry their own twins since round 96/98). The .tooltip-pop popup itself STAYS below:
   tooltip.ts appends it to <body>, outside every shadow root. See src/frontend/pages/settings.ts. */
.tooltip-pop {
	position: fixed;
	z-index: 10500;
	max-width: 300px;
	padding: 8px 10px;
	font-size: 0.75rem;
	line-height: 1.5;
	background: var(--bg3);
	color: var(--text);
	border: 1px solid var(--border);
	border-radius: var(--radius);
	box-shadow: var(--shadow-pop);
	/* ⚠ This was `none` until 2026-08-12, and that is what made a link inside a tooltip impossible
	   (owner: *"allow moving the mouse into tooltips without that causing them to be dismissed, as
	   that's the only way we can support clicking links inside them"*). The reason it was `none` -
	   the bubble must never swallow a click meant for the page - is answered by the rule below it:
	   the pop is `display: none` when hidden, so it has no box to swallow anything with unless it
	   is on screen, and a bubble with no action still closes the instant the pointer leaves its
	   trigger. See tooltip.ts for the timer that lets the pointer cross the 6px gap. */
	pointer-events: auto;
	white-space: pre-line;
}
.tooltip-pop.hidden {
	display: none;
}
/* The one thing in a tooltip a reader can act on (tooltip.ts builds it from data attributes, never
   from markup - see that module's header for why parsing HTML here would be an injection site).
   Block, so it takes its own line under the sentence whatever the sentence's length. */
.tooltip-action {
	display: block;
	margin-top: 6px;
	font-weight: 600;
	color: var(--accent);
}
/* (.locale-selector-container .queue-actions + .locale-add-select moved into gh-settings-page's shadow
   with the rest of the localeCard family, round 99 - see the note above .tooltip-pop.) */
.form-group label {
	display: block;
	font-size: 0.8125rem;
	color: var(--text2);
	margin-bottom: 4px;
}
/* .inline-check + .ab-split(-tag) moved into <gh-compose-page>'s shadow (round 99) - compose was
   their only consumer. See src/frontend/pages/compose.ts. */
/* The Angles-subpage scoreboard (.angle-card-head / .angle-card-score) moved into
   <gh-releases-page>'s shadow (round 98). */
/* Explanatory copy that supports a control, a section, or an empty state. Without this base
   rule a .hint outside a .form-group inherits plain body text and reads as loud as the
   content it is supposed to be annotating. */
.hint {
	font-size: 0.8125rem;
	line-height: 1.5;
	color: var(--text2);
}
/* Tighter under a form control, where the label already carries the weight */
.form-group .hint {
	font-size: 0.6875rem;
	opacity: 0.7;
}
/* The light-DOM twin of `controlStyles` in components/sharedStyles.ts - the two must stay
   identical, and that file's comment carries the WHY for the date types and their min-height. */
input[type="text"],
input[type="search"],
input[type="url"],
input[type="email"],
input[type="number"],
input[type="password"],
input[type="tel"],
input[type="date"],
input[type="time"],
input[type="datetime-local"],
textarea,
select {
	width: 100%;
	padding: 8px 12px;
	border: 1px solid var(--border-strong);
	border-radius: var(--radius);
	background: var(--bg);
	color: var(--text);
	font-size: 0.875rem;
	font-family: inherit;
}
/* The light-DOM twin of controlStyles' one-height rule - see that fragment for why the height and
   the line-height are stated for every input type rather than left to each one's own content box. */
input[type="text"],
input[type="search"],
input[type="url"],
input[type="email"],
input[type="number"],
input[type="password"],
input[type="tel"],
input[type="date"],
input[type="time"],
input[type="datetime-local"],
input[type="color"] {
	min-height: var(--control-h);
	line-height: normal;
}
/* A color input is a control like any other and was in none of these lists - see the shadow twin. */
input[type="color"] {
	width: 56px;
	padding: 3px;
	cursor: pointer;
}
/* A search input keeps its own UA appearance, which overrides the box above, so it renders short and
   in the wrong font until this turns it off. Added 2026-08-10, the hour the Outlets search box
   started using the type. */
input[type="search"] {
	appearance: none;
	-webkit-appearance: none;
}
input[type="search"]::-webkit-search-cancel-button {
	cursor: pointer;
}
input[type="date"]::-webkit-calendar-picker-indicator,
input[type="time"]::-webkit-calendar-picker-indicator,
input[type="datetime-local"]::-webkit-calendar-picker-indicator {
	cursor: pointer;
}
textarea {
	resize: vertical;
}
select {
	appearance: auto;
}

/* Tables (.table-wrap moved into <gh-releases-page>'s shadow in round 98 - releases was its last
   light consumer; the shadow list/studio pages carry their own twins) */
table {
	width: 100%;
	border-collapse: collapse;
	font-size: 0.875rem;
}
th,
td {
	padding: 10px 12px;
	text-align: left;
	border-bottom: 1px solid var(--border);
}
th {
	color: var(--text2);
	font-weight: 600;
	font-size: 0.75rem;
	text-transform: uppercase;
	letter-spacing: 0.5px;
}
tr:hover {
	background: var(--bg3);
}
/* The Sections & Lyrics studio chrome (.studio-* .tl-* .sec-* .lyr-* .chip-kind) moved into
   <gh-sections-studio>'s static styles when it went shadow (studio shadow-flip cohort) - see
   src/frontend/pages/sectionsStudio.ts. Drawn only by that element, so this is now its sole home. */
/* The 🎬 Shorts studio chrome (.shorts-* .sh-* .slp-* .variant-thumb-*, incl. the .shorts-queue-strip
   wrapper) moved into <gh-shorts-studio>'s static styles when it went shadow (studio shadow-flip
   cohort) - see src/frontend/pages/shortsStudio.ts. Drawn only by that element (Runway, the only other
   .shorts-queue-strip consumer, flipped too and renders the composer without it), so it left global. */

/* The 🚀 Runway ladder chrome (.runway-*) moved into <gh-runway-studio>'s static styles when it
   went shadow (studio shadow-flip cohort) - see src/frontend/pages/runway.ts. It was drawn only by
   that element (releases.ts only names the tag and comments), so this is now its sole home. */
/* The per-release workspace shell (.release-detail-layout, #release-detail-header, .release-subpage,
   .inline-stats, .epk-preview-frame) moved into gh-releases-page's shadow in round 98 (releases was
   their last light consumer; artists carries its own twins since round 96). The inner-sidebar .subnav
   + .group-label followed into gh-settings-page's shadow in round 99, when settings - their last light
   consumer - flipped; releases/artists carry their own .subnav/.group-label twins. */
/* The Settings workspace's own chrome (.settings-layout / .settings-content / .subpage-intro /
   .settings-card-grid / .card-head / .connection-status / .chip-row / #native-language +
   #target-languages) and the shared .group-label moved into gh-settings-page's shadow (round 99).
   See src/frontend/pages/settings.ts. */
/* .inline-stats + the .appearance-card appearance-row controls moved into <gh-releases-page>'s
   shadow (round 98); the .appearance-card base is the shared cardStyles fragment. */
/* The smart-link layout picker (.link-layout-*) and the link-in-bio studio list (.bio-studio-*)
   moved into <gh-artists-page>'s shadow in round 96 - artists was their only consumer (its
   .artist-* roster cards left the shared .release-* lists above the same round). */
/* The sent-log cell classes (.cell-inline / td.cell-nowrap / .cell-stack) moved into
   <gh-sent-page>'s shadow in round 95 - sent was their last light consumer (accepted +
   rejected carry their own shadow twins since round 91). */
/* .table-actions (+ the now-dead .blog-actions twin - blogs is shadow) moved into gh-settings-page's
   shadow (round 99); settings was their last light consumer, every other is a shadow page with its
   own twin. The generic td a link colour stays global. */
td a {
	color: var(--accent-text);
	text-decoration: none;
}
td a:hover {
	text-decoration: underline;
}

/* Native <dialog> confirmations (replaces window.confirm) */
.app-dialog {
	/* The universal margin reset removes the UA's margin:auto centering - restore it */
	margin: auto;
	background: var(--bg2);
	color: var(--text);
	border: 1px solid var(--border);
	border-radius: var(--radius);
	padding: 0;
	width: 420px;
	max-width: calc(100vw - 48px);
	max-height: calc(100vh - 48px);
	box-shadow: var(--shadow-modal);
}
.app-dialog::backdrop {
	background: rgba(0, 0, 0, 0.6);
	backdrop-filter: blur(2px);
}
.app-dialog[open] {
	animation: dialog-pop var(--motion-fast) var(--motion-ease);
}
.app-dialog[open]::backdrop {
	animation: backdrop-fade var(--motion-fast) var(--motion-ease);
}
@keyframes dialog-pop {
	from {
		opacity: 0;
		transform: translateY(8px) scale(0.97);
	}
}
@keyframes backdrop-fade {
	from {
		opacity: 0;
	}
}
/* Padding lives on the body so backdrop clicks are distinguishable from content clicks */
.app-dialog-body {
	padding: 24px;
}
.app-dialog-message {
	color: var(--text2);
	font-size: 0.875rem;
	white-space: pre-wrap;
}

/* Modals */
/* ⚠ GRID, not flex, and it is the corner exit that decided it (owner, 2026-08-10: "I want the X at
   the very edge", with a reference shot of a circle straddling a dialog's corner). The mark cannot
   live inside .modal-content: that box is a scroll container, which clips its children strictly, so
   anything hung over the edge is simply cut off. So it is a SIBLING - and a sibling needs a box the
   size of the dialog to hang off. With place-content: center the single implicit track sizes to the
   largest item, which is .modal-content, so both children share a cell that IS the dialog's own box
   and place-self addresses its corners. Flex cannot do this: in a single-line flex container the
   line's cross size is the CONTAINER's, so align-self: flex-start means the top of the viewport
   rather than the top of the dialog. Keep identical to modalStyles in components/sharedStyles.ts. */
.modal {
	position: fixed;
	inset: 0;
	background: rgba(0, 0, 0, 0.6);
	display: grid;
	place-content: center;
	z-index: 10000;
}
.modal > * {
	grid-area: 1 / 1;
}
.modal.hidden {
	display: none;
}
/* ⚠ THE PAGE BEHIND AN OPEN DIALOG DOES NOT SCROLL (owner, 2026-08-10). A fixed, full-viewport
   backdrop looks like it should swallow a wheel event and does not - the document under it scrolls
   regardless, so this is a body rule and not a backdrop one. The class is toggled by `a11y.ts`'s
   modal manager, beside the ARIA and the focus trap, because it is a property of *a dialog being
   open* rather than of any one of the twenty templates that draw one - the twenty-first would
   otherwise be born without it. `a11y.ts` also reserves the scrollbar's width while this is on, or
   the whole page (and the centred dialog with it) jumps sideways at the moment it opens.
   The native <dialog> lane (dialog.ts) needs none of this: `showModal()` blocks the document itself. */
body.modal-open {
	overflow: hidden;
}
.modal-content {
	background: var(--bg2);
	border: 1px solid var(--border);
	border-radius: var(--radius);
	padding: 24px;
	width: min(640px, calc(100vw - 32px));
	max-height: 85vh;
	overflow-y: auto;
}
/* The light-DOM twin of `modalStyles`' `.modal-x` - the corner exit (owner, 2026-08-10). It is here
   for the three dialogs that are static chrome in public/index.html; `initModalFocus()` in a11y.ts
   stamps the button into those, because they are the one closed list of modals Lit does not own.
   ⚠ STICKY, not absolute: the box it sits in is the one that scrolls, so an absolutely positioned
   mark scrolls away with the content, which is the whole complaint. The scrollport of a padded box
   is its PADDING box, so top zero lands it flush against the border rather than 24px inside. Keep
   this identical to the shadow copy in components/sharedStyles.ts. */
.modal-x {
	place-self: start end;
	/* Centred ON the corner rather than tucked inside it. Half the button hangs over the backdrop,
	   which is what makes it read as belonging to the dialog's edge instead of to its content. */
	/* ⚠ 40%, not 50%: the mark hangs 13.6px past the dialog's right edge and a full-width dialog on a
	   phone leaves exactly 16px of margin there. At 50% the document would scroll sideways. */
	transform: translate(40%, -40%);
	z-index: 2;
	display: inline-flex;
	align-items: center;
	justify-content: center;
	width: 34px;
	height: 34px;
	padding: 0;
	border: 1px solid var(--border);
	border-radius: 50%;
	background: var(--bg2);
	box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
	color: var(--text2);
	cursor: pointer;
	transition:
		background var(--motion-fast) var(--motion-ease),
		color var(--motion-fast) var(--motion-ease);
}
.modal-x:hover {
	background: var(--danger);
	border-color: var(--danger);
	color: var(--on-danger);
}
/* The release picker's dropdown (≈260px) opens *inside* the modal, so a short modal makes it
   scroll internally. Reserve height for it so it drops open in the clear - but ONLY while it is
   open (owner, 2026-08-05: "make this modal height auto-grow more dynamically").

   ⚠ The room was reserved unconditionally until then, and the cost was paid the whole time the
   list was SHUT: two controls and a button row floating at the top of a 440px box, which reads as
   a modal that failed to load its content rather than as a short form. `gh-select` mirrors its
   open state onto its host for this (see the note on `updated()` there), because `:has()` is the
   only way a light-DOM ancestor can lay itself out around a shadow-rooted child's state.

   The transition is what makes it read as growing rather than jumping, and it is a token so
   `prefers-reduced-motion` can reach it - a literal here would be a duration that setting cannot
   turn off. */
#modal-check-followups .modal-content {
	transition: min-height var(--motion-base) var(--motion-ease);
}
/* ⚠ The reservation is the LIST's height plus what sits above it, not a round number (2026-08-11,
   owner: the dialog was "way too tall"). 440px was set when the picker opened ITSELF on arrival, so
   the tall state was the state you always saw and the number only had to be big enough. With the
   auto-open gone it applies while the list is actually down, and it should be the smallest height
   that keeps the list from being clipped by the dialog's own scroll box: the dropdown caps at 260px
   (components/select.ts), the title and label above it come to about 90, and the modal's padding is
   the rest. */
#modal-check-followups .modal-content:has(gh-select[open]) {
	min-height: 380px;
}
/* Preview-as-outlet (.epk-preview-wide / .epk-preview-frame / .epk-preview-note) + the release
   roster (.release-* / a.chip:hover) moved into <gh-releases-page>'s shadow in round 98 - releases
   was their last light consumer. */
.modal-wide {
	width: min(880px, calc(100vw - 32px));
}
.modal-actions {
	display: flex;
	flex-wrap: wrap;
	justify-content: flex-end;
	gap: 8px;
	margin-top: 16px;
}

/* The release roster (.release-grid / .release-card* / .release-cover* / .release-countdown /
   .release-artist / .release-genres / .release-chips / a.chip:hover / .release-card-actions) moved
   into <gh-releases-page>'s shadow in round 98 - releases was the last light consumer, after the
   campaign roster (round 95) and the artist roster (round 96) took their twins into their own
   islands' shadow. The .badge/.chip primitives below are shared by many light pages and stay. */

/* The pitch-card chrome (.queue-card*) that the Queue + Approved islands drew moved into the
   `pitchCardStyles` shadow fragment in round 92 - see src/frontend/components/sharedStyles.ts.
   The badge/chip primitives below are shared by many light pages and stay. */
.badge {
	display: inline-block;
	font-size: 0.6875rem;
	padding: 3px 8px;
	border-radius: 12px;
	font-weight: 600;
	text-transform: uppercase;
	/* A two-word badge ("payment ask", "outlet passed") must not break across lines - a
	   wrapped pill reads as two broken pills */
	white-space: nowrap;
}

/* Chips: small labels/annotations (scores, traits, states) - subtler than badges */
.chip {
	display: inline-flex;
	align-items: center;
	gap: 4px;
	font-size: 0.6875rem;
	line-height: 1.4;
	padding: 2px 8px;
	border-radius: 999px;
	border: 1px solid var(--border);
	background: var(--bg3);
	color: var(--text2);
	white-space: nowrap;
}
.chip-accent {
	color: var(--accent-text);
	border-color: var(--accent);
	background: transparent;
}
.chip-success {
	color: var(--success);
	border-color: var(--success);
	background: transparent;
}
.chip-warning {
	color: var(--warning);
	border-color: var(--warning);
	background: transparent;
}
.chip-danger {
	color: var(--danger);
	border-color: var(--danger);
	background: transparent;
}
.badge-queued {
	background: var(--warning);
	color: var(--on-warning);
}
.badge-approved {
	background: var(--success);
	color: var(--on-success);
}
.badge-sent {
	background: var(--accent);
	color: var(--on-accent);
}
.badge-failed {
	background: var(--danger);
	color: var(--on-danger);
}
.badge-followup {
	background: #74b9ff;
	color: #333;
}

/* The {{placeholder}} cheat-sheet (.placeholder-grid / .placeholder-row) moved into
   gh-settings-page's shadow (round 99). See src/frontend/pages/settings.ts. */

/* .checkbox-list + .blog-select-actions moved into <gh-compose-page>'s shadow (round 99) - compose
   was the only LIGHT consumer (blogs/sent draw .blog-select-actions in their own shadow). See
   src/frontend/pages/compose.ts. */

/* The Discover page's own chrome (.discover-grid / .pack-buttons / .curator-row / .progress-*) moved
   into <gh-discover-page>'s static styles in round 93 - see src/frontend/pages/discover.ts. */

/* Toast.
   ⚠ `bottom` is a TOKEN, not a literal, because this corner is shared as of 2026-08-07:
   `<gh-help-bubble>` docks a 48px launcher here and raises `--gh-corner-dock` on :root while it is
   mounted, so the toast rises out of its way and drops back when it unmounts. That is the app's own
   idiom for an ambient condition - an inherited property, never one surface reaching into another's
   rules - and anything else that ever docks bottom-right reads the same token. */
.toast {
	position: fixed;
	bottom: var(--gh-corner-dock);
	right: 24px;
	background: var(--bg2);
	border: 1px solid var(--accent);
	color: var(--text);
	padding: 12px 20px;
	border-radius: var(--radius);
	font-size: 0.875rem;
	z-index: 100000;
	transition: opacity var(--motion-base) var(--motion-ease);
}
.toast-success {
	border-color: var(--success);
}
.toast-error {
	border-color: var(--danger);
	background: var(--danger-surface);
}
.toast-warning {
	border-color: var(--warning);
}
.toast.hidden {
	opacity: 0;
	pointer-events: none;
}

.hidden {
	display: none !important;
}

.help-text {
	font-size: 0.75rem;
	color: var(--text2);
	margin-top: 4px;
}
.help-text code {
	background: var(--bg3);
	padding: 1px 4px;
	border-radius: 3px;
	font-size: 0.6875rem;
}

.queue-actions {
	display: flex;
	gap: 8px;
	flex-wrap: wrap;
	margin-bottom: 20px;
}

/* An empty state is icon over sentence since `frontend/emptyState.ts` (a fresh install is nothing
   BUT empty states, so this is the app's first impression). The icon is dimmed rather than given
   its own token: it is the same colour as the sentence, one step quieter, which is what keeps it
   from reading as an error mark. A call site that passes no icon still renders exactly as before. */
/* ⚠ An empty state is a REGION, not a floating sentence (2026-08-03, owner: "doesn't look clean").
   It was an icon at 0.45 opacity over grey text on the page background, which is visually the same
   thing as a page that failed to load - and on a fresh install every list in the app is one of these,
   so it is collectively the app's first impression. Giving it an edge and a seat for the mark is what
   makes it read as a state the app is REPORTING rather than an absence of rendering. The border is
   dashed on purpose: a solid one reads as a card with content in it, which is the thing this is not.
   ⚠ The tint is `color-mix`, never a pasted hex - a literal here is right in the theme it was written
   in and wrong in the other one (tests/theme.test.ts). */
.empty-state {
	display: flex;
	flex-wrap: wrap;
	align-items: center;
	justify-content: center;
	gap: 18px;
	text-align: left;
	padding: 24px;
	color: var(--text2);
	background: color-mix(in srgb, var(--bg2) 55%, transparent);
	border: 1px dashed var(--border);
	border-radius: var(--radius);
}
/* The message column of an empty state - the shadow twin lives in sharedStyles.ts. A link spliced
   into the sentence wraps mid-phrase, so the ONE action goes on its own line. */
.empty-text {
	display: flex;
	flex-direction: column;
	align-items: flex-start;
	gap: 6px;
	min-width: 0;
}
/* The sentence keeps its own inline flow - a trailing ? belongs beside the words, not under
   them, and the column around it would otherwise make each a flex item of its own. */
.empty-message {
	display: inline;
}
.empty-action {
	font-size: 0.8125rem;
	font-weight: 600;
}
/* ⚠ A paragraph's spacing has to be OURS. Both resets (the global sheet's and resetStyles') zero
   every margin on *, so a <p> inherits nothing from the UA - and from the day resetStyles grew its
   margin half, every paragraph in every island sat flush against whatever followed it. The owner
   reported it as a button with no gap above it on the Settings data cards (2026-08-04); it was
   every <p> in the app. Bottom only, so a paragraph never pushes down on the heading above it -
   that top margin is exactly what made cards look 38px-padded before the reset landed. */
p {
	margin-bottom: 12px;
}
p:last-child {
	margin-bottom: 0;
}
/* The illustration (2026-08-04). It caps at its drawn size and yields on a narrow card, because an
   empty state appears inside panels of very different widths and a fixed 132px overflows the narrow
   ones. Height is auto so the viewBox keeps the aspect. */
/* ⚠ THIS ELEMENT DOES NOT ANIMATE, and that is a rule rather than an omission (owner, 2026-08-12:
   *"never make an empty state illustration sway. only animate its inner contents"*). It carried a
   whole-picture rise for a day and a half; the twin in `chromeStyles` carries the reasoning, and this
   copy went with it in the same edit - **a twin that keeps a rule its sibling dropped is the version
   of drift that only shows up in whichever root you were not looking at.**
   ⚠ Taking it out ATE THE CLOSING BRACE and shipped, which is why the note above this one is now
   outside the block: the animation was the last declaration, so deleting it and the keyframes that
   followed took the `}` with them. See tests/theme.test.ts - the sheet is parsed now. */
.empty-art {
	width: 132px;
	max-width: 60%;
	height: auto;
	display: block;
}
/* The mark gets a seat rather than an opacity fade. `gh-icon` draws in currentColor, so the softening
   is a colour rather than an `opacity` - an opacity on an ancestor composites everything under it
   (round 148), and this element has text under it. */
.empty-state gh-icon {
	display: grid;
	place-items: center;
	width: 54px;
	height: 54px;
	border-radius: 50%;
	background: color-mix(in srgb, var(--text2) 9%, transparent);
	color: color-mix(in srgb, var(--text2) 80%, transparent);
}
/* A measure. The sentence says which list this is and what puts something in it, and a line running
   the full width of a 1200px page is the hardest possible way to read one. */
/* ⚠ An in-app link inside an empty state's SENTENCE is underlined, and axe caught the day it was
   not (2026-08-10, when the Content page's four unconnected panels became illustrated cards). A
   link inside a text block distinguished only by colour fails WCAG 1.4.1 - the rest of the app's
   links get away with colour alone because they sit in chrome, where nothing around them is a
   competing colour. This is the one place a link is embedded in prose. */
.empty-state a {
	color: var(--accent-text);
	text-decoration: underline;
	text-underline-offset: 2px;
}
.empty-state span {
	max-width: 46ch;
	line-height: 1.55;
	/* Matches .hint, which is what the rest of a page's explanatory copy is set in. It was
	   inheriting the body size, so an empty state read LARGER than the live content it stands
	   in for - the one place on a page that should be the quietest. */
	font-size: 0.8125rem;
}

/* Multi-select dropdowns */
/* A switch and the ? that explains it, on one line - the shadow twin lives in sharedStyles.ts. */
.toggle-row {
	display: inline-flex;
	align-items: center;
	gap: 6px;
}
.form-row {
	display: flex;
	gap: 16px;
}
.form-half {
	flex: 1;
	min-width: 0;
}
/* (.locale-group + .btn-locale-remove / .btn-locale-translate moved into gh-settings-page's shadow with
   the rest of the localeCard family, round 99 - see the note above .tooltip-pop. The photo-thumb ×
   override below is a separate, artists-page concern.) */
/* On photo thumbnails the × sits on the image - give it a scrim for contrast */
[data-photo-id].btn-locale-remove,
[data-photo-key].btn-locale-remove {
	background: rgba(0, 0, 0, 0.55);
	color: #fff;
}
/* The multiselect's whole `.multi-select*`/`.tag*` block moved into `<gh-multiselect>`'s shadow
   styles in round 85 (it was used by nothing else). See components/multiSelect.ts. */

/* Busy buttons: slow (mostly AI) actions in flight get a spinner next to their label */
.btn-busy {
	pointer-events: none;
	opacity: 0.8;
}
.btn-busy::after {
	content: "";
	display: inline-block;
	width: 11px;
	height: 11px;
	margin-left: 7px;
	vertical-align: -1px;
	border: 2px solid currentColor;
	border-right-color: transparent;
	border-radius: 50%;
	animation: btn-busy-spin var(--motion-status-loop) linear infinite;
}
@keyframes btn-busy-spin {
	to {
		transform: rotate(360deg);
	}
}

/* .ai-loading (+ its @keyframes) moved into gh-settings-page's shadow (round 99, keyframes are
   tree-scoped so they travel with the rule). See src/frontend/pages/settings.ts. */

/* Loading skeleton */
.loading-skeleton {
	padding: 20px;
}
.skeleton-line {
	height: 16px;
	background: linear-gradient(90deg, var(--bg3) 25%, var(--border) 50%, var(--bg3) 75%);
	background-size: 200% 100%;
	animation: skeleton-shimmer var(--motion-decor-loop) var(--motion-decor-iterations);
	border-radius: 4px;
	margin-bottom: 12px;
}
.skeleton-line.short {
	width: 60%;
}
@keyframes skeleton-shimmer {
	0% {
		background-position: 200% 0;
	}
	100% {
		background-position: -200% 0;
	}
}

/* Dashboard enhancements */
.dashboard-section h3 {
	margin-bottom: 12px;
	font-size: 1.1em;
	color: var(--text);
}
.genre-bars {
	display: flex;
	flex-direction: column;
	gap: 8px;
}
.genre-bar-row {
	display: flex;
	align-items: center;
	gap: 12px;
}
.genre-bar-label {
	width: 140px;
	font-size: 0.9em;
	color: var(--text2);
	text-align: right;
	flex-shrink: 0;
}
.genre-bar-track {
	flex: 1;
	height: 18px;
	background: var(--bg3);
	border-radius: 4px;
	overflow: hidden;
}
.genre-bar-fill {
	height: 100%;
	background: var(--accent);
	border-radius: 4px;
	transition: width var(--motion-base) linear;
}
.genre-bar-count {
	width: 40px;
	font-size: 0.85em;
	color: var(--text2);
}
.recent-date {
	color: var(--text2);
	font-size: 0.85em;
}

/* ── Data-viz: what is left in the GLOBAL sheet, and why ──
   The tiles, sparklines, bar rows, funnel, area chart AND both analytics panels (<gh-stats-panel>,
   <gh-campaign-insights>) moved into src/frontend/components/ and took their CSS with them (shadow
   DOM - see components/ghElement.ts). `.stat-tiles` (the KPI grid) and `.kpi-hero` (its bigger-number
   knob) were the last light holdouts - drawn only by the dashboard hero - and left too when
   <gh-dashboard-page> went shadow in round 94 (the stats panel had kept its own shadow copy of
   `.stat-tiles` since round 86). `.stats-columns` and the `.wave-*` timeline left with their panels in
   round 86 (nothing else drew them); the shared `.hint`/`table`/`.chip`/`.badge` primitives they also
   drew now live in components/sharedStyles.ts (`chromeStyles`), copied there for shadow while the light
   pages keep their copies here. The `--viz-*` color tokens above stay - many consumers. */

.dashboard-section {
	margin-top: 22px;
}
.dashboard-section > h3 {
	margin-bottom: 10px;
}
/* Settings → Appearance → Fonts: every uploaded face previewed in itself */
.font-table {
	width: 100%;
	margin-top: 10px;
}
.font-table td {
	padding: 8px 10px 8px 0;
	vertical-align: middle;
}
.font-sample {
	font-size: 1.3125rem;
	line-height: 1.3;
}
/* .shorts-fontrow (the bio studio's typeface row) and .bio-preview-toggle moved into
   <gh-artists-page>'s shadow in round 96 - artists was their only consumer. */
/* The reel Type card's .reel-type* rules moved into <gh-shorts-studio>'s shadow in round 208 - that
   studio was their only consumer, and it has been shadow since round 97, so they had been styling
   nothing at all for eleven rounds. See src/frontend/pages/shortsStudio.ts, and
   tests/classResolution.test.ts for the gate that found them. */
/* Settings → Appearance → Fonts (.font-cards / .font-card / .font-card-head / .font-sample /
   .font-spacing*) moved into gh-settings-page's shadow (round 99). See src/frontend/pages/settings.ts. */

/* "Your domain" card (.domain-guide / .nameserver-box / .domain-checks / .check-row / .check-dot
   + @keyframes check-pulse / .check-main / .check-label / .check-detail / .domain-live) and the
   .btn-link text button moved into gh-settings-page's shadow (round 99). See
   src/frontend/pages/settings.ts. */

/* Content-page chrome (the .content-x .crosspost-x .xp-x .bt-x .bc-x .mention-x .boost-x .ad-preview-x
   families + the shared adShared .chip.xp-verdict-x and .ad-flags) moved into gh-content-page's shadow,
   and the per-artist .connections-artist-bar row moved into gh-settings-page's shadow (round 99). Both
   pages were the last LIGHT consumers; the shadow Ads panel + shorts studio carry their own adShared
   twins. NOTE: class globs are written ".foo-x" not ".foo-*" on purpose - a literal star-slash would
   close this CSS comment early. See src/frontend/pages/content.ts + settings.ts. */

/* The release workspace's 📢 Ads panel chrome (.ad-camp-*) moved into <gh-release-ads>'s static
   styles when it went shadow (studio shadow-flip cohort) - see src/frontend/pages/releaseAds.ts.
   It was drawn only by that element, so this is now its sole home. */

/* ── SaaS sign-in gate (round 46) ─────────────────────────────────────────
   Selfhost never reveals #login-screen; the saas boot shows it while signed out.
   A standalone overlay rather than a .page: the signed-out state has no sidebar,
   and the router's page/nav pairing must never see it. Only this .login-screen
   overlay is styled here now - the .login-card chrome inside it moved into the
   <gh-login-screen> element's shadow root in round 90 (B.3f). */
.login-screen {
	min-height: 100vh;
	display: flex;
	align-items: center;
	justify-content: center;
	padding: 24px;
	/* THE DOT GRID (owner's pick, 2026-08-05, after six candidates on the real page and two rounds of
	   retiring the beat that was on it - the app-wide layer at the top of this file carries that
	   history).
	   NOTE - it goes on THIS element and not inside <gh-login-screen>: the card is shadow-rooted, and
	   a background declared in there stops at the card's own box. The overlay is the only thing that
	   reaches the page edges.
	   NOTE - color-mix over a TOKEN, never a literal, so it inverts with the theme instead of being a
	   dark-mode texture that vanishes in light (tests/theme.test.ts is the gate, and this is exactly
	   the case it was written for). */
	background-image: radial-gradient(color-mix(in srgb, var(--text) var(--dot-grid-opacity-solo), transparent) 1px, transparent 1px);
	/* Slightly stronger than the app-wide layer, and the strength is that layer's SIBLING TOKEN: this
	   is one card on an empty field, where the grid carries the page rather than filling the gaps
	   between cards - but "how strong" is still a decision `prefers-contrast` gets to overrule. */
	background-size: 22px 22px;
	/* For the wash below, which is absolutely positioned against this box rather than the viewport:
	   the overlay already fills the screen, and a `fixed` layer would be the wrong ancestor the day
	   anything above it grows a transform. */
	position: relative;
}
/* THE GLOW - see `--signin-glow-opacity` for the owner report that put it here. It is a SEPARATE
   LAYER rather than a second background-image on the overlay, because the two want opposite stacking:
   the dots are a texture UNDER the wash, and one element paints its background-images in one order
   with the first on top. Painted under everything and ignoring the pointer.
   NOTE - out here rather than inside <gh-login-screen> for the reason the dot grid is: the card is
   shadow-rooted, so anything declared in there stops at the card's own box, and this layer's whole
   job is to reach the page edges. */
.login-screen::before {
	content: "";
	position: absolute;
	inset: 0;
	z-index: 0;
	pointer-events: none;
	background-image: radial-gradient(58% 50% at 50% 44%, color-mix(in srgb, var(--accent) var(--signin-glow-opacity), transparent), transparent 72%);
}
/* The card sits ON the lit part. Without this it paints BELOW the pseudo-element: a positioned
   ::before with z-index 0 is painted after in-flow non-positioned children, so the wash would wash
   over the form rather than under it. */
.login-screen > * {
	position: relative;
	z-index: 1;
}

/* Signed-in account chrome at the sidebar's foot (saas only; stays hidden elsewhere) */
.nav-account {
	margin: 14px 16px 0;
	padding-top: 12px;
	border-top: 1px solid var(--border);
	display: flex;
	flex-direction: column;
	gap: 8px;
}
.nav-account-email {
	font-size: 0.75rem;
	color: var(--text2);
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
}

/* Account tab (.session-row, round 47) moved into gh-settings-page's shadow (round 99). See
   src/frontend/pages/settings.ts. */

/* AI usage / budget (.notice / .notice-warning / .usage-meter / .usage-meter-fill, round 51) moved
   into gh-settings-page's shadow (round 99). See src/frontend/pages/settings.ts. */

/* Release pre-flight (.preflight / .pf-*, round 56) moved into <gh-compose-page>'s shadow (round 99)
   - compose is its only consumer. See src/frontend/pages/compose.ts. */

/* Pricing (.pricing-interval / .pricing-grid / .pricing-tier / .pricing-current / .pricing-price-row /
   .pricing-price / .pricing-per / .pricing-features, round 61) moved into gh-settings-page's shadow
   (round 99). See src/frontend/pages/settings.ts. */
