/* NOVAL base stylesheet.
   Owns: the cascade layer order, a reset, and :root fallback values for every
   design token in DESIGN.md, so nothing renders unstyled before a skin loads.
   Skins redefine these tokens under [data-skin="<id>"]; they never add new ones.
   See DESIGN.md sections 2, 3, 5, 6 and Rebuild_Plan.md section 6.6. */

@layer reset, tokens, base, components, utilities;

/* The shared UI kit: page, section, grid, card, empty, skeleton, badge.
   Imported here rather than linked from index.html because index.html is
   not the Refiner's file, and a kit that only takes effect after someone
   else edits markup would leave five screens unstyled in the meantime.
   An @import must precede every rule and may follow a layer statement,
   which is why it sits exactly here. It loads before every feature
   stylesheet, all of which ensureCss appends to <head> at runtime, so a
   feature overrides a kit value without having to raise specificity.
   Moving this to a <link> beside base.css is a fair later change; see
   agent_UI-fixes.md. */
@import url("/app/styles/kit.css") layer(components);

/* The 3D map's token set: every `--map-*` fallback, and nothing else.
   Split out rather than added here because this file is already 559 lines
   against CLAUDE.md rule 9's 400, and because the map's whole look IS a
   token set (DESIGN-DECISIONS.md 2026-09-23), so the set is a component.
   Into `layer(tokens)`, so it behaves exactly as the block below does: a
   skin's unlayered stylesheet still wins over it. DESIGN.md section 14 is
   the authority; that file carries the grammar and the reasoning. */
/* THE STAGE: the full-bleed canvas-with-chrome-in-the-margins layout, shared
   by the star map, the flow chart, the manuscript and the outline board. A
   LAYOUT PRIMITIVE with no look of its own - see its own header. Into
   `layer(components)` beside the kit, so a feature stylesheet (which
   `ensureCss` appends unlayered) overrides any of it without raising
   specificity. */
@import url("/app/styles/stage.css") layer(components);

@import url("/app/styles/map-tokens.css") layer(tokens);

/* The scrollbar's token set and the four declarations that consume it
   (owner, 2026-09-24: every scrollbar in the product loses its background).
   Split out for the same two reasons as the map's tokens - this file is
   already 574 lines against CLAUDE.md rule 9's 400, and a scrollbar's whole
   look is a token set.
   Imported WITHOUT a `layer()`, unlike the two above, because it spans two
   layers of its own: `tokens` for the five custom properties, so a skin's
   unlayered stylesheet still beats them, and `base` for the rules, so a
   component can still override a scrollbar locally. Forcing it into
   `layer(tokens)` here would flatten that distinction. */
@import url("/app/styles/scrollbars.css");

/* ── Layer order declaration only. Components add to `components`. ─────── */

