/* bulletlist.org — a Workflowy-style outliner.

   The design has three layers and they are kept apart on purpose:

     TOKENS      colour, elevation, geometry — the only place a literal lives,
                 with PALETTES restating the colour half of it per scheme
     FRAME       the app shell: two bars, a rail, and one scrolling document
     DOCUMENT    the outline itself, which is the only thing that should ever
                 look like content rather than chrome

   The frame is deliberately quiet: no fills, no shadows, one hairline per
   edge. Everything that carries colour — the accent, the halo, the highlight —
   is spent inside the document, because that is where meaning is. */

/* The layers above are FILES, in cascade order. Order is the whole reason this
   is a list of imports rather than a row of <link> tags: a later rule wins, so
   `touch.css` can quietly restate what `frame.css` said, and `print.css` gets
   the last word. Moving a line in this list changes the design.

   `@import` has to come before any rule, which is why this file holds nothing
   else — and it costs no extra request in the build: Vite inlines every one of
   them into the one emitted stylesheet. */
/* TOKENS — colour, elevation, geometry. The only place a literal lives.

   COLOUR IS DECLARED IN PAIRS AND BOUND IN ONE PLACE. Every palette writes
   `--l-<token>` and `--d-<token>` — its light value and its dark one, side by
   side on the same line — and the three binding blocks at the bottom of this
   file are the only thing that decides which half of each pair becomes the live
   `--<token>`. `[data-theme]` is what lets an explicit choice beat the media
   query in both directions (js/settings.ts writes it), exactly as before.

   The indirection buys one thing and it is worth the read: the dark values used
   to be written out TWICE, once media-scoped and once attribute-scoped, under a
   comment asking whoever came next to keep the copies in step. The named
   palettes in css/palettes.css would have made that three copies each, of five
   palettes — so the copies now carry token NAMES rather than colours, which
   means the worst a drift can do is leave a token unbound instead of leaving a
   colour subtly wrong. test/theme.test.ts holds the two dark blocks identical.

   Nothing here selects an element; everything below in the other files reads
   from the live tokens and never from a pair. */

:root {
  /* ---- surfaces ------------------------------------- light | dark ------- */
  --l-bg: #ffffff;              --d-bg: #16181d;
  /* Menus and sheets float ABOVE the page, so they get their own surface —
     equal to --bg in light, deliberately lighter than it in dark, where a
     floating panel painted the page's colour reads as a hole. */
  --l-raised: #ffffff;          --d-raised: #1b1e25;
  /* NOT THE DARKEST INK AVAILABLE, and that is the point. Near-black on white
     is 15:1 — past the range the eye reads comfortably for long stretches, and
     it makes every glyph edge ring. Backing off to ~11:1 keeps body copy well
     clear of AAA (7:1) while letting the page feel like paper rather than a
     terminal. The quiet tier moves with it, but only as far as 4.5:1: notes and
     completed rows are CONTENT set in --text-dim, so AA for small text is the
     floor it is allowed to reach and not a step further.

     The dark halves are the same step down, mirrored: light-on-dark optically
     BLOOMS, so the softening matters more there, not less. */
  --l-text: #353c46;            --d-text: #c8cdd6;
  --l-text-dim: #717783;        --d-text-dim: #838c99;
  --l-dot: #6b7280;             --d-dot: #9aa4b2;
  /* Gutter controls are interactive, not de-emphasised text, so they get their
     own token: --text-dim is tuned for body copy and falls under 3:1 at glyph
     size. 4.0:1 on white here, 7.0:1 on the dark background. */
  --l-gutter-icon: #718096;     --d-gutter-icon: #9aa4b2;
  --l-accent: #4f7cf0;          --d-accent: #6b93f5;
  --l-line: #e6e9ee;            --d-line: #23272f;
  /* STATUS, not identity — which is why these two have no dark half here and
     none in most of the palettes either: a red that means "this failed" has a
     legibility floor to clear before it has a family to belong to, and the
     binding below falls back to the light value wherever a palette hasn't
     offered a better one. --warn is deliberately NOT --danger: nothing has gone
     wrong when an edit is safe in IndexedDB and waiting for a network. */
  --l-danger: #e53e3e;
  --l-warn: #b7791f;

  /* ---- elevation -------------------------------------------------------- */
  --l-shadow-1: 0 1px 2px rgba(16, 24, 40, .05), 0 6px 16px rgba(16, 24, 40, .07);
  --d-shadow-1: 0 1px 2px rgba(0, 0, 0, .3), 0 6px 16px rgba(0, 0, 0, .3);
  --l-shadow-2: 0 8px 20px rgba(16, 24, 40, .08), 0 24px 48px rgba(16, 24, 40, .12);
  --d-shadow-2: 0 8px 20px rgba(0, 0, 0, .32), 0 24px 48px rgba(0, 0, 0, .42);

  /* ---- the tiers that are a step off another one -------------------------
     DERIVED RATHER THAN DECLARED, and so they appear exactly once — no pair, no
     palette. Each of these four is only ever "the same colour, nearly all the
     way back to the surface", which is a ratio and not a choice: the halo is
     the bullet's own grey at a whisper, the soft fill is the accent at a
     whisper, and an indent guide is a step quieter than a border because at
     --line weight the outline reads as a table. The percentages are the ones
     that reproduce the hand-picked values these used to be, in both modes,
     which is why they are these numbers and not round ones — and it is what
     lets a palette in css/palettes.css declare eight colours instead of twelve
     and still hold together. */
  --dot-halo: color-mix(in srgb, var(--dot) 22%, var(--bg));
  --accent-soft: color-mix(in srgb, var(--accent) 12%, var(--bg));
  --guide: color-mix(in srgb, var(--line) 85%, var(--bg));
  --guide-lit: color-mix(in srgb, var(--text-dim) 35%, var(--bg));

  /* ---- geometry --------------------------------------------------------- */
  --bar-h: 57px;
  --docbar-h: 46px;
  --rail-w: 250px;
  --col: 720px;
  --col-pad: 28px;
  --r-sm: 6px;
  --r-md: 8px;
  --r-lg: 14px;
  --r-pill: 999px;
  /* One 28px grid for every icon button in the chrome, so the bars line up
     however many controls a state happens to show. */
  --hit: 28px;

  --mono: ui-monospace, SFMono-Regular, Menlo, monospace;
  /* `system-ui` FIRST, and it is not decoration. The stack used to open with
     `-apple-system` / `BlinkMacSystemFont`, which name the Apple UI face and
     nothing else — on Android both miss, `"Segoe UI"` misses, and the text
     landed on whatever `Roboto` resolved to rather than on the platform's
     actual UI font at its actual optical size. `system-ui` is the one keyword
     every current engine answers with its real interface face (SF on Apple,
     Roboto on Android, Segoe UI Variable on Windows), and those are the faces
     tuned for reading at these sizes. The rest stays as the fallback it always
     was. */
  --sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;

  /* ---- the document's type ------------------------------------------------
     The OUTLINE's scale, kept out of the chrome's: a bullet, its note and the
     zoom title are the only things here that are being read rather than
     operated, and on a phone they are read at arm's length on a smaller
     screen. Naming them means the narrow override at the bottom of this file
     is four lines instead of a hunt through the document rules. */
  --doc-text: 16px;
  --doc-line: 22px;
  --doc-note: 13px;
  --doc-note-line: 19px;
  --doc-title: 27px;
  /* THE WEIGHT IS A TOKEN because it is not the same number on every screen.
     A phone is read at arm's length, usually one-handed, often in the dark
     theme — and light-on-dark optically THINS a face, which is why the same
     400 that is right on a laptop reads washed out on a handset. The coarse
     block lifts it; nothing else may set a weight on body copy. */
  --doc-weight: 400;

  /* ---- motion ------------------------------------------------------------
     Three speeds and two curves, and nothing in the app is allowed a fourth.
     The durations are duplicated in js/motion.ts, which is where the
     script-driven half of the same system lives; keep the two in step.

     FAST is for affordances — a hover, a focus ring, a control lighting up.
     BASE is a panel arriving. MOVE is reserved for the STRUCTURAL changes: a
     zoom, a branch unrolling, a row changing places. Anything that redraws the
     outline gets MOVE, so every one of them takes the same amount of time and
     the app has one tempo rather than a dozen. */
  --dur-fast: 90ms;
  --dur-base: 150ms;
  --dur-move: 200ms;
  /* Decelerate: things arrive and settle. The default for anything appearing. */
  --ease: cubic-bezier(.22, .7, .3, 1);
  /* Accelerate: for things LEAVING, which should pick up speed on the way out.
     A decelerating exit reads as hesitation. */
  --ease-exit: cubic-bezier(.5, 0, .85, .3);
}

/* ---- the binding -------------------------------------------------------
   Which half of every pair is live. THREE BLOCKS AND NOTHING ELSE MAY DO THIS:
   the light one is the default, and the system decides between them — unless
   data-theme says otherwise, in which case the choice wins in both directions.

   All three bind on `:root` alone, deliberately: a palette in css/palettes.css
   sets pairs and never a live token, so it can be one flat block per palette
   with no mode in its selector and nothing to outrank here.

   The dark body appears twice (media-scoped and attribute-scoped) because CSS
   has no way to write one rule for both. They are token NAMES, so keep the two
   identical — test/theme.test.ts fails if they diverge by a character. */
:root {
  color-scheme: light;
  --bg: var(--l-bg);
  --raised: var(--l-raised);
  --text: var(--l-text);
  --text-dim: var(--l-text-dim);
  --dot: var(--l-dot);
  --gutter-icon: var(--l-gutter-icon);
  --accent: var(--l-accent);
  --line: var(--l-line);
  --danger: var(--l-danger);
  --warn: var(--l-warn);
  --shadow-1: var(--l-shadow-1);
  --shadow-2: var(--l-shadow-2);
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    color-scheme: dark;
    --bg: var(--d-bg);
    --raised: var(--d-raised);
    --text: var(--d-text);
    --text-dim: var(--d-text-dim);
    --dot: var(--d-dot);
    --gutter-icon: var(--d-gutter-icon);
    --accent: var(--d-accent);
    --line: var(--d-line);
    --danger: var(--d-danger, var(--l-danger));
    --warn: var(--d-warn, var(--l-warn));
    --shadow-1: var(--d-shadow-1);
    --shadow-2: var(--d-shadow-2);
  }
}
:root[data-theme="dark"] {
  color-scheme: dark;
  --bg: var(--d-bg);
  --raised: var(--d-raised);
  --text: var(--d-text);
  --text-dim: var(--d-text-dim);
  --dot: var(--d-dot);
  --gutter-icon: var(--d-gutter-icon);
  --accent: var(--d-accent);
  --line: var(--d-line);
  --danger: var(--d-danger, var(--l-danger));
  --warn: var(--d-warn, var(--l-warn));
  --shadow-1: var(--d-shadow-1);
  --shadow-2: var(--d-shadow-2);
}
/* PALETTES — the same app, in somebody else's colours.

   Eight schemes that people already know from their editor, each of which ships
   a light AND a dark half, because that is the shape of this app's colour: the
   light/dark switch is a MODE and a palette is a FAMILY, and the two are
   independent. Pick Nord and the ⌥ light/dark row still does what it always did;
   it just moves between Snow Storm and Polar Night instead of white and slate.

   Every block declares pairs (`--l-*` / `--d-*`) and never a live token — the
   binding at the bottom of web/css/tokens.css is what chooses a half, so nothing
   here mentions a mode, a media query or data-theme. Eight colours is the whole
   contract; the halo, the soft accent fill and the two indent-guide tiers are
   DERIVED from those eight over in tokens.css, which is what keeps a palette to
   ten lines and keeps the derived tiers coherent with whatever surface they
   land on. --danger and --warn are optional and appear only where the family
   has a red or an amber that is legible at text size; where it doesn't, the
   default palette's carries, because a status colour has a floor to clear
   before it has a family to belong to.

   THE HEXES ARE THE UPSTREAM ONES, unchanged, so that a palette is recognisably
   itself. The only deviations are written as a color-mix() of the family's own
   colours rather than as a new hex, so the reason for the deviation is readable
   in the value: `#6272a4` is Dracula's comment tier and it is 2.5:1 on its own
   background, which is under the floor for note text, so the dim tier here is
   that comment lifted toward the foreground it belongs to.

   The second selector in every block is the PICKER'S DISC (js/ui/ThemePicker.
   svelte): a swatch carries the same data-palette attribute, so it resolves that
   palette's pairs on itself and can paint a preview of a scheme that isn't the
   live one — see .palettes in css/frame.css, which splits each disc diagonally
   and shows both halves at once.

   The blocks are in the picker's own order, warmest to coolest, so the file
   reads in the order the strip does. js/palettes.ts is where that order lives. */

/* ---------- Solarized · Ethan Schoonover, 2011 ---------------------------
   The one that started this genre, and still the only one whose two halves are
   the same sixteen colours: only the four background/ink tiers swap, which is
   why the accent below is a single value used in both modes rather than a pair
   of tuned ones. Ink stays one step apart from the quiet tier by design — this
   is a low-contrast scheme on purpose and it is not our place to invent a third
   tier for it. */
:root[data-palette="solarized"],
.swatch[data-palette="solarized"] {
  --l-bg: #fdf6e3;              --d-bg: #002b36;   /* base3 | base03 */
  --l-raised: #fdf6e3;          --d-raised: #073642;                /* base02 */
  --l-text: #586e75;            --d-text: #93a1a1; /* base01 | base1 */
  --l-text-dim: #657b83;        --d-text-dim: #839496; /* base00 | base0 */
  --l-dot: #657b83;             --d-dot: #839496;
  --l-gutter-icon: #657b83;     --d-gutter-icon: #93a1a1;
  --l-accent: #268bd2;          --d-accent: #268bd2;                /* blue */
  --l-line: #eee8d5;            --d-line: #073642; /* base2 | base02 */
  --l-danger: #dc322f;          --d-danger: #dc322f;                /* red */
  /* Orange in light, yellow in dark: Solarized's yellow is ~3:1 on base3, which
     is fine for a rule and not for the word "Offline". */
  --l-warn: #cb4b16;            --d-warn: #b58900;
}

/* ---------- Gruvbox · Pavel Pertsev, 2012 --------------------------------
   The warm one. Where Solarized went quiet, Gruvbox goes the other way: high
   contrast on a paper that is genuinely yellow, and an orange for an accent
   where every other family here reaches for a blue. The two halves are the
   palette's own "medium" backgrounds, and each mode takes its status colours
   from the matching set — bright on dark, faded on light — which is Gruvbox's
   own rule and the reason its reds and yellows read at both ends. */
:root[data-palette="gruvbox"],
.swatch[data-palette="gruvbox"] {
  --l-bg: #fbf1c7;              --d-bg: #282828;   /* light0 | dark0 */
  --l-raised: #fbf1c7;          --d-raised: #3c3836;                /* dark1 */
  --l-text: #3c3836;            --d-text: #ebdbb2; /* dark2 | light1 */
  --l-text-dim: #665c54;        --d-text-dim: #a89984; /* dark3 | gray */
  --l-dot: #7c6f64;             --d-dot: #a89984;
  --l-gutter-icon: #665c54;     --d-gutter-icon: #bdae93;          /* light3 */
  --l-accent: #af3a03;          --d-accent: #fe8019;   /* orange, faded | bright */
  --l-line: #ebdbb2;            --d-line: #504945; /* light1 | dark2 */
  --l-danger: #9d0006;          --d-danger: #fb4934;                  /* red */
  --l-warn: #b57614;            --d-warn: #fabd2f;                 /* yellow */
}

/* ---------- Everforest · sainnhe, 2021 -----------------------------------
   A green-grey, and the softest thing here: its own docs describe it as tuned
   to be easy on the eyes rather than to be legible at distance, which is why
   both quiet tiers below have to be lifted a step. The dark half is the medium
   background; the light half is the HARD one, because Everforest's medium light
   is base3 to the byte and would have made two of these swatches identical. */