@layer tokens {
  :root {
    color-scheme: dark;

    /* Surfaces and text (neutral fallback: readable with no skin loaded) */
    --bg: #0b0c0f;
    --surface-1: #14161b;
    --surface-2: #1c1f26;
    --surface-3: #242832;
    --text: #e9ecf2;
    --text-muted: #a3aab8;
    /* A THIRD INK (2026-10-05). The product had two - full and muted - and
       the v2 sheet the owner approved runs on three: an ink for what you
       read, a dim one for metadata, and a faint one for the marks that order
       a page without being read, like the 01/02/03 down a card bar or the
       uppercase label beside a section rule.

       Derived from `--text` rather than stated, the way `--divider` is, so a
       skin that changes its ink gets a matching faint tier for free and can
       still override this line outright. NOT for anything a reader has to
       act on: at 55% it is below the contrast a control needs, which is the
       point of it. */
    --text-faint: color-mix(in oklab, var(--text) 55%, transparent);
    --text-on-accent: #0b0c0f;
    --border: rgb(255 255 255 / 0.11);

    /* The section rule, as opposed to the box outline. A hairline drawn all
       the way across a screen reads heavier than the same hairline around a
       240px card, so the two are separate values: `--border` outlines,
       `--divider` separates stacked content. Measured off four dark products
       before splitting them - Linear's card edge sits at 1.21:1 and its
       full-width containers at 1.09:1.

       A FORMULA, not a literal (DESIGN.md 12.6). Built from `--text`, so a
       skin that has not claimed this token gets a divider in its own ink
       rather than importing Noval's periwinkle. `--border` above predates that
       rule and is left exactly as it is; renaming or retuning a token every
       consumer already reads would fail silently, because every one of them
       would simply fall back to this file. */
    --divider: color-mix(in oklab, var(--text) 9%, transparent);

    /* How strongly the skin's edge light gathers at the window edges. A plain
       number rather than a colour, because the light itself is built from the
       skin's own three accents — so a skin inherits this strength and still
       glows in its own hue, and a skin that wants none sets it to 0. */
    --edge-glow: 0.13;

    /* How hard a translucent surface blurs what is behind it. The nav bar
       uses its own 14px - it is the deepest pane in the product and sits over
       the whole page - and every in-page element takes this, which the owner
       asked to be "a less intense blur" (2026-09-11).

       It is `backdrop-filter` at every call site, never `filter`: the backdrop
       property blurs only what shows THROUGH the element and leaves its own
       text and icons untouched, which is exactly the distinction the owner
       drew. `filter: blur()` would smear the label as well.

       Only surfaces that are actually translucent set it. An opaque button or
       a fully transparent ghost gains nothing from a blur and still pays for
       the compositor layer, so those reset it to `none`.

       It holds the whole filter value, not just a radius, so a skin whose
       surfaces are flat and opaque can switch it off outright rather than
       being stuck with `blur(0px)` - which still promotes a layer. Holo is
       that skin: a console's panels are solid.

       The neutral surface fallbacks above stay opaque hexes, so with no skin
       loaded these two blur something that cannot show through. That is
       accepted rather than overlooked: the fallback exists to keep the app
       readable when a skin is missing or still loading, and one idle
       compositor layer in a degraded state is not worth making every skin
       restate a blur it wants anyway. */
    --glass-filter: blur(6px);

    /* The deeper pane, for surfaces that FLOAT over the page rather than
       sitting in it: the nav bar, menus and popovers, dialogs, toasts, the
       drawer, the reference stack. Those have to hide the page's own text,
       not merely soften a star field, and 6px leaves readable words showing
       through a menu. Same property, same guarantee - the panel's own labels
       are never blurred. */
    --glass-filter-strong: blur(14px);

    /* ── The manuscript page ───────────────────────────────────────────
       The surface a chapter is read and written on, shared by the editor's
       `.page-sheet` and the reader's `.reader-page` so Write, Read and Review
       are visibly the same piece of paper (owner, 2026-09-11).

       It is its own pair of tokens rather than `--surface-1` and
       `--glass-filter-strong` because it has been tuned four times against
       those two and always away from them: the page is darker than a card and
       blurs a little less than a menu. Giving it a name stops the next tuning
       pass from dragging every card and dialog along with it.

       Darker than `--bg` rather than lighter: a page of prose is a well you
       read into, and the star field behind it has to recede. The blur is what
       makes the translucency legible at all - without it the stars twinkle
       under live text. */
    --sheet-surface: color-mix(in oklab, color-mix(in oklab, black 10%, var(--bg)) 63%, transparent);
    --sheet-blur: blur(13px);

    /* The card's ground. Nearly opaque on purpose - see kit.css's own note:
       a star field is made of point sources, and a translucent card lets them
       through as speckle behind the text.
       A FORMULA over --bg, so a skin that has not claimed it gets a card in
       its own ground rather than importing Noval's near-black. */
    --card-surface: color-mix(in oklab, var(--bg) 97%, transparent);

    /* Accents */
    --accent: #6aa9ff;
    --accent-2: #a855f7;
    --accent-3: #ec4899;
    --focus-ring: var(--accent);

    /* Status */
    --danger: #f2555a;
    --warning: #f5a524;
    --success: #45c98a;
    --info: #6aa9ff;

    /* A misspelled word, in the writing surface (owner, 2026-09-11).
       Deliberately NOT `--danger`: a typo is not an error the product is
       reporting, it is a word the writer may well have meant, and red beside
       prose reads as something having gone wrong. Magenta is the owner's
       call, and it is nowhere else in the product, so it cannot be mistaken
       for anything. Given its own token rather than a literal because rule 7
       says so and because a skin may need to move it. */
    --misspell: #ff57d8;

    /* Component colours: fixed across skins, mutually distinguishable on dark.
       Never the only signal; the map also varies shape and lists show labels. */
    --component-event: #fbbf24;
    --component-person: #38bdf8;
    --component-innovation: #c084fc;
    --component-tradition: #4ade80;
    --component-article: #f472b6;
    --component-location: #94a3b8;
    /* The seventh core type (2026-09-20). Added late: Story Block shipped as a
       core type without one, so its chip and its list dot fell back to muted
       grey while the other six were coloured - no error, just the one type in
       the index that looked switched off. Teal, the gap left between Character's
       sky blue and Tradition's green. */
    --component-storyBlock: #2dd4bf;
    --component-custom: #fb923c;

    /* 3D map. The whole `--map-*` set — thirty-two tokens, `--map-bg`,
       `--map-node` and `--map-link` among them — now lives in
       `map-tokens.css`, imported at the top of this file into this same
       layer. It is not defined here twice on purpose: two homes for one
       token is the drift this project keeps paying for.

       `--map-link` was `rgb(255 255 255 / 0.22)` here. The alpha moved out
       into `--map-link-opacity`, because THREE.Color parses an rgba string
       and silently discards its alpha — the line would have drawn opaque and
       nothing would have said so. See `map-tokens.css` and DESIGN.md 14. */

    /* Fonts. Skins self-host and override; these stacks must stand alone. */
    /* Both shipped skins set a geometric sans here (Outfit, Orbitron) and
       DESIGN.md section 3 is explicit that the wordmark is a sans and not a
       serif. A serif fallback contradicted both, and it is what actually
       renders for the length of the skin stylesheet request and permanently
       if a skin fails to load. */
    --font-display: ui-sans-serif, system-ui, "Segoe UI", Roboto, sans-serif;

    /* The wordmark's own weight and tracking, declared as tokens so anything
       that has to sit NEXT to it can match - and, in the universe crumb's
       case, step one weight off it. Each skin overrides both; these are the
       unstyled fallbacks. */
    --brand-weight: 400;
    --brand-tracking: 0.14em;
    --font-body: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
    --font-mono: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
    --font-prose: ui-serif, Georgia, "Iowan Old Style", "Times New Roman", serif;

    /* Type scale: 1.25 ratio from 16px. Body never below 16px. */
    --text-xs: 0.75rem;    --text-xs-lh: 1.4;
    --text-sm: 0.875rem;   --text-sm-lh: 1.5;
    --text-base: 1rem;     --text-base-lh: 1.6;
    --text-lg: 1.125rem;   --text-lg-lh: 1.6;
    --text-xl: 1.25rem;    --text-xl-lh: 1.4;
    --text-2xl: 1.5625rem; --text-2xl-lh: 1.3;
    --text-3xl: 1.9375rem; --text-3xl-lh: 1.2;
    --text-4xl: 2.4375rem; --text-4xl-lh: 1.15;
    --text-5xl: 3.0625rem; --text-5xl-lh: 1.05;
    /* One step past the documented eight, for the landing hero only. The
       scale's own 1.25 ratio continued: 49px x 1.25 = 61px. A 49px headline
       set in the wordmark's 200 weight is adrift in a 1400px column, and the
       alternative was writing a raw rem value into landing.css. Proposed as a
       ninth row of DESIGN.md section 3's scale; see agent_UI-fixes.md. */
    --text-6xl: 3.8125rem; --text-6xl-lh: 1.02;

    /* Prose: story reader and editor only. Size, leading and measure are
       user-adjustable in Settings. */
    --prose-size: 1.1875rem;
    --prose-leading: 1.7;
    --prose-measure: 68ch;

    /* THE MANUSCRIPT'S OWN FACE AND WEIGHT, in one place because three
       surfaces render the same words and they had drifted apart: Write set
       `--font-display` at 300 (the owner's 2026-09-11 ruling - "FONT needs to
       match font used in nav-bar here", then "font weight too heavy"), while
       Read and Review were still rendering `--font-prose` at the 400 default.
       The same chapter looked like two different books depending on the tab
       (owner, 2026-09-21).

       250, not 300: the owner asked for lighter again, and Outfit is a
       variable face declared `font-weight: 100 900`, so this is a real weight
       rather than a rounded one. It is a token so the answer moves in one
       place, and a skin whose display face needs a different weight to read at
       this size can say so. */
    --prose-family: var(--font-display);
    --prose-weight: 250;

    /* Spacing, 4px base */
    --space-1: 0.25rem;  --space-2: 0.5rem;   --space-3: 0.75rem;
    --space-4: 1rem;     --space-5: 1.25rem;  --space-6: 1.5rem;
    --space-7: 2rem;     --space-8: 2.5rem;   --space-9: 3rem;
    --space-10: 4rem;    --space-11: 5rem;    --space-12: 6rem;

    /* Radius: one shape system, nested radii always smaller than the parent */
    --radius-sm: 6px;
    --radius-md: 10px;
    --radius-lg: 16px;
    --radius-pill: 999px;
    /* THE BUTTON'S OWN RADIUS (2026-10-05), separate from `--radius-md`
       because a button is the one control whose shape the owner set directly:
       the v2 sheet draws them square, after anka's mono pairing, and every
       other box on the page keeps its 10px. A skin that wants rounded buttons
       sets this one line; Grid already draws everything at 1-2px and Null can
       take it to `--radius-pill` without touching a card. */
    --btn-radius: 0px;

    /* THE COVER CARD'S OWN RADIUS (2026-10-13), and the third member of this
       pair for the same reason the second one exists: a card and a button are
       different objects and a skin has to be able to shape them separately.

       `styles/kit.css`'s cover card - the component grid, the universe
       carousel's slides and Home's suggested strip, which are one primitive -
       drew at `--radius-lg` until today, which is the PRE-2026-10-05 language:
       16px photographs under a design that `DESIGN.md` 0.1 settles as
       "Shape | square". It could not simply read `--btn-radius`, because that
       token is documented four lines up as the BUTTON's alone and a skin is
       invited to take it to `--radius-pill` - which would have given a skin
       with pill buttons pill PHOTOGRAPHS.

       `features/image-carousel/carousel-slide.css` wrote a literal `0` for
       exactly that reason on 2026-10-08 and recorded the cost in its own
       CONTEXT.md: the owner's standing skin rule from 2026-09-24 - "so if
       cards are rounded in one skin and not in the other the carousel cards
       match" - could no longer be honoured by moving a token, because there
       was no token to move. This is that token. All five skins declare it;
       Grid keeps its own 2px here, which is what makes the rule true again.

       0 is the default because square is the product's answer. A skin that
       wants rounded cards sets this one line and every surface that draws a
       cover card follows it at once. */
    --card-radius: 0px;

    /* Elevation: layered and tinted toward the background, never pure black */
    --shadow-1: 0 1px 2px rgb(0 0 0 / 0.28), 0 0 0 1px rgb(255 255 255 / 0.04);
    --shadow-2: 0 8px 28px rgb(0 0 0 / 0.42), 0 2px 8px rgb(0 0 0 / 0.28),
                0 0 0 1px rgb(255 255 255 / 0.06);

    /* Motion. Two speeds: micro feedback, then spatial change. */
    --dur-1: 90ms;
    --dur-2: 160ms;
    --dur-3: 240ms;
    --dur-4: 320ms;
    --dur-5: 480ms;
    --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
    --ease-in: cubic-bezier(0.4, 0, 1, 1);
    --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
    --motion: 1; /* collapses to 0 under reduced motion; see below */

    /* Z-index budget. No raw z-index anywhere else in the codebase. */
    --z-skin-back: 0;
    --z-app: 1;
    --z-skin-front: 50;
    --z-drawer: 100;
    --z-popover: 200;
    --z-overlay: 300;

    /* Layout */
    --app-max-width: 1400px;
    /* 40px on a fine pointer, down from 48 (owner, 2026-10-05: "nav-bar
       height should be 40 instead of 48 height (keep everything in it same
       size)"). Nothing INSIDE the bar changed - the wordmark, the rail and
       the burger are all the sizes they were; the bar simply holds them
       more tightly.

       53.334 rather than a round base because the number that matters is the
       rendered one: `--ui-scale` is 0.75 on a fine pointer, and 53.334 x 0.75
       is 40.0 exactly. Home's first screen is budgeted to the pixel against
       this value, so a base chosen for looking tidy here would show up as
       half a line of drift at the fold. On a touch pointer (scale 1) the bar
       is 53px, comfortably clear of the 44px burger inside it. */
    --nav-height: calc(53.334px * var(--ui-scale));

    /* THE MANUSCRIPT PAGE'S WIDTH, and the reason it is a token rather than
       three copies of `820px`.

       DESIGN.md 11.3: "Read and Review should be the writing surface with the
       writing controls removed, not a different document." That is only true
       while all three modes measure the same piece of paper, and on
       2026-09-16 they did not: `story-editor.css` wrote `max-width: 820px`,
       `chapter-screen.css` wrote `calc(820px + ...)` for the column that
       holds it, and `story-reader.css` wrote `46rem` - so Read served the
       same chapter on a 736px page against Write's 820px, with the prose
       column 40px narrower and the sheet 42px further right at 1440.

       Product-owned structure, not a look value (DESIGN.md 12.6): a skin
       changing the width of the page would re-paginate the manuscript, which
       is not restyling. `--sheet-inset` is the 36px the page's own media
       column is inset by; it is EMERGENT (11.1) - 1px border plus 20px body
       padding plus the 15px a right-aligned gutter pushes the line number in
       by - so it is derived here and re-measured if the gutter changes. */
    --sheet-w: 820px;
    --sheet-gutter: calc(44px * var(--ui-scale));
    --sheet-inset: calc(var(--space-5) + var(--space-4));
  }

  /* Reduced motion, from the OS or the in-app Settings toggle.
     Durations collapse and --motion zeroes any translate written as
     calc(var(--motion) * Npx), so transforms flatten automatically. */
  @media (prefers-reduced-motion: reduce) {
    :root {
      --dur-1: 0ms; --dur-2: 100ms; --dur-3: 100ms;
      --dur-4: 100ms; --dur-5: 120ms; --motion: 0;
    }
  }
  :root[data-motion="reduced"] {
    --dur-1: 0ms; --dur-2: 100ms; --dur-3: 100ms;
    --dur-4: 100ms; --dur-5: 120ms; --motion: 0;
  }
}

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

  html { -webkit-text-size-adjust: 100%; }

  body, h1, h2, h3, h4, p, figure, blockquote, dl, dd, ol, ul {
    margin: 0;
    padding: 0;
  }

  ol[role="list"], ul[role="list"] { list-style: none; }

  img, picture, video, canvas, svg { display: block; max-width: 100%; }

  input, button, textarea, select { font: inherit; color: inherit; }

  button { background: none; border: none; }

  a { color: inherit; }

  [hidden] { display: none !important; }
}

@layer base {
  /* ── THE UI SCALE ───────────────────────────────────────────────────────
   *
   * The owner, 2026-10-01: *"overall the ui is to big, when i zoom out to
   * 75% on my browser it feels correct sized"*.
   *
   * ONE KNOB, because every size in this product that should move is already
   * relative. The whole type scale (`--text-xs` … `--text-4xl`), the whole
   * spacing scale (`--space-1` … `--space-12`), the prose size and every
   * `--control-h` are in `rem`, so they all follow the root. `--prose-measure`
   * is in `ch`, so the manuscript keeps its 68-character measure at any
   * scale - the line length does not change, only the size of the type
   * setting it.
   *
   * A PERCENTAGE, NEVER A PIXEL VALUE. `calc(100% * 0.75)` is three quarters
   * of whatever the reader's own browser default is, so somebody who has set
   * 20px because they need it still gets 15px rather than being overridden
   * down to a fixed number. A `font-size: 12px` here would have quietly
   * cancelled an accessibility setting.
   *
   * FINE POINTERS ONLY, and that is not timidity - it is the two floors in
   * DESIGN.md that a blanket scale would have broken:
   *
   *   §4   "Minimum hit area 24px, and 44px on touch pointers."
   *        Three `@media (pointer: coarse)` rules raise a control to
   *        `2.75rem` to meet that 44px. At 0.75 they would compute to 33px.
   *   §    "Inputs are at least 16px."
   *        The iOS rule: Safari zooms the page when a focused input is under
   *        16px. `--text-base` at 0.75 is 12px, so every field in the
   *        product would have made the page jump on focus.
   *
   * Both failures are touch failures, and the complaint is a desktop one - a
   * browser window being zoomed out. So the scale applies where a mouse is
   * the primary input and touch layouts keep full size, which satisfies both
   * floors by construction rather than by a list of exceptions to maintain.
   *
   * WHAT THIS IS NOT. Browser zoom also shrinks the VIEWPORT, so at 75% a
   * 1440px window reports 1920 CSS px and the layout moves up a breakpoint.
   * Media queries are in `px` and do not follow the root font size, so this
   * keeps the 1440 layout and makes its contents smaller. Denser, not wider.
   * If the wider layout was part of what felt right, that is a second and
   * much larger change - em-based breakpoints - and it is not this.
   */
  :root { --ui-scale: 1; }

  @media (pointer: fine) {
    :root { --ui-scale: 0.75; }
  }

  /* ── THE DISPLAY TIER, BY DEPTH ─────────────────────────────────────────
   *
   * The product had no display tier at all, and this was written down a year
   * ago and never fixed. `DESIGN-DECISIONS.md` recorded the universe page
   * topping out at a largest-to-body ratio of **1.2** against reference pages
   * at 6.67. Measured again on 2026-10-01: still 1.2. Home measured 1.56 - its
   * biggest text was 18.75px over a 12px body. Seven distinct sizes on the
   * universe page lived inside a 6px band.
   *
   * That is what "flat and un-inspired" is mostly made of. Nothing on a screen
   * says THIS ONE, because nothing is allowed to be large.
   *
   * ONE SET OF TOKENS, READ BY EVERY SCREEN, so a masthead is the same gesture
   * everywhere instead of each feature picking a size. They step DOWN with
   * `data-depth` because the product is a descent: loud at the public face,
   * near-silent at the manuscript. `DESIGN.md` section 0's dial table already
   * said so in numbers; this is the type half of it.
   *
   * The weight falls as the size rises: a 36px heading at 600 is a shout, and
   * the display face is drawn for air. The brand weight is the skin's own
   * (`--brand-weight`, 200 in Supernoval), so a skin that wants a heavier
   * display sets one number.
   */
  :root {
    --page-title-size: var(--text-4xl);
    --page-title-lh: var(--text-4xl-lh);
    --page-title-weight: 300;
  }

  [data-depth="0"] {
    --page-title-size: var(--text-5xl);
    --page-title-lh: var(--text-5xl-lh);
    --page-title-weight: var(--brand-weight, 250);
  }

  [data-depth="1"] {
    --page-title-size: var(--text-4xl);
    --page-title-lh: var(--text-4xl-lh);
    --page-title-weight: 300;
  }

  /* The study is where a writer works, so it is quieter than the shop window
     but still has a masthead. (The owner's "universe title page" ruling, which
     would make this the LOUDEST surface in the product, is still owed - see
     COMPONENT-CONTRACT.md section 6 item 1. Until then this descends.) */
  [data-depth="2"] {
    --page-title-size: var(--text-3xl);
    --page-title-lh: var(--text-3xl-lh);
    --page-title-weight: 400;
  }

  /* The manuscript. Chrome recedes and the page of text is the product. */
  [data-depth="3"] {
    --page-title-size: var(--text-xl);
    --page-title-lh: var(--text-xl-lh);
    --page-title-weight: 500;
  }

  html { font-size: calc(100% * var(--ui-scale)); }

  html, body { height: 100%; }

  /* The nav bar is pinned, so the top of the scrollport is not actually
     visible. Anything scrolled into view - an in-page anchor, a
     scrollIntoView() call, a focus the browser has to scroll to - must stop
     below it rather than behind it. Declared on both because `overflow-x`
     on body means either can be the scrollport; it applies to whichever is.
     See DESIGN.md: "visible focus never obscured by a sticky element". */
  html, body {
    scroll-padding-top: calc(var(--nav-height) + env(safe-area-inset-top, 0) + var(--space-2));
  }

  body {
    margin: 0;
    background: var(--bg);
    color: var(--text);
    font-family: var(--font-body);
    font-size: var(--text-base);
    line-height: var(--text-base-lh);
    -webkit-font-smoothing: antialiased;
    text-rendering: optimizeLegibility;
    overflow-x: hidden;
  }

  h1, h2, h3, h4 {
    font-family: var(--font-display);
    font-weight: 500;
    text-wrap: balance;
  }

  h1 { font-size: var(--text-3xl); line-height: var(--text-3xl-lh); }
  h2 { font-size: var(--text-2xl); line-height: var(--text-2xl-lh); }
  h3 { font-size: var(--text-xl);  line-height: var(--text-xl-lh); }
  h4 { font-size: var(--text-lg);  line-height: var(--text-lg-lh); }

  p { text-wrap: pretty; }

  code, kbd, samp, pre { font-family: var(--font-mono); }

  /* One focus treatment for the whole product. A skin may recolour
     --focus-ring but may not remove the ring. */
  :focus-visible {
    outline: 2px solid var(--focus-ring);
    outline-offset: 3px;
    border-radius: var(--radius-sm);
  }
  :focus:not(:focus-visible) { outline: none; }

  /* The one exception, and it is a region rather than a control. The router
     focuses the route outlet after every navigation so a screen reader
     announces the new screen; Chrome then treats that programmatic focus as
     keyboard focus and paints a cyan rectangle around the entire page on every
     hop. The announcement is the point and is unaffected; the rectangle is
     noise. Focus still lands here from the skip link, and the next Tab enters
     the content, which is what the skip link is for. */
  .route-outlet:focus, .route-outlet:focus-visible { outline: none; }

  /* The wordmark with no skin loaded. Both skins define their own
     `.brand-title`; this is the neutral twin, so the one asset that must
     never be wrong is not left unstyled while a stylesheet is in flight.
     Deliberately carries no gradient: a gradient needs a skin's palette and
     inventing one here would be inventing brand colour. */
  .brand-title {
    font-family: var(--font-display);
    font-weight: 200;
    letter-spacing: 0.06em;
  }

  /* ── Tap behaviour, declared once for everything interactive ─────────
     `touch-action: manipulation` drops the 300ms double-tap-zoom delay.
     `-webkit-tap-highlight-color` drops the grey rectangle iOS paints over a
     tapped element. Neither is optional on a touch device and neither is
     inherited.

     This was per-component and it failed as a rule that has to be remembered.
     `button.css` had it, `nav.css` re-declared it for the hamburger with a
     comment explaining that the hamburger is not a `.btn`, `toast.css` had its
     own copy, and this run found it missing again from the auth plan tiles,
     the auth panel link, the drawer's close button, the kit's tab strip and
     the kit's menu items: five more, every one of them for the same reason.
     The process log's rule for this shape is to fix the structure rather than
     document it, so it is one rule here and the per-component copies are gone.

     `:where()` so it carries zero specificity: anything that genuinely needs a
     different `touch-action` (a drag surface, a canvas) overrides it with an
     ordinary selector and does not have to fight this.

     Deliberately not `[tabindex]`, `[contenteditable]` or `*`: the editor's
     writing surface must keep every gesture the browser gives it, and a blanket
     rule is how that would quietly be taken away. */
  :where(
    a[href], button, label, summary,
    input, select, textarea,
    [role="button"], [role="tab"], [role="menuitem"],
    [role="option"], [role="radio"], [role="checkbox"], [role="switch"]
  ) {
    touch-action: manipulation;
    -webkit-tap-highlight-color: transparent;
  }

  /* Any compared number renders in tabular figures. */
  .tabular { font-variant-numeric: tabular-nums; }

  /* Skip link: first focusable element in the shell. */
  .skip-link {
    position: absolute;
    inset-inline-start: var(--space-4);
    inset-block-start: calc(var(--space-4) * -4);
    z-index: var(--z-overlay);
    padding: var(--space-2) var(--space-4);
    background: var(--surface-3);
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    transition: inset-block-start var(--dur-2) var(--ease-out);
  }
  .skip-link:focus-visible { inset-block-start: var(--space-4); }

  /* Screen-reader-only text. */
  .visually-hidden:not(:focus):not(:active) {
    clip-path: inset(50%);
    height: 1px; width: 1px;
    overflow: hidden;
    position: absolute;
    white-space: nowrap;
  }

  /* ── Skin mount layers ──────────────────────────────────────────────
     Two fixed planes a skin may draw into. The app itself sits between
     them: back (0) under `#app` (1), front (50) over it. The front layer
     never receives pointer events.

     `display: contents`, and it has to be. This wrapper was `position:
     fixed`, and a fixed element creates a STACKING CONTEXT whatever its
     z-index - so `--z-skin-front: 50` only ordered the front layer inside
     this box, and the box itself sat at `z-index: auto` under `#app`'s 1.
     The front plane was therefore behind the entire application, and had
     been since it was written: no skin had drawn into it, so nothing ever
     showed that the token was fiction. Grid's scanlines and static are the
     first thing to need it and they rendered only in the gaps between
     cards, which is what gave it away.

     With `display: contents` the wrapper generates no box, both planes
     become children of the root stacking context, and the three z-index
     tokens mean what they say. Each plane already carries its own
     `position: fixed; inset: 0`, so nothing is lost - the same reasoning
     `.nav-host` records a few rules below. Overlays stay at
     `--z-overlay` (300), above both, so dialogs and toasts are never
     drawn through. */
  #skin-layer { display: contents; }

  .skin-layer-back {
    position: fixed;
    inset: 0;
    z-index: var(--z-skin-back);
    overflow: hidden;
    /* Decorative, and therefore never a hit target - the same guarantee
       `.skin-layer-front` has carried three rules below since it was written.
       The back layer lacked it, and the asymmetry was load-bearing in the
       worst way: this is a `position: fixed; inset: 0` plane covering the
       whole viewport, so the app is clickable only while `#app`'s stacking
       context stays above it. If that context is ever lost, every control in
       the product - the nav bar included - paints and hit-tests UNDERNEATH
       the star field. Silently: no visual change, no console error, and a
       reload fixes it, which is what makes it so hard to catch.
       Found by the Monitor session at `elementFromPoint` returning this
       element at the centre of a dead hamburger button. The trigger was never
       pinned across seven bisected candidates, and this makes the trigger not
       matter: a decorative layer that cannot receive a pointer cannot swallow
       one. */
    pointer-events: none;
  }

  .skin-layer-front {
    position: fixed;
    inset: 0;
    z-index: var(--z-skin-front);
    overflow: hidden;
    pointer-events: none;
  }

  #app {
    position: relative;
    z-index: var(--z-app);
    min-height: 100dvh;
  }

  /* The nav bar is mounted into a wrapper div so main.js can prepend it
     without touching #main. That wrapper must generate no box: `position:
     sticky` confines an element to its parent, and a wrapper only as tall as
     the header itself left the nav no room to travel, so it scrolled away.
     With `display: contents` the header becomes a layout child of #app, which
     grows with the page, and it stays pinned. It also keeps its place in flow,
     which is what `calc(100dvh - var(--nav-height))` relies on in
     features/landing and features/story-editor. */
  .nav-host { display: contents; }

  /* While the editor has focus the skin freezes its ambient motion.
     Skins scope their animations under :not([data-writing]). */
  body[data-writing] .skin-layer-back,
  body[data-writing] .skin-layer-front { animation-play-state: paused; }

  /* ── Highlights ───────────────────────────────────────────────────────
     `CSS.highlights` + `Range`, styled by `::highlight()`. The registry and
     these pseudo-elements are GLOBAL to the document, so the rules belong
     here rather than in whichever feature happens to register a range -
     `features/story-editor` and `features/story-reader/review-mode` both
     paint `nv-misspell`, and only one of them loads the editor's stylesheet.

     ── ONE NAME PER PROOFREADER ON SCREEN AT ONCE ─────────────────────
     `CSS.highlights.set(name, ...)` REPLACES everything registered under
     that name, and `delete` removes it outright. Two proofreaders sharing a
     name therefore erase each other. That is not hypothetical: the Story
     Outline lives in the chapter's right rail, beside the manuscript, and
     both check their own text - the outline's first paint (no scenes open,
     so no ranges) wiped every squiggle out of the chapter next to it, and
     switching to the Reviews tab deleted them for good. So the outline
     registers `nv-misspell-outline` / `nv-grammar-outline` and is styled
     identically here. Anything else that grows a second proofreader on the
     same screen needs its own pair of names too.

     ── Why colour and not a squiggle ───────────────────────────────────
     A wavy underline was the obvious drawing and it was the wrong one.
     `::highlight()` accepts only a narrow set of properties, and support for
     `text-decoration` among them is recent: on a Chromium a version or two
     behind, the rule parses, the highlight registers, and NOTHING IS
     PAINTED. The owner reported spellcheck as broken three times against
     exactly that, with a checker that was working perfectly and drawing with
     a property their browser ignored. `color` has been supported since the
     API shipped. So a misspelled word changes colour (owner, 2026-09-11) -
     visible everywhere, and never silently absent. */
  ::highlight(nv-misspell) { color: var(--misspell); }

  /* Grammar keeps its underline for the browsers that can draw one, but the
     colour is what carries the meaning, for the reason above. */
  ::highlight(nv-grammar) {
    color: var(--info);
    text-decoration-line: underline;
    text-decoration-style: wavy;
    text-decoration-color: var(--info);
    text-decoration-thickness: 1px;
    text-underline-offset: 3px;
  }

  /* The Story Outline's own pair, styled identically. SEPARATE RULES rather
     than a selector list on the two above, and that is not a style choice:
     `tests/e2e/chapter-modes.spec.js` proves the manuscript's misspelling is
     drawn with `color` by finding the rule whose `selectorText` is exactly
     `::highlight(nv-misspell)` - a guard that exists because a wavy underline
     silently painted nothing on a Chromium a version behind, and the owner
     reported spellcheck as broken three times against it. Folding the outline
     into that selector renames it and the guard stops matching. */
  ::highlight(nv-misspell-outline) { color: var(--misspell); }

  ::highlight(nv-grammar-outline) {
    color: var(--info);
    text-decoration-line: underline;
    text-decoration-style: wavy;
    text-decoration-color: var(--info);
    text-decoration-thickness: 1px;
    text-underline-offset: 3px;
  }

  /* The review rail's "View" marker: where a reviewer's note points. A wash
     rather than an underline, because it marks a PASSAGE rather than a fault
     in a word - and in the accent, because it is the product pointing at
     something rather than telling the writer they got it wrong. */
  ::highlight(nv-review) {
    background-color: color-mix(in oklab, var(--accent) 28%, transparent);
    color: var(--text);
  }

}