:root[data-palette="everforest"],
.swatch[data-palette="everforest"] {
  --l-bg: #fffbef;              --d-bg: #2d353b;   /* bg0 hard | bg0 medium */
  --l-raised: #fffbef;          --d-raised: #343f44;                  /* bg1 */
  --l-text: #5c6a72;            --d-text: #d3c6aa;                     /* fg */
  /* grey2 is 3.2:1 on the light page and grey1 3.8:1 on the dark one — both
     under the floor for note text, so each is pulled toward its own mode's ink
     rather than swapped for a colour the family does not have. */
  --l-text-dim: color-mix(in srgb, #829181 40%, #5c6a72);
                                --d-text-dim: #9da9a0;              /* grey2 */
  --l-dot: #829181;             --d-dot: #859289;   /* grey2 | grey1 */
  --l-gutter-icon: #708089;     --d-gutter-icon: #9da9a0;
  --l-accent: #8da101;          --d-accent: #a7c080;                /* green */
  --l-line: #e4e1cd;            --d-line: #475258; /* bg4 | bg3 */
  --l-danger: #f85552;          --d-danger: #e67e80;                  /* red */
                                --d-warn: #dbbc7f;                 /* yellow */
}

/* ---------- Stone · Tailwind CSS, 2022 -----------------------------------
   THE WARM GREY, and nothing else: Tailwind ships five neutral ramps that differ
   only in what they lean toward, and this is the one that leans brown. It is the
   only family here with no colour of its own to borrow for an accent, so it
   takes the warmest one Tailwind has — orange — which keeps the whole scheme on
   a single side of neutral. Its greys are also the closest thing to Paper in the
   set, which is the point of having it: the same app, one step warmer. */
:root[data-palette="stone"],
.swatch[data-palette="stone"] {
  --l-bg: #fafaf9;              --d-bg: #1c1917;   /* stone-50 | stone-900 */
  --l-raised: #fafaf9;          --d-raised: #292524;              /* stone-800 */
  --l-text: #44403c;            --d-text: #d6d3d1; /* stone-700 | stone-300 */
  --l-text-dim: #78716c;        --d-text-dim: #a8a29e; /* stone-500 | stone-400 */
  --l-dot: #78716c;             --d-dot: #a8a29e;
  --l-gutter-icon: #57534e;     --d-gutter-icon: #a8a29e;         /* stone-600 */
  --l-accent: #c2410c;          --d-accent: #fb923c; /* orange-700 | orange-400 */
  --l-line: #e7e5e4;            --d-line: #44403c; /* stone-200 | stone-700 */
  --l-danger: #b91c1c;          --d-danger: #f87171;   /* red-700 | red-400 */
  /* No amber of its own that isn't the accent's neighbour — the default's
     carries, and stays a hue apart from the orange above. */
}

/* ---------- Dracula · Zeno Rocha & contributors, 2013 --------------------
   Dark first and dark by reputation; the light half is Alucard, the same
   project's own answer to it, which is why this is one family and not two. High
   contrast is Dracula's identity, so its foreground stays as shipped — but
   Alucard's #1f1f1f on #fffbeb is 17:1, past the range the eye reads
   comfortably for long stretches, so the light ink is pulled back toward the
   page (see the ink note in tokens.css). */
:root[data-palette="dracula"],
.swatch[data-palette="dracula"] {
  --l-bg: #fffbeb;              --d-bg: #282a36;
  --l-raised: #fffbeb;          --d-raised: #44475a;         /* current line */
  --l-text: color-mix(in srgb, #1f1f1f 86%, #fffbeb);
                                --d-text: #f8f8f2;             /* foreground */
  --l-text-dim: #6c664b;        --d-text-dim: color-mix(in srgb, #6272a4 55%, #f8f8f2);
  --l-dot: #6c664b;             --d-dot: color-mix(in srgb, #6272a4 65%, #f8f8f2);
  --l-gutter-icon: #6c664b;     --d-gutter-icon: color-mix(in srgb, #6272a4 40%, #f8f8f2);
  --l-accent: #644ac9;          --d-accent: #bd93f9;               /* purple */
  --l-line: color-mix(in srgb, #6c664b 20%, #fffbeb);
                                --d-line: color-mix(in srgb, #44475a 75%, #282a36);
  --l-danger: #cb3a2a;          --d-danger: #ff5555;                  /* red */
  --l-warn: #846e15;            --d-warn: #f1fa8c;                 /* yellow */
}

/* ---------- Catppuccin · Catppuccin org, 2021 ----------------------------
   Latte and Mocha, the two ends of the four-flavour set — and the only family
   here that ships its ink in named tiers (text, subtext0, overlay2), which map
   onto this app's three almost exactly. Latte's yellow is a pastel and stays
   out of the status slot for the same reason nord13 does. */
:root[data-palette="catppuccin"],
.swatch[data-palette="catppuccin"] {
  --l-bg: #eff1f5;              --d-bg: #1e1e2e;   /* base | base */
  --l-raised: #eff1f5;          --d-raised: #313244;              /* surface0 */
  --l-text: #4c4f69;            --d-text: #cdd6f4;                    /* text */
  --l-text-dim: #6c6f85;        --d-text-dim: #a6adc8;            /* subtext0 */
  --l-dot: #7c7f93;             --d-dot: #9399b2;                 /* overlay2 */
  --l-gutter-icon: #6c6f85;     --d-gutter-icon: #a6adc8;
  --l-accent: #1e66f5;          --d-accent: #89b4fa;                  /* blue */
  --l-line: #ccd0da;            --d-line: #45475a; /* surface0 | surface1 */
  --l-danger: #d20f39;          --d-danger: #f38ba8;                   /* red */
                                --d-warn: #f9e2af;                  /* yellow */
}

/* ---------- Nord · Arctic Ice Studio, 2016 -------------------------------
   Polar Night below, Snow Storm above — the palette names its own two halves,
   which is rarer than it sounds. Nord's UI tier (nord3) sits at 1.8:1 on nord0,
   so the dark quiet tier is that tier lifted toward nord4 rather than dropped
   in as-is; the light half needs no such help. */
:root[data-palette="nord"],
.swatch[data-palette="nord"] {
  --l-bg: #eceff4;              --d-bg: #2e3440;   /* nord6 | nord0 */
  --l-raised: #eceff4;          --d-raised: #3b4252;                /* nord1 */
  --l-text: #2e3440;            --d-text: #d8dee9; /* nord0 | nord4 */
  --l-text-dim: #4c566a;        --d-text-dim: color-mix(in srgb, #d8dee9 60%, #4c566a);
  --l-dot: #4c566a;             --d-dot: color-mix(in srgb, #d8dee9 55%, #4c566a);
  --l-gutter-icon: #4c566a;     --d-gutter-icon: #d8dee9;
  --l-accent: #5e81ac;          --d-accent: #88c0d0; /* nord10 | nord8 */
  --l-line: #d8dee9;            --d-line: #434c5e; /* nord4 | nord2 */
  --l-danger: #bf616a;          --d-danger: #bf616a;                /* nord11 */
  /* nord13 is a pale sand — legible on Polar Night, invisible on Snow Storm, so
     the light half keeps the default amber. */
                                --d-warn: #ebcb8b;                  /* nord13 */
}

/* ---------- GitHub · Primer, 2018 ----------------------------------------
   The neutral, and the one most people have already read a million words in.
   Its dark half is DARK DIMMED rather than the default dark: #0d1117 is very
   nearly black, and what was asked for here is a grey. Dimmed is the same
   palette with the contrast taken off — a soft blue-grey that sits between
   Nord's slate and Paper's near-black, and the one dark surface in this set
   that reads as a page rather than as a void. */
:root[data-palette="github"],
.swatch[data-palette="github"] {
  --l-bg: #ffffff;              --d-bg: #22272e;   /* canvas default */
  --l-raised: #ffffff;          --d-raised: #2d333b;         /* canvas inset */
  /* Primer's fg is #1f2328 — 16:1 on white, past the range the eye reads
     comfortably, so it is pulled back toward the page (see tokens.css). */
  --l-text: color-mix(in srgb, #1f2328 88%, #ffffff);
                                --d-text: #adbac7;
  --l-text-dim: #656d76;        --d-text-dim: #909dab;         /* fg muted */
  --l-dot: #656d76;             --d-dot: #768390;
  --l-gutter-icon: #656d76;     --d-gutter-icon: #909dab;
  --l-accent: #0969da;          --d-accent: #539bf5;            /* accent fg */
  --l-line: #d0d7de;            --d-line: #444c56;              /* border */
  --l-danger: #cf222e;          --d-danger: #e5534b;            /* danger fg */
  --l-warn: #9a6700;            --d-warn: #c69026;           /* attention fg */
}
/* The handful of rules that are about the DOCUMENT rather than about this app:
   the box model, the page's own type and colour, and what focus looks like on
   every control in the app. */

* { box-sizing: border-box; }

html, body {
  margin: 0;
  padding: 0;
  height: 100%;
  background: var(--bg);
  color: var(--text);
  font-family: var(--sans);
  font-size: 16px;
  /* NO `-webkit-font-smoothing: antialiased`, AND THAT IS THE WHOLE POINT.
     Forcing grayscale AA is the single change that made this app's body copy
     read thinner and softer than every other outliner on the same machine: it
     throws away the subpixel coverage the platform rasteriser computes for
     LCD geometry, so each stem loses roughly a third of its apparent weight
     and every edge goes fuzzy. The face is right, the weight token is right —
     the rasteriser was being overruled. Left at `auto`, 400 on white looks
     like 400 on white.
     `-moz-osx-font-smoothing` is gone with it; it exists only to keep Gecko in
     step with the WebKit hint, so with nothing to match there is nothing to
     say. Same for `text-rendering: optimizeLegibility`, which buys ligatures
     and kerning the UI face already has and, in Blink, quietly changes metrics
     mid-paragraph. */
  /* Mobile Safari inflates the text of anything it decides is a column,
     which on a phone silently overrides every size in this file. */
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
}

::selection { background: color-mix(in srgb, var(--accent) 26%, transparent); }

/* Every focusable thing in the app is a control, and every control gets the
   SAME focus ring — stated once here so no component has to remember. */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
  border-radius: var(--r-sm);
}
[contenteditable]:focus-visible { outline: none; }
/* …except where the control ALREADY says it has focus by lighting its own
   border. Two rings around one field is not twice as clear. */
.jump-head input:focus-visible,
.auth-card input:focus-visible,
.link-editor input:focus-visible { outline: none; }

/* Icons inherit their button's colour, and never pick up baseline gaps. */
.icon { display: block; flex: none; }
/* FRAME — the app shell: two bars, a rail, and one scrolling document.

   Deliberately quiet: no fills, no shadows, one hairline per edge. Everything
   that carries colour is spent inside the document, because that is where
   meaning is. Includes the surfaces that belong to the frame rather than to the
   outline — the search field, the account and ⋯ menus, and the sidebar. */

/* #app is the window. Everything inside it is sized off that one height, and
   the only thing that scrolls is the document — see js/boot.ts. */
#app {
  position: relative;
  height: 100vh;
  height: 100dvh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

#app-body {
  flex: 1;
  min-height: 0;
  display: flex;
}

/* The rail's mount point. It exists so the panel it holds can be told to fill
   the column: `.sidebar` is what boot.ts mounts INTO this, not what it appends
   to #app-body, so without a stretching host the panel would be only as tall as
   its own rows and its footer would float mid-window. */
#sidebar-host {
  flex: none;
  display: flex;
  min-height: 0;
}

#doc {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
}

/* THE scrolling surface. Also the positioning context for the zoom ghost,
   which has to travel with the content it is a copy of. */
#shell-host {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  overflow-x: hidden;
  position: relative;
}

/* Both bars are full-bleed: background and hairline span the window while the
   document stays on its own centred column beneath them. */
.topbar {
  flex: none;
  height: var(--bar-h);
  background: var(--bg);
  border-bottom: 1px solid var(--line);
}
.topbar-inner {
  height: 100%;
  display: flex;
  align-items: center;
  gap: 18px;
  padding: 0 16px;
}
/* Three zones of equal width, so the search field is centred on the WINDOW and
   stays there however long the email or the save label happens to be. */
.bar-zone {
  flex: 1 1 0;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: 6px;
}
.bar-end { justify-content: flex-end; }

.brand {
  font: inherit;
  display: flex;
  align-items: center;
  gap: 11px;
  padding: 4px 8px 4px 6px;
  margin-left: -6px;
  border: none;
  background: none;
  color: var(--text);
  border-radius: var(--r-md);
  cursor: pointer;
}
.brand-dot {
  width: 10px;
  height: 10px;
  flex: none;
  border-radius: 50%;
  background: var(--dot);
  box-shadow: 0 0 0 4px var(--dot-halo);
  transition: transform var(--dur-fast) var(--ease), background var(--dur-fast);
}
.brand-name {
  font-size: 16px;
  font-weight: 700;
  letter-spacing: -0.02em;
}
/* The domain suffix is part of the mark but not the NAME — smaller and
   quieter, so "bulletlist" still reads as the word and ".org" as the address
   it lives at. `em`, not px, so it tracks whatever size the mark is set at:
   the sign-in screen wears the same logo at 22px. */
.brand-tld {
  font-size: .74em;
  font-weight: 600;
  letter-spacing: 0;
  color: var(--text-dim);
}
.brand:hover .brand-dot { transform: scale(1.15); background: var(--accent); }

.docbar {
  flex: none;
  height: var(--docbar-h);
  background: var(--bg);
  border-bottom: 1px solid var(--line);
}
/* COMPLETED ARE SHOWING — say so quietly, and say it in the chrome.
   The rows themselves are struck through, but that only tells you what each
   one is; nothing told you that the LIST is longer than it usually is, and the
   preference persists, so the state was easy to leave on a week ago and then
   blame the outline for feeling cluttered. So the bar's own hairline warms.
   It's a pixel that is already in the layout — nothing moves, nothing new to
   dismiss — and warn rather than accent because this is a mode you are meant
   to leave, not a feature you turned on. It is also the ONLY cue a phone gets:
   the ✓ toggle is not in the touch bar (see the touch section below). */
.docbar.completed-showing { border-bottom-color: color-mix(in srgb, var(--warn) 55%, var(--line)); }
.docbar-inner {
  height: 100%;
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 0 14px;
}
.docbar-end {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: 4px;
  flex: none;
}
/* The bars' only divider: a hairline, never a border on a control. */
.bar-div {
  flex: none;
  width: 1px;
  height: 18px;
  margin: 0 5px;
  background: var(--line);
}

/* ⌂ and the trail are ONE control, not a button next to unrelated text. */
.nav {
  flex: 1 1 auto;
  min-width: 120px;
  display: flex;
  align-items: center;
  gap: 2px;
  overflow: hidden;
}

/* Sidebar, ‹, ›, ⌂, ✓, ? — navigation and view, not bullets, so they all sit
   on one grid rather than being glyphs of assorted sizes. */
.nav-btn {
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--hit);
  height: var(--hit);
  padding: 0;
  border: none;
  background: none;
  color: var(--text-dim);
  border-radius: var(--r-md);
  cursor: pointer;
  transition: color var(--dur-fast), background var(--dur-fast), opacity var(--dur-fast);
}
.nav-btn:hover:not(:disabled) { background: var(--accent-soft); color: var(--accent); }
/* Nothing to go back to isn't an error — it's just quiet and unclickable. */
.nav-btn:disabled { opacity: .3; cursor: default; }
/* A view toggle that is ON says so with the accent, not with a fill that would
   read as "pressed right now". */
.nav-btn.on { color: var(--accent); background: var(--accent-soft); }
/* …except the ✓, which is the control for the mode the hairline is warning
   about. Accent would have said "this is how the outline is meant to look".
   Same warm as the hairline, and it keeps it on hover — hovering it is how you
   turn the mode off, so it must not look like it is about to turn on. */
.nav-btn.completed-toggle.on,
.nav-btn.completed-toggle.on:hover:not(:disabled) {
  color: var(--warn);
  background: color-mix(in srgb, var(--warn) 12%, transparent);
}
/* No `at-root` dimming any more: at the top the slot is not a home button
   holding still, it is the outline browser — see DocBar.svelte. */

.crumbs {
  /* .nav owns the flexing; the trail just has to be allowed to shrink inside
     it rather than being the only thing that gives way. */
  min-width: 0;
  display: flex;
  align-items: center;
  gap: 2px;
  font-size: 15px;
  color: var(--text-dim);
  overflow: hidden;
  white-space: nowrap;
}
.crumbs:empty { display: none; }
/* A crumb is a crumb: the current one clamps exactly like the links do, so a
   long title ends in an ellipsis instead of being hard-clipped by the row. */
.crumbs a,
.crumbs button,
.crumbs .here {
  max-width: 260px;
  overflow: hidden;
  text-overflow: ellipsis;
  padding: 4px 8px;
  border-radius: var(--r-md);
}
.crumbs a,
.crumbs button {
  font: inherit;
  border: none;
  background: none;
  color: var(--text-dim);
  text-decoration: none;
  cursor: pointer;
  display: block;
}
/* A crumb should read as clickable, not as dim text that happens to respond. */
.crumbs a:hover,
.crumbs button:hover { background: var(--accent-soft); color: var(--accent); }
/* The separator was var(--line) — all but invisible. Subordinate, but there. */
.nav .sep {
  color: var(--text-dim);
  opacity: .55;
  user-select: none;
  flex: none;
}
.crumbs .ellip { color: var(--text-dim); user-select: none; cursor: default; }
.crumbs .here { color: var(--text); font-weight: 600; }

/* ---------- search -------------------------------------------------------
   One field for both ways of finding a bullet: type to filter in place, or
   press the key it advertises. It is a pill rather than a box because it is
   the app bar's only control of its kind and shouldn't read as a form — and it
   is a BUTTON now, not a field: the whole of it opens the search panel, so
   there is nothing here to type into and nothing to cancel out of. */
.search {
  font: inherit;
  flex: 0 1 380px;
  min-width: 0;
  height: 32px;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 0 6px 0 12px;
  border: 1px solid var(--line);
  border-radius: var(--r-pill);
  background: none;
  color: var(--text-dim);
  cursor: pointer;
  text-align: left;
  transition: border-color var(--dur-base), background var(--dur-base);
}
.search:hover { border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); }
.search-icon { flex: none; display: flex; }
.search-label {
  font-size: 13.5px;
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* The binding, shown where you'd look for it. */
.search kbd {
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: .04em;
  flex: none;
  padding: 3px 7px;
}

.save-state {
  font-size: 11.5px;
  opacity: .85;
  color: var(--text-dim);
  min-width: 52px;
  text-align: right;
  transition: opacity .3s;
}
.save-state.error { color: var(--danger); }
/* Offline is not an error: the edit IS saved, just to IndexedDB rather than to
   S3. Amber rather than red says "held", not "lost". */
.save-state.offline { color: var(--warn); }

/* Offered, never applied silently — a cached app can otherwise sit on an old
   build forever. Reads as an invitation, so accent rather than alarm. */
.update-chip {
  font: inherit;
  font-size: 11.5px;
  display: flex;
  align-items: center;
  gap: 5px;
  height: var(--hit);
  padding: 0 9px;
  color: var(--accent);
  background: var(--accent-soft);
  border: none;
  border-radius: var(--r-md);
  cursor: pointer;
  white-space: nowrap;
}
.update-chip:hover { filter: brightness(.97); }


/* ---------- menus --------------------------------------------------------
   The ⋯ app menu piggybacks on .user-menu for all dropdown styling (it IS the
   same dropdown); only the trigger differs. */
.user-menu {
  position: relative;
  font-size: 13px;
  flex: none;
}
.user-menu > button {
  font: inherit;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 1px solid var(--line);
  background: none;
  color: var(--text-dim);
  border-radius: var(--r-pill);
  width: var(--hit);
  height: var(--hit);
  cursor: pointer;
  font-weight: 600;
  text-transform: uppercase;
  transition: color var(--dur-fast), border-color var(--dur-fast), background var(--dur-fast);
}
.user-menu > button:hover { color: var(--accent); border-color: var(--accent); }
/* The ⋯ trigger holds an icon, not an initial — no ring around it. */
.app-menu > button {
  border-color: transparent;
  border-radius: var(--r-md);
}
.app-menu > button:hover { border-color: transparent; background: var(--accent-soft); }
.user-menu.open > button { color: var(--accent); background: var(--accent-soft); }
/* The ⋯ wearing an update. Only ever set on touch, where the app bar and its
   Update chip are off screen and this dot is the whole announcement. */
.app-menu > button.offering { position: relative; color: var(--accent); }
.app-menu > button.offering::after {
  content: "";
  position: absolute;
  top: 3px;
  right: 3px;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--accent);
}

.user-menu .dropdown {
  display: none;
  position: absolute;
  right: 0;
  top: calc(var(--hit) + 8px);
  z-index: 40;
  background: var(--raised);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  padding: 6px;
  box-shadow: var(--shadow-2);
  min-width: 232px;
}
.user-menu.open .dropdown { display: block; animation: menu-in var(--dur-fast) var(--ease) both; }
.app-menu .dropdown { min-width: 258px; }
/* IT HAS TO FIT THE SCREEN IT OPENS ON. This menu is thirteen rows and a palette
   strip, and it had no ceiling at all — on a short phone the last rows, Sign out
   among them, simply hung off the bottom of the window with no way to reach
   them. Bounded by what is left below the bar it drops from, and scrolling when
   that is not enough. `dvh` rather than `vh`: the browser's own chrome comes and
   goes on a phone, and `vh` is the LARGEST of those states — the one where this
   overflows. */
.user-menu .dropdown {
  max-height: calc(100dvh - var(--docbar-h) - env(safe-area-inset-top, 0px) - 28px);
  overflow-y: auto;
  overscroll-behavior: contain;
}
@keyframes menu-in {
  from { opacity: 0; transform: translateY(-4px); }
  to { opacity: 1; transform: none; }
}
.user-menu .email {
  padding: 5px 9px 8px;
  color: var(--text-dim);
  font-size: 12.5px;
  overflow: hidden;
  text-overflow: ellipsis;
}
.user-menu .dropdown button {
  font: inherit;
  font-size: 13.5px;
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  text-align: left;
  padding: 7px 9px;
  border: none;
  background: none;
  color: var(--text);
  border-radius: var(--r-md);
  cursor: pointer;
}
.user-menu .dropdown button:hover { background: var(--accent-soft); }
/* The update row reads as the offer it is, not as one more action. */
.user-menu .dropdown button.offer,
.user-menu .dropdown button.offer .mi { color: var(--accent); }
.user-menu .dropdown .mi { flex: none; display: flex; color: var(--gutter-icon); }
.user-menu .dropdown button:hover .mi { color: var(--accent); }
.user-menu .dropdown .ml { flex: 1; min-width: 0; }
.user-menu .dropdown button .keys {
  flex: none;
  font-family: var(--mono);
  color: var(--text-dim);
  font-size: 11.5px;
  letter-spacing: .04em;
}
.menu-rule {
  height: 1px;
  margin: 5px 9px;
  background: var(--line);
}

/* The palette strip USED TO BE A MENU ROW here, and it is in the settings sheet
   now — see the `.palettes` block in css/document.css, which is where the sheets
   are styled. Nothing in this file selects it any more; the menus hold what
   ACTS, and a colour scheme is something you set. */

/* ---------- sidebar ------------------------------------------------------
   A third way to aim the zoom (crumbs go up, ‹ › retrace visits, this shows
   the shape). A column of its own: fixed head and foot, scrolling middle, so a
   deep outline never drags the document beside it. */
.sidebar {
  flex: none;
  width: var(--rail-w);
  display: flex;
  flex-direction: column;
  min-height: 0;
  border-right: 1px solid var(--line);
  font-size: 13.5px;
}
.sb-head { flex: none; display: flex; justify-content: flex-start; padding: 9px 10px 4px; }
.sb-scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 4px 8px 24px; }
.sb-foot {
  flex: none;
  border-top: 1px solid var(--line);
  padding: 9px 14px;
  font-size: 11.5px;
  color: var(--text-dim);
}
.sb-toggle {
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--hit);
  height: var(--hit);
  border: none;
  background: none;
  color: var(--text-dim);
  border-radius: var(--r-md);
  cursor: pointer;
}
.sb-toggle:hover { background: var(--accent-soft); color: var(--accent); }

.sb-section { margin-bottom: 18px; }
.sb-label {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 4px 8px 5px;
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: .06em;
  color: var(--text-dim);
}
/* ---------- THE TREE, WHEREVER IT IS SHOWN ----------
   One row shape for the rail and both sheets over the document (ui/TreeRow.svelte,
   view/browse.ts). It lives here rather than beside either of them because it
   is neither one's: the rail, the outline browser and the destination picker
   draw the same list, and the day they stopped doing so was the day the sheets
   grew a header, a breadcrumb strip and two labelled verbs per row that the
   rail had never needed.

   TWO COLUMNS AND NOTHING ELSE: a twist that opens what is inside, and a name
   that picks the bullet. Every metric is a variable so the HOST can size it —
   the rail is a mouse's list at 13px, the sheets are a finger's at 15 with a
   44px target on every control — without either restating the shape. */
.tree-row {
  display: flex;
  align-items: center;
  gap: 1px;
  /* Depth is an indent, not a nested box — one flat list keeps the panel's
     scrolling simple however deep the tree goes. */
  padding-left: calc(var(--depth, 0) * var(--tree-indent));
  border-radius: var(--r-sm);
  transition: background var(--dur-fast);
}
/* HOVER AND HERE PAINT THE SAME BOX, and it is this one — the whole row,
   twist column included. The hover used to sit on the label, which is the
   flex:1 name and so starts 18px in and stops short of the indent: running the
   mouse down the list made every row appear to change width and shift its left
   edge. One painted element, one geometry, whichever state is showing.

   They also say DIFFERENT things. Hover was --accent-soft on --accent, i.e.
   character-for-character what `here` is, so pointing at any row made it look
   like the row you were already zoomed into. So: hover on an idle row is a
   NEUTRAL lift and leaves the text alone — it means "you could go here" —
   while the accent is reserved for "you are here". */
.tree-row:hover { background: color-mix(in srgb, var(--text) 6%, var(--bg)); }
/* `here` is where the DOCUMENT is; `active` is where the KEYBOARD is, in the
   two panels that have a cursor at all. The same wash: they are the same
   claim — this row is the one in question — and they cannot both be true of
   two different rows in a way that matters, because a keyboard cursor only
   moves once you have started driving. */
.tree-row.here,
.tree-row.active { background: var(--accent-soft); }
/* Hovering the current row brightens its own fill rather than replacing it:
   `here` must never look less selected for being pointed at. */
.tree-row.here:hover { background: color-mix(in srgb, var(--accent) 20%, var(--bg)); }
.tree-row.here .tree-name { color: var(--accent); font-weight: 500; }
.tree-twist {
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--tree-twist);
  height: var(--tree-hit);
  padding: 0;
  border: none;
  background: none;
  color: var(--gutter-icon);
  cursor: pointer;
  border-radius: 5px;
  opacity: .75;
  -webkit-tap-highlight-color: transparent;
}
.tree-twist:hover { color: var(--accent); opacity: 1; }
/* A LEAF KEEPS THE COLUMN AND LOSES THE CONTROL. `visibility`, not `display`:
   the names have to start on one x whether or not there is anything under
   them, or every childless row in the list would jump left. */
.tree-twist.invisible { visibility: hidden; cursor: default; }
.tree-name {
  font: inherit;
  flex: 1;
  min-width: 0;
  text-align: left;
  padding: 3px 6px;
  font-size: var(--tree-size);
  border: none;
  background: none;
  color: var(--text);
  cursor: pointer;
  border-radius: var(--r-sm);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  -webkit-tap-highlight-color: transparent;
}
/* No hover of its own: the ROW is the affordance (see .tree-row:hover above),
   and a second fill on the label would reintroduce the two-geometry problem. */

/* THE RAIL'S OWN SIZE — a mouse's list, which is what lets a deep outline fit
   in a 250px column. */
.sidebar {
  --tree-indent: 13px;
  --tree-twist: 18px;
  --tree-hit: 24px;
  --tree-size: 13.5px;
}

/* A completed bullet looks completed WHEREVER it is shown. All three panels
   offer them once "show completed" is on, and until now they appeared there
   exactly as an open bullet does — so the one place you go to see the shape of
   the outline was the one place that would not tell you which parts of it were
   finished. Same strike, same grey, same rule as the document (see `.item.done`
   above): the fact has one appearance. */
.tree-row.done .tree-name {
  color: var(--text-dim);
  text-decoration: line-through;
}
/* UNDER a completed bullet: greyed, but NOT struck. Same split the document
   draws — the strike says "you finished this one", the grey says "this goes
   with something you finished". A child of a completed parent was never
   completed itself, and un-completing the parent brings it straight back. */
.tree-row.under-done .tree-name { color: var(--text-dim); }
.sb-hint {
  color: var(--text-dim);
  font-size: 12px;
  line-height: 1.55;
  padding: 0 8px;
  margin: 0;
}
/* A narrow window can't afford 250px of furniture beside the outline. */
@media (max-width: 900px) { .sidebar { display: none; } }
/* DOCUMENT — the outline itself, the only thing here that should ever look like
   content rather than chrome.

   This is where the colour is: the accent on a dot, the halo on a zoom, the
   highlight on a selection. It also carries everything that opens OVER a bullet
   — the bullet menu, the link editor, the sheets and the quick finder — because
   each of those is about the row it came from. */

.shell {
  max-width: var(--col);
  margin: 0 auto;
  padding: 20px 28px 40vh;
  /* Popovers are placed in SHELL coordinates, so the shell has to be the thing
     they're positioned against. */
  position: relative;
}

/* Everything a zoom moves, in one box — see Shell.svelte for why the shell
   itself must not be the thing that gets transformed. Deliberately styleless:
   a plain block in the same place its children used to sit, so it is a handle
   for the animation and changes no geometry. */

/* ---------- zoom title ---------- */
.zoom-head {
  display: flex;
  align-items: flex-start;
  /* No gap: the ⋮ is pulled entirely into the gutter (see `.title-handle`), so
     a gap here would be the one thing left pushing the title off the column's
     left edge. */
  margin: 14px 0 2px;
}
.zoom-title {
  flex: 1;
  min-width: 0;
  font-size: var(--doc-title);
  font-weight: 700;
  letter-spacing: -0.022em;
  line-height: 1.25;
  outline: none;
  min-height: 34px;
  word-break: break-word;
}
.zoom-title:empty::before {
  content: "Untitled";
  color: var(--text-dim);
}
/* THE TITLE'S ⋮ SITS IN THE GUTTER, immediately left of the title — the same
   side the rows' grip is already on (ui/RowActions.svelte parks that one 20px
   left of a row's own edge). It used to sit at the END of the title line, on
   the reading that a title has no dot to sit beside and no fixed slot to sit
   in; but a zoomed title and the bullets under it share one left edge, so the
   slot the rows use is the slot the title wants too, and running the eye down
   the gutter now finds every ⋮ in one column instead of one off on its own.

   PULLED OUT BY MARGIN, NOT POSITIONED, so the title keeps the full column to
   wrap in and there is no absolute box to keep in step with a heading whose
   height changes. `margin-top` centres it on the title's FIRST line, whatever
   that line wraps to.

   THE TWO HORIZONTAL MARGINS MUST SUM TO -22px, THE BUTTON'S OWN WIDTH. That
   is the whole rule, and it is easy to break: a flex item's MARGIN box is what
   the row lays out, so -30/0 makes this one 8px of NEGATIVE width and the
   title — `flex: 1` — grows 8px leftward to swallow it. The gap looked
   unchanged because both things had moved, and the title was left outdented
   from the bullets under it. -30 out, 8 back: the box still nets to zero, so
   the title starts on the column's edge exactly as it did.

   AND THE GAP IS 8px, not the 4 that pulling out by 22 alone would leave. 4px
   is what the row grip gets, and on a row it is right — what it stands beside
   is an 8px dot with air all round it. Beside a bold `--doc-title` heading the
   same 4px reads as the glyph touching the word.

   The gutter it hangs in is a mouse's: the `pointer: fine` block below widens
   the shell's padding to 44px to carry the grip and the fold, and a coarse
   pointer draws neither. On touch this button is hover-only and therefore
   never painted, so the 16px of padding a phone keeps costs it nothing. */
.title-handle {
  margin: 8px 8px 0 -30px;
  opacity: 0;
}
.zoom-head:hover > .title-handle { opacity: 1; }
.title-handle:focus-visible { opacity: 1; }

/* What this level holds, counted. The only number on the page. */
.doc-meta {
  font-size: 12px;
  color: var(--text-dim);
  margin-bottom: 14px;
}

/* THE OFFER TO BECOME A DOCUMENT — ui/Shell.svelte, and web/edit/doc-hint.ts
   for why it is here rather than on the row.

   Sized and coloured as the meta line above it, and pulled up under it, so it
   reads as the last thing the page says about itself rather than as a button
   somebody put on the page. It is on EVERY zoomed bullet that is not already a
   document, which is most of them, so it has to survive being ignored — no
   fill, no border, no accent until the pointer is on it. */
.doc-offer {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  margin: -10px 0 14px -6px;
  padding: 3px 8px 3px 6px;
  border: none;
  border-radius: var(--r-sm);
  background: none;
  font: inherit;
  font-size: 12px;
  color: var(--text-dim);
  cursor: pointer;
  transition: color var(--dur-fast), background var(--dur-fast);
}
.doc-offer:hover { color: var(--accent); background: var(--accent-soft); }
.doc-offer kbd {
  font-family: var(--mono);
  font-size: 11px;
  opacity: 0.75;
}

/* "Last edit: 2h ago" — a link to the freshest bullet — and the two counts
   beside it, which fold the page to the number they name. All three are
   dressed as part of the meta line: same size, same dimness, and only the
   hover admits they are a way somewhere. */
.meta-edited,
.meta-fold {
  border: 0;
  padding: 0;
  background: none;
  font: inherit;
  color: inherit;
  cursor: pointer;
}
.meta-edited:hover,
.meta-fold:hover { color: var(--text); text-decoration: underline; }

/* ---------- items ---------- */
.outline { margin-top: 6px; position: relative; }

.item { position: relative; }

/* A SUBTREE BIG ENOUGH TO BE WORTH SKIPPING WHILE IT IS OFF SCREEN.
   js/ui/Item.svelte decides which blocks get this and supplies each one's
   `contain-intrinsic-size` from the number of rows it actually holds — read the
   note there, because a WRONG size here is what turns this from virtualisation
   into a broken scrollbar.

   IT GOES ON `.children`, NEVER ON `.item`. content-visibility implies paint
   containment, and `.toggle` hangs 20px into the gutter OUTSIDE its item's box:
   contained there it is clipped away, and a branch you opened can no longer be
   closed. `.children`'s padding puts that gutter on the inside. */
.children.virtual { content-visibility: auto; }

/* …AND IT STAYS ON THROUGH A DRAG, which it did not used to.
   `.shell.dragging .children.virtual { content-visibility: visible }` lived
   here, so picking a bullet up un-virtualised the whole document: the drag
   measured every row's position at pick-up and needed honest numbers rather
   than intrinsic-size estimates to do it. Three things were wrong with that,
   all measured on the 10,576-bullet export:

     · the pick-up became a full-document layout — 229ms on a laptop with
       everything expanded, seconds on a phone
     · every FRAME of the drag then styled, laid out and painted a document with
       no virtualisation at all — frame stalls of 127ms while moving a bullet
     · and it was not even true: switching the skipping back on moved 1,252 of
       those rows by up to 9px, so the numbers the drag had cached and the
       outline on screen disagreed by that much the whole time

   js/dnd.ts asks the DOM where the FINGER is instead, one `elementFromPoint`
   per move, and never measures a row it is not pointing at. Nothing has to be
   un-skipped for that, so nothing here has to be undone. */

.row {
  position: relative;
  display: flex;
  align-items: flex-start;
  gap: 2px;
  padding: 1px 0;
}

/* ROOM IN THE GUTTER FOR BOTH CONTROLS, and the 102px the old strip reserved
   out on the right is gone entirely.

   The gutter carries `⋮ ⌄ •` on a mouse: the grip, then the fold, then the dot.
   That is 40px, where the shell's own padding was 28 — so the padding grows to
   cover it, and it is paid for many times over by what the right-hand side gave
   back. The document sits 16px further right and its lines are ~80px longer.

   HOW THE OUTER SLOT ESCAPES THE CLIP, which is the whole reason this can be
   two controls deep. `.children` indents 21px and a big branch carries
   `content-visibility: auto` while it is off screen, which brings paint
   containment: anything a DESCENDANT draws outside that 21px is cut away. The
   fold toggle at -20px fits inside it, as it always has. The grip at -40px
   could not — but the grip is not a descendant. It lives in `.outline`
   (ui/RowActions.svelte), one element for the whole document, positioned at a
   measured offset. Nothing on its way up the tree contains anything.

   Mouse only. A coarse pointer has no hover, no grip, and a gutter it cannot
   spare — css/touch.css pins the fold to the row's right edge instead, and the
   note there is the argument. */
@media (pointer: fine) {
  .shell { padding-left: 44px; }
}

/* The collapse toggle sits OUT OF FLOW, in the indent gutter, so the row lines
   up identically whether or not a bullet has children. */
.toggle {
  position: absolute;
  left: -20px;
  top: 1px;
  width: 18px;
  height: var(--doc-line);
  border: none;
  background: none;
  color: var(--gutter-icon);
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  opacity: 0;
  border-radius: var(--r-sm);
  transition: opacity var(--dur-fast), color var(--dur-fast), background var(--dur-fast);
  user-select: none;
}
/* Hover-only in EVERY state, collapsed included. It used to stay lit while a
   branch was folded, on the theory that it was the only sign of hidden content
   — but it isn't: the dot wears a halo whenever there are children out of
   sight (see `.item.collapsed .dot i` below), and that reads at a glance
   without putting a control in the gutter of every folded row. Two persistent
   marks for one fact was one too many, and the noisier of the two went. */
/* TWO WAYS OF SAYING THE SAME THING, and both are load-bearing.
   `:hover` is the pointer literally on the row. `.hovered` is what
   web/input/events/hover.ts puts there, and it covers the two places the
   pointer can be while this row is still the subject: on the ⋮ grip, which
   lives outside the row entirely, and out in the margin beside it. Without the
   class the caret blinked out the moment you reached for the menu. */
.item > .row:hover > .toggle,
.item > .row.hovered > .toggle { opacity: 1; }
.toggle:focus-visible { opacity: 1; }
.toggle:hover { background: var(--accent-soft); color: var(--accent); }

.dot {
  font: inherit;
  border: none;
  background: none;
  padding: 0;
  flex: none;
  width: 20px;
  /* Tied to the line, not a fixed 24px. The row aligns its children to the
     TOP, so a control shorter than the line sits above the text's centre —
     invisible at 22px, and 4.5px out once the phone's type went to 31px. The
     1px is the text's own top padding, so the two centres land on each other
     rather than merely near each other. */
  height: var(--doc-line);
  margin-top: 1px;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  border-radius: 50%;
}
/* Workflowy proportions: a heavier dot, and a broad soft halo when children
   are hidden — the halo is the "there's more here" signal, so it earns size. */
.dot i {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--dot);
  transition: box-shadow var(--dur-fast) var(--ease), transform var(--dur-fast) var(--ease), background var(--dur-fast);
}
.item.collapsed > .row .dot i { box-shadow: 0 0 0 5px var(--dot-halo); }
.dot:hover i { transform: scale(1.3); background: var(--accent); }

.text {
  flex: 1;
  outline: none;
  padding: 1px 4px;
  font-size: var(--doc-text);
  font-weight: var(--doc-weight);
  line-height: var(--doc-line);
  min-height: var(--doc-line);
  /* TRACKING IS THE FACE'S OWN. The negative hair that used to sit here was
     compensating for the forced grayscale AA in css/base.css — thin glyphs do
     read loose — and with the rasteriser left alone the compensation is what
     reads wrong: SF's metrics at 16px are already optically sized, and tighten-
     ing them just crowds the stems back into the lightness we were fixing.
     The chrome and the zoom title keep their own tracking; they are set at UI
     sizes and in bold, where it is a real correction. */
  letter-spacing: normal;
  word-break: break-word;
  white-space: pre-wrap;
  border-radius: 4px;
  /* THE CARET DOES NOT GET TO PIN THE SCROLL. Scroll anchoring treats the
     focused element and the selection's node as PRIORITY candidates — ahead of
     the normal top-of-viewport pick — so while a bullet is being edited the
     browser will adjust `#shell-host`'s scrollTop to hold THAT row still
     whenever it changes position. Which is precisely backwards for ⌘⇧↑: the row
     is the one thing that is supposed to move, and pinning it meant the whole
     document slid a row down the screen on every press, so holding the key
     scrolled the view instead of walking the bullet.
     Excluded here rather than turned off on the scroller, because anchoring
     itself is load-bearing: `.children.virtual` blocks above the fold report
     their ESTIMATE rather than their layout (see `.children.virtual` above),
     so their height changes as you scroll and
     anchoring is what stops that from moving what you are reading. With `.text`
     out of the running the walk simply picks the `.row` or `.item` around it —
     a box that travels with the content, which is the anchor that case wants. */
  overflow-anchor: none;
}
/* ---------- completed ----------
   Two different facts, so two different marks.

   The bullet you completed is STRUCK THROUGH: you did that to it. Everything
   under it is only implicitly gone — it disappears with its parent when
   completed are hidden, and comes back with it — so it is GREYED and not
   struck. Striking the subtree would claim each of those bullets was finished
   on its own, which is exactly what un-completing the parent disproves. At
   full brightness, though, a completed subtree read as still active, which is
   the bug this fixes. */
.item.done > .row > .text {
  color: var(--text-dim);
  text-decoration: line-through;
}
/* Colour only. A descendant that IS completed in its own right still gets the
   strike from the rule above — this never removes one, it only adds the grey. */
.item.done .item > .row > .text { color: var(--text-dim); }

/* ---------- the hold ----------
   A bullet completed while completed are hidden used to vanish on the
   keystroke, which reads as a delete. It is held instead: struck through and
   greyed, its subtree greyed with it, and only then does it leave.

   The timing is `HOLD_MS` in js/completed.ts and the two must stay in step —
   the row is removed from the DOM on that timer, so the fade has to be over by
   then or it disappears mid-way. It holds at full opacity for most of the
   window, so what you mainly see is the strike-through, not a fade.

   LINEAR on purpose, and this one is easy to get wrong: under the app's usual
   ease the curve reaches 68% of the animation about 400ms in, so the hold
   silently became a third of what the number says and the row was all but
   invisible by half time. Linear is what makes these keyframe percentages mean
   wall-clock milliseconds. */
.item.leaving { animation: complete-leave 1100ms linear both; }
@keyframes complete-leave {
  0%, 68% { opacity: 1; transform: none; }
  100% { opacity: 0; transform: translateY(-3px); }
}
/* The hold is information, so it stays; only the drift is motion. */
@media (prefers-reduced-motion: reduce) {
  @keyframes complete-leave {
    0%, 68% { opacity: 1; }
    100% { opacity: 0; }
  }
}

/* ---------- the row's grip ----------
   ONE control for the whole outline (ui/RowActions.svelte), placed on whichever
   row the mouse is over. It used to be four buttons per row, revealed by a
   `:hover` rule — cheap to write, and on a ten-thousand-bullet outline forty
   thousand buttons plus forty thousand icons for the browser to style and lay
   out so that four could be seen.

   `left` and `top` are both set inline, from one rect read per hovered row.
   `left` is new: the group is no longer pinned to the column's right edge but
   parked in the gutter beside the dot, and a row's left edge moves 30px per
   level of nesting. */
.row-actions {
  position: absolute;
  display: flex;
  /* Above the row it sits beside, and — being out in the indent gutter — above
     the guide hairline it may cross on a deeply nested row. */
  z-index: 2;
}
.row-actions > .menu-handle {
  width: 20px;
  height: var(--doc-line);
  margin-top: 1px;
}
.menu-handle:hover { background: var(--accent-soft); color: var(--accent); }

/* The title's ⋮ shares the row handle's shape but not its flow rules. */
.menu-handle {
  display: flex;
  align-items: center;
  justify-content: center;
  flex: none;
  width: 22px;
  height: 24px;
  padding: 0;
  border: none;
  background: none;
  color: var(--gutter-icon);
  border-radius: var(--r-sm);
  cursor: pointer;
  transition: opacity var(--dur-fast), color var(--dur-fast), background var(--dur-fast);
}

/* Workflowy's trailing +: always one click from a fresh bullet at the end.
   THE BUTTON IS THE GLYPH AND NOTHING ELSE — a 24px circle, pulled 2px back so
   the + sits on the dots' centre line. It spent a while as a full-width row so
   the click needed no aim; what that bought was a hit box stretched across the
   outline's foot, which answered clicks meant for the empty page below it with
   a bullet nobody asked for. An added bullet is a click you have to mean. */
.plus-row {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 24px;
  margin: 2px 0 0 -2px;
  padding: 0;
  border: none;
  background: none;
  color: var(--text-dim);
  border-radius: 50%;
  cursor: pointer;
  transition: color var(--dur-fast), background var(--dur-fast);
}
/* The hover lights up the very spot the new bullet appears — which is now the
   same box the click has to land in. */
.plus-row:hover { color: var(--accent); background: var(--accent-soft); }

/* ---------- indent guides ----------
   One hairline per open branch. It lights up under the pointer, which is the
   cheapest way to answer "which parent does this belong to" on a deep level.

   `.children` exists ONLY while a branch is open — there is no `display: none`
   rule any more, because a box that is not laid out cannot be animated, and
   this is the element the branch transition eases the height of (js/motion.ts).
   The guide shrinking with the rows is what makes a closing branch look like it
   rolled up rather than blinked out. */
.children {
  margin-left: 9px;
  padding-left: 21px;
  border-left: 1px solid var(--guide);
  transition: border-color var(--dur-fast);
}
.item:hover > .children { border-left-color: var(--guide-lit); }

.empty-hint {
  display: block;
  border: 0;
  background: none;
  font: inherit;
  text-align: left;
  cursor: pointer;
  color: var(--text-dim);
  font-size: 14px;
  padding: 8px 0 0 6px;
}
.empty-hint:hover { color: var(--text); }

/* ---------- comments ----------
   A bullet's second line (`note`) — on any node, documents included
   (MD-DEC-15). Multi-line content, but AT REST only the FIRST LINE shows,
   with an ellipsis whenever there is more: line-clamp rather than nowrap,
   because nowrap MERGES the lines before clipping — three short lines read
   as one long one and earned no ellipsis at all. While the caret is in it,
   it opens to its real height — you cannot edit what you cannot see — and
   there is no highlight behind it: the caret is the signal. */
/* THE GAP UNDER A COMMENT IS PADDING, NOT MARGIN, and that is load-bearing.
   The comment is the LAST child of its `.item` whenever the bullet has no open
   children, so a bottom MARGIN collapses straight out through the item and sits
   between the two — outside the box the selection band paints. A picked bullet
   that carried a comment ended its band 3px early and left a slit above the
   next one. Same 4px below the text either way (it was 1px padding + 3px
   margin); this way the item's own box contains it. */
.note {
  margin: -1px 0 0 26px;
  padding: 1px 4px 4px;
  outline: none;
  font-size: var(--doc-note);
  line-height: var(--doc-note-line);
  color: var(--text-dim);
  white-space: pre-wrap;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 1;
  line-clamp: 1;
  overflow: hidden;
  word-break: break-word;
  border-radius: 4px;
}
.note:focus {
  display: block;
  -webkit-line-clamp: unset;
  line-clamp: unset;
  overflow: visible;
}

/* ---------- the one-time nudge ----------
   Shown once, ever, under a comment that has outgrown being one — see
   web/edit/doc-hint.ts for the rule and for why there is exactly one showing.

   IN THE FLOW, not over it. A popover would cover the words that earned the
   offer, on the one bullet you are certainly reading. Indented to the comment's
   own 26px so it belongs to that bullet and not to the row below it, and set at
   note size in the dim colour so it is an aside about an aside — the accent is
   spent on the one thing here that does something. */
.doc-hint {
  display: flex;
  align-items: center;
  gap: 6px;
  /* AS WIDE AS WHAT IT SAYS, not as wide as the bullet. A full-bleed band
     stretched the dismiss × out to the far edge of the column and read as a
     banner across the document rather than as an aside under one comment. */
  width: fit-content;
  max-width: 100%;
  margin: 2px 0 4px 26px;
  padding: 3px 6px;
  font-size: var(--doc-note);
  line-height: var(--doc-note-line);
  color: var(--text-dim);
  border-radius: var(--r-sm);
  background: var(--accent-soft);
}
.doc-hint > span { min-width: 0; }
.doc-hint-take {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  flex: none;
  border: none;
  background: none;
  font: inherit;
  padding: 0;
  color: var(--accent);
  cursor: pointer;
}
.doc-hint-take:hover { text-decoration: underline; }
.doc-hint-take kbd {
  font-family: var(--mono);
  font-size: 11px;
  color: var(--text-dim);
}
/* Last, small, and no accent: dismissing is the outcome we are not selling. */
.doc-hint-x {
  display: flex;
  align-items: center;
  justify-content: center;
  flex: none;
  margin-left: 2px;
  width: 18px;
  height: 18px;
  padding: 0;
  border: none;
  background: none;
  color: var(--gutter-icon);
  border-radius: var(--r-sm);
  cursor: pointer;
}
.doc-hint-x:hover { color: var(--text); }

/* ---------- links ----------
   Rendered from plain text, so the stored string never changes. */
.text a.link,
.note a.link {
  color: var(--accent);
  text-decoration: underline;
  text-underline-offset: 2px;
  cursor: pointer;
}
.text a.link:hover, .note a.link:hover { text-decoration-thickness: 2px; }

/* ---------- multi-select ----------
   A filled band, so it never reads as the caret's own text selection — and
   THE BAND IS THE BLOCK, NOT THE ROW.

   Picking a bullet picks its whole subtree (outline/selection.ts), so the
   highlight belongs to the top-most pick and covers everything under it: one
   box, on that bullet's `.item`. Painted per `.row` it stepped in by the 30px
   indent at every level, and a selected branch came out as a staircase — a
   notch at each turn, with the indent gutters cutting unpainted channels
   through the middle of what is supposed to be one block. Workflowy draws the
   rectangle; this is the same reading of the gesture.

   Nothing else has to change for a run of picks to meet: sibling `.item` boxes
   touch, so adjacent blocks still make ONE band, and `--accent-soft` is opaque
   (mixed with the page, not with transparent) so nested paint could never
   double up anyway. Square on purpose, for the same reason as before: rounding
   every box reads as a stack of pinched pills.

   The comment needs no rule of its own now — it lives inside the `.item` whose
   background this is, and is covered where it stands. */
.item.sel-block {
  background: var(--accent-soft);
}
/* What a keyboard move just relocated. Same band as a selection — the two mean
   "these bullets" and should look alike — but a separate class, because this
   is transient feedback and NOT something the next keystroke acts on. */
.item.move-block {
  background: var(--accent-soft);
}
@media (prefers-reduced-motion: no-preference) {
  .item.move-block { transition: background var(--dur-fast) ease-out; }
}
.row.selected .text::selection,
.row.selected .text *::selection { background: transparent; }

/* While a drag owns the selection, hide the browser's own text highlight so
   you never see both at once. */
.shell.selecting-rows .text,
.shell.selecting-rows .note,
.shell.selecting-rows .zoom-title { user-select: none; }

/* The rubber band is deliberately not drawn — the row highlight is the only
   feedback for the gesture. The rect lives on in JS as hit-testing geometry. */

/* ---------- drag & drop ----------
   The indicator is a LINE placed at the chosen gap, indented to the chosen
   depth — its indentation is the only thing that tells you where it will land. */
.dot[draggable="true"] { cursor: grab; }
.drop-line {
  position: absolute;
  /* The line is placed from the TOP by a transform (js/dnd.ts explains why a
     transform and not `top`), so it needs an origin to be transformed from —
     without this it sits at its static position and every offset is measured
     from wherever in the flow it happened to land. */
  top: 0;
  right: 0;
  height: 2px;
  margin-top: -1px;
  background: var(--accent);
  border-radius: 2px;
  pointer-events: none;
  z-index: 5;
}
.drop-line::before {
  content: "";
  position: absolute;
  left: -4px;
  top: -3px;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--accent);
}

/* ---------- hold to drag ----------
   THE TEXT SELECTION THE PRESS MUST NOT MAKE. js/touch/press.ts puts
   `.pressing` on the shell at pointerdown and takes it off at pointerup, so the
   OS never gets to start a word-select underneath a gesture that is about to
   become a drag. A tap is unaffected: the caret is placed on the CLICK, by which
   time the class is gone.

   THIS RULE ONLY BITES BECAUSE press.ts ALSO TAKES `contenteditable` OFF the
   pressed row. `user-select` does not apply to an editing host — a
   contenteditable element is selectable whatever is declared here — so on its
   own this reaches everything except the bullet text it was written for. The two
   halves are one fix; neither works alone, and js/touch/press.ts opens with the
   same warning from the other side.

   `-webkit-` prefixes and `-webkit-touch-callout` are the load-bearing half on
   iOS, in Safari and in the WKWebView the native shell runs in: the callout is
   what puts the magnifier and the Copy / Look Up bar over a press. */
.shell.pressing,
.shell.pressing .text,
.shell.pressing .note,
.shell.pressing .zoom-title {
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}
/* While a row is actually travelling, nothing in the outline is selectable and
   nothing responds to a hover it cannot have. */
.shell.dragging,
.shell.dragging .text,
.shell.dragging .note,
.shell.dragging .zoom-title {
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}
/* Belt and braces with the touchmove handler in js/touch/index.ts: once a drag
   is in flight the outline owns every direction, not just the horizontal one it
   gets from `pan-y`. */
.shell.dragging .item > .row { touch-action: none; }
/* The block being carried. Lifted rather than hidden, so you can see WHAT is
   moving as well as where it will land — the drop line says where.

   On the block and not on `.row`, now that the band is the block's: fading the
   rows alone left the highlight behind them at full strength, and the thing
   being carried looked like it had come apart. `.sel-block` is only ever on the
   TOP-most pick, so a nested pick cannot fade a second time. */
.shell.dragging .item.sel-block {
  opacity: 0.5;
  transform: scale(0.995);
}
/* The line is the target, so during a touch drag it earns a little more
   presence than it needs under a mouse cursor that is already pointing at it. */
.shell.dragging .drop-line { height: 3px; }

/* ---------- search panel bits shared with the finder ---------- */
.no-hits {
  color: var(--text-dim);
  font-size: 14px;
  padding: 18px 4px;
}
/* WHY THIS ROW IS ON THE LIST, said the same way in both panels. It matters
   more in the tree than in the palette: a narrowed tree lists the branches a hit
   LIVES in as well as the hit, and the lit text is what separates the two. */
.jump-hit mark,
.tree-name mark {
  background: color-mix(in srgb, var(--accent) 28%, transparent);
  color: inherit;
  border-radius: 3px;
  padding: 0 1px;
}

/* ---------- bullet menu ---------- */
.bullet-menu {
  position: absolute;
  z-index: 60;
  min-width: 196px;
  padding: 5px;
  background: var(--raised);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-2);
  animation: menu-in var(--dur-fast) var(--ease) both;
  /* IT HAS TO FIT THE SCREEN IT OPENS ON. Placed at a bullet, which can be
     anywhere, so it takes a share of the window rather than a subtraction from a
     known edge — and scrolls when a long action list will not fit a short phone.
     `dvh`, not `vh`: the browser's chrome comes and goes, and `vh` is the
     largest of those states, which is the one this overflows in. */
  max-height: 72dvh;
  overflow-y: auto;
  overscroll-behavior: contain;
}
.bullet-menu .menu-scope {
  font-size: 11.5px;
  color: var(--text-dim);
  padding: 4px 8px 6px;
  border-bottom: 1px solid var(--line);
  margin-bottom: 4px;
}
.bullet-menu button {
  font: inherit;
  font-size: 13.5px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 18px;
  width: 100%;
  padding: 6px 8px;
  border: none;
  background: none;
  color: var(--text);
  border-radius: var(--r-sm);
  cursor: pointer;
  text-align: left;
}
.bullet-menu button:hover { background: var(--accent-soft); }

/* SECTIONS, because this menu is the whole action set now rather than the
   overflow behind a row that already carried the common three. Thirteen rows in
   one undivided column is a list you read; four groups is one you aim at.

   THE RULE IS THE SECTION'S, not a row's. Drawn as the top border of every
   group but the first, so a group can disappear — "Mark" does, on a bullet with
   no link and… well, see BulletMenu.svelte — without leaving a hairline over
   nothing behind it. */
.bullet-menu .menu-sect + .menu-sect {
  margin-top: 5px;
  padding-top: 5px;
  border-top: 1px solid var(--line);
}
/* The rail's heading, at the rail's size — see `.sb-label` in css/frame.css.
   The app has exactly one way of writing "this is a group of things" and this
   is it; a second one would be a second design. */
.bullet-menu .menu-head {
  padding: 3px 8px 4px;
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: .06em;
  color: var(--text-dim);
}
.bullet-menu kbd {
  font-family: var(--mono);
  font-size: 11px;
  color: var(--text-dim);
}

/* ---------- link editor ---------- */
.link-editor {
  position: absolute;
  z-index: 61;
  width: 300px;
  max-width: calc(100vw - 32px);
  padding: 8px;
  background: var(--raised);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-2);
}
.link-editor input {
  font: inherit;
  font-size: 13.5px;
  width: 100%;
  padding: 7px 9px;
  color: var(--text);
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  outline: none;
}
.link-editor input:focus { border-color: var(--accent); }
.link-actions {
  display: flex;
  justify-content: flex-end;
  gap: 6px;
  margin-top: 8px;
}
.link-actions button {
  font: inherit;
  font-size: 13px;
  padding: 5px 11px;
  color: var(--text);
  background: none;
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  cursor: pointer;
}
.link-actions button:hover { border-color: var(--accent); color: var(--accent); }
.link-actions button.primary {
  color: #fff;
  background: var(--accent);
  border-color: var(--accent);
}
.link-actions button.primary:hover { filter: brightness(1.07); color: #fff; }

/* ---------- sheets (shortcuts panel, command palette) ---------- */
.sheet-backdrop {
  position: fixed;
  inset: 0;
  z-index: 80;
  background: rgba(0, 0, 0, .3);
  animation: fade-in var(--dur-fast) ease-out both;
}
@keyframes fade-in { from { opacity: 0 } to { opacity: 1 } }
.sheet {
  position: fixed;
  z-index: 81;
  left: 50%;
  top: 12vh;
  transform: translateX(-50%);
  width: min(560px, calc(100vw - 32px));
  max-height: 70vh;
  display: flex;
  flex-direction: column;
  background: var(--raised);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-2);
  overflow: hidden;
  animation: sheet-in var(--dur-base) var(--ease) both;
}
@keyframes sheet-in {
  from { opacity: 0; transform: translate(-50%, -6px); }
  to { opacity: 1; transform: translate(-50%, 0); }
}
.sheet-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 20px 18px 16px;
  border-bottom: 1px solid var(--line);
}
.sheet-head h2 { font-size: 15px; margin: 0; }
.sheet-close {
  font: inherit;
  border: none;
  background: none;
  color: var(--text-dim);
  cursor: pointer;
  border-radius: var(--r-sm);
  padding: 3px 7px;
}
.sheet-close:hover { background: var(--accent-soft); color: var(--accent); }
.sheet-body { overflow-y: auto; padding: 6px 18px 18px; }

/* ---- a QUESTION rather than a panel (ui/DiscardSheet.svelte). The same frame
   as the sheets above — one dialog shape in the app — but narrower and shorter
   than any of them, because it holds three lines and two buttons and a 560px
   box around that reads as a page rather than a question. It sits higher up the
   screen for the same reason: a short box at 12vh floats in the middle of
   nowhere, while 22vh puts it where the eye already is. */
.sheet.confirm {
  width: min(400px, calc(100vw - 32px));
  top: 22vh;
}
/* Scrolls only where it has to: the touch rules cap every sheet's height
   against the viewport, and a phone in landscape is the one place three lines
   and two buttons can still be taller than what is left. */
.confirm-body { padding: 18px; overflow-y: auto; }
/* Tighter than the settings sheet's, where the same class sits under a heading
   with a rule above it; here the title IS the first line. */
.sheet.confirm .danger-title { font-size: 15px; }
.sheet.confirm .danger-note:first-of-type { margin-top: 10px; }
/* The one glyph in the prose — ⌘Z, named because the sentence promises it.
   The sheet's own kbd shape, sized down to sit inside a line of text. */
.sheet.confirm kbd {
  font-family: var(--mono);
  font-size: 11.5px;
  color: var(--text-dim);
  background: var(--accent-soft);
  border-radius: 5px;
  padding: 1px 5px;
  white-space: nowrap;
}
.sheet-body section { margin-top: 14px; }
.sheet-body h3 {
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: .06em;
  color: var(--text-dim);
  margin: 0 0 6px;
}
.sheet-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 20px;
  padding: 5px 0;
  font-size: 14px;
}
.sheet-row kbd {
  font-family: var(--mono);
  font-size: 12px;
  color: var(--text-dim);
  background: var(--accent-soft);
  border-radius: 5px;
  padding: 2px 7px;
  white-space: nowrap;
}
/* ---------- the docs sheet ----------
   The documentation, inside the app (ui/DocsSheet.svelte). Wider and taller
   than the other sheets because it is a document rather than a list, and the
   contents column needs a column beside it to sit in. The prose rules are the
   ones css/page.css and css/landing.css state for the same markup — a third
   frame for one text. */
.sheet.docs-sheet {
  width: min(920px, calc(100vw - 32px));
  top: 8vh;
  max-height: 84vh;
}
.sheet-head-acts {
  display: flex;
  align-items: center;
  gap: 14px;
}
.sheet-link {
  font-size: 12px;
  color: var(--text-dim);
  text-decoration: none;
}
.sheet-link:hover { color: var(--accent); }

.docs-sheet .sheet-body { padding: 20px 22px 28px; }
.docs-sheet .docs {
  display: grid;
  grid-template-columns: 12rem minmax(0, 1fr);
  gap: 2.5rem;
  font-size: 14.5px;
  line-height: 1.65;
}
.docs-sheet .docs-toc {
  position: sticky;
  top: 0;
  align-self: start;
  font-size: 13px;
}
.docs-sheet .docs-toc b {
  color: var(--text-dim);
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: .06em;
}
.docs-sheet .docs-toc ol { list-style: none; margin: 8px 0 0; padding: 0; }
.docs-sheet .docs-toc a {
  display: block;
  padding: 3px 0 3px 10px;
  border-left: 2px solid var(--line);
  color: var(--text-dim);
  text-decoration: none;
}
.docs-sheet .docs-toc a:hover { color: var(--text); }
.docs-sheet .docs-toc a[aria-current="true"] {
  color: var(--text);
  border-left-color: var(--accent);
}
.docs-sheet .docs-body > *:first-child { margin-top: 0; }
.docs-sheet .docs-body h1 { font-size: 20px; margin: 0 0 4px; }
.docs-sheet .docs-body h2 { font-size: 15px; margin: 26px 0 6px; scroll-margin-top: 8px; }
.docs-sheet .docs-body .updated { color: var(--text-dim); margin: 0 0 20px; }
.docs-sheet .docs-body ul { padding-left: 20px; }
.docs-sheet .docs-body a { color: var(--accent); }
.docs-sheet .keys { overflow-x: auto; }
.docs-sheet .keys table { border-collapse: collapse; width: 100%; font-size: 13.5px; }
.docs-sheet .keys th {
  text-align: left;
  font-weight: 600;
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: .06em;
  color: var(--text-dim);
}
.docs-sheet .keys th,
.docs-sheet .keys td {
  padding: 4px 12px 4px 0;
  border-bottom: 1px solid var(--line);
  white-space: nowrap;
}
.docs-sheet .docs-body kbd {
  font-family: var(--mono);
  font-size: 12px;
  color: var(--text-dim);
  background: var(--accent-soft);
  border-radius: 5px;
  padding: 2px 7px;
  white-space: nowrap;
}
/* A phone has no room for a column beside the text. */
@media (max-width: 720px) {
  .docs-sheet .docs { grid-template-columns: minmax(0, 1fr); gap: 16px; }
  .docs-sheet .docs-toc { position: static; }
}

/* The way OUT of the key list: the docs. A full-width row after the groups,
   set apart by its rule rather than by colour — it is an offer, not the
   panel's subject. */
.sheet-foot-act {
  display: flex;
  align-items: center;
  gap: 9px;
  width: 100%;
  margin-top: 18px;
  padding: 11px 0 2px;
  border: 0;
  border-top: 1px solid var(--line);
  background: none;
  font: inherit;
  font-size: 13px;
  color: var(--text-dim);
  cursor: pointer;
}
.sheet-foot-act:hover { color: var(--accent); }

/* ---------- the settings sheet ----------
   Every preference the two dropdown menus used to split between them, plus the
   account. Same frame as the shortcuts panel — this is a list of settings, and
   a settings screen that invents its own chrome is a second app.

   The rows reuse `.sheet-row`, so a label sits left and its control sits right
   on one baseline. What is added here is the three shapes a menu row could not
   express: a two-option segment, a switch, and the danger zone. */
/* ---------- the rail ----------
   Settings is a place with named parts, not one long column (see the note in
   ui/SettingsSheet.svelte). The sheet is therefore two boxes side by side, and
   a little wider than the others to pay for the rail.

   THE PANE SCROLLS, NOT THE SHEET. `.sheet` is already a flex column with a
   `max-height`, so making the row below `flex: 1` and giving the body its own
   overflow keeps the rail and the title in place while a long pane moves under
   them — which is the whole reason a rail beats a list of anchors. */
.settings-sheet { width: min(720px, calc(100vw - 32px)); }
.settings-sheet .sheet-body { flex: 1; }
.set-nav {
  flex: none;
  display: flex;
  flex-direction: column;
  gap: 2px;
  width: 168px;
  padding: 10px 10px 18px;
  border-right: 1px solid var(--line);
  overflow-y: auto;
}
.set-nav-item {
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 7px 10px;
  border: 0;
  border-radius: var(--r-md);
  background: none;
  color: var(--text-dim);
  font: inherit;
  font-size: 13.5px;
  text-align: left;
  white-space: nowrap;
  cursor: pointer;
}
.set-nav-item:hover { background: var(--accent-soft); color: var(--accent); }
/* The current pane is stated by WEIGHT and colour rather than by a fill: the
   rail sits beside content that already has a heading saying the same thing,
   and two loud claims about where you are is one too many. */
.set-nav-item.on { color: var(--text); font-weight: 600; background: var(--accent-soft); }
.set-nav-item .icon { flex: none; }

/* A hidden pane is not rendered away — the sections hold live state (a typed
   confirmation, an in-flight erase) that must survive a glance at another
   pane. */
.settings-sheet section.hidden { display: none; }
/* The first heading in a pane has the sheet's own head above it and needs no
   second gap. */
.settings-sheet .sheet-body > section:not(.hidden) > h3:first-child { margin-top: 4px; }

/* A ROW THAT WRAPS RATHER THAN OVERFLOWS. Both halves of this are load-bearing
   at a phone's width, and both were wrong at first:

   `flex-wrap` — the palette strip is nine discs and the mode segment is two
   named buttons, and neither shrinks. Side by side with their label they are
   wider than a 398px sheet, and a `.sheet-row` that cannot wrap simply runs off
   the edge. Wrapping drops the control onto its own line, which is also exactly
   the shape a thumb wants; it means the touch rules below need only resize the
   swatches, not restructure the row.

   `min-width: 0` on the label — a flex item's floor is its min-content width,
   so a `nowrap` value like an email address holds the whole row open however
   little space there is and the ellipsis it was given never fires. This is what
   pushed "Signed in as lauren@example.com" past the sheet's right edge. */
.settings-sheet .set-frame {
  display: flex;
  flex: 1;
  min-height: 0;
}
.settings-sheet .sheet-row {
  flex-wrap: wrap;
  gap: 8px 20px;
  min-height: 30px;
}
.settings-sheet .sheet-row > :first-child { min-width: 0; }
.settings-sheet .set-value {
  color: var(--text-dim);
  font-size: 13px;
  /* An email is the one value here that can be longer than its column. */
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* A row's own action — Export, Sign out, Cancel. Quiet by default: these are
   offers beside a label, not the subject of the sheet. */
.set-act {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  font: inherit;
  font-size: 13px;
  padding: 5px 10px;
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  background: none;
  color: var(--text-dim);
  cursor: pointer;
  white-space: nowrap;
}
.set-act:hover { color: var(--accent); border-color: var(--accent); }

/* ---------- experiments ----------
   One line under the heading saying what an experiment IS, and then a row per
   installed one. The blurb sits under the name rather than beside it: these
   are sentences, and a sentence in the right-hand column of a settings row
   fights the switch for the space. */
.set-note {
  margin: -2px 0 10px;
  font-size: 12px;
  line-height: 1.45;
  color: var(--text-dim);
}
/* THE ONE ROW HERE THAT MUST NOT WRAP. The rows above wrap on purpose — a
   nine-disc palette strip beside its label does not fit a phone (see the note
   on `.settings-sheet .sheet-row`). But this row's control is a 38px switch and
   its label is a sentence: wrapped, the switch lands under the blurb on a line
   of its own, reading as a control for whatever comes next. The label shrinks
   and the sentence rewraps instead, which is what the space is for. */
.settings-sheet .sheet-row.row-exp { flex-wrap: nowrap; }
.set-exp {
  display: flex;
  align-items: flex-start;
  gap: 9px;
  /* `min-width: 0` on both boxes, or the sentence's min-content width holds the
     row open and the switch is pushed off the edge instead of the text
     rewrapping — the same trap the email row fell into above. */
  min-width: 0;
  flex: 1 1 auto;
  padding-right: 14px;
}
.set-exp > span { min-width: 0; }
/* The glyph rides the first line of the name, not the middle of the block. */
.set-exp .icon { margin-top: 2px; color: var(--text-dim); }
.set-exp small {
  display: block;
  margin-top: 2px;
  font-size: 12px;
  line-height: 1.45;
  color: var(--text-dim);
}

/* ---- the mode segment. Two named buttons rather than one toggle, because a
   toggle that says "Dark mode" while you are in light never tells you which one
   you are wearing. The pressed half carries the accent; the pair carries the
   answer. */
.seg {
  display: inline-flex;
  padding: 2px;
  gap: 2px;
  border: 1px solid var(--line);
  border-radius: var(--r-md);
}
.seg-btn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font: inherit;
  font-size: 13px;
  padding: 4px 10px;
  border: none;
  border-radius: 6px;
  background: none;
  color: var(--text-dim);
  cursor: pointer;
}
.seg-btn:hover { color: var(--accent); }
.seg-btn.on { background: var(--accent-soft); color: var(--accent); }

/* ---- the switch. One boolean, drawn as the thing every OS draws it as. */
.switch {
  flex: none;
  width: 38px;
  height: 22px;
  padding: 0;
  border: 1px solid var(--line);
  border-radius: var(--r-pill);
  background: var(--bg);
  cursor: pointer;
  position: relative;
  transition: background var(--dur-base), border-color var(--dur-base);
}
.switch > i {
  position: absolute;
  top: 2px;
  left: 2px;
  width: 16px;
  height: 16px;
  border-radius: 50%;
  background: var(--gutter-icon);
  transition: transform var(--dur-base) var(--ease), background var(--dur-base);
}
.switch.on { background: var(--accent-soft); border-color: var(--accent); }
.switch.on > i { transform: translateX(16px); background: var(--accent); }
@media (prefers-reduced-motion: reduce) {
  .switch, .switch > i { transition: none; }
}

/* ---- the palette strip. It was a row of the account dropdown (and lived in
   css/frame.css with the menus); it is a row of this sheet now, which is also
   what let the discs stop being 21px — a menu is as wide as its longest label,
   a sheet is 560px, and these are the only controls in the app whose entire job
   is to be looked at. */
.palettes { display: flex; align-items: center; }
.palettes .swatches {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: 8px;
}
/* EACH DISC WEARS ITS OWN PALETTE, both halves of it: the pairs resolve on this
   element (css/palettes.css matches .swatch as well as :root), and the gradient
   is a hard 50% stop rather than a blend so it reads as two colours and not as a
   smear. The inner <i> is the same split done with the accent, which is the one
   colour a scheme is actually recognised by. */
.palettes .swatch {
  width: 24px;
  height: 24px;
  padding: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 1px solid var(--line);
  border-radius: 50%;
  background-image: linear-gradient(135deg, var(--l-bg) 0 50%, var(--d-bg) 50% 100%);
  cursor: pointer;
  transition: transform var(--dur-fast) var(--ease), box-shadow var(--dur-fast);
}
.palettes .swatch > i {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background-image: linear-gradient(135deg, var(--l-accent) 0 50%, var(--d-accent) 50% 100%);
}
.palettes .swatch:hover { transform: scale(1.12); }
/* The ring is the LIVE accent, not the swatch's own — it says "this is the one
   in force", and it has to read that way against the surface it sits on. */
.palettes .swatch.on {
  box-shadow: 0 0 0 2px var(--raised), 0 0 0 3.5px var(--accent);
}
@media (prefers-reduced-motion: reduce) {
  .palettes .swatch { transition: none; }
}

/* ---- the danger zone. Set apart by a rule and by distance rather than by a
   red box: the section is not dangerous to LOOK at, and a permanently alarming
   panel is one people learn to scroll past. Only the button that starts it and
   the words that follow it are red. */

/* THE CONFIRMATION IS THE WHOLE SHEET while it is up (see `takeover` in
   ui/SettingsSheet.svelte), so it carries the body's own breathing room rather
   than the compact spacing of a section at the bottom of a list. It is also
   what makes the step FIT: on a phone with the keyboard up the sheet is
   `100dvh - --kb` tall, and the settings that used to sit above this were more
   than that on their own. */
.settings-sheet .danger-confirm,
.settings-sheet .danger-busy[data-step="erasing"] { padding: 10px 0 4px; }
.danger {
  margin-top: 16px;
  padding-top: 14px;
  border-top: 1px solid var(--line);
}
.danger-btn {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  font: inherit;
  font-size: 13px;
  padding: 6px 12px;
  border: 1px solid color-mix(in srgb, var(--danger) 40%, var(--bg));
  border-radius: var(--r-md);
  background: none;
  color: var(--danger);
  cursor: pointer;
}
.danger-btn:hover:not(:disabled) {
  background: color-mix(in srgb, var(--danger) 10%, var(--bg));
  border-color: var(--danger);
}
.danger-btn:disabled { opacity: .45; cursor: not-allowed; }
.danger-note {
  font-size: 12.5px;
  line-height: 1.5;
  color: var(--text-dim);
  margin: 8px 0 0;
}
.danger-error {
  font-size: 13px;
  line-height: 1.5;
  color: var(--danger);
  margin: 0 0 8px;
}
/* The repair offered inside a sentence about the damage — a link rather than a
   button shape, because it is part of the reading rather than the next step. */
.danger-link {
  font: inherit;
  padding: 0;
  border: 0;
  background: none;
  color: var(--accent);
  cursor: pointer;
  text-decoration: underline;
}
.danger + .danger-btn,
.danger-note + .danger-btn { margin-top: 10px; }
.danger-title { font-size: 14px; font-weight: 600; margin: 0 0 8px; }
.danger-list {
  margin: 0;
  padding-left: 18px;
  font-size: 13px;
  line-height: 1.65;
  color: var(--text-dim);
}
.danger-field {
  display: block;
  margin-top: 14px;
  font-size: 13px;
  color: var(--text-dim);
}
.danger-field input {
  display: block;
  width: 100%;
  margin-top: 6px;
  font: inherit;
  /* 16px, AND IT IS NOT A TASTE DECISION. iOS zooms the whole page in when a
     focused input's computed font-size is under 16px — there is no way to opt
     out of that per-field, and at 14px this control did exactly that: tapping
     to type the confirmation word scaled the app up and left the sheet
     half off-screen. Every other input in the app (the sign-in fields, the
     search panel) is already at or above 16 for the same reason. */
  font-size: 16px;
  padding: 10px 12px;
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  background: var(--bg);
  color: var(--text);
  outline: none;
}
.danger-field input:focus { border-color: var(--danger); }
.danger-acts {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
  margin-top: 14px;
}
/* Named steps while it runs. The shared `.spinner` is drawn in white for the
   accent-filled submit button it was made for, so on this surface it needs its
   own two colours — the ring and the arc, nothing else. */
.danger-busy {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: 13px;
  color: var(--text-dim);
  padding: 4px 0;
}
.danger-busy .spinner {
  border-color: var(--line);
  border-top-color: var(--danger);
}
/* The two documents, at the foot: what is kept, and what the app does. */
.sheet-foot-links {
  display: flex;
  flex-wrap: wrap;
  gap: 18px;
  margin-top: 20px;
  padding-top: 12px;
  border-top: 1px solid var(--line);
  font-size: 12.5px;
}
.sheet-foot-links a { color: var(--text-dim); text-decoration: none; }
.sheet-foot-links a:hover { color: var(--accent); }

/* ---------- command palette ----------
   Bullets and commands in one list, because "find the thing" and "do the
   thing" are the same intention when you have already reached for the keys. */
.sheet.jump { padding: 0; }
.jump-head {
  flex: none;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 13px 14px;
  border-bottom: 1px solid var(--line);
}
.jump-icon { flex: none; display: flex; color: var(--text-dim); }
.jump-head input {
  font: inherit;
  font-size: 16px;
  flex: 1;
  min-width: 0;
  padding: 0;
  border: none;
  background: none;
  color: var(--text);
  outline: none;
  caret-color: var(--accent);
}
.jump-head input::placeholder { color: var(--text-dim); }
.jump-head input::-webkit-search-cancel-button { display: none; }
.jump-esc {
  flex: none;
  font-family: var(--mono);
  font-size: 11px;
  color: var(--text-dim);
}
.jump-hits { overflow-y: auto; padding: 6px 8px 12px; }
.jump-group {
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: .06em;
  color: var(--text-dim);
  padding: 0 8px;
  margin: 10px 0 4px;
}
.jump-hit {
  font: inherit;
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  text-align: left;
  border: none;
  background: none;
  color: var(--text);
  padding: 7px 8px;
  border-radius: var(--r-md);
  cursor: pointer;
}
.jump-hit:hover, .jump-hit.active { background: var(--accent-soft); }
.jump-ico { flex: none; display: flex; width: 16px; justify-content: center; color: var(--gutter-icon); }
.jump-hit.active .jump-ico { color: var(--accent); }
/* The ✓ that replaces the caret on a completed hit — accent, the same colour
   the document gives a completed bullet's own ✓, and it keeps that colour when
   the row is active, where the caret takes the accent by itself. */
.jump-hit.done .jump-ico, .jump-hit.done.active .jump-ico { color: var(--accent); }
.jump-hit .jump-body { flex: 1; min-width: 0; }
.jump-hit .path {
  display: block;
  font-size: 12px;
  color: var(--text-dim);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.jump-hit .hit {
  display: block;
  line-height: 20px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* WHERE a body-only match was found: one dim line of the note or document
   source around the match, the match itself marked like the title's. */
.jump-hit .hit-snippet {
  display: block;
  font-size: 12px;
  line-height: 17px;
  color: var(--text-dim);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.jump-hit kbd {
  flex: none;
  font-family: var(--mono);
  font-size: 11.5px;
  color: var(--text-dim);
  background: var(--accent-soft);
  border-radius: 5px;
  padding: 2px 7px;
}

/* ---------- the quick finder ----------
   The outline as a tree you fold. Shares the sheet's box with the palette
   because it is the same kind of thing — a surface over the document you pick
   something from — and differs only in how you find it: typed there, browsed
   here. */
.sheet.finder { padding: 0; }
/* ---------- the outline browser, and the picker built on it ----------
   THE TREE, IN A SHEET. The row itself is defined once in frame.css — it is the
   rail's row, and these two panels now draw the same one (ui/TreeRow.svelte).
   What is here is only what a SHEET adds to it: its own strip of chrome, and a
   finger's worth of size on every control.

   What this replaced is worth recording, because it was three builds of the
   same mistake. The browser walked the outline a level at a time, so it needed
   a header saying which level, a breadcrumb strip to get back out, and — since
   a row then had two possible meanings — a labelled "Into" and a labelled
   "Open" on every line, ranked against each other by one step of the type
   scale, with a hairline between them to say there were two. That is a lot of
   apparatus for a list of names, and the rail beside the document had been
   getting by with none of it: twist to open, name to go. A tree you fold needs
   no header, because the path to what you are looking at is the rows above it,
   and it throws nothing away when you step, because stepping is just
   unfolding.

   TOUCH SIZE IS THE ONLY THING THESE PANELS RESTATE. The rail is a mouse's
   list; a sheet is opened on a phone as often as not, so every control on it
   gets the full 44px target and the type goes up one step to match. */
.sheet.finder {
  --tree-indent: 18px;
  --tree-twist: 30px;
  --tree-hit: var(--hit-touch);
  --tree-size: 15px;
}
/* The rail's rounded row at a finger's size, in a list with a margin — same
   shape, same hover, one scale up. Edge-to-edge rows with hairlines between
   them were what the level-at-a-time panel needed, because a row there was two
   controls and a divider; a row that is one target needs no ruling. */
.sheet.finder .tree-row { padding-right: 8px; }
.sheet.finder .tree-name { padding: 0 8px; font-weight: 500; }
/* The glyph grows with its target — 12px is a rail's chevron, and on a phone
   the twist is the smaller of the two things on the row to hit. */
.sheet.finder .tree-twist svg { width: 15px; height: 15px; }

/* The panel's own strip: what this is, and the way out. Both belong to the
   PANEL rather than to any bullet, which is why they are up here and not on a
   row — that separation is the one thing worth keeping from the build before,
   where anything sitting at the right edge of a row pushed the row's controls
   inward. */
.finder-chrome {
  flex: none;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 9px 14px;
  border-bottom: 1px solid var(--line);
}
.finder-title {
  flex: 1;
  min-width: 0;
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: .06em;
  color: var(--text-dim);
}
/* 44px of target on a 15px glyph, taken out of the strip's own padding so the
   button reaches the panel's corner without widening the strip. `auto` on the
   left so it stays pinned right whatever sits beside it. */
.finder-close {
  flex: none;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  width: var(--hit-touch);
  height: var(--hit-touch);
  margin: -8px -14px -8px auto;
  padding-right: 14px;
  border: none;
  background: none;
  color: var(--text-dim);
  border-radius: var(--r-md);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.finder-close:hover, .finder-close:active { color: var(--accent); }
/* The list scrolls inside the sheet; the chrome above it does not move. */
.finder-list { overflow-y: auto; padding: 6px 6px 10px; }

/* ---------- move to… ----------
   The destination picker (ui/MoveTo.svelte). It IS the browser above — same
   tree, same row, same fold — with a search field on top that narrows it and
   one difference in what a name does: it files the bullet in flight rather
   than going there. So nothing here restates a layout; it is the two things
   the browser has no equivalent of. */
/* WHAT IS IN THE AIR, said once at the top and kept there while you look. Dim
   for the sentence, full strength for the bullet — the name is the part being
   checked against. */
.moveto-subject {
  flex: 1;
  min-width: 0;
  font-size: 12px;
  color: var(--text-dim);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.moveto-subject b { font-weight: 600; color: var(--text); }

/* ---------- keyboard hint strip ---------- */
.kbd-hint {
  position: fixed;
  bottom: 14px;
  right: 16px;
  z-index: 20;
  font-size: 11.5px;
  color: var(--text-dim);
  background: var(--raised);
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  padding: 6px 10px;
  opacity: .8;
  user-select: none;
  transition: opacity var(--dur-base);
}
.kbd-hint:hover { opacity: 1; }
.kbd-hint kbd {
  font-family: var(--mono);
  font-size: 10.5px;
  background: var(--dot-halo);
  border-radius: 4px;
  padding: 1px 5px;
  /* A clear step before each key opens the pairs up — the strip read as one
     run of glyphs with everything packed at 1px. None before the first. */
  margin: 0 4px 0 14px;
}
.kbd-hint kbd:first-child {
  margin-left: 0;
}
@media (max-width: 640px) { .kbd-hint { display: none; } }

/* ---------- zoom motion ----------
   The outgoing level is a clone laid over the new one; js/zoom.ts drives the
   anchored version of the motion and explains the shape of it. What is here is
   geometry, layer promotion, and the un-anchored fallback for the zooms that
   have no row to travel from (the ⌂ button out of a collapsed branch, a jump
   from search). */
.zoom-ghost {
  position: absolute;
  /* `top` is set per-zoom, in the scroller's CONTENT coordinates. */
  top: 0;
  left: 0;
  right: 0;
  pointer-events: none;
  /* Promote the clone to its own layer BEFORE anything happens to it. On the
     anchored path it only fades, and a fade of a document-sized element is
     still worth compositing rather than repainting; on the fallback below it
     also scales, and the promotion is what keeps that from re-hinting every
     glyph on every frame. */
  will-change: transform, opacity;
  transform-origin: 50% 0;
}

/* Fallback only — the zooms with no bullet to anchor to (⌂ out of a collapsed
   branch, a jump from search). Nothing is shared between the two views on this
   path, so the ramps are staggered rather than crossfaded: the outgoing clone
   is gone by 60% and the arriving document does not start until 30%, and two
   different outlines are never legible at once. Translate and opacity on the
   live half, no scale — there is no morph here to justify one. */
@keyframes zoom-enter-in {
  0%   { opacity: 0; transform: translateY(14px); }
  30%  { opacity: 0; }
  100% { opacity: 1; transform: none; }
}
@keyframes zoom-leave-in {
  0%   { opacity: 1; transform: none; }
  60%  { opacity: 0; }
  100% { opacity: 0; transform: translateY(-40px) scale(1.03); }
}
@keyframes zoom-enter-out {
  0%   { opacity: 0; transform: translateY(-14px); }
  30%  { opacity: 0; }
  100% { opacity: 1; transform: none; }
}
@keyframes zoom-leave-out {
  0%   { opacity: 1; transform: none; }
  60%  { opacity: 0; }
  100% { opacity: 0; transform: translateY(40px) scale(.97); }
}
/* The arriving half eases IN, the leaving half eases OUT — see the tokens.
   The class goes on `.shell` (that is what the zoom module and the suites name)
   but the ARRIVING animation is applied one level down, to `.doc-flow`: a
   transform on `.shell` would make it the containing block for the fixed hint
   strip inside it, which would then ride along and snap back. The ghost has no
   such passengers — they are stripped from the clone — so it animates whole. */
.shell.enter-in > .doc-flow  { animation: zoom-enter-in  var(--dur-move) var(--ease) both; }
.shell.enter-out > .doc-flow { animation: zoom-enter-out var(--dur-move) var(--ease) both; }
.shell.leave-in  { animation: zoom-leave-in  var(--dur-move) var(--ease-exit) both; }
.shell.leave-out { animation: zoom-leave-out var(--dur-move) var(--ease-exit) both; }

@media (prefers-reduced-motion: reduce) {
  .shell.enter-in > .doc-flow, .shell.enter-out > .doc-flow,
  .shell.leave-in, .shell.leave-out,
  .sheet, .sheet-backdrop, .user-menu.open .dropdown, .bullet-menu { animation: none; }
}

/* ---------------------------------------------------------------- documents
   Spec 004: a node's note grown into a markdown document. MD-DEC-14: a doc
   node's ZOOMED PAGE is the document — full-width below the breadcrumbs,
   between the zoom title and the children, which stay the ordinary outline.
   One navigation for bullets and documents alike. The Doc Mode design's
   colours map to the app's own tokens: its blue is `--accent`, its hover
   wash `--accent-soft`, its hairlines `--line`. */

/* The dot that became a file. Same box as the dot so the row's geometry never
   moves when a bullet turns into a document; the glyph takes the dot's colour
   and the dot's hover accent. Clicking it zooms — exactly what a dot does. */
.dot-doc {
  color: var(--dot);
  transition: color var(--dur-fast), transform var(--dur-fast) var(--ease);
}
.dot-doc:hover { color: var(--accent); transform: scale(1.15); }
/* The halo still means "children out of sight" on a folded doc row — and it is
   the GLYPH's halo, not the button's. On the button it wrapped the whole 20×22
   hit box: a 26×28 slab beside the plain dot's 18px ring, so a folded document
   read as a far bigger claim than a folded bullet two rows up. Around the 13px
   glyph box it comes out at 18px, which is the dot's halo to the pixel. */
.item.collapsed > .row .dot-doc .icon { border-radius: 4px; box-shadow: 0 0 0 2.5px var(--dot-halo); }

/* ---------- the surface ----------
   The document's chrome on its page: mode bar, then (in Write) the toolbar. */
.doc-surface {
  margin: 2px 0 20px;
  border-bottom: 1px solid var(--line);
  padding-bottom: 14px;
}
/* THE BAR STICKS, because the controls in it are wanted at the bottom of a
   long document as much as at the top — and this is the one bar in the app
   that can, since `#shell-host` is the scroller and the docbar above is
   outside it (css/frame.css). Opaque background and a hairline under it: text
   passes beneath this row, and a control set floating over its own paragraph
   would be unreadable in exactly the moment it is being used. */
.doc-bar {
  position: sticky;
  top: 0;
  z-index: 5;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 0;
  background: var(--bg);
  border-bottom: 1px solid var(--line);
  margin-bottom: 10px;
}
.doc-kind {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 12.5px;
  color: var(--text-dim);
}
.doc-bar-space { flex: 1; }
.doc-act {
  flex: none;
  width: 28px;
  height: 28px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 0;
  border-radius: 7px;
  background: none;
  color: var(--text-dim);
  cursor: pointer;
}
.doc-act:hover { background: var(--accent-soft); color: var(--accent); }
/* Read is a LOOK, not a mode (MD-DEC-19) — so it is a button that stays
   pressed rather than a tab in a segment. A segment asks which of N things
   this text is; there is only one thing it is, and this is a way of looking
   at it. */
.doc-act.on { background: var(--accent-soft); color: var(--accent); }
/* The bin at the end of the bar deletes the PAGE (MD-DEC-20). It looks like
   every other control here until you point at it, and then it goes red: a
   permanently red button in a bar you read on every visit is alarm nobody
   keeps hearing, and the press it guards is one confirmation away regardless. */
.doc-kill:hover {
  background: color-mix(in srgb, var(--danger) 10%, var(--bg));
  color: var(--danger);
}

/* ---------- the formatting controls (MD-DEC-18) ----------
   A row inside the document's bar on a pointer — ui/FormatBar.svelte carries
   the argument for why they are permanent rather than summoned. On touch they
   are not here at all: the strip above the keyboard is that half.

   They scroll rather than wrap, because the bar is one line high and has to
   stay one line high while it is stuck to the top of the document: a narrow
   window would otherwise fold the controls onto a second row and push the
   text down under a bar that grew. */
.fmt-group {
  display: flex;
  align-items: center;
  gap: 2px;
  min-width: 0;
  overflow-x: auto;
  scrollbar-width: none;
}
.fmt-group::-webkit-scrollbar { display: none; }
.fmt-btn {
  flex: none;
  min-width: var(--hit);
  height: var(--hit);
  padding: 0 4px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 0;
  border-radius: 7px;
  background: none;
  color: var(--text-dim);
  font: inherit;
  font-size: 13.5px;
  cursor: pointer;
  transition: background var(--dur-fast), color var(--dur-fast);
}
.fmt-btn:hover { background: var(--accent-soft); color: var(--accent); }
/* LIT MEANS THE SELECTION ALREADY WEARS IT. In a source surface the asterisks
   say so too, and a bar that disagreed with the characters under it would be
   the half nobody believed — so `on` is read off the same source the writer
   is reading (the block and marks under the caret, read live). */
.fmt-btn.on { background: var(--accent-soft); color: var(--accent); }
.fmt-b { font-weight: 800; }
.fmt-i { font-style: italic; font-weight: 600; font-family: Georgia, serif; }
.fmt-code { font-family: var(--mono); font-size: 11px; }
.fmt-q { font-family: Georgia, serif; font-size: 16px; font-weight: 700; }
.fmt-ol { font-size: 12px; font-weight: 600; font-family: var(--mono); }
.fmt-h { font-size: 12px; font-weight: 700; }
.fmt-split {
  flex: none;
  width: 1px;
  height: 18px;
  background: var(--line);
  margin: 0 4px;
}

/* ---------- the document body ---------- */
.document .doc-rendered,
.document .doc-editor {
  font-size: 15px;
  line-height: 1.65;
}
.document .doc-rendered { cursor: text; min-height: 1.5em; }
.document .doc-rendered:focus-visible { outline: none; }
.document :is(h2, h3, h4, h5, h6) { margin: 0.9em 0 0.35em; line-height: 1.25; }
/* Design rhythm: paragraphs a bit over half a line apart, and LISTS tight —
   ProseMirror (and CommonMark loose lists) wrap every item's text in a <p>,
   and the browser's 1em paragraph margins inside 1em list margins read as a
   blank line between every bullet. */
.document p { margin: 0.55em 0; }
.document :is(ul, ol) { margin: 0.35em 0; padding-left: 1.4em; }
.document li { margin: 0.15em 0; }
.document li > p { margin: 0.1em 0; }
.document li > :is(ul, ol) { margin: 0.15em 0; }
.document :is(.ProseMirror, .doc-rendered) > :first-child { margin-top: 0; }
.document pre {
  font-family: var(--mono);
  font-size: 12.5px;
  line-height: 1.6;
  background: var(--accent-soft);
  border: 1px solid var(--line);
  padding: 12px 14px;
  border-radius: 8px;
  overflow-x: auto;
}
.document code { font-family: var(--mono); font-size: 0.85em; background: var(--accent-soft); border-radius: 5px; padding: 1px 6px; }
.document pre code { background: none; border-radius: 0; padding: 0; font-size: inherit; }
.document blockquote {
  margin: 0.5em 0;
  padding-left: 0.8em;
  border-left: 3px solid var(--line);
  color: var(--text-dim);
}
.doc-empty { color: var(--text-dim); font-style: italic; }
.md-image {
  display: inline-block;
  padding: 0 0.35em;
  border: 1px dashed var(--line);
  border-radius: 4px;
  color: var(--text-dim);
  font-size: 0.85em;
}
/* ---------- the editor (MD-DEC-19) ----------
   ProseMirror's host: rich text in the page's flow, with no box around it.
   The document's type is set once, on `.document .doc-editor` above, and
   everything inside inherits — which is what lets css/narrow.css grow the
   whole document on a phone by changing one number. */
.doc-editor .ProseMirror { outline: none; white-space: pre-wrap; }

/* A BODY HEADING IS NOT THE PAGE'S HEADING. The bullet's own text is the only
   H1 here (MD-DEC-08) and it is set at --doc-title above; a `#` typed in the
   body renders one visual step below it, in the editor as well as in the read
   view, so the page never shows two titles the same size. */
.doc-editor .ProseMirror h1 { font-size: 1.35em; margin: 0.9em 0 0.35em; line-height: 1.25; }

/* THE EMPTY PAGE MUST BE CLICKABLE. A fresh document is one invisible line of
   ProseMirror, and a click in the white space under it would land on page
   padding — nothing focused, nothing typed. The editor reaches below its last
   line instead; clicks there put the caret at the end. Modest reach, because
   the children sit under the surface and must stay close. */
.doc-surface .doc-editor { cursor: text; }
.doc-surface .doc-editor .ProseMirror {
  min-height: 7rem;
  padding-bottom: 2.5rem;
}
/* The empty-state line, as a pure-CSS placeholder: an empty PM doc is exactly
   one paragraph holding one trailing break. Absolute, so the caret sits at the
   line start underneath it rather than after it.

   TWO WORDS. It used to teach the syntax — "or type markdown: # heading
   **bold** - list" — which was a tutorial printed across the top of every
   blank page, in the one place a writing app should be quiet. The bar directly
   above it now shows those same moves as buttons, so the line has nothing left
   to say except that this page is empty and yours. */
.doc-surface .doc-editor .ProseMirror > p:only-child { position: relative; }
.doc-surface .doc-editor .ProseMirror > p:only-child:has(> br.ProseMirror-trailingBreak:only-child)::before {
  content: "Start writing…";
  position: absolute;
  left: 0;
  top: 0;
  color: var(--text-dim);
  pointer-events: none;
}
/* The screens that exist before the app does: the boot spinner and the sign-in
   card. See web/boot/screens.ts, which paints the first two of the three as HTML
   strings because Svelte may be exactly what has not started yet. */

/* ---------------------------------------------------------------- boot */
.boot {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
}
.boot-dot {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: var(--dot);
  animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
  0%, 100% { transform: scale(1); opacity: .5; }
  50% { transform: scale(1.6); opacity: 1; }
}

/* ------------------------------------------------------------ noscript */
/* The fallback body in index.html — what a crawler that does not execute, and
   the rare reader with scripting off, gets instead of the landing page. Nobody
   with JavaScript on ever sees it, so it is deliberately the plainest thing in
   the stylesheet: prose in the app's own tokens, one column, no chrome. It sits
   inside `#app`, whose flex column would otherwise stretch it. */
.noscript {
  flex: 1;
  max-width: 34rem;
  margin: 0 auto;
  padding: 3rem 1.25rem;
  font: 16px/1.6 var(--sans);
  color: var(--text);
}
.noscript h1 {
  font-size: 1.6rem;
  line-height: 1.25;
  margin: 0 0 .5rem;
}
.noscript ul {
  padding-left: 1.15rem;
}
.noscript li,
.noscript p {
  margin: .6rem 0;
}
.noscript a {
  color: var(--accent);
}

/* ---------------------------------------------------------------- auth */
.auth-wrap {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
}
.auth-card {
  width: 100%;
  max-width: 360px;
}
.auth-logo {
  display: flex;
  align-items: center;
  gap: 11px;
  margin-bottom: 28px;
}
.auth-logo .logo-dot {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: var(--dot);
  box-shadow: 0 0 0 5px var(--dot-halo);
}
.auth-logo h1 {
  font-size: 22px;
  font-weight: 700;
  margin: 0;
  letter-spacing: -0.022em;
}
.auth-card h2 {
  font-size: 15px;
  font-weight: 500;
  color: var(--text-dim);
  margin: 0 0 20px;
}
.auth-card form {
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.auth-card input {
  font: inherit;
  padding: 11px 13px;
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  background: var(--bg);
  color: var(--text);
  outline: none;
  transition: border-color var(--dur-base);
}
.auth-card input:focus { border-color: var(--accent); }
.auth-card button[type="submit"] {
  font: inherit;
  font-weight: 600;
  padding: 11px;
  margin-top: 4px;
  border: none;
  border-radius: var(--r-md);
  background: var(--accent);
  color: #fff;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 9px;
}
.auth-card button[type="submit"]:hover { filter: brightness(1.07); }
.auth-card button[type="submit"]:disabled { opacity: .75; cursor: progress; filter: none; }

/* ---------- busy state ----------
   While a step is in flight the inputs go read-only and the button turns into
   a named progress indicator, so there is never an open field over work that
   is already running. */
.auth-card input:disabled {
  opacity: .55;
  cursor: not-allowed;
}
.auth-card.is-busy { cursor: progress; }
.spinner {
  width: 14px;
  height: 14px;
  flex: none;
  border-radius: 50%;
  border: 2px solid rgba(255, 255, 255, .35);
  border-top-color: #fff;
  animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  .spinner { animation-duration: 2s; }
}
/* ---------- the way in without an account ----------
   Below the form and set apart by a rule, because it is the OTHER answer to the
   same question rather than another field of the form. Outlined rather than
   filled: signing in is still the primary move, and two solid buttons stacked
   would make the card ask you to choose between them.

   `.auth-note` after it says what the demo does not do; the rule above the group
   is what stops that note reading as a second remark about the email field. */
.auth-alt {
  margin-top: 4px;
  padding-top: 16px;
  border-top: 1px solid var(--line);
}
.auth-alt-btn {
  width: 100%;
  font: inherit;
  font-weight: 600;
  padding: 10px;
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  background: none;
  color: var(--text);
  cursor: pointer;
}
.auth-alt-btn:hover { border-color: var(--accent); color: var(--accent); }
.auth-alt .auth-note { margin: 10px 0 0; }

.auth-links {
  margin-top: 16px;
  display: flex;
  flex-direction: column;
  gap: 6px;
  font-size: 13.5px;
}
.auth-links a,
.auth-links button {
  font: inherit;
  padding: 0;
  border: none;
  background: none;
  text-align: left;
  color: var(--accent);
  text-decoration: none;
  cursor: pointer;
}
.auth-links a:hover,
.auth-links button:hover { text-decoration: underline; }
.auth-error {
  color: var(--danger);
  font-size: 13.5px;
  min-height: 18px;
  margin: 6px 0 0;
}
.auth-note {
  font-size: 13.5px;
  color: var(--text-dim);
  margin: 0 0 14px;
  line-height: 1.5;
}
/* A note that explains the form it follows needs more air than the form's own
   10px row gap — otherwise it reads as another row of the form rather than a
   remark about it, and sits on the submit button's shoulder. */
.auth-card form + .auth-note {
  margin-top: 18px;
}
/* Full-card progress, used before any screen exists (e.g. restoring a session). */
.auth-busy {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: 14px;
  color: var(--text-dim);
  padding: 6px 0 2px;
}
.auth-busy .spinner {
  border-color: var(--line);
  border-top-color: var(--accent);
}

/* ---------------------------------------------------------------- demo */
/* The strip above the chrome in the demo (web/boot/index.ts enterDemo): where
   the outline lives while there is no account, and the way to change that.
   It is above `.topbar` in the document, so on a phone — where that bar is
   display:none — it is the top of the window and carries the notch itself;
   `.has-demo-strip` on #app is what stops the document bar carrying it too.
   `env()` is 0 in a browser, so one rule serves both. */
.demo-strip {
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-wrap: wrap;
  gap: 4px 14px;
  padding: calc(7px + env(safe-area-inset-top, 0px)) 14px 7px;
  font-size: 13px;
  text-align: center;
  background: var(--accent-soft);
  border-bottom: 1px solid var(--line);
}
/* A BUTTON, NOT A LINK, and it reads as one on purpose. The link it replaced
   went to `/` — a reload that threw the demo away at the exact moment somebody
   decided to keep it. This one opens the sign-in sheet over the outline that is
   already on screen (ui/SignInSheet.svelte), so the strip's promise and the
   strip's control finally agree. */
.demo-strip button {
  font: inherit;
  font-weight: 600;
  color: #fff;
  background: var(--accent);
  border: none;
  border-radius: var(--r-pill);
  padding: 5px 13px;
  cursor: pointer;
}
.demo-strip button:hover {
  filter: brightness(1.07);
}

/* ---------------------------------------------------------------- sign-in sheet */
/* The card inside a sheet. It brings its own .auth-wrap from above, which is
   sized for a full screen — `flex: none` and no padding hand the spacing back
   to .sheet-body, exactly as css/landing.css does for the panel on the landing
   page. */
.signin-sheet .auth-wrap {
  flex: none;
  overflow: visible;
  padding: 0;
  width: 100%;
}
.signin-sheet .auth-card {
  max-width: none;
}
.signin-lead {
  margin: 4px 0 16px;
  font-size: 13px;
  line-height: 1.5;
  color: var(--text-dim);
  text-wrap: pretty;
}
/* The other half of the promise, in the quieter voice: an account that already
   has an outline opens THAT one. Second because it is the rarer case, and said
   at all because it is not discoverable afterwards. */
.signin-note {
  margin: 14px 0 0;
  font-size: 12px;
  line-height: 1.5;
  color: var(--text-dim);
  text-wrap: pretty;
}
/* LANDING — the signed-out web front door (js/ui/Landing.svelte).

   Every colour is a live token from tokens.css, so the page follows the theme
   and the palette like the app does. The composition is design "1a": headline,
   the sign-in panel, a working demo outliner, three claims, a quiet footer.
   Everything is prefixed `landing-` because this mounts into the same #app the
   outline does, and nothing here may leak into it. */

.landing {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  background: var(--bg);
  color: var(--text);
}

/* ---------------------------------------------------------------- bar */
.landing-bar {
  height: 64px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 40px;
  border-bottom: 1px solid var(--line);
}
.landing-logo {
  display: flex;
  align-items: center;
  gap: 10px;
}
.landing-dot {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 0 5px var(--accent-soft);
}
.landing-mark {
  font-size: 17px;
  font-weight: 700;
  letter-spacing: -0.02em;
}
.landing-nav {
  display: flex;
  align-items: center;
  gap: 26px;
}
.landing-navlink {
  font: inherit;
  font-size: 14px;
  color: var(--text-dim);
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
  /* Buttons never had one, but the Docs bar item is an anchor. */
  text-decoration: none;
}
.landing-navlink:hover {
  color: var(--text);
}
.landing-cta {
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  height: 34px;
  padding: 0 16px;
  border: 0;
  border-radius: var(--r-md);
  background: var(--accent);
  color: #fff;
  cursor: pointer;
  text-decoration: none;
  display: inline-flex;
  align-items: center;
}
.landing-cta:hover {
  filter: brightness(1.08);
}

/* ---------------------------------------------------------------- hero */
.landing-hero {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 18px;
  padding: 64px 24px 8px;
  text-align: center;
}
.landing-hero h1 {
  margin: 0;
  font-size: clamp(34px, 6vw, 52px);
  font-weight: 800;
  letter-spacing: -0.03em;
  line-height: 1.08;
  max-width: 720px;
  text-wrap: balance;
}
.landing-sub {
  margin: 0;
  font-size: 18px;
  line-height: 1.6;
  color: var(--text-dim);
  max-width: 560px;
  text-wrap: pretty;
}

/* ---------------------------------------------------------------- sign-in */
/* The AuthCard brings its own .auth-wrap/.auth-card styling from screens.css;
   this section only sizes and centres it. `flex: none` undoes .auth-wrap's
   stretch, which is for the full-screen card and would swallow the page. */
.landing-auth {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  padding: 18px 24px 0;
}
.landing-auth .auth-wrap {
  flex: none;
  overflow: visible;
  padding: 0;
  width: 100%;
}
/* Light or dark, above the card and NAMED. A segmented pair rather than one
   sun/moon button: two labelled halves say which mode you are in and what the
   other one is, where an icon can only say "this will change". The selected
   half is the raised surface, so the control reads the way the app's own
   segmented controls do. */
.landing-mode {
  display: inline-flex;
  padding: 3px;
  border: 1px solid var(--line);
  border-radius: var(--r-pill);
  background: var(--bg);
  margin-bottom: 4px;
}
.landing-mode-opt {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  height: 28px;
  padding: 0 14px;
  border: 0;
  border-radius: var(--r-pill);
  background: none;
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  color: var(--text-dim);
  cursor: pointer;
}
.landing-mode-opt:hover {
  color: var(--text);
}
.landing-mode-opt.on {
  background: var(--raised);
  color: var(--text);
  box-shadow: var(--shadow-1);
}
.landing-reassure {
  font-size: 12.5px;
  color: var(--text-dim);
}
/* The air between the submit button and its explanation now comes from
   `.auth-card form + .auth-note` in screens.css, which the full-screen card
   needed just as much as the landing page did. */

/* ---------------------------------------------------------------- demo */
.landing-demo-wrap {
  display: flex;
  justify-content: center;
  padding: 40px 16px 0;
}
.landing-demo {
  width: 720px;
  max-width: 100%;
  background: var(--raised);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-2);
  overflow: hidden;
}
.landing-demo-head {
  height: 40px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 16px;
  border-bottom: 1px solid var(--line);
}
.landing-demo-title {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  font-weight: 600;
}
.landing-demo-open {
  font-size: 12.5px;
  font-weight: 600;
  color: var(--accent);
  text-decoration: none;
}
.landing-demo-open:hover {
  text-decoration: underline;
}
.landing-demo-title i {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 0 3.5px var(--accent-soft);
}
/* Shell's own shortcut strip is `position: fixed` for the app frame, which on
   this page would park it in the window corner, outside the card it explains.
   Here the shell becomes a column as tall as the panel, so `margin: auto` can
   hold the strip in the bottom-right corner; sticky keeps it at the scrollport
   edge once the demo outline outgrows the card. The app's 40vh scroll-past-end
   padding goes too — a 360px card has no "past the end". */
.landing .shell {
  min-height: 100%;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  padding-bottom: 16px;
}
.landing .kbd-hint {
  position: sticky;
  bottom: 8px;
  width: fit-content;
  margin: auto 4px 0 auto;
}
/* The demo hosts the REAL document — DocBar then Shell, inside the same
   #doc / #shell-host contract frame.css describes. frame.css sizes the column
   with flex for the app frame; in the card it gets a height instead, and
   #shell-host stays the scroll surface. */
.landing-demo-doc {
  height: 360px;
  display: flex;
  flex-direction: column;
  text-align: left;
}

/* ---------------------------------------------------------------- claims */
/* The benefits stay NEAR THE HERO: right under the demo, before the page
   opens up into the two big sections. Same cards as the Apps section — the
   grid and card styles below are shared. */
.landing-claims {
  padding: 64px 24px 0;
  margin: 0 auto;
  max-width: 768px;
  text-align: center;
}

/* -------------------------------------------------- why free & apps */
/* Two centred 720px sections from the design: a mono kicker, a statement,
   and the section's one artifact (the donate card; the three app cards). */
.landing-why,
.landing-apps {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: 16px;
  padding: 112px 24px 0;
  margin: 0 auto;
  max-width: 768px;
}
/* ---------------------------------------------------------------- close */
/* The page's last word is the same as its first: start. It carries the
   closing air itself — the footer's hairline should not sit tight under it. */
.landing-close {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: 18px;
  padding: 112px 24px 120px;
}
.landing-close h2 {
  margin: 0;
  font-size: 30px;
  font-weight: 750;
  letter-spacing: -0.025em;
  line-height: 1.15;
}
.landing-close-act {
  height: 46px;
  padding: 0 26px;
  font-size: 15px;
  border-radius: 9px;
}
.landing-kicker {
  font-family: var(--mono);
  font-size: 11.5px;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-dim);
}
.landing-why h2,
.landing-apps h2 {
  margin: 0;
  font-size: 30px;
  font-weight: 750;
  letter-spacing: -0.025em;
  line-height: 1.15;
  text-wrap: balance;
}
.landing-why-lede {
  margin: 0;
  font-size: 15.5px;
  line-height: 1.65;
  color: var(--text-dim);
  max-width: 560px;
  text-wrap: pretty;
}
.landing-donate {
  margin-top: 8px;
  max-width: 560px;
  width: 100%;
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  background: var(--raised);
  padding: 20px 24px;
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 14px 20px;
  text-align: left;
}
.landing-donate-text {
  flex: 1;
  min-width: 240px;
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.landing-donate-text b {
  font-size: 14.5px;
  font-weight: 650;
}
.landing-donate-text p {
  margin: 0;
  font-size: 13px;
  line-height: 1.55;
  color: var(--text-dim);
}
/* The one button this section exists for — the design drew it as a ghost, but
   a quiet border on a quiet card buried the only action on the page's whole
   argument. Full accent, a step larger than the bar's CTA. */
.landing-donate-act {
  height: 44px;
  padding: 0 22px;
  flex: none;
  gap: 8px;
  border-radius: 9px;
  font-size: 14.5px;
}
.landing-app-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 16px;
  width: 100%;
  margin-top: 6px;
}
.landing-app {
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  padding: 20px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
}
.landing-app svg {
  color: var(--dot);
}
.landing-app b {
  font-size: 14.5px;
  font-weight: 650;
}
.landing-app p {
  margin: 0;
  font-size: 12.5px;
  line-height: 1.5;
  color: var(--text-dim);
}
.landing-app-act {
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  color: var(--accent);
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
  text-decoration: none;
}
.landing-app-act:hover {
  text-decoration: underline;
}
.landing-app-soon {
  font-size: 13px;
  font-weight: 600;
  color: var(--text-dim);
}
@media (max-width: 640px) {
  .landing-app-grid {
    grid-template-columns: 1fr;
  }
}

/* ---------------------------------------------------------------- privacy */
/* ------------------------------------------------------- the static pages */

/* The policy and the docs, rendered in-page from the same sources the static
   /privacy and /docs pages are built from (see Landing.svelte). These restate
   css/page.css's few layout rules, scoped, so one text reads the same in both
   frames — the static page cannot lend its stylesheet, because only its
   <main> crosses over. */
.landing-prose {
  max-width: 40rem;
  margin: 0 auto;
  padding: 2rem 1.25rem 4rem;
  font-size: 16px;
  line-height: 1.6;
}
.landing-prose h1 {
  font-size: 1.5rem;
  margin: 1rem 0 0.25rem;
}
.landing-prose h2 {
  font-size: 1.05rem;
  margin: 2rem 0 0.5rem;
}
.landing-prose p,
.landing-prose li {
  margin: 0.5rem 0;
}
.landing-prose ul {
  padding-left: 1.25rem;
}
.landing-prose .updated {
  color: var(--text-dim);
  margin: 0 0 2rem;
}
.landing-prose a {
  color: inherit;
}
.landing-prose main > footer {
  margin-top: 3rem;
  padding-top: 1rem;
  border-top: 1px solid var(--line);
  color: var(--text-dim);
  font-size: 0.9rem;
}

/* The docs are two columns — contents, prose — and so want more width than the
   policy's single column. These restate css/page.css's `.docs` block for the
   in-page frame; the markup is identical on both sides. */
.landing-docs {
  max-width: 55rem;
}
.landing-docs .docs {
  display: grid;
  grid-template-columns: 12rem minmax(0, 40rem);
  gap: 3rem;
}
.landing-docs .docs-toc {
  position: sticky;
  top: 1rem;
  align-self: start;
  font-size: 0.9rem;
}
.landing-docs .docs-toc b {
  color: var(--text-dim);
  font-size: 0.8rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
}
.landing-docs .docs-toc ol {
  list-style: none;
  margin: 0.6rem 0 0;
  padding: 0;
}
.landing-docs .docs-toc a {
  display: block;
  padding: 0.25rem 0 0.25rem 0.75rem;
  border-left: 2px solid var(--line);
  color: var(--text-dim);
  text-decoration: none;
}
.landing-docs .docs-toc a:hover {
  color: var(--text);
}
/* Set by js/docs-toc.ts, which the landing calls with its own scroller as the
   root — the window never moves on this page. */
.landing-docs .docs-toc a[aria-current="true"] {
  color: var(--text);
  border-left-color: var(--accent);
}
.landing-docs .docs-body > *:first-child {
  margin-top: 0;
}
@media (max-width: 860px) {
  .landing-docs .docs {
    grid-template-columns: minmax(0, 1fr);
    gap: 1.5rem;
  }
  .landing-docs .docs-toc {
    position: static;
  }
}

/* The docs' shortcut table. It scrolls INSIDE its own wrapper: three columns
   of key glyphs are wider than a phone, and the landing page must not scroll
   sideways because of them. */
.landing-prose .keys {
  overflow-x: auto;
}
.landing-prose table {
  border-collapse: collapse;
  width: 100%;
  font-size: 0.95rem;
}
.landing-prose th {
  text-align: left;
  font-weight: 600;
  color: var(--text-dim);
  font-size: 0.85rem;
}
.landing-prose th,
.landing-prose td {
  padding: 0.35rem 0.75rem 0.35rem 0;
  border-bottom: 1px solid var(--line);
  white-space: nowrap;
}
.landing-prose kbd {
  font: inherit;
  font-size: 0.9em;
  border: 1px solid var(--line);
  border-radius: 4px;
  padding: 0.1em 0.4em;
  background: var(--raised);
}

/* ---------------------------------------------------------------- footer */
.landing-foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 10px 20px;
  padding: 18px 40px;
  border-top: 1px solid var(--line);
  font-size: 12.5px;
  color: var(--text-dim);
}
/* The mark, not a caption. The dot was only half of what made this look like a
   different logo: the wordmark was inheriting the footer's own 12.5px dimmed
   body type, so the same <BrandMark /> came out grey and unweighted next to a
   17px/700 one in the bar. Same treatment as `.landing-mark`, one size down —
   and `.brand-tld` (css/frame.css) goes on quietening the `.org` by ratio, so
   the two marks are the same shape at two sizes. The links beside it keep the
   footer's quiet type: it is the mark that is the brand, not the row. */
.landing-site {
  display: flex;
  align-items: center;
  gap: 8px;
}
/* ONE SIZE DOWN, and nothing else: `.landing-mark` above already carries the
   weight, the tracking and the colour, so the footer mark differs from the
   bar's by a font-size and no more. That is what makes them the same mark
   rather than two that happen to agree today. */
.landing-site .landing-mark {
  font-size: 14px;
  color: var(--text);
}
/* The company line, between the mark and the links — three items in a
   space-between row, so it centres itself without being centred. Quieter than
   both neighbours: it is a statement of fact, not a thing to click. */
.landing-legal {
  color: var(--text-dim);
}
/* Two links now — docs and privacy — so they need a gap of their own. */
.landing-foot nav {
  display: flex;
  gap: 18px;
}
/* THE SAME MARK AS THE BAR, one size down — accent and halo, not `--dot`.
   `--dot` is the outline's own bullet grey, so a grey dot here read as a
   bullet where the two above it read as the logo. Same treatment as the demo
   card's head, which is the small size of this mark. */
.landing-site i {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 0 3.5px var(--accent-soft);
}
.landing-foot a {
  color: var(--text-dim);
  text-decoration: none;
}
.landing-foot a:hover {
  color: var(--text);
}

@media (max-width: 640px) {
  .landing-bar,
  .landing-foot {
    padding-left: 20px;
    padding-right: 20px;
  }
  /* The one bar item a phone can spare: the anchor. Scoped to the BAR —
     `← Back` on the docs and privacy views wears the same class, and hiding
     that left a phone with no way out of either but the browser's own back. */
  .landing-nav .landing-navlink {
    display: none;
  }
  .landing-hero {
    padding-top: 44px;
  }
  .landing-demo-keys {
    display: none;
  }
  /* The footer wraps at this width, and the company line is what it can spare:
     the mark and the two links are what someone on a phone came for. (If the
     policy ever names the operating entity — it does not today — that becomes
     the place a phone can still find it.) */
  .landing-legal {
    display: none;
  }
}
/* ---------------------------------------------------------------- narrow */
@media (max-width: 760px) {
  /* THE CHROME SHRINKS AND THE DOCUMENT GROWS, and those are opposite moves on
     purpose. A phone has less room, so the furniture gives some up — but the
     outline is the thing being READ, held further from the eye than a laptop
     screen and with no window to widen. Scaling it down with everything else
     is the reflex, and it is the wrong one: the text got smaller exactly where
     it needed to be bigger.

     `:root`, not a bare declaration — a custom property still needs something
     to hang on, and inside a media query with no selector it is simply
     dropped.

     STILL BIGGER THAN THE LAPTOP'S, just not by as much as it was. 20px was
     over-corrected: it is a reading size for a paragraph, and an outline is not
     paragraphs — it is short lines, most of them a few words, and at 20/31 a
     phone screen held about eight of them. 18/28 keeps the "further from the
     eye" allowance over the 16px desktop scale and gives back roughly two rows
     of outline per screen, which is the thing the extra size was costing. */
  :root {
    --doc-text: 18px;
    --doc-line: 28px;
    --doc-note: 15px;
    --doc-note-line: 22px;
    --doc-title: 25px;
    /* Half a step, not a whole one. 500 turns body copy into a heading and
       flattens the difference between a bullet and the title above it; 450 is
       the amount that survives being held at arm's length in dark mode and
       still reads as text. Faces without a 450 round to the nearest they
       have, which is the same 400 this replaces — never worse. */
    --doc-weight: 450;
  }

  /* The wordmark is the first thing a tight bar can afford to lose; the dot
     still says which app this is, and still goes home. */
  .brand-name { display: none; }
  .topbar-inner { gap: 10px; padding: 0 12px; }
  .bar-zone { flex: 0 0 auto; }
  .search { flex: 1 1 auto; }
  .save-state { display: none; }
  .docbar-inner { padding: 0 10px; }
  .shell { padding-left: 16px; padding-right: 16px; }
  /* …except where the gutter's two controls need the room. A mouse in a narrow
     WINDOW still summons ui/RowActions.svelte, and the grip parks 40px to the
     left of a root bullet's dot with the fold toggle between them; 16px of
     shell padding would take most of that off the page. A phone is the case
     this file is really for, and it draws neither. */
  @media (pointer: fine) {
    .shell { padding-left: 44px; }
  }
  /* The trail is chrome, and it comes down with the document rather than
     staying put — it sits directly above the title, and a crumb set larger
     than the outline reads as the heading of it. */
  .crumbs { font-size: 14px; }
  .crumbs a, .crumbs button, .crumbs .here { max-width: 170px; }
  /* The two lines under the title are chrome-sized, and at this scale they
     read as part of the document unless they stay small. */
  .doc-meta { font-size: 12px; }
  /* A DOCUMENT IS THE ONE THING ON THIS SCREEN THAT IS ACTUALLY PROSE, so it
     takes the reading size the outline takes and a little more leading than
     short lines need. The editor inherits every bit of this — the surface is
     the editor with no type of its own (css/document.css), which is what
     makes one number here move the whole document. */
  .document .doc-editor,
  .document .doc-rendered {
    font-size: var(--doc-text);
    line-height: 1.62;
  }
  /* The chrome above it does NOT grow with it: "Document" and its two buttons
     are furniture, and furniture set at reading size reads as a heading for
     the page rather than as a label on it. */
  .doc-kind { font-size: 12px; }
  .empty-hint { font-size: var(--doc-text); }
  .jump-hit .hit { line-height: var(--doc-line); }

  /* ---------- settings: the rail becomes a strip ----------
     Same markup, laid the other way: a column of names beside the pane needs
     168px this screen does not have, and the alternative — a drill-down — is a
     second navigation with a back button of its own inside a sheet that
     already has a close. So the rail lies down above the pane and scrolls
     sideways if it has to, which is a shape a thumb already knows. */
  .settings-sheet .set-frame { flex-direction: column; }
  .set-nav {
    flex-direction: row;
    width: auto;
    gap: 4px;
    padding: 8px 12px;
    border-right: 0;
    border-bottom: 1px solid var(--line);
    overflow-x: auto;
    /* No scrollbar under a row of five chips, and no bounce-trap: the row
       scrolls only when it must. */
    scrollbar-width: none;
  }
  .set-nav::-webkit-scrollbar { display: none; }
  .set-nav-item { padding: 6px 10px; }
}
/* ---------------------------------------------------------------- touch
   A phone is missing three things this app was built on — hover, modifier
   keys, and a keyboard — and this section replaces each of them. See
   js/touch.ts for the gesture half; what follows is only what CSS can own.

   The queries are deliberately different from the width ones above. Width says
   how much ROOM there is; `pointer: coarse` says what is POINTING at it. A
   narrow desktop window still has a mouse and should keep its hover reveals,
   and a tablet in landscape is wide but still all thumbs. Sizing on width and
   affordances on pointer is the split. */

:root {
  /* How much of the window the virtual keyboard is covering. Written by
     js/touch.ts off visualViewport; 0 whenever there is no keyboard, which is
     what makes every rule below safe on a desktop. */
  --kb: 0px;
  /* One 44px target on touch — the smallest thing a finger hits reliably. */
  --hit-touch: 44px;
  /* The bottom navigation strip. Same height as a comfortable target plus the
     breathing room a bar needs around one. */
  --nav-h: 52px;
}

/* ---------- the minimal touch frame ----------
   TWO BARS BECOME ONE, AND THE SECOND ONE MOVES TO THE BOTTOM.

   On a laptop the split is right: an APP bar that never changes (wordmark,
   search, account) above a DOCUMENT bar that changes as you navigate. On a
   phone the two together are about a hundred pixels — a third of what is left
   once the keyboard is up — and the largest single thing in them is a search
   field nobody has asked for yet.

   So on touch the top keeps only the answer to WHERE AM I: ⌂, the crumbs, and
   the ⋯ that acts on this outline. Everything that answers WHERE DO I GO —
   the rail, ‹, +, ✓, ⌕ — goes to ui/NavBar.svelte at the bottom, in reach of
   a thumb instead of across the screen from it. The outline gets the space,
   which is the only thing on screen anyone came to read.

   Nothing is lost, and that is a rule rather than a hope: every control this
   section hides is either in the bottom strip or in the ⋯ menu, which picks up
   the account rows on touch (see DocBar.svelte). */
@media (pointer: coarse) {
  /* THE APP BAR IS SIMPLY NOT ON SCREEN. It used to slide on when the ⌕ asked
     for it, because the search field lived in it and there was nowhere else to
     type; the field is a button that opens a panel now, and the ⌕ opens that
     panel directly — so there is nothing in this bar a phone ever needs. */
  .topbar { display: none; }

  /* The document bar, reduced to the trail. Each of these has a home in the
     bottom strip; none of them is gone. */
  .docbar-inner > .nav-btn,      /* the rail toggle  → ☰ */
  .docbar-inner > .bar-div,
  .nav > .nav-btn:not(.home),    /* ‹ ›              → ‹ */
  .nav .bar-div,
  .docbar-end .bar-div,
  .completed-toggle { display: none; }   /* ✓ */

  /* It is the top of the window now, so it carries the notch itself. */
  .docbar {
    height: calc(var(--docbar-h) + env(safe-area-inset-top, 0px));
    padding-top: env(safe-area-inset-top, 0px);
  }
  /* …unless the demo's strip is above it, in which case that is the top of the
     window and has the notch (css/screens.css). Both claiming it is a double
     inset: a band of page background under the status bar, and a bar pushed a
     notch's worth down the screen. #app carries the class rather than a `:has()`
     because boot puts the strip there and boot takes it away again — see
     enterDemo/beginSession in web/boot/index.ts. */
  .has-demo-strip .docbar {
    height: var(--docbar-h);
    padding-top: 0;
  }

  /* Two lines of banner is a lot of a phone screen to give up permanently, so
     the sentence keeps only the half a phone does not already know: an
     installed app reached the demo by TAPPING "Continue without an account",
     where a browser tab may have landed on /demo cold. */
  .demo-strip .demo-lede { display: none; }
}

/* ---------- no double-tap zoom on anything that is chrome ----------
   THE GESTURE THE VIEWPORT TAG NEVER COVERED. `user-scalable=no` is about
   PINCH; a double-tap is a separate gesture with a separate switch, and it is
   the one that was reported — two quick taps on the ⋯ or the avatar zoomed the
   whole page in on bulletlist.org, leaving the app scaled up with its bars off
   the side of the screen.

   `touch-action: manipulation` is the switch, and unlike the other two levers in
   input/touch/zoom.ts it behaves identically in every browser: it says "panning
   and continuous zooming only", which drops double-tap-to-zoom and keeps
   ordinary scrolling intact.

   NOT DECLARED GLOBALLY, and the exclusion is the point. The outline's own rows
   already carry `touch-action: pan-y` for the swipe (below), and the editable
   text is left alone deliberately — double-tap in a contenteditable is how a
   phone selects a word, and that is a text gesture worth keeping. What is listed
   here is the chrome: surfaces where a zoom is never what anybody meant.

   Outside `pointer: coarse` on purpose: a trackpad double-tap on a laptop is a
   click, and a tablet with a keyboard case can flip the pointer mid-session. */
button,
[role="button"],
[role="switch"],
.topbar,
.docbar,
.navbar,
.touchbar,
.sidebar,
.user-menu,
.user-menu .dropdown,
.bullet-menu,
.sheet,
.sheet-backdrop,
.kbd-hint { touch-action: manipulation; }

/* ---------- the bottom navigation ----------
   Pinned to the window, not to the scrolling surface, and NOT to the keyboard:
   it is only ever on screen when no bullet is current, which is exactly when
   there is no keyboard to ride. TouchBar takes over from there — the two swap,
   they never stack, and NavBar's `open` is the mirror of TouchBar's. */
.navbar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 39;
  display: flex;
  align-items: stretch;
  height: calc(var(--nav-h) + env(safe-area-inset-bottom, 0px));
  padding-bottom: env(safe-area-inset-bottom, 0px);
  background: var(--raised);
  border-top: 1px solid var(--line);
}
.nav-item {
  flex: 1 1 0;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  background: none;
  padding: 0;
  color: var(--gutter-icon);
  cursor: pointer;
  /* A finger has no hover, so the only feedback available is the press. */
  -webkit-tap-highlight-color: transparent;
  touch-action: manipulation;
}
.nav-item:disabled { opacity: .3; }
.nav-item:not(:disabled):active { color: var(--accent); }
/* `on` is a STATE, not a press: completed showing, search open. */
.nav-item.on { color: var(--accent); }
/* The one control here that changes the outline rather than the view of it.
   On a phone "add a bullet" is why the app is open, so it is centred and it is
   the only one carrying the accent at rest. */
.nav-add { color: var(--accent); }

/* The safe areas. Both bars are full-bleed, so they absorb the insets
   themselves rather than the app being padded — otherwise the notch would sit
   on a strip of page background above the bar. */
@supports (padding: env(safe-area-inset-top)) {
  .topbar {
    height: calc(var(--bar-h) + env(safe-area-inset-top));
    padding-top: env(safe-area-inset-top);
  }
  .shell { padding-bottom: calc(48px + env(safe-area-inset-bottom)); }
}

/* ---------- the keyboard toolbar ----------
   Fixed to the VISUAL viewport, riding on --kb. `position: fixed` alone is not
   enough on iOS, where the layout viewport does not shrink for the keyboard —
   that is the whole reason --kb is measured rather than assumed. */
.touchbar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  /* RIDES THE KEYBOARD ON A TRANSFORM, NOT ON `bottom`.
     `bottom: var(--kb)` moved the bar by LAYOUT, and --kb changes on every frame
     of the keyboard's animation — so each frame invalidated layout for a fixed
     element over a document that can be ten thousand rows long, and the bar
     arrived a frame late every time. That lag is what reads as flicker. A
     translate is composited: no layout, no reflow, and the strip lands where the
     number says on the same frame the number changes. */
  transform: translate3d(0, calc(-1 * var(--kb, 0px)), 0);
  z-index: 40;
  /* No transition on the transform: the bar is TRACKING the keyboard, and easing
     a value that is already being animated by the OS shows up as lag on the way
     up and a slide-down after the keys have gone. */
  transition: none;
  /* MOUNTS FADING IN. The first frames of a mount can catch --kb mid-flight —
     the keyboard is still animating, and a third-party keyboard (Gboard) can
     report a nonsense viewport for a frame or two — so the bar's first paint
     could land visibly off its final spot and then snap. The strip only
     mounts on real transitions now (strip-hold.svelte.ts), so a short fade is
     rare enough to read as intent and long enough to hide the settling.
     Opacity only: a position ease here would fight the tracking above. */
  animation: tb-in 120ms ease-out;
  /* The compositor is told in advance, so the first frame of the keyboard's
     arrival is not the one that promotes the layer. */
  will-change: transform;
  display: flex;
  align-items: center;
  gap: 4px;
  /* THE SAFE-AREA INSET IS PADDING, NOT AN OFFSET — which is what makes this a
     footer rather than a bar hovering above one. Lifting the whole strip clear
     of the home indicator left the page showing through underneath it, and a
     drop shadow over that gap only underlined the point: two edges, a strip of
     outline between them, and nothing sitting on the bottom of the screen. Held
     down here instead, the bar's own background fills the indicator's room and
     the border-top is the only line, exactly as `.navbar` above does it. */
  padding: 5px 6px calc(5px + env(safe-area-inset-bottom, 0px));
  background: var(--raised);
  border-top: 1px solid var(--line);
}
@keyframes tb-in {
  from { opacity: 0; }
}
/* The actions scroll if the phone is too narrow for all of them; the dismiss
   button does not, so there is always a way out of the keyboard. */
.touchbar-scroll {
  flex: 1;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: 4px;
  overflow-x: auto;
  scrollbar-width: none;
  /* Ten actions do not fit on a 390px phone, and a strip cut off flat at the
     edge does not look scrollable — it looks finished. Fading the last few
     pixels IS the affordance; there is no room for a chevron and nowhere
     honest to put one. */
  -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent);
  mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent);
}
/* The keyboard is ON the home indicator, so the inset above is already covered
   by it — keeping the padding as well floated the strip a thumb's width off the
   keys, with a band of outline showing through the gap. `kb-open` is written by
   js/touch/keyboard.ts, which is the only thing that knows the keyboard is
   really up. */
:root.kb-open .touchbar { padding-bottom: 5px; }
.touchbar-scroll::-webkit-scrollbar { display: none; }
/* THE SAME STRIP, FORMATTING A DOCUMENT (spec 004, MD-DEC-18). Some of its
   verbs have no icon worth drawing — H1, B, i, 1. and " ARE their own glyphs,
   and drawing a picture of a letter is worse than setting the letter. Same
   28px box, same targets, so the row's rhythm does not change when the caret
   moves from a bullet into a document. */
.tb-glyph {
  font-size: 15px;
  font-weight: 600;
  line-height: 1;
}
.tb-glyph-h1, .tb-glyph-h2 { font-size: 13.5px; font-weight: 700; }
.tb-glyph-bold { font-weight: 800; }
.tb-glyph-italic { font-family: Georgia, serif; font-style: italic; font-weight: 600; }
.tb-glyph-ordered { font-family: var(--mono); font-size: 13px; }
.tb-glyph-quote { font-family: Georgia, serif; font-size: 20px; line-height: 0.6; }
.tb-glyph-code { font-family: var(--mono); font-size: 11.5px; }

.tb-btn {
  font: inherit;
  flex: none;
  width: var(--hit-touch);
  height: var(--hit-touch);
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  background: none;
  color: var(--text);
  border-radius: var(--r-md);
  cursor: pointer;
  /* A finger has no hover, so the only feedback available is the press. */
  -webkit-tap-highlight-color: transparent;
  touch-action: manipulation;
}
.tb-btn:active { background: var(--accent-soft); color: var(--accent); }
/* Nothing to take back reads as nothing to take back. Quiet rather than absent:
   a button that comes and goes moves the nine beside it, and this strip is
   navigated by where things ARE. */
.tb-btn:disabled { opacity: .3; }
.tb-btn:disabled:active { background: none; color: var(--text); }
/* Starred reads as on, the same accent the row's own controls use for "already
   true" — so the button says which way tapping it goes. */
.tb-on { color: var(--accent); }
/* Delete is the one action here that costs something to hit by accident, so it
   sits behind a rule and answers in red rather than in the accent. */
.tb-danger { margin-left: 6px; border-left: 1px solid var(--line); border-radius: 0; padding-left: 6px; }
.tb-danger:active { background: color-mix(in srgb, var(--danger) 14%, transparent); color: var(--danger); }

/* THE WAY OUT, AND IT HAS TO LOOK LIKE ONE.
   This was the dimmest thing on the bar — `--text-dim` on the same flat surface
   as nine other icons — which is the wrong way round: the other ten are things
   you MIGHT do to a bullet, and this is the one that ends what you are doing.
   Filled in the accent, it stops being the eleventh icon and becomes the button,
   read before the row is scanned rather than hunted for at the end of it. */
.tb-dismiss {
  flex: none;
  /* Its own surface, so the strip scrolls BEHIND it rather than appearing to
     end there — this is the one control that must never move. */
  background: var(--accent);
  color: #fff;
  border-radius: var(--r-md);
  /* The rule stays, but as a gutter between the scrolling actions and this,
     rather than as this one's left edge. */
  margin-left: 8px;
  border-left: none;
  box-shadow: -8px 0 8px -6px color-mix(in srgb, var(--raised) 80%, transparent);
}
/* Pressed reads as pressed even though the surface is already filled. */
.tb-dismiss:active {
  background: color-mix(in srgb, var(--accent) 82%, #000);
  color: #fff;
}

/* ---------- coarse pointers ---------- */
@media (pointer: coarse) {
  /* GROW THE TARGET, NOT THE BUTTON.
     `--hit` is 28px — a mouse's target, and what the ⋯ and the rail toggle size
     themselves from. A finger covers ~44px, so 28px is a coin toss. The first
     attempt at this simply redefined `--hit` to 44px, and that was wrong in a
     way a screenshot showed instantly: the PAINTED box grew too, so the ⋯ filled
     its corner of a 46px bar edge to edge with a pixel to spare above and below.
     A control that big reads as a mistake, however easy it is to hit.

     So the box keeps its size and hangs an invisible 44px target off its middle
     — exactly what `.dot::after` and `.toggle::after` below already do for the
     outline's own controls. Nothing moves, nothing looks different, and the
     square a thumb has to land in is finger-sized. It may overhang the bar's
     padding: an oversized target at the screen edge is easier to hit, not
     harder, and there is nothing beside it to steal a press from. */
  .docbar-inner .nav-btn,
  .docbar-inner .app-menu > button,
  /* The account button rides beside the ⋯ on touch (DocBar.svelte mounts
     ui/AccountMenu.svelte there, because `pointer: coarse` hides the app bar it
     otherwise lives in), so it needs the same finger-sized target. */
  .docbar-inner .account-menu > button,
  .palettes .swatch { position: relative; }
  /* `::before`, NOT `::after` — an element has one of each, and `::after` was
     already taken. `.app-menu > button.offering::after` is the waiting-update
     badge: a 6px accent disc in the corner. Putting the target on the same
     pseudo-element meant the two rules fought over one box, this one won the
     size and the badge kept the fill, and the ⋯ turned into a 44px blue circle
     covering the whole control. Two jobs, two pseudo-elements. */
  .docbar-inner .nav-btn::before,
  .docbar-inner .app-menu > button::before,
  .docbar-inner .account-menu > button::before,
  .palettes .swatch::before {
    content: "";
    position: absolute;
    inset: 50% 50%;
    width: var(--hit-touch);
    height: var(--hit-touch);
    transform: translate(-50%, -50%);
  }

  /* MENU ROWS ARE TARGETS TOO, and they were the smallest of the lot: 13.5px of
     text in 6px of padding is a 30px row, in a list where the rows either side
     do something else entirely. These are the one case where the box itself has
     to grow — an invisible target cannot overlap its neighbours in a stack.

     40px, not 44. A menu is a COLUMN of these: at 44 the ⋯ menu's thirteen rows
     plus the palette strip ran seven tenths of the screen, which is its own kind
     of unusable. 40px is still a comfortable landing for a thumb and gives back
     a row's worth of height over the list.

     `> button` — DIRECT children only. The first version said `button`, which
     also caught the nine palette swatches nested inside `.palettes`: 21px discs
     stretched to 44px tall and padded to 45px wide, i.e. eggs. Scoping to the
     row level leaves the swatches the circles they are drawn as.

     The bullet menu's rows sit one level down now, inside the `.menu-sect` its
     groups are drawn with — so the selector follows them down rather than
     losing them. A row this rule stops matching is a row a thumb cannot hit. */
  .bullet-menu .menu-sect > button,
  .user-menu .dropdown > button {
    min-height: 40px;
    padding-left: 12px;
    padding-right: 12px;
  }


  /* HOVER REPLACEMENT, and the shape of it changed once there was a bottom
     toolbar to put things in.

     A finger is nowhere until it lands, so nothing can be revealed on hover —
     but the answer is NOT to leave all four row controls sitting there
     permanently. That was three buttons of furniture on every line of a screen
     that has ~360px to spend, and it was the text that paid. So the row keeps
     exactly one: the disclosure. ✓, zoom and ⋮ move to ui/TouchBar.svelte,
     which is a better home for them anyway — one set of large targets at the
     bottom of the screen, acting on the bullet you are in, instead of a set
     per row sized for a mouse.

     Nothing needs hiding here any more: the grip that replaced the strip lives
     in ui/RowActions.svelte, which a coarse pointer never summons at all —
     js/input/events/hover.ts leaves `hoverId` null and the component renders
     nothing.

     THE DISCLOSURE GOES RIGHT, and stays visible in every state.

     On the left it lived in the indent gutter, which is the one piece of
     horizontal space a deep outline cannot spare — at depth four the gutter is
     most of the screen. Pinned to the right edge it costs the text nothing
     that isn't already the row's own padding, it lands under the thumb rather
     than across the screen from it, and a column of them reads as a column
     rather than as a ragged left margin that moves with the indent.

     A MOUSE KEEPS IT ON THE LEFT, where it has always been. That layout has a
     gutter to spend — see the `pointer: fine` block in css/document.css, which
     widens it to carry the ⋮ grip as well — and a fold control at the far edge
     of a 720px column would be nowhere near the bullet it folds. The argument
     above is about a screen 360px wide, and it does not travel. */
  .item > .row > .toggle {
    /* `absolute` restated on purpose. The hit-target rule below used to say
       `.dot, .toggle { position: relative }` so its ::after had something to
       hang on — which quietly dropped the toggle OUT of the gutter and INTO
       the row, where it took real width from the text on every parent line.
       An absolutely positioned box is a containing block too, so ::after is
       just as happy and the row gets its width back. */
    position: absolute;
    left: auto;
    right: 0;
    top: 0;
    opacity: 1;
    height: var(--doc-line);
  }
  /* Room for it, so a long bullet wraps before it reaches the control rather
     than running underneath it. Only where there IS one — an unindentable leaf
     should use the full width. */
  .item > .row:has(> .toggle) > .text { padding-right: 26px; }

  /* Finger-sized targets for the two the row still has. The row's own height
     is left alone — inflating every line would halve how much outline fits on
     a phone screen — so the extra area is claimed OUTSIDE the box instead. */
  .dot::after,
  .toggle::after {
    content: "";
    position: absolute;
    inset: 50% 50%;
    width: var(--hit-touch);
    height: var(--hit-touch);
    transform: translate(-50%, -50%);
  }
  /* The toggle is absolute (above) and so already a containing block. */
  .dot { position: relative; }

  /* SWIPE. `pan-y` hands vertical movement to the scroller and keeps
     horizontal for js/touch.ts — without it the browser claims the gesture
     first and the row never moves. */
  .item > .row { touch-action: pan-y; }
  /* Follows the finger with no transition; the snap back when a swipe is
     abandoned is the one animated part. */
  .item > .row.swiping { transition: none; }
  .item > .row { transition: transform var(--dur-fast) var(--ease); }

  /* The dot is a long-press target here, so the OS text callout has to stay
     out of it — otherwise iOS offers "Copy / Look Up" over the menu. */
  .dot { -webkit-touch-callout: none; user-select: none; }

  /* Nothing here has a hover state to promise, and a sticky :hover on a
     touchscreen leaves the last-tapped row lit indefinitely. */
  .toggle:hover, .dot:hover i { background: none; }
  .toggle:hover { color: var(--gutter-icon); }
  .dot:hover i { transform: none; background: var(--dot); }
  .toggle:active { background: var(--accent-soft); color: var(--accent); }

  /* The last rows must clear the toolbar, which is fixed over them — and the
     KEYBOARD, which overlays the page rather than shrinking it (see the
     viewport meta in index.html). Without --kb in here the final bullets of an
     outline simply cannot be scrolled out from under the keys. */
  .shell {
    padding-bottom: calc(var(--kb) + var(--hit-touch) + 40px + env(safe-area-inset-bottom, 0px));
  }

  /* THE PANELS START AT THE TOP, because the bottom half is the keyboard.

     The search panel opens with its field focused, so the keyboard is up before
     the panel has finished animating in. (The browser and the move sheet do NOT
     take the keyboard on a phone — they browse as well as search, and half the
     screen is the tree — but they are the same box and want the same shape.) `top: 12vh`
     and `max-height: 70vh` are a desktop shape: on a phone they spend a tenth
     of the screen on empty space above the panel and then run the list down
     behind the keys, so the hits you are typing towards are the ones you
     cannot see. Pinned near the top with the keyboard subtracted from the
     height, the whole panel is the part you can actually read.

     `dvh` rather than `vh`: `vh` is the LARGEST viewport, address bar
     excluded, so it overshoots by the height of the browser chrome on exactly
     the screens with the least room to give. */
  .sheet {
    top: calc(env(safe-area-inset-top, 0px) + 8px);
    max-height: calc(100dvh - var(--kb) - env(safe-area-inset-top, 0px) - 16px);
  }

  /* THE PALETTE STRIP BECOMES A GRID, AND ITS ROW BECOMES A COLUMN.

     Nine discs in one line is a mouse's layout: side by side it puts ~26px
     between centres, and the trick used above for the dot and the disclosure —
     claiming hit area outside the box — would only overlap the neighbour's
     target. Nine targets that each mean a different colour is the last place to
     allow a mis-tap.

     Five columns over two rows instead, which is the shape that makes each CELL
     44px wide at a phone's sheet width: the disc is 32px and the space around it
     is the rest of the target. Nothing is hidden and nothing needs a second tap.

     Nothing here has to restructure the ROW: `.settings-sheet .sheet-row` wraps
     (css/document.css), so a full-width strip drops onto its own line by itself.
     That is deliberately not a `:has()` rule — wrapping is the same answer at
     every width, including a desktop window dragged narrow, which has no coarse
     pointer and would never have matched this block.
     (These rules were scoped to the account dropdown, which is where the strip
     used to live. It is a settings row now; the shape it needs on a phone is
     unchanged.) */
  .palettes { width: 100%; }
  .palettes .swatches {
    width: 100%;
    display: grid;
    grid-template-columns: repeat(5, 1fr);
    gap: 12px 4px;
    justify-items: center;
    padding: 8px 0 2px;
  }
  .palettes .swatch {
    width: 32px;
    height: 32px;
    -webkit-tap-highlight-color: transparent;
    touch-action: manipulation;
  }
  .palettes .swatch > i { width: 9px; height: 9px; }
  /* No hover to promise — the ring is the only state a finger can read. */
  .palettes .swatch:hover { transform: none; }

  /* The switch and the mode segment are already past 44px tall in their row's
     min-height, but the segment's two halves are not wide enough apart: at
     13px text and 10px padding each half is ~72px, which is fine, while the
     switch is 38px and needs the same overhang treatment as everything else in
     this file. */
  .settings-sheet .switch { position: relative; }
  .settings-sheet .switch::before {
    content: "";
    position: absolute;
    inset: 50% 50%;
    width: var(--hit-touch);
    height: var(--hit-touch);
    transform: translate(-50%, -50%);
  }
}

@media (prefers-reduced-motion: reduce) {
  .item > .row { transition: none; }
}
/* ---------------------------------------------------------------- print */
/* Print is the outline and nothing else. */
@media print {
  .topbar, .docbar, .sidebar, .kbd-hint, .plus-row, .doc-hint, .doc-offer, .fmt-bar { display: none; }
  body { background: #fff; }
  #app, #app-body, #doc { display: block; height: auto; overflow: visible; }
  #shell-host { overflow: visible; }
  .shell { margin: 0; max-width: none; padding: 0; box-shadow: none; }
  .row-actions, .menu-handle, .toggle { display: none; }
}
/* THE SHEET PLUGIN'S OWN STYLESHEET (plugins/sheet).
   Imported by the plugin's entry module, so it ships and is dropped with it —
   the app's own cascade (web/css/style.css) says nothing about sheets.

   It borrows the DOCUMENT's chrome deliberately — `.doc-bar`, `.doc-kind`,
   `.doc-act` are the page's bar, and two kinds of page wearing one bar is the
   point of the kind registry. Everything a grid needs beyond that is here.

   THE TABLE'S CLASSES ARE `grid-*` AND NOT `sheet-*`, WHICH IS NOT A STYLE
   PREFERENCE. `.sheet` and `.sheet-head` are already taken, 1200 lines up, by
   the modal PANELS — settings, docs, sign-in, jump, move-to. A table wearing
   them inherited `position: fixed`, a card's background and radius, and
   `display: flex`: the grid rendered as a floating white panel over the
   outline with its column letters stacked in a pile, because flex children do
   not lay out as table cells. Only the outer `.sheet-surface` keeps the word,
   and only because nothing else claims it.

   THE GRID LINES ARE THE COMPONENT. A spreadsheet is legible because the eye
   can run along a row and down a column without counting; drawn any lighter
   than the outline's own `--line` that stops being true, and drawn heavier it
   becomes the loudest thing on a page whose job is to hold a few numbers. So:
   exactly --line, on every edge, and nothing else carrying weight. */
.sheet-surface {
  margin: 2px 0 20px;
  border-bottom: 1px solid var(--line);
  padding-bottom: 14px;
}
/* The one thing on a bullet's page allowed to scroll sideways: a table wider
   than the column is a real thing people build, and reflowing it into
   something narrower would be inventing a layout the numbers don't have. */
.grid-scroll { overflow-x: auto; }
.grid-table {
  border-collapse: collapse;
  font-size: 13.5px;
  /* Digits that line up under each other — the whole reason a column of
     figures reads as a column rather than as ragged text. */
  font-variant-numeric: tabular-nums;
}
.grid-table th,
.grid-table td { border: 1px solid var(--line); padding: 0; }
/* THE ADDRESS STRIP IS ONE FRAME, not two edges that happen to meet. It reads
   as one only if it is drawn as one: the same ink, the same weight, and both
   labels CENTRED in their own box.

   The row numbers used to be right-aligned, which was the mistake — a column of
   digits hard against the cells while the letters floated in the middle of
   theirs, so the corner never squared up and the eye read two strips of
   different kinds. Centred in both directions, the corner is a corner and the
   labels sit on the axes of the things they name.

   The strip is also SCENERY: dimmer than the content it frames, never a place
   the caret can land, and only incidentally the thing that carries a delete
   button. */
.grid-addr {
  position: relative;
  padding: 3px 6px;
  font-size: 11px;
  font-weight: 500;
  /* Tabular figures, so 1 and 11 and 111 sit on the same axis rather than
     drifting left as the sheet gets longer. */
  font-variant-numeric: tabular-nums;
  color: var(--text-dim);
  background: color-mix(in srgb, var(--line) 30%, var(--bg));
  text-align: center;
  user-select: none;
}
/* Wide enough for three digits before the column has to move, which is where a
   sheet in an outliner stops being a sheet. */
.grid-rownum { width: 34px; }
.grid-corner {
  width: 34px;
  background: color-mix(in srgb, var(--line) 30%, var(--bg));
}
/* Hover-revealed, because a × on every row and column at rest would be
   thirty destructive buttons on a table with nothing in it yet. */
.grid-kill {
  position: absolute;
  inset: 0;
  border: 0;
  border-radius: 0;
  background: var(--danger);
  color: #fff;
  font-size: 12px;
  line-height: 1;
  opacity: 0;
  cursor: pointer;
  transition: opacity var(--dur-fast) var(--ease);
}
.grid-addr:hover .grid-kill { opacity: .9; }
.grid-cell input {
  display: block;
  width: 100%;
  min-width: 96px;
  border: 0;
  background: none;
  padding: 5px 8px;
  font: inherit;
  color: inherit;
  /* The browser's own focus ring is drawn OUTSIDE the box and would be clipped
     by the cell's neighbours; an inset shadow is the same signal, complete. */
  outline: none;
}
.grid-cell input:focus { box-shadow: inset 0 0 0 2px var(--accent); }
/* THE RANGE, and it is painted on the CELL rather than on the text: a
   spreadsheet selection is a rectangle of cells, and the browser's own text
   highlight inside one input cannot say that. The active cell keeps its ring
   and stays unshaded, so "where the caret is" survives a big selection. */
.grid-cell.sel { background: var(--accent-soft); }
.grid-cell.sel.at { background: none; }
/* The address strip lights for the range too — which column am I in is the
   question a grid gets asked most, and counting is not an answer. */
.grid-addr.lit {
  background: color-mix(in srgb, var(--accent) 16%, var(--bg));
  color: var(--accent);
}
/* The caret's address, in the bar, in the notation formulas use. */
.grid-where {
  font-family: var(--mono);
  font-size: 11.5px;
  color: var(--text-dim);
  margin-right: 4px;
}
/* Numbers right, text left — the alignment IS the type check: a figure that
   sits on the left is one the sheet is not counting, which is the mistake
   worth seeing before the total is wrong. */
.grid-cell.num input { text-align: right; }
.grid-cell.err input { color: var(--danger); }
.grid-add {
  display: flex;
  align-items: center;
  gap: 4px;
  height: 24px;
  padding: 0 8px;
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  background: none;
  color: var(--text-dim);
  font-size: 12px;
  cursor: pointer;
}
.grid-add:hover { border-color: var(--accent); color: var(--accent); }
/* THE HINT IS A FOOTNOTE, and it was drawn as a paragraph of chips: eight
   filled pills under a table with nothing in it yet, which made the loudest
   thing on the page the thing explaining the page. It is read once and then
   never again, so it gets a footnote's weight — smaller, dimmer, and with the
   code set in mono WITHOUT a fill. The monospace face is already enough to say
   "type this"; the pill behind it was saying it twice. */
.grid-hint {
  margin: 9px 0 0;
  font-size: 11.5px;
  line-height: 1.6;
  color: color-mix(in srgb, var(--text-dim) 78%, var(--bg));
}
.grid-hint code {
  font-family: var(--mono);
  font-size: 11px;
  color: var(--text-dim);
}
