/* Relay browser UI — stylesheet.
 *
 * Extracted from index.html (stage 1 of splitting that file up): it was 925 of
 * its 2,564 lines. Served from /static/app.css; the markup keeps only the
 * before-paint theme script inline, which has to run before the first paint to
 * avoid a flash and so cannot move here.
 *
 * Section markers below are the original ones — they are also the seams the
 * stylesheet would split along if it ever needs to.
 */

*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* Form controls take `buttontext`/`fieldtext` from the UA, which `color-scheme`
   resolves to white on a dark theme and black on a light one — a colour owned by
   no theme. Harmless while every control that renders text sets its own colour
   (`.menu-btn` draws bars, not glyphs), but it leaks a foreign value into the
   page and it is what the per-theme colour audit trips over. `hr` is here for
   the same reason: it renders as a `border-top`, so its `color` is never
   painted, but the UA still hands it a grey belonging to no theme. The audit
   found it the moment a fixture first contained a horizontal rule. */
button, input, select, textarea, hr { color: inherit; }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Dialog panels take focus from script when they open (js/dialog.js) so a
   screen reader announces them; that landing spot is not a control and must
   not draw a ring around the whole sheet. */
.pm-inner:focus, .sm-inner:focus { outline: none; }

/* One switch for the whole UI: card entrances, the new-post flash, sheet and
   modal entrances, the accordion. Durations collapse rather than vanish, so
   anything waiting on animationend/transitionend still hears it. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    animation-delay: 0ms !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}
a:focus-visible      { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 2px; }

:root, [data-theme="dark"] {
  color-scheme: dark;
  --bg:          #090a0d;
  --surface:     #0f1116;
  --border:      #1d2230;
  --text:        #d8dde8;
  --muted:       #718096;   /* 4.70:1 on --surface — #4a5568 was 2.51, timestamps barely there */
  --accent:      #f59e0b;
  --accent-dim:  #d97706;
  --accent-glow: rgba(245, 158, 11, 0.10);
  --tag-bg:      #141208;
  --tag-active:  #231a05;
  --green:       #10b981;
  --red:         #f87171;
  --body:        #b8bfcc;
  --body-strong: #c4cad8;
  --wash-raise:  rgba(255, 255, 255, 0.02);

  /* Semantic tokens. Components reference these, never a literal — a theme
     redefines this block and nothing else. Values derivable from --accent or
     --red are not tokens at all: they are color-mix() at the point of use, so
     they follow the accent into every theme for free. */
  /* Secondary and tertiary accents, for chips that are decorative rather than
     semantic — the id pill, the tag count, the uppercase section labels, the
     `via` source. Without them every such chip is grey in every theme, because
     the contract had one accent and nothing else.

     Violet and cyan against relay's amber: far enough round the wheel to read
     as separate information, and clear of --green and --red, which are
     semantic and must not be borrowed for decoration. They sit at the same
     lightness step as the tokens already here — the bright step on dark, the
     deep step on light — so they read as part of the same set rather than as
     stickers on it. */
  --accent-2:      #a78bfa;            /* violet */
  --accent-3:      #22d3ee;            /* cyan */
  --on-accent:     #000;               /* foreground on an --accent fill */
  --on-scrim:      #eee;               /* foreground on a dark scrim */
  --border-hover:  #2d3348;
  --scrim-rgb:     0, 0, 0;            /* channels, so call sites pick the alpha */
  --shadow-card:   0 4px 24px rgba(0, 0, 0, 0.35);
  --shadow-pill:   0 2px 10px rgba(0, 0, 0, 0.35);
  --shadow-modal:  0 24px 64px rgba(0, 0, 0, 0.6);
  --shadow-drawer: 8px 0 40px rgba(0, 0, 0, 0.6);
  font-family: var(--font-ui);
  font-size: 13px;
}

/* Two faces, two jobs: the mono is relay's voice — chrome, metadata, titles,
   headings, tables, code, the editor — and the sans carries only running
   prose (paragraphs, list items, quotes), where line after line of mono tires
   the eye. Same family, so they sit together. On `html`, not `:root`: they
   are not per-theme tokens. */
html {
  --font-ui:   'IBM Plex Mono', monospace;
  --font-read: 'IBM Plex Sans', system-ui, sans-serif;
}

:root[data-theme="light"], [data-theme="light"] {
  color-scheme: light;
  --bg:          #edeae1;
  --surface:     #ffffff;
  --border:      #d2ccbb;
  --text:        #1c1e24;
  --muted:       #6b6656;   /* 4.77:1 on --bg (the sidebar), 5.74 on --surface — #736e5d was 4.24 */
  --accent:      #935305;   /* 6.03:1 as text on white, 5.15 on --tag-bg */
  --accent-dim:  #7a4404;   /* hover fill: 7.91:1 under --on-accent */
  --accent-glow: rgba(147, 83, 5, 0.12);
  --tag-bg:      #f7ecd6;
  --tag-active:  #efe0c0;
  --green:       #047857;
  --red:         #c81e1e;
  --body:        #33373f;
  --body-strong: #22252c;
  --wash-raise:  rgba(0, 0, 0, 0.03);

  --accent-2:      #6d28d9;            /* violet, deep step to match this theme */
  --accent-3:      #0e7490;            /* cyan, deep step */
  --on-accent:     #fff;   /* 6.03:1 on #935305 */
  --on-scrim:      #eee;
  --border-hover:  #b8b09a;
  --scrim-rgb:     0, 0, 0;
  --shadow-card:   0 4px 16px rgba(0, 0, 0, 0.10);
  --shadow-pill:   0 2px 10px rgba(0, 0, 0, 0.12);
  --shadow-modal:  0 24px 64px rgba(0, 0, 0, 0.18);
  --shadow-drawer: 8px 0 40px rgba(0, 0, 0, 0.16);
}

/* Gruvbox classic dark (morhetz). Not invented here — these are the values from
   the corellia desktop identity, and `relay_tui/palettes/gruvbox.toml` already
   anchors on the same three (#fabd2f, #504945, #1d2021), so the browser UI, the
   TUI, the terminal and the desktop agree.

   This is the theme that justified two of the semantic tokens. `--on-accent` is
   #1d2021 rather than black — gruvbox puts dark *brown* on its yellows, which is
   what the ricing does everywhere else — and `--border-hover` maps onto bg3, a
   colour the palette already defines for exactly that job. A theme that only
   ever restated #000 would not have proven either token was needed. */
:root[data-theme="gruvbox"], [data-theme="gruvbox"] {
  color-scheme: dark;
  --bg:          #1d2021;   /* bg0_h (hard) */
  --surface:     #282828;   /* bg0 */
  --border:      #504945;   /* bg2 */
  --text:        #fbf1c7;   /* fg0 — the brightest fg, not fg1 */
  --muted:       #a89984;   /* fg4 — gray was 4.02:1 */
  --accent:      #fabd2f;   /* yellow */
  --accent-dim:  #d79921;   /* neutral yellow */
  --accent-glow: rgba(250, 189, 47, 0.10);
  --tag-bg:      #32302f;   /* bg0_s — a warm step off the surface */
  --tag-active:  #3c3836;   /* bg1 */
  --green:       #b8bb26;
  --red:         #fb4934;
  --body:        #ebdbb2;   /* fg1 — fg2 left prose at 8.6:1 */
  --body-strong: #fbf1c7;   /* fg0 */
  --wash-raise:  rgba(235, 219, 178, 0.04);   /* lifts with fg, not pure white */

  --accent-2:      #d3869b;   /* purple */
  --accent-3:      #8ec07c;   /* aqua */
  --on-accent:     #1d2021;
  --on-scrim:      #ebdbb2;
  --border-hover:  #665c54;   /* bg3 */
  --scrim-rgb:     18, 20, 21;   /* a hair warmer than pure black, like the bg */
  --shadow-card:   0 4px 24px rgba(0, 0, 0, 0.40);
  --shadow-pill:   0 2px 10px rgba(0, 0, 0, 0.40);
  --shadow-modal:  0 24px 64px rgba(0, 0, 0, 0.62);
  --shadow-drawer: 8px 0 40px rgba(0, 0, 0, 0.62);
}

/* Tokyo Night (folke/tokyonight.nvim, "night" variant). Every value below is
   from the reference palette — the elevated surface is storm's `bg`, which is
   what the theme itself uses for floats and panels, and the two border steps
   are `fg_gutter` and `dark3` rather than anything mixed by hand.

   This is the first *cool* theme, and that was the point of adding it: the
   literal check cannot see a token used for the wrong role, only for the wrong
   value. Everything shipped before it was amber or yellow, so a warm assumption
   baked into a token's meaning would have gone unnoticed. Nothing outside this
   block changed to accommodate it.

   Two role corrections after seeing it rendered. `--body` is post prose — the
   main reading text — so it takes `fg`; `fg_dark` is the palette's *secondary*
   UI colour and using it here was a mis-mapping that cost 3.7 points of
   contrast. And the canvas drops to `bg_dark` so cards sit on the theme's
   signature `bg` rather than on storm's lighter panel tone. Together those took
   prose from 6.9:1 to 10.6:1 — see the contrast rule in tests/test_css_tokens.py.

   `--accent` was `blue` (#7aa2f7) — 89% saturation, but at 72% lightness, which
   reads as pastel next to this surface (6.8:1) no matter how saturated the hue
   is. `blue2` keeps the same 89% saturation at 45% lightness instead: still
   inside the reference palette, same family as `blue`/`blue0`, but it actually
   registers as accent rather than tint (7.3:1). `--accent-dim` follows it to
   `green2`, the palette's own hue-neighbour of `blue2` (188° vs 189°) rather
   than staying on `blue0`, whose 223° no longer sits next to the new accent. */
:root[data-theme="tokyo-night"], [data-theme="tokyo-night"] {
  color-scheme: dark;
  --bg:          #16161e;   /* bg_dark — the palette's darker canvas */
  --surface:     #1a1b26;   /* night bg — cards sit on the theme's signature tone */
  --border:      #3b4261;   /* fg_gutter */
  --text:        #c0caf5;   /* fg */
  --muted:       #737aa2;   /* dark5 — comment (#565f89) was 2.76:1; see TEXT_ROLE_EXCEPTIONS */
  --accent:      #0db9d7;   /* blue2 — see block note on why not the pastel `blue` */
  --accent-dim:  #41a6b5;   /* green2 — blue2's hue-neighbour, not blue0 */
  --accent-glow: rgba(13, 185, 215, 0.10);
  --tag-bg:      #24283b;   /* storm bg */
  --tag-active:  #414868;   /* terminal_black */
  --green:       #9ece6a;
  --red:         #f7768e;
  --body:        #c0caf5;   /* fg — see note: fg_dark is secondary UI, not prose */
  --body-strong: #c0caf5;   /* fg */
  --wash-raise:  rgba(192, 202, 245, 0.04);   /* lifts with fg, not pure white */

  --accent-2:      #bb9af7;   /* magenta */
  --accent-3:      #7dcfff;   /* cyan */
  --on-accent:     #1a1b26;   /* 7.3:1 on #0db9d7 */
  --on-scrim:      #c0caf5;
  --border-hover:  #545c7e;   /* dark3 */
  --scrim-rgb:     22, 22, 30;   /* bg_dark #16161e */
  --shadow-card:   0 4px 24px rgba(0, 0, 0, 0.42);
  --shadow-pill:   0 2px 10px rgba(0, 0, 0, 0.42);
  --shadow-modal:  0 24px 64px rgba(0, 0, 0, 0.64);
  --shadow-drawer: 8px 0 40px rgba(0, 0, 0, 0.64);
}

/* ANSI Dark / ANSI Light — the IBM VGA 16-colour set, which is what a terminal
   actually shows and where the vintage feel comes from. (The other common
   "VGA" table is Microsoft's #800000/#c0c0c0 variant; this is the #aa/#55 one.)
   Sourced from cli.r-lib.org's `vga` palette:

     black   #000000   br_black   #555555      blue    #0000aa   br_blue    #5555ff
     red     #aa0000   br_red     #ff5555      magenta #aa00aa   br_magenta #ff55ff
     green   #00aa00   br_green   #55ff55      cyan    #00aaaa   br_cyan    #55ffff
     yellow  #aa5500   br_yellow  #ffff55      white   #aaaaaa   br_white   #ffffff

   Two things follow from a 16-colour palette with nothing between #aaaaaa and
   #ffffff. **Cards are drawn, not filled**: `--surface` equals `--bg` and the
   card is defined by its border, which is how a terminal frames anything — the
   alternative was inventing an off-black, and no such colour exists here.
   **There is no text hierarchy by lightness**: prose and headings are both pure
   white (21:1), and emphasis comes from colour, again as it does in a terminal.
   #aaaaaa would have put prose at 9.0:1, under the floor.

   `--accent` is bright cyan rather than green: green is already `--green`, and
   an accent indistinguishable from the "connected" dot is a bad trade for
   phosphor nostalgia. The light theme takes blue so the pair reads as one
   family, the way Relay Dark and Relay Light share amber. */
:root[data-theme="ansi-dark"], [data-theme="ansi-dark"] {
  color-scheme: dark;
  --bg:          #000000;   /* black */
  --surface:     #000000;   /* black — cards are framed, not filled */
  --border:      #555555;   /* br_black */
  --text:        #ffffff;   /* br_white */
  --muted:       #aaaaaa;   /* white */
  --accent:      #55ffff;   /* br_cyan */
  --accent-dim:  #00aaaa;   /* cyan */
  --accent-glow: rgba(85, 255, 255, 0.10);
  --tag-bg:      #000000;   /* black — the pill is its accent-tinted outline */
  --tag-active:  #555555;   /* br_black */
  --green:       #55ff55;   /* br_green */
  --red:         #ff5555;   /* br_red */
  --body:        #ffffff;   /* br_white */
  --body-strong: #ffffff;   /* br_white */
  --wash-raise:  rgba(255, 255, 255, 0.06);

  --accent-2:      #ff55ff;   /* br_magenta */
  --accent-3:      #ffff55;   /* br_yellow */
  --on-accent:     #000000;
  --on-scrim:      #ffffff;
  --border-hover:  #aaaaaa;   /* white */
  --scrim-rgb:     0, 0, 0;
  --shadow-card:   0 4px 24px rgba(0, 0, 0, 0.6);
  --shadow-pill:   0 2px 10px rgba(0, 0, 0, 0.6);
  --shadow-modal:  0 24px 64px rgba(0, 0, 0, 0.8);
  --shadow-drawer: 8px 0 40px rgba(0, 0, 0, 0.8);
}

:root[data-theme="ansi-light"], [data-theme="ansi-light"] {
  color-scheme: light;
  --bg:          #ffffff;   /* br_white */
  --surface:     #ffffff;   /* br_white — cards are framed, not filled */
  --border:      #aaaaaa;   /* white */
  --text:        #000000;   /* black */
  --muted:       #555555;   /* br_black */
  --accent:      #0000aa;   /* blue */
  --accent-dim:  #5555ff;   /* br_blue — the palette has nothing darker, so the
                               hover lifts instead of deepening */
  --accent-glow: rgba(0, 0, 170, 0.10);
  --tag-bg:      #ffffff;   /* br_white */
  --tag-active:  #aaaaaa;   /* white */
  --green:       #00aa00;   /* green */
  --red:         #aa0000;   /* red */
  --body:        #000000;   /* black */
  --body-strong: #000000;   /* black */
  --wash-raise:  rgba(0, 0, 0, 0.06);

  --accent-2:      #aa00aa;   /* magenta */
  --accent-3:      #aa5500;   /* yellow/brown */
  --on-accent:     #ffffff;
  --on-scrim:      #ffffff;   /* scrims stay dark even under a light theme */
  --border-hover:  #555555;   /* br_black */
  --scrim-rgb:     0, 0, 0;
  --shadow-card:   0 4px 16px rgba(0, 0, 0, 0.14);
  --shadow-pill:   0 2px 10px rgba(0, 0, 0, 0.16);
  --shadow-modal:  0 24px 64px rgba(0, 0, 0, 0.22);
  --shadow-drawer: 8px 0 40px rgba(0, 0, 0, 0.20);
}

/* Catppuccin — four flavours, from catppuccin.com/palette. Role mapping follows
   the project's own guidance: `mantle` is the canvas and `base` the raised
   surface, `surface0/1/2` step up through borders. Muted is the first step of
   the text ramp that reaches AA as meta text — `overlay2` (subtext1 on Latte);
   `overlay1`, the guidance's "subtle" tone, sat at 2.8–4.1:1.

   **The three dark flavours carry different accents on purpose.** Built to the
   letter they were near-indistinguishable: Catppuccin varies its backgrounds
   between flavours but barely its accents, so mauve everywhere gave #ca9ee6,
   #c6a0f6 and #cba6f7 — the same light purple three times — over backgrounds
   only 2.5x apart in luminance, which at that darkness reads as one theme. So
   Frappé takes **peach** (warm), Macchiato **blue** (cool), Mocha keeps the
   canonical **mauve**, and Mocha drops to `crust`/`mantle` so it is also
   unmistakably the deepest — widening the spread to 3.6x and *raising* its
   contrast from 11.3:1 to 12.1:1. Choosing an accent is what Catppuccin expects
   a port to do; mauve is its default, not a requirement.

   Frappé cannot be pushed the other way to widen the spread further: on
   `surface0` its text falls to 6.2:1, under the accessibility floor.

   **The decorative chips had the same problem one layer down.** `--accent-2`/
   `--accent-3` leaned on the same three hues as the accents themselves — peach
   appeared in all three flavours, mauve and teal in two — so a flavour's chips
   often just echoed a sibling's identity colour instead of having their own.
   Every dark flavour's full accent-eligible set (all fourteen members, `--red`/
   `--green` excluded as semantic) was ranked by contrast against its own
   surface: the lower the number, the less pastel it reads at that flavour's
   darkness. Frappé's own ceiling *is* peach/mauve/teal — its next-best options
   score worse — so only the mauve echo was worth removing there, to sapphire,
   at essentially no cost (5.60:1 → 6.25:1). Macchiato and Mocha both had real
   headroom: peach was replacing genuinely punchier maroon in both (7.62:1 →
   6.80:1; 9.92:1 → 8.49:1), and Mocha's teal gave way to sky for the same
   reason (11.79:1 → 11.29:1). Every replacement still clears the 4.5:1 chip
   floor with margin — the tightest is Frappé's new sapphire at 5.13:1.

   Latte is the exception, and the reason is worth recording because it is a
   fact about the palette rather than a choice. Measured as chip text on Latte's
   own base, **every one of its fourteen accent members lands under AA** — best
   is mauve at 4.18:1, and the ceiling with no tint at all is 4.80:1. Its warm
   members are far worse (peach 2.38:1, yellow 2.13:1) simply because orange and
   yellow on a near-white ground cannot do better. Dropping the chip tint to 4%
   would have rescued mauve at 4.54:1, but a 4% tint is not a chip — that trades
   the design in nine themes to accommodate one. So Latte's decorative chips are
   `text`: legible, and honestly uncoloured. Its colour shows in the accent,
   green and red instead.

   Its prose also tops out at 7.06:1 — exactly WCAG AAA, and the palette has no
   darker text than #4c4f69. See the two-tier floor in tests/test_css_tokens.py:
   a faithful reproduction is held to the accessibility floor, not to the house
   target relay's own themes are designed against. */

:root[data-theme="catppuccin-latte"], [data-theme="catppuccin-latte"] {
  color-scheme: light;
  --bg:          #e6e9ef;     /* mantle */
  --surface:     #eff1f5;     /* base */
  --border:      #ccd0da;     /* surface0 */
  --text:        #4c4f69;     /* text */
  --muted:       #5c5f77;   /* subtext1 — overlay1 was 2.83:1 */
  --accent:      #8839ef;     /* mauve — Catppuccin's own default accent */
  --accent-dim:  #4c4f69;   /* text — hover fill; lavender under base was 2.81:1 */
  --accent-glow: rgba(136, 57, 239, 0.10);
  --tag-bg:      #eff1f5;   /* base — mauve on surface0 was 3.51:1; the pill is its outline */
  --tag-active:  #bcc0cc;     /* surface1 */
  --green:       #40a02b;     /* green */
  --red:         #d20f39;     /* red */
  --body:        #4c4f69;     /* text */
  --body-strong: #4c4f69;     /* text */
  --wash-raise:  rgba(0, 0, 0, 0.05);

  --accent-2:    #4c4f69;     /* text — no member of Latte clears AA as a chip */
  --accent-3:    #4c4f69;     /* text — see the Latte note above */
  --on-accent:     #eff1f5;
  --on-scrim:      #4c4f69;
  --border-hover:  #acb0be;     /* surface2 */
  --scrim-rgb:     220, 224, 232;   /* crust */
  --shadow-card:   0 4px 24px rgba(0, 0, 0, 0.12);
  --shadow-pill:   0 2px 10px rgba(0, 0, 0, 0.14);
  --shadow-modal:  0 24px 64px rgba(0, 0, 0, 0.2);
  --shadow-drawer: 8px 0 40px rgba(0, 0, 0, 0.18);
}

:root[data-theme="catppuccin-frappe"], [data-theme="catppuccin-frappe"] {
  color-scheme: dark;
  --bg:          #292c3c;     /* mantle */
  --surface:     #303446;     /* base */
  --border:      #414559;     /* surface0 */
  --text:        #c6d0f5;     /* text */
  --muted:       #949cbb;   /* overlay2 — overlay1 was 3.65:1 */
  --accent:      #ef9f76;     /* peach — Frappé's signature here, see note */
  --accent-dim:  #e5c890;     /* yellow */
  --accent-glow: rgba(239, 159, 118, 0.10);
  --tag-bg:      #292c3c;   /* mantle — peach on surface0 was 4.45:1 */
  --tag-active:  #51576d;     /* surface1 */
  --green:       #a6d189;     /* green */
  --red:         #e78284;     /* red */
  --body:        #c6d0f5;     /* text */
  --body-strong: #c6d0f5;     /* text */
  --wash-raise:  rgba(198, 208, 245, 0.05);

  --accent-2:    #85c1dc;     /* sapphire — was mauve, which just echoed Mocha's accent */
  --accent-3:    #81c8be;     /* teal */
  --on-accent:     #232634;
  --on-scrim:      #c6d0f5;
  --border-hover:  #626880;     /* surface2 */
  --scrim-rgb:     35, 38, 52;   /* crust */
  --shadow-card:   0 4px 24px rgba(0, 0, 0, 0.42);
  --shadow-pill:   0 2px 10px rgba(0, 0, 0, 0.42);
  --shadow-modal:  0 24px 64px rgba(0, 0, 0, 0.62);
  --shadow-drawer: 8px 0 40px rgba(0, 0, 0, 0.62);
}

:root[data-theme="catppuccin-macchiato"], [data-theme="catppuccin-macchiato"] {
  color-scheme: dark;
  --bg:          #1e2030;     /* mantle */
  --surface:     #24273a;     /* base */
  --border:      #363a4f;     /* surface0 */
  --text:        #cad3f5;     /* text */
  --muted:       #939ab7;   /* overlay2 — overlay1 was 4.14:1 */
  --accent:      #8aadf4;     /* blue — Macchiato's signature here */
  --accent-dim:  #7dc4e4;     /* sapphire */
  --accent-glow: rgba(138, 173, 244, 0.10);
  --tag-bg:      #363a4f;     /* surface0 */
  --tag-active:  #494d64;     /* surface1 */
  --green:       #a6da95;     /* green */
  --red:         #ed8796;     /* red */
  --body:        #cad3f5;     /* text */
  --body-strong: #cad3f5;     /* text */
  --wash-raise:  rgba(202, 211, 245, 0.05);

  --accent-2:    #ee99a0;     /* maroon — was peach, which just echoed Frappé's accent */
  --accent-3:    #c6a0f6;     /* mauve */
  --on-accent:     #181926;
  --on-scrim:      #cad3f5;
  --border-hover:  #5b6078;     /* surface2 */
  --scrim-rgb:     24, 25, 38;   /* crust */
  --shadow-card:   0 4px 24px rgba(0, 0, 0, 0.42);
  --shadow-pill:   0 2px 10px rgba(0, 0, 0, 0.42);
  --shadow-modal:  0 24px 64px rgba(0, 0, 0, 0.62);
  --shadow-drawer: 8px 0 40px rgba(0, 0, 0, 0.62);
}

:root[data-theme="catppuccin-mocha"], [data-theme="catppuccin-mocha"] {
  color-scheme: dark;
  --bg:          #11111b;     /* crust — Mocha takes the palette's darkest step */
  --surface:     #181825;     /* mantle */
  --border:      #313244;     /* surface0 */
  --text:        #cdd6f4;     /* text */
  --muted:       #7f849c;     /* overlay1 */
  --accent:      #cba6f7;     /* mauve — the canonical default, kept for Mocha */
  --accent-dim:  #b4befe;     /* lavender */
  --accent-glow: rgba(203, 166, 247, 0.10);
  --tag-bg:      #313244;     /* surface0 */
  --tag-active:  #45475a;     /* surface1 */
  --green:       #a6e3a1;     /* green */
  --red:         #f38ba8;     /* red */
  --body:        #cdd6f4;     /* text */
  --body-strong: #cdd6f4;     /* text */
  --wash-raise:  rgba(205, 214, 244, 0.05);

  --accent-2:    #eba0ac;     /* maroon — was peach, which just echoed Frappé's accent */
  --accent-3:    #89dceb;     /* sky — was teal, which just echoed Frappé's accent-3 */
  --on-accent:     #11111b;
  --on-scrim:      #cdd6f4;
  --border-hover:  #585b70;     /* surface2 */
  --scrim-rgb:     17, 17, 27;   /* crust */
  --shadow-card:   0 4px 24px rgba(0, 0, 0, 0.42);
  --shadow-pill:   0 2px 10px rgba(0, 0, 0, 0.42);
  --shadow-modal:  0 24px 64px rgba(0, 0, 0, 0.62);
  --shadow-drawer: 8px 0 40px rgba(0, 0, 0, 0.62);
}

/* Dracula.
   The spec's four neutrals — Background, Current Line/Selection, Comment,
   Foreground — plus BGDark (#21222c), which the official VS Code theme uses for
   its sidebar and panels: that is the canvas, so cards on Background read as
   raised instead of the page being one flat colour. `Current Line` is Dracula's
   own divider colour, which is exactly what a card border is. Comment is the
   palette's only muted tone and sits at 3.03:1 — see TEXT_ROLE_EXCEPTIONS. */
:root[data-theme="dracula"], [data-theme="dracula"] {
  color-scheme: dark;
  --bg:          #21222c;   /* BGDark — canvas one step below the cards */
  --surface:     #282a36;
  --border:      #44475a;
  --text:        #f8f8f2;
  --muted:       #6272a4;
  --accent:      #bd93f9;
  --accent-dim:  #ff79c6;
  --accent-glow: rgba(189, 147, 249, 0.10);
  --tag-bg:      #21222c;   /* BGDark — purple on Current Line was 3.79:1 */
  --tag-active:  #6272a4;
  --green:       #50fa7b;
  --red:         #ff5555;
  --body:        #f8f8f2;
  --body-strong: #f8f8f2;
  --wash-raise:  rgba(248, 248, 242, 0.05);

  --accent-2:    #ffb86c;
  --accent-3:    #8be9fd;
  --on-accent:     #282a36;
  --on-scrim:      #f8f8f2;
  --border-hover:  #6272a4;
  --scrim-rgb:     25, 26, 33;
  --shadow-card:   0 4px 24px rgba(0, 0, 0, 0.42);
  --shadow-pill:   0 2px 10px rgba(0, 0, 0, 0.42);
  --shadow-modal:  0 24px 64px rgba(0, 0, 0, 0.62);
  --shadow-drawer: 8px 0 40px rgba(0, 0, 0, 0.62);
}

/* Everforest Dark.
   `--green` takes **aqua** rather than green: Everforest's green is its accent,
   and a connected-dot indistinguishable from the accent is the trade the ANSI
   themes already refused. Aqua still reads as green, so the signal survives.
   Cards are bg0 on a bg_dim canvas — the palette's own "dimmed" step, so the
   foreground stays on bg0 (on bg1 it falls to 6.40:1, under the floor) while
   the page still gets depth. Muted is grey2: grey1 was 3.84:1. */
:root[data-theme="everforest-dark"], [data-theme="everforest-dark"] {
  color-scheme: dark;
  --bg:          #232a2e;   /* bg_dim — canvas one step below the cards */
  --surface:     #2d353b;
  --border:      #475258;
  --text:        #d3c6aa;
  --muted:       #9da9a0;   /* grey2 — grey1 was 3.84:1 */
  --accent:      #a7c080;
  --accent-dim:  #dbbc7f;
  --accent-glow: rgba(167, 192, 128, 0.10);
  --tag-bg:      #343f44;
  --tag-active:  #3d484d;
  --green:       #83c092;
  --red:         #e67e80;
  --body:        #d3c6aa;
  --body-strong: #d3c6aa;
  --wash-raise:  rgba(211, 198, 170, 0.05);

  --accent-2:    #d699b6;
  --accent-3:    #7fbbb3;
  --on-accent:     #2d353b;
  --on-scrim:      #d3c6aa;
  --border-hover:  #56635f;
  --scrim-rgb:     35, 42, 46;
  --shadow-card:   0 4px 24px rgba(0, 0, 0, 0.42);
  --shadow-pill:   0 2px 10px rgba(0, 0, 0, 0.42);
  --shadow-modal:  0 24px 64px rgba(0, 0, 0, 0.62);
  --shadow-drawer: 8px 0 40px rgba(0, 0, 0, 0.62);
}

/* Gruvbox Light.
   The mirror of the Gruvbox we already ship: same roles, the light half of the
   same palette, in the faded variants gruvbox uses on a light ground. The
   accent is **faded_orange**, not the yellow that mirrors Gruvbox Dark's:
   `--on-accent` on faded_yellow reaches only 3.91:1 whichever way it goes,
   because a mid-tone yellow suits neither dark nor light text. faded_blue
   replaces faded_aqua as the second chip for the same reason (3.89 -> 5.03). */
:root[data-theme="gruvbox-light"], [data-theme="gruvbox-light"] {
  color-scheme: light;
  --bg:          #f2e5bc;
  --surface:     #fbf1c7;
  --border:      #d5c4a1;
  --text:        #282828;
  --muted:       #665c54;   /* fg3 — fg4 was 4.29:1 */
  --accent:      #af3a03;
  --accent-dim:  #9d0006;   /* faded_red — hover fill; faded_yellow under bg0 was 3.33:1 */
  --accent-glow: rgba(175, 58, 3, 0.10);
  --tag-bg:      #f2e5bc;   /* bg0_s — orange on bg1 was 4.46:1 */
  --tag-active:  #d5c4a1;
  --green:       #79740e;
  --red:         #9d0006;
  --body:        #3c3836;
  --body-strong: #282828;
  --wash-raise:  rgba(0, 0, 0, 0.05);

  --accent-2:    #8f3f71;
  --accent-3:    #076678;
  --on-accent:     #fbf1c7;
  --on-scrim:      #282828;
  --border-hover:  #bdae93;
  --scrim-rgb:     40, 40, 40;
  --shadow-card:   0 4px 24px rgba(0, 0, 0, 0.12);
  --shadow-pill:   0 2px 10px rgba(0, 0, 0, 0.14);
  --shadow-modal:  0 24px 64px rgba(0, 0, 0, 0.2);
  --shadow-drawer: 8px 0 40px rgba(0, 0, 0, 0.18);
}

/* Molokai.
   The accent is **cyan**, not the green the TUI palette uses as its primary:
   Molokai has exactly one green and one red, and both are needed for `--green`
   and `--red`. Cyan is Molokai's Type colour and collides with nothing, so all
   six of its signature colours stay distinct on screen.

   Both `--accent` and `--accent-dim` shipped at the reference hex — #66d9ef and
   #ae81ff — which read as pastel for the same reason `blue` did in Tokyo Night:
   81%/100% saturation, but 67%/75% lightness, so the hue never gets to register
   before the colour reads as light. Same fix, same hue, lower lightness: cyan
   drops to 50% (7.4:1 against the surface, still 8.1:1 for `--on-accent` sitting
   on it) and purple to 71% — pushed less far, since it is a *button-hover fill*
   with `--on-accent` text on top, and violet's sRGB gamut is dark enough that
   71% is already the last stop with that text still clearing AA (4.85:1; 70%
   drops to 4.64, 65% to 3.71). */
:root[data-theme="molokai"], [data-theme="molokai"] {
  color-scheme: dark;
  --bg:          #1b1d1e;
  --surface:     #232526;
  --border:      #465457;
  --text:        #f8f8f2;
  --muted:       #7e8e91;
  --accent:      #18c4e7;   /* cyan, darkened — see block note */
  --accent-dim:  #9f6bff;   /* purple, darkened — see block note */
  --accent-glow: rgba(24, 196, 231, 0.10);
  --tag-bg:      #293739;
  --tag-active:  #403d3d;
  --green:       #a6e22e;
  --red:         #f92672;
  --body:        #f8f8f2;
  --body-strong: #f8f8f2;
  --wash-raise:  rgba(248, 248, 242, 0.05);

  --accent-2:    #fd971f;
  --accent-3:    #e6db74;
  --on-accent:     #1b1d1e;
  --on-scrim:      #f8f8f2;
  --border-hover:  #7e8e91;
  --scrim-rgb:     0, 0, 0;
  --shadow-card:   0 4px 24px rgba(0, 0, 0, 0.42);
  --shadow-pill:   0 2px 10px rgba(0, 0, 0, 0.42);
  --shadow-modal:  0 24px 64px rgba(0, 0, 0, 0.62);
  --shadow-drawer: 8px 0 40px rgba(0, 0, 0, 0.62);
}

/* Nord.
   `--muted` is **nord9**, not nord3. Nord's comment tone sits at 1.36:1 against
   nord1 — invisible rather than recessive — so the muted role moves up into the
   Frost range. `--text` takes nord6 rather than nord4 for the same reason: the
   contrast the palette can reach, not the first value that looks right. Cards
   are framed on nord0; nord1 was light enough to flatten every chip. And the
   decorative pair is nord13 + nord7, not the Aurora purple and orange that look
   the part — on nord0 those reach 3.79 and 3.81, under AA. */
:root[data-theme="nord"], [data-theme="nord"] {
  color-scheme: dark;
  --bg:          #2e3440;
  --surface:     #2e3440;
  --border:      #434c5e;
  --text:        #eceff4;
  --muted:       #81a1c1;
  --accent:      #88c0d0;
  --accent-dim:  #81a1c1;   /* nord9 — hover fill; nord10 under nord0 was 3.10:1 */
  --accent-glow: rgba(136, 192, 208, 0.10);
  --tag-bg:      #3b4252;
  --tag-active:  #434c5e;
  --green:       #a3be8c;
  --red:         #bf616a;
  --body:        #e5e9f0;
  --body-strong: #eceff4;
  --wash-raise:  rgba(236, 239, 244, 0.05);

  --accent-2:    #ebcb8b;
  --accent-3:    #8fbcbb;
  --on-accent:     #2e3440;
  --on-scrim:      #eceff4;
  --border-hover:  #4c566a;
  --scrim-rgb:     36, 41, 51;
  --shadow-card:   0 4px 24px rgba(0, 0, 0, 0.42);
  --shadow-pill:   0 2px 10px rgba(0, 0, 0, 0.42);
  --shadow-modal:  0 24px 64px rgba(0, 0, 0, 0.62);
  --shadow-drawer: 8px 0 40px rgba(0, 0, 0, 0.62);
}

/* Solarized Dark / Solarized Light (Ethan Schoonover). Drawn from the official
   8-tone base scale (base03…base3) and the 8 accent hues, except the tag fills
   (Dark's hover fill, Light's fill and hover), which sit between base tones the
   scale has no step for. The scale is *reassigned*, not read off in its canonical roles.
   Dark frames its cards on base03 — Solarized's own background — with base02
   (its "background highlight") for fills and borders; base02 as a raised card
   surface put every accent a full step closer to the text it sits on. Hover
   fills take cyan: the darkened blue under black text was 3.32:1.

   Solarized's own prose colour is base0 on dark / base00 on light, and both
   sit below this project's 7:1 accessibility floor (4.75:1 and 4.13:1 against
   the surfaces below) — that low contrast is the palette's whole design
   thesis, not an oversight, but it fails the floor here regardless. So `--body`
   and `--text` are promoted to the scale's brighter step — base2/base3 on
   dark, base02/base03 on light — the "background highlight" tones in the
   official spec, repurposed as foreground. It clears the house target
   outright (12.3:1 / 12.1:1 body), so unlike the Catppuccin flavours this
   needed no entry in REPRODUCTIONS. The accents cannot be lifted the same way —
   one step per hue — so blue as link/pill text and red and green as text stay
   under AA; each is recorded in TEXT_ROLE_EXCEPTIONS.

   `--accent` is the signature blue. `--on-accent` is plain black rather than
   base03 — base03-on-blue is 4.08:1, under the floor; black reaches 5.71:1,
   which also holds for the light block below since the accent is unchanged.
   `--accent-2`/`--accent-3` fall back to `--text`, the same move Catppuccin
   Latte makes: every Solarized accent lands under AA as chip text on its own
   10% tint (orange, the best, reaches 3.75), so two illegible colourful chips
   are not worth shipping over one legible flat one. */
:root[data-theme="solarized-dark"], [data-theme="solarized-dark"] {
  color-scheme: dark;
  --bg:          #002b36;   /* base03 */
  --surface:     #002b36;   /* base03 — Solarized's own background; cards are framed */
  --border:      #073642;   /* base02 — base01 framed every card at 2.4:1, harsher than any peer */
  --text:        #fdf6e3;   /* base3 — promoted; see block note */
  --muted:       #839496;   /* base0 — base00 was 2.92:1 */
  --accent:      #268bd2;   /* blue */
  --accent-dim:  #2aa198;   /* cyan — hover fill; darkened blue under black was 3.32:1 */
  --accent-glow: rgba(38, 139, 210, 0.10);
  --tag-bg:      #073642;   /* base02 */
  --tag-active:  #0d455c;
  --green:       #859900;
  --red:         #dc322f;
  --body:        #eee8d5;   /* base2 — promoted; see block note */
  --body-strong: #fdf6e3;   /* base3 */
  --wash-raise:  rgba(253, 246, 227, 0.03);

  --accent-2:      #fdf6e3;   /* text — no Solarized accent clears AA as a chip */
  --accent-3:      #fdf6e3;   /* text — see block note */
  --on-accent:     #000;      /* 5.71:1 on #268bd2 — base03 only reaches 4.08:1 */
  --on-scrim:      #fdf6e3;
  --border-hover:  #586e75;   /* base01 */
  --scrim-rgb:     0, 43, 54;   /* base03 */
  --shadow-card:   0 4px 24px rgba(0, 0, 0, 0.40);
  --shadow-pill:   0 2px 10px rgba(0, 0, 0, 0.40);
  --shadow-modal:  0 24px 64px rgba(0, 0, 0, 0.62);
  --shadow-drawer: 8px 0 40px rgba(0, 0, 0, 0.62);
}

:root[data-theme="solarized-light"], [data-theme="solarized-light"] {
  color-scheme: light;
  --bg:          #eee8d5;   /* base2 */
  --surface:     #fdf6e3;   /* base3 — the lightest tone, cards at the ceiling */
  --border:      #93a1a1;   /* base1 — base01 framed every card at 5:1, harsher than any peer */
  --text:        #002b36;   /* base03 — promoted; see block note */
  --muted:       #586e75;   /* base01 — base00 was 4.13:1 */
  --accent:      #268bd2;   /* blue */
  --accent-dim:  #2aa198;   /* cyan — hover fill, as in Dark */
  --accent-glow: rgba(38, 139, 210, 0.10);
  --tag-bg:      #ecede2;
  --tag-active:  #d6e3e0;
  --green:       #859900;
  --red:         #dc322f;
  --body:        #073642;   /* base02 — promoted; see block note */
  --body-strong: #002b36;   /* base03 */
  --wash-raise:  rgba(0, 43, 54, 0.03);

  --accent-2:      #002b36;   /* text — no Solarized accent clears AA as a chip */
  --accent-3:      #002b36;   /* text — see block note */
  --on-accent:     #000;      /* 5.71:1 on #268bd2, same accent as Dark */
  --on-scrim:      #fdf6e3;
  --border-hover:  #586e75;   /* base01 */
  --scrim-rgb:     0, 43, 54;   /* base03, matching --text like Gruvbox Light does */
  --shadow-card:   0 4px 16px rgba(0, 0, 0, 0.12);
  --shadow-pill:   0 2px 10px rgba(0, 0, 0, 0.14);
  --shadow-modal:  0 24px 64px rgba(0, 0, 0, 0.18);
  --shadow-drawer: 8px 0 40px rgba(0, 0, 0, 0.16);
}

/* Rosé Pine (rose-pine.io, main variant). Every value is a named palette member:
   `base` canvas, `surface` cards, `overlay` chip fills, the three `highlight`
   steps for borders. Muted is `subtle`, not `muted` — the palette's own
   "muted" is 3.20:1 on surface, below AA for meta text. The accent is the
   name-sake `rose`, deepening to `love` on hover as the palette pairs them.
   Rosé Pine has no green: `foam` stands in for `--green`, the way Everforest's
   aqua does, since the connected dot and Restore only need to read as "ok". */
:root[data-theme="rose-pine"], [data-theme="rose-pine"] {
  color-scheme: dark;
  --bg:          #191724;   /* base */
  --surface:     #1f1d2e;   /* surface */
  --border:      #403d52;   /* highlight med */
  --text:        #e0def4;   /* text */
  --muted:       #908caa;   /* subtle — `muted` is 3.20:1 */
  --accent:      #ebbcba;   /* rose */
  --accent-dim:  #eb6f92;   /* love */
  --accent-glow: rgba(235, 188, 186, 0.10);
  --tag-bg:      #26233a;   /* overlay */
  --tag-active:  #403d52;   /* highlight med */
  --green:       #9ccfd8;   /* foam — the palette has no green */
  --red:         #eb6f92;   /* love */
  --body:        #e0def4;   /* text */
  --body-strong: #e0def4;   /* text */
  --wash-raise:  rgba(224, 222, 244, 0.04);
  --accent-2:      #c4a7e7;   /* iris */
  --accent-3:      #f6c177;   /* gold */
  --on-accent:     #191724;   /* base */
  --on-scrim:      #e0def4;
  --border-hover:  #524f67;   /* highlight high */
  --scrim-rgb:     25, 23, 36;   /* base */
  --shadow-card:   0 4px 24px rgba(0, 0, 0, 0.42);
  --shadow-pill:   0 2px 10px rgba(0, 0, 0, 0.42);
  --shadow-modal:  0 24px 64px rgba(0, 0, 0, 0.62);
  --shadow-drawer: 8px 0 40px rgba(0, 0, 0, 0.62);
}
/* One Dark (Atom). The editor background #282c34 carries the cards and the UI
   chrome's darker #21252b is the canvas, as Atom itself lays them out. One
   Dark's foreground #abb2bf is 6.57:1 — under the 7:1 floor — so it steps down
   to muted, and text/body are promoted to the palette's white #dcdfe4 (its
   terminal white), the same promotion Solarized makes. The comment grey
   (#5c6370, 2.32:1) is too faint for any text role. Chips skip the purple
   (4.09:1 on its tint) for cyan and yellow; red is recorded in
   TEXT_ROLE_EXCEPTIONS at 4.38:1, the palette's only red. */
:root[data-theme="one-dark"], [data-theme="one-dark"] {
  color-scheme: dark;
  --bg:          #21252b;   /* UI chrome */
  --surface:     #282c34;   /* editor background */
  --border:      #3e4451;   /* selection */
  --text:        #dcdfe4;   /* white — promoted; see block note */
  --muted:       #abb2bf;   /* foreground, stepped down */
  --accent:      #61afef;   /* blue */
  --accent-dim:  #56b6c2;   /* cyan */
  --accent-glow: rgba(97, 175, 239, 0.10);
  --tag-bg:      #21252b;   /* UI chrome */
  --tag-active:  #3e4451;   /* selection */
  --green:       #98c379;
  --red:         #e06c75;
  --body:        #dcdfe4;   /* white */
  --body-strong: #dcdfe4;
  --wash-raise:  rgba(220, 223, 228, 0.04);
  --accent-2:      #56b6c2;   /* cyan */
  --accent-3:      #e5c07b;   /* yellow */
  --on-accent:     #282c34;
  --on-scrim:      #dcdfe4;
  --border-hover:  #4b5263;   /* gutter grey */
  --scrim-rgb:     33, 37, 43;   /* UI chrome */
  --shadow-card:   0 4px 24px rgba(0, 0, 0, 0.42);
  --shadow-pill:   0 2px 10px rgba(0, 0, 0, 0.42);
  --shadow-modal:  0 24px 64px rgba(0, 0, 0, 0.62);
  --shadow-drawer: 8px 0 40px rgba(0, 0, 0, 0.62);
}
/* Kanagawa (rebelot/kanagawa.nvim, wave). The sumiInk ramp does the layout —
   ink0 canvas, ink3 cards (the editor background), ink4 chip fills, ink5/ink6
   borders — with fujiWhite text at 11.3:1. The accent is crystalBlue, the
   palette's function colour, easing to springBlue on hover. Muted is
   springViolet1: the comment grey fujiGray is 3.33:1. Chips are sakuraPink and
   carpYellow — the violets land under AA on their own tint. */
:root[data-theme="kanagawa"], [data-theme="kanagawa"] {
  color-scheme: dark;
  --bg:          #16161d;   /* sumiInk0 */
  --surface:     #1f1f28;   /* sumiInk3 */
  --border:      #363646;   /* sumiInk5 */
  --text:        #dcd7ba;   /* fujiWhite */
  --muted:       #938aa9;   /* springViolet1 — fujiGray is 3.33:1 */
  --accent:      #7e9cd8;   /* crystalBlue */
  --accent-dim:  #7fb4ca;   /* springBlue */
  --accent-glow: rgba(126, 156, 216, 0.10);
  --tag-bg:      #2a2a37;   /* sumiInk4 */
  --tag-active:  #363646;   /* sumiInk5 */
  --green:       #98bb6c;   /* springGreen */
  --red:         #e46876;   /* waveRed */
  --body:        #dcd7ba;   /* fujiWhite */
  --body-strong: #dcd7ba;
  --wash-raise:  rgba(220, 215, 186, 0.04);
  --accent-2:      #d27e99;   /* sakuraPink */
  --accent-3:      #e6c384;   /* carpYellow */
  --on-accent:     #16161d;   /* sumiInk0 */
  --on-scrim:      #dcd7ba;
  --border-hover:  #54546d;   /* sumiInk6 */
  --scrim-rgb:     22, 22, 29;   /* sumiInk0 */
  --shadow-card:   0 4px 24px rgba(0, 0, 0, 0.42);
  --shadow-pill:   0 2px 10px rgba(0, 0, 0, 0.42);
  --shadow-modal:  0 24px 64px rgba(0, 0, 0, 0.62);
  --shadow-drawer: 8px 0 40px rgba(0, 0, 0, 0.62);
}
/* GitHub Dark / GitHub Light (Primer, "default" variants). Named Primer
   functional colours throughout — canvas, border, fg, accent, success, danger,
   done/sponsors — and nothing else. Dark lays cards (`canvas.default`) on the
   page chrome's `canvas.inset`; Light lays white cards on `canvas.subtle`, as
   github.com does. Light's two chip colours step one Primer scale down
   (purple-6, pink-6): `done.fg` and `sponsors.fg` are 4.42 and 4.37:1 on
   their own tint, under AA. Both clear every floor with no exception. */
:root[data-theme="github-dark"], [data-theme="github-dark"] {
  color-scheme: dark;
  --bg:          #010409;   /* canvas.inset */
  --surface:     #0d1117;   /* canvas.default */
  --border:      #30363d;   /* border.default */
  --text:        #e6edf3;   /* fg.default */
  --muted:       #8b949e;   /* fg.muted */
  --accent:      #58a6ff;   /* accent.fg */
  --accent-dim:  #79c0ff;   /* blue-2 — hover fill */
  --accent-glow: rgba(88, 166, 255, 0.10);
  --tag-bg:      #161b22;   /* canvas.subtle */
  --tag-active:  #21262d;   /* border.muted */
  --green:       #3fb950;   /* success.fg */
  --red:         #f85149;   /* danger.fg */
  --body:        #e6edf3;   /* fg.default */
  --body-strong: #f0f6fc;   /* fg.onEmphasis */
  --wash-raise:  rgba(230, 237, 243, 0.03);
  --accent-2:      #a371f7;   /* done.fg */
  --accent-3:      #d29922;   /* attention.fg */
  --on-accent:     #0d1117;   /* canvas.default */
  --on-scrim:      #e6edf3;
  --border-hover:  #484f58;   /* neutral.emphasis */
  --scrim-rgb:     1, 4, 9;   /* canvas.inset */
  --shadow-card:   0 4px 24px rgba(0, 0, 0, 0.42);
  --shadow-pill:   0 2px 10px rgba(0, 0, 0, 0.42);
  --shadow-modal:  0 24px 64px rgba(0, 0, 0, 0.62);
  --shadow-drawer: 8px 0 40px rgba(0, 0, 0, 0.62);
}
:root[data-theme="github-light"], [data-theme="github-light"] {
  color-scheme: light;
  --bg:          #f6f8fa;   /* canvas.subtle */
  --surface:     #ffffff;   /* canvas.default */
  --border:      #d0d7de;   /* border.default */
  --text:        #1f2328;   /* fg.default */
  --muted:       #656d76;   /* fg.muted */
  --accent:      #0969da;   /* accent.fg */
  --accent-dim:  #0550ae;   /* blue-6 — hover fill */
  --accent-glow: rgba(9, 105, 218, 0.10);
  --tag-bg:      #ddf4ff;   /* accent.subtle */
  --tag-active:  #b6e3ff;   /* blue-1 */
  --green:       #1a7f37;   /* success.fg */
  --red:         #d1242f;   /* danger.fg */
  --body:        #1f2328;   /* fg.default */
  --body-strong: #1f2328;
  --wash-raise:  rgba(31, 35, 40, 0.03);
  --accent-2:      #6639ba;   /* purple-6 — done.fg is 4.42:1 on its chip */
  --accent-3:      #99286e;   /* pink-6 — sponsors.fg is 4.37:1 on its chip */
  --on-accent:     #ffffff;
  --on-scrim:      #ffffff;
  --border-hover:  #afb8c1;   /* neutral.muted */
  --scrim-rgb:     31, 35, 40;   /* fg.default */
  --shadow-card:   0 4px 16px rgba(0, 0, 0, 0.10);
  --shadow-pill:   0 2px 10px rgba(0, 0, 0, 0.12);
  --shadow-modal:  0 24px 64px rgba(0, 0, 0, 0.18);
  --shadow-drawer: 8px 0 40px rgba(0, 0, 0, 0.16);
}
/* Ayu Dark (ayu-theme/ayu-colors). `common.bg` is the canvas and `editor.bg`
   the cards — on the editor background alone the foreground is 10.12:1, the
   house target, and nowhere else in the palette is brighter. The accent is
   Ayu's own `common.accent` gold, easing to the function orange on hover.
   Ayu's greys are UI chrome, not text: the gutter grey is its brightest and
   still sits at 3.99:1, recorded in TEXT_ROLE_EXCEPTIONS. */
:root[data-theme="ayu-dark"], [data-theme="ayu-dark"] {
  color-scheme: dark;
  --bg:          #0b0e14;   /* common.bg */
  --surface:     #0d1017;   /* editor.bg */
  --border:      #2a2e36;   /* gutter grey #6c7380 at 30% over editor.bg — ui.line (#1b1f29) all but vanished */
  --text:        #bfbdb6;   /* editor.fg */
  --muted:       #6c7380;   /* gutter.active — see block note */
  --accent:      #e6b450;   /* common.accent */
  --accent-dim:  #ffb454;   /* syntax.func */
  --accent-glow: rgba(230, 180, 80, 0.10);
  --tag-bg:      #131721;   /* editor.line */
  --tag-active:  #1b1f29;   /* ui.line */
  --green:       #7fd962;   /* vcs.added */
  --red:         #f26d78;   /* vcs.removed */
  --body:        #bfbdb6;   /* editor.fg */
  --body-strong: #bfbdb6;
  --wash-raise:  rgba(191, 189, 182, 0.04);
  --accent-2:      #d2a6ff;   /* syntax.constant */
  --accent-3:      #39bae6;   /* syntax.tag */
  --on-accent:     #0b0e14;   /* common.bg */
  --on-scrim:      #bfbdb6;
  --border-hover:  #565b66;   /* ui.fg */
  --scrim-rgb:     11, 14, 20;   /* common.bg */
  --shadow-card:   0 4px 24px rgba(0, 0, 0, 0.42);
  --shadow-pill:   0 2px 10px rgba(0, 0, 0, 0.42);
  --shadow-modal:  0 24px 64px rgba(0, 0, 0, 0.62);
  --shadow-drawer: 8px 0 40px rgba(0, 0, 0, 0.62);
}
/* Ayu Darker — relay's own variant of Ayu Dark, not a reproduction: the canvas
   and cards a step darker, the gold and function orange more saturated and
   brighter, the foreground lifted. Text is 12.1:1 (Ayu Dark 10.1), and the
   muted grey is brightened from Ayu's gutter grey until it clears AA (5.2:1),
   so unlike Ayu Dark it needs no TEXT_ROLE_EXCEPTIONS entry. Every other hue
   keeps Ayu's angle and gains saturation. */
:root[data-theme="ayu-darker"], [data-theme="ayu-darker"] {
  color-scheme: dark;
  --bg:          #06080c;
  --surface:     #080b10;
  --border:      #2b2f37;   /* --muted at 30% over --surface, as in Ayu Dark */
  --text:        #cdcbc4;
  --muted:       #7b8391;
  --accent:      #f7b336;   /* common.accent, hsl(39 92% 59%) */
  --accent-dim:  #ffa13d;   /* syntax.func, hsl(31 100% 62%) */
  --accent-glow: rgba(247, 179, 54, 0.12);
  --tag-bg:      #0e121a;
  --tag-active:  #161a23;
  --green:       #7fe55d;
  --red:         #f96776;
  --body:        #cdcbc4;
  --body-strong: #cdcbc4;
  --wash-raise:  rgba(205, 203, 196, 0.04);
  --accent-2:      #d6adff;
  --accent-3:      #34c0ef;
  --on-accent:     #06080c;
  --on-scrim:      #cdcbc4;
  --border-hover:  #5f6571;
  --scrim-rgb:     6, 8, 12;
  --shadow-card:   0 4px 24px rgba(0, 0, 0, 0.50);
  --shadow-pill:   0 2px 10px rgba(0, 0, 0, 0.50);
  --shadow-modal:  0 24px 64px rgba(0, 0, 0, 0.70);
  --shadow-drawer: 8px 0 40px rgba(0, 0, 0, 0.70);
}
/* Night Owl (sdras/night-owl-vscode-theme). Cards on the editor's deep navy,
   the canvas one step darker at the inactive-tab tone, chips on the input
   background. `#5f7e97` is the theme's own UI grey and its brightest muted
   tone — 4.29:1, recorded in TEXT_ROLE_EXCEPTIONS; the comment grey is
   fainter still. Accents are the syntax blue, purple and string gold. */
:root[data-theme="night-owl"], [data-theme="night-owl"] {
  color-scheme: dark;
  --bg:          #01111d;   /* tab.inactiveBackground */
  --surface:     #011627;   /* editor.background */
  --border:      #1d3b53;   /* selection */
  --text:        #d6deeb;   /* editor.foreground */
  --muted:       #5f7e97;   /* UI grey — see block note */
  --accent:      #82aaff;   /* blue */
  --accent-dim:  #7fdbca;   /* cyan */
  --accent-glow: rgba(130, 170, 255, 0.10);
  --tag-bg:      #0b253a;   /* input.background */
  --tag-active:  #1d3b53;   /* selection */
  --green:       #addb67;
  --red:         #ef5350;   /* errorForeground */
  --body:        #d6deeb;
  --body-strong: #d6deeb;
  --wash-raise:  rgba(214, 222, 235, 0.04);
  --accent-2:      #c792ea;   /* purple */
  --accent-3:      #ecc48d;   /* string */
  --on-accent:     #011627;
  --on-scrim:      #d6deeb;
  --border-hover:  #5f7e97;   /* UI grey */
  --scrim-rgb:     1, 17, 29;   /* tab.inactiveBackground */
  --shadow-card:   0 4px 24px rgba(0, 0, 0, 0.42);
  --shadow-pill:   0 2px 10px rgba(0, 0, 0, 0.42);
  --shadow-modal:  0 24px 64px rgba(0, 0, 0, 0.62);
  --shadow-drawer: 8px 0 40px rgba(0, 0, 0, 0.62);
}
/* `hidden` always hides: otherwise any class setting `display` (buttons are
   inline-flex here) silently overrides it. */
[hidden] { display: none !important; }

html { color: var(--text); }   /* see note in tests/test_css_tokens.py */

body {
  background: var(--bg); color: var(--text);
  height: 100vh; display: flex; flex-direction: column; overflow: hidden;
}

/* ── Header ─────────────────────────────────────────── */
header {
  display: flex; align-items: center; gap: 14px;
  padding: 0 20px; height: 48px;
  border-bottom: 1px solid var(--border);
  background: var(--surface); flex-shrink: 0;
}
.brand-mark { height: 20px; width: auto; display: block; flex-shrink: 0; }
.brand-name {
  font-size: 13px; font-weight: 600; color: var(--accent);
}
.brand-sep { color: var(--border); font-weight: 300; font-size: 18px; line-height: 1; }

.live-indicator { display: flex; align-items: center; gap: 5px; flex-shrink: 0; }
.live-dot {
  width: 6px; height: 6px; border-radius: 50%;
  background: var(--muted); flex-shrink: 0; transition: background 0.3s, box-shadow 0.3s;
}
.live-dot.connected { background: var(--green); box-shadow: 0 0 6px var(--green); }
.live-dot.error     { background: var(--red); }
.live-label {
  font-size: 11px; color: var(--muted); transition: color 0.3s;
}
.live-dot.connected ~ .live-label { color: var(--green); }
.live-dot.error     ~ .live-label { color: var(--red); }

.header-right { display: flex; align-items: center; gap: 10px; margin-left: auto; }

/* One shape for every icon control in the header. They used to be sized by
   their contents — a text "i" and an emoji ☀ in the same padded box render at
   different optical weights and widths, so the row read as three unrelated
   controls. A fixed square with a drawn 15px icon makes them a set. */
.icon-btn {
  width: 28px; height: 28px; flex-shrink: 0; padding: 0;
  display: inline-flex; align-items: center; justify-content: center;
  background: none; border: 1px solid var(--border); border-radius: 4px;
  color: var(--muted); cursor: pointer;
  transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.icon-btn svg { display: block; }
.icon-btn:hover { color: var(--accent); border-color: var(--accent); }
.icon-btn:focus-visible { color: var(--accent); border-color: var(--accent); }
/* Open state, not hover: the picker stays lit while its menu is up. */
.icon-btn[aria-expanded="true"] {
  color: var(--accent); border-color: var(--accent); background: var(--wash-raise);
}
/* Ending the session is the one header action you cannot undo with another
   click, so it answers in red rather than accent — the same language .btn-delete
   already speaks. */
.icon-btn-danger:hover, .icon-btn-danger:focus-visible {
  color: var(--red); border-color: var(--red);
}

/* ── Theme picker ────────────────────────────────────────── */
.theme-picker { position: relative; display: flex; }
.theme-menu {
  display: none; position: absolute; top: calc(100% + 6px); right: 0; z-index: 60;
  /* Wide enough that the longest label — "Catppuccin Macchiato" — sits on one
     line. It wrapped to two at 168px, which made the row taller than its
     neighbours and the list read as broken rather than long. */
  min-width: 236px; padding: 4px;
  /* Fifteen themes is taller than a short window. Scroll the menu rather than
     letting it run off the bottom of the screen. */
  max-height: min(80vh, 760px); overflow-y: auto; overscroll-behavior: contain;
  background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
  box-shadow: var(--shadow-modal);
  animation: modalIn 0.14s ease backwards;
}
.theme-menu.open { display: block; }
.theme-opt {
  display: flex; align-items: center; gap: 9px; width: 100%;
  background: none; border: none; border-radius: 4px; cursor: pointer;
  padding: 7px 9px; font-family: inherit; font-size: 12px; color: var(--text);
  text-align: left; transition: background 0.12s;
}
.theme-opt:hover { background: var(--wash-raise); }
.theme-opt.active { color: var(--accent); }
.theme-sep {
  height: 1px; margin: 4px 9px; background: var(--border);
}
/* Family group label above each cluster of related themes in the picker. */
.theme-group-label {
  padding: 6px 9px 2px;
  font-size: 11px; font-weight: 600;
  color: var(--muted); pointer-events: none;
}
/* The swatch carries its own `data-theme`, so the theme's token block paints it
   — a live sample rather than a hex value copied into a second place. */
.theme-swatch {
  width: 15px; height: 15px; border-radius: 4px; flex-shrink: 0;
  background: var(--bg); border: 2px solid var(--accent);
}
.theme-label {
  flex: 1; min-width: 0;
  /* Never wrap: a two-line row in a list of one-line rows looks like a bug. */
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* The tick is its own element, not `::after` on the label — appended to the
   text it joined the wrap and landed on a line of its own. Reserving the column
   in every row also stops the labels shifting as the selection moves. */
.theme-check {
  flex-shrink: 0; width: 12px; text-align: center;
  color: var(--accent); visibility: hidden;
}
.theme-opt.active .theme-check { visibility: visible; }

@media (max-width: 768px) {
  /* Same reasoning as the sheets' 44px floor, capped by a 48px header bar.
     `.btn-new-post` cannot be sized here — its base rule is declared further
     down the file and would win on source order. It is set in the mobile header
     block instead. */
  .icon-btn { width: 40px; height: 40px; }

  /* The sidebar is a drawer on a phone, so its controls are thumb targets like
     any other. Measured before this: the tab strip 41x22, the tag row's rename
     and expiry icons 17x17, the add button 22x18 — all well under the floor the
     sheets and the header already hold. The tag row itself is 29px of text; the
     padding lifts the whole row rather than just the icons, so the tap area
     matches what looks tappable. */
  .sb-tab { min-width: 44px; min-height: 44px; display: inline-flex;
            align-items: center; justify-content: center; }
  .sidebar-add-btn { min-width: 44px; min-height: 44px; }
  .tag-item { min-height: 44px; }
  .tag-rename, .tag-config-btn {
    min-width: 44px; min-height: 44px;
    display: inline-flex; align-items: center; justify-content: center;
    margin: -12px -10px -12px 0;   /* grow the hit area, not the row */
  }
  .theme-opt { min-height: 44px; }
  .theme-menu { min-width: 190px; }
}

/* Visually hidden but available to screen readers. */
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0;
}

/* Sidebar collapse toggle (desktop) */
.collapse-btn {
  background: none; border: none; color: var(--muted);
  cursor: pointer; padding: 4px; margin-left: -2px;
  display: flex; align-items: center; flex-shrink: 0;
  transition: color 0.15s;
}
.collapse-btn:hover { color: var(--accent); }
.collapse-btn svg { display: block; transition: transform 0.24s cubic-bezier(0.4,0,0.2,1); }
.collapse-btn.collapsed svg { transform: rotate(180deg); }

.key-wrap { display: flex; align-items: center; gap: 6px; }
.key-wrap input {
  background: var(--bg); border: 1px solid var(--border); color: var(--text);
  padding: 5px 10px; border-radius: 4px; font-size: 12px; width: 220px;
  font-family: inherit; letter-spacing: 0.04em; transition: border-color 0.15s;
}
.key-wrap input:focus  { outline: none; border-color: var(--accent); }
.key-wrap input::placeholder { color: var(--muted); }

.btn-connect {
  background: var(--accent); border: none; color: var(--on-accent);
  padding: 5px 12px; border-radius: 4px; cursor: pointer;
  font-family: inherit; font-size: 12px; font-weight: 600;
  transition: background 0.15s;
}
.btn-connect:hover { background: var(--accent-dim); }
.login-error { color: var(--red); font-size: 12px; margin-top: 12px; text-align: center; }

.oidc-login { display: flex; align-items: center; gap: 10px; }
.link-btn {
  background: none; border: none; color: var(--muted); cursor: pointer;
  font-family: inherit; font-size: 11px; letter-spacing: 0.04em;
  padding: 3px 0; border-bottom: 1px solid transparent;
  transition: color 0.15s, border-color 0.15s;
}
.link-btn:hover { color: var(--text); border-color: var(--border); }

.connected-bar { display: flex; align-items: center; }

.btn-new-post {
  height: 28px; display: inline-flex; align-items: center; gap: 0.5em; flex-shrink: 0;
  background: var(--accent); border: none; color: var(--on-accent);
  padding: 0 12px; border-radius: 4px; cursor: pointer;
  font-family: inherit; font-size: 12px; font-weight: 600;
  transition: background 0.15s;
}
.btn-new-post:hover { background: var(--accent-dim); }

/* ── Login view (logged-out) ─────────────────────────── */
.login-view { flex: 1; display: flex; align-items: center; justify-content: center; padding: 24px; }
/* Signed in or out is one class on <body> (main.js bootstrap/init), not a
   dozen inline toggles. Signed out there is only the login card: the sidebar
   used to stay live behind it, and the header read "offline" about a server
   that was up. */
body.signed-out :is(aside, .sidebar-overlay, .menu-btn, .brand-sep, .live-indicator, #collapseBtn,
  #newPostBtn, #statusBtn, #connectedBar, #searchBar, .feed, #newPostsPill) { display: none; }
body:not(.signed-out) .login-view { display: none; }
.login-card {
  display: flex; flex-direction: column; align-items: center; gap: 12px;
  text-align: center; width: 100%; max-width: 300px;
}
.login-mark { height: 56px; width: auto; margin-bottom: 2px; }
.login-title { font-size: 20px; font-weight: 700; letter-spacing: 0.14em; color: var(--accent); margin: 0; }
.login-tag { font-size: 12px; color: var(--muted); margin: 0 0 10px; }
.login-card .oidc-login { flex-direction: column; gap: 12px; width: 100%; }
.btn-login-primary {
  width: 100%; background: var(--accent); border: none; color: var(--on-accent);
  padding: 12px 16px; border-radius: 6px; cursor: pointer;
  font-family: inherit; font-size: 13px; font-weight: 600; transition: background 0.15s;
}
.btn-login-primary:hover { background: var(--accent-dim); }
.login-card .key-wrap { flex-direction: column; gap: 10px; width: 100%; }
.login-card .key-wrap input[type=password] { width: 100%; padding: 11px 12px; font-size: 13px; }
.login-card .btn-connect { width: 100%; padding: 11px 16px; font-size: 12px; }

/* ── Layout ──────────────────────────────────────────── */
.layout { display: flex; flex: 1; overflow: hidden; }

/* ── Sidebar ─────────────────────────────────────────── */
aside {
  width: 220px; flex-shrink: 0;
  border-right: 1px solid var(--border);
  display: flex; flex-direction: column; overflow: hidden;
  background: var(--bg);
  transition: width 0.24s cubic-bezier(0.4,0,0.2,1),
              border-right-color 0.24s ease,
              opacity 0.18s ease;
}
/* Desktop collapse — width animates to 0, content clipped by overflow:hidden */
aside.collapsed {
  width: 0; opacity: 0; border-right-color: transparent;
  pointer-events: none;
}
aside.collapsed > * { min-width: 220px; }
.sidebar-header {
  padding: 14px 16px 10px;
  font-size: 11px; font-weight: 600; color: var(--muted);
  display: flex; align-items: center; justify-content: space-between;
}
/* Three tabs and the `+` button fit a 220px sidebar with room. A fourth did
   not — `tests/ui/test_sidebar_tabs.py` measures the strip against the header's
   content box, so the next attempt to add one fails loudly instead of pushing
   the add button off the edge. */
.sidebar-tabs { display: flex; gap: 3px; min-width: 0; }
.sb-tab {
  background: none; border: none; color: var(--muted); cursor: pointer;
  font: inherit; font-size: 11px; font-weight: 500;
  padding: 3px 8px; border-radius: 4px; white-space: nowrap;
}
.sb-tab.active { color: var(--accent); background: var(--tag-active); }
.sb-tab:hover:not(.active) { color: var(--text); }
/* A fixed gutter, so the "all" row (which carries an empty one) lines its label
   up with the folder rows instead of hanging left of them. The icon is drawn
   (`ICON_FOLDER`) and strokes in `currentColor`, so `--accent` reaches it —
   which the U+1F4C1 emoji that shipped here first could never do. */
.folder-item .folder-ico {
  display: inline-flex; align-items: center; justify-content: flex-start;
  width: 1.25em; margin-right: 4px; color: var(--accent);
}
.folder-item .folder-ico svg { display: block; }
.sidebar-add-btn {
  background: none; border: 1px solid var(--border); color: var(--muted);
  cursor: pointer; font-size: 14px; line-height: 1; padding: 1px 6px;
  border-radius: 3px; font-family: inherit;
  transition: border-color 0.15s, color 0.15s;
}
.sidebar-add-btn:hover { border-color: var(--accent); color: var(--accent); }

.tag-new { padding: 0 12px 10px; }
.tag-new-input {
  background: var(--surface); border: 1px solid var(--accent); color: var(--text);
  border-radius: 3px; font-size: 12px; padding: 4px 8px; width: 100%;
  outline: none; font-family: inherit;
}

.tag-list { overflow-y: auto; flex: 1; padding-bottom: 8px; }
.tag-list::-webkit-scrollbar { width: 3px; }
.tag-list::-webkit-scrollbar-track { background: transparent; }
.tag-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }

.tag-item {
  display: flex; align-items: center; gap: 8px;
  padding: 7px 16px; cursor: pointer;
  border-left: 2px solid transparent;
  transition: background 0.12s;
}
.tag-item:hover  { background: var(--wash-raise); }
.tag-item.active { background: var(--tag-active); border-left-color: var(--accent); }

.tag-name  { font-size: 12px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The label is a <button> so the row is reachable by keyboard (main.js
   tagLabel); stripped back to look exactly like the text it replaced. */
button.tag-name {
  background: none; border: 0; padding: 0; margin: 0; min-width: 0;
  font-family: inherit; color: inherit; text-align: left; cursor: pointer;
}
.tag-count {
  font-size: 9px; color: var(--accent-3);
  background: color-mix(in srgb, var(--accent-3) 10%, transparent);
  padding: 1px 5px; border-radius: 8px;
  flex-shrink: 0; font-variant-numeric: tabular-nums;
  transition: background 0.12s, color 0.12s;
}
.tag-item.active .tag-count { background: var(--accent); color: var(--on-accent); }

/* Both row controls share a shape: a comfortable hit area around a 13px icon,
   revealed on hover. They carry SVGs now, so size comes from the icon rather
   than a font's dingbat metrics. */
.tag-rename, .tag-config-btn {
  background: none; border: none; cursor: pointer;
  padding: 2px; opacity: 0; flex-shrink: 0; line-height: 0;
  color: var(--muted); border-radius: 3px;
  display: inline-flex; align-items: center; justify-content: center;
  transition: opacity 0.12s, color 0.12s;
}
.tag-item:hover .tag-rename,
.tag-item:hover .tag-config-btn { opacity: 0.75; }
.tag-rename:hover, .tag-config-btn:hover { opacity: 1 !important; color: var(--accent); }
.tag-rename:focus-visible, .tag-config-btn:focus-visible { opacity: 1; color: var(--accent); }
/* A tag that expires its posts says so without a hover. */
.tag-config-btn.has-expiry { opacity: 0.75; color: var(--accent-3); }
/* Touch devices have no hover, so the controls would never appear at all. */
@media (hover: none) {
  .tag-rename, .tag-config-btn { opacity: 0.55; }
}

.tag-rename-input {
  background: var(--bg); border: 1px solid var(--accent); color: var(--text);
  border-radius: 3px; font-size: 12px; padding: 1px 5px;
  width: 100%; outline: none; font-family: inherit;
}

/* ── Feed ────────────────────────────────────────────── */
main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }


.feed {
  flex: 1; overflow-y: auto; overflow-x: hidden;
  padding: 16px 20px; display: flex; flex-direction: column; gap: 10px;
}
.feed::-webkit-scrollbar { width: 4px; }
.feed::-webkit-scrollbar-track { background: transparent; }
.feed::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }

/* Grid view: cards flow into a responsive column grid instead of a stack. */
.feed.grid {
  display: grid; align-content: start;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  gap: 12px;
}
.feed.grid > .post.pinned,
.feed.grid > .empty,
.feed.grid > .auth-prompt { grid-column: 1 / -1; }   /* master doc / states span full width */

/* ── Post card ───────────────────────────────────────── */
.post {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: 6px; padding: 16px; min-width: 0;
  transition: border-color 0.2s, box-shadow 0.2s;
  animation: cardIn 0.3s ease both;
}
@keyframes cardIn {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: translateY(0); }
}
.post:hover { border-color: var(--border-hover); box-shadow: var(--shadow-card); }
.post.card-focused { border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); outline: none; }
.post.new { animation: cardIn 0.3s ease, flash 1.4s ease; }
@keyframes flash {
  0%   { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), 0 0 24px var(--accent-glow); }
  100% { border-color: var(--border); box-shadow: none; }
}

/* Feed cards lay their parts out on a grid so the media thumbnail can sit
   beside the text in list mode and jump on top as a hero in grid mode —
   one markup, pure-CSS switch. */
.feed > .post {
  display: grid; align-items: start;
  grid-template-columns: 1fr auto;
  grid-template-areas: "badge  badge" "header header" "body media" "footer footer";
}
/* The gap only when there is a thumbnail to hold apart: on an image-less card
   the empty `auto` track still took the gap, and every body (text, tables)
   stopped 14px short of the footer's edge. */
.feed > .post:has(> .post-media) { column-gap: 14px; }
/* Every area needs min-width:0: a `1fr` track's automatic minimum is the
   item's min-content width, so one non-shrinkable child (a nowrap source,
   a nowrap table) would otherwise widen the track past the card border and
   everything inside it would paint outside the frame. */
.feed > .post > * { min-width: 0; }
.feed > .post .master-badge   { grid-area: badge; }
.feed > .post .post-header    { grid-area: header; }
.feed > .post .post-body-wrap { grid-area: body; }
.feed > .post .post-media     { grid-area: media; }
.feed > .post .post-footer    { grid-area: footer; }
.feed.grid > .post {
  grid-template-columns: 1fr;
  grid-template-areas: "media" "badge" "header" "body" "footer";
}
/* A tile is as tall as its content, up to 340px: a row of one-line notes stays
   short instead of being 340px of empty space, and one long note or hero image
   cannot blow a row out. Tiles stretch to their row's tallest, so a row's
   edges still line up. The body row flex-fills and clips with the fade; the
   footer pins to the bottom. The full-width master banner keeps its own height. */
.feed.grid > .post:not(.pinned) {
  max-height: 340px;
  grid-template-rows: auto auto auto minmax(0, 1fr) auto;
}
.feed.grid > .post:not(.pinned) .post-body-wrap {
  max-height: none; height: 100%; min-height: 0;
}

.post-media { align-self: start; line-height: 0; }
.post-media img {
  width: 108px; height: 78px; object-fit: cover; display: block;
  border-radius: 5px; border: 1px solid var(--border);
}
.feed.grid .post-media { margin-bottom: 10px; }
.feed.grid .post-media img { width: 100%; height: 100px; }
.post-media-count { font-size: 10px; color: var(--muted); }

/* A card previews its opening and ends on a whole line: the first block (plus
   the one after it when it opens on a heading), a paragraph cut at three lines
   with an ellipsis, a list at three items, code at four lines, a table at two
   rows. It used to clip at a pixel height and fade the cut line out, which
   only drew the eye to the cut. The master document, opened, shows in full. */
.feed .post-body { font-size: 14px; line-height: 1.55; }
.feed:not(.grid) .post-body { max-width: 80ch; }   /* a wide list card still reads at line length */
.feed .post-body > :first-child { margin-top: 0; }
.feed .post-body :is(h1, h2, h3) { font-size: 13px; }   /* under the card's 14px title, never over it */
.feed .post:not(.master-doc) .post-body > :not(:first-child):not(:is(h1, h2, h3, h4, h5, h6):first-child + *) { display: none; }
.feed .post:not(.master-doc) .post-body > :is(p, blockquote) {
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden;
}
.feed.grid .post:not(.master-doc) .post-body > :is(p, blockquote) { -webkit-line-clamp: 7; }
.feed .post:not(.master-doc) .post-body :is(ul, ol) > li:nth-child(n+4) { display: none; }
.feed.grid .post:not(.master-doc) .post-body :is(ul, ol) > li:nth-child(n+4) { display: list-item; }
.feed.grid .post:not(.master-doc) .post-body :is(ul, ol) > li:nth-child(n+7) { display: none; }
.feed .post:not(.master-doc) .post-body pre { max-height: calc(4lh + 20px); overflow: hidden; }
.feed .post:not(.master-doc) .post-body tr:nth-child(n+3) { display: none; }

/* Narrow grid tiles: stack the header (title on its own line, tags below) so
   tags don't crush the title into a hard ellipsis, and let long titles wrap
   to two lines. The footer meta wraps instead of overflowing. */
/* `stretch` (not flex-start) so the tag row inherits the header's definite
   width — that's what makes the source's max-width:100% ellipsis resolve. */
.feed.grid .post-header { flex-direction: column; align-items: stretch; gap: 6px; }
.feed.grid .post-title {
  white-space: normal; overflow: hidden; text-overflow: clip;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
}
.feed.grid .post-footer-left { flex-wrap: wrap; row-gap: 3px; }
/* A tile footer can't fit pill + both stamps + two labelled buttons on one
   line, so grid mode drops the two most redundant parts: the created stamp
   when an edit stamp is already there (same information, usually the same
   value) and the button captions, leaving the icons. List mode keeps both. */
.feed.grid .t-created:not(:only-child) { display: none; }
.feed:not(.grid) .t-created + .t-edited::before { content: " · "; }
.feed.grid .btn-label { display: none; }
.feed.grid .post-actions button { padding-left: 7px; padding-right: 7px; }
/* The note's own "Last updated:" line is already in the body, which the
   detail modal shows in full — in a tile it only buys a second footer row. */
.feed.grid .t-doc-updated { display: none; }
.feed:not(.grid) .t-doc-updated::before { content: "· "; }

.post-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; }
.post-title  { font-size: 14px; font-weight: 600; line-height: 1.35; color: var(--text); flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
a.post-title { text-decoration: none; }
/* List mode: the title sizes to its text and the tags wrap under it once they
   no longer fit beside it — five tags used to shrink a title to "A very long
   title that goes on and on to test how the card header handles wrapping acros…"
   while every pill stayed whole. */
.feed:not(.grid) .post-title { flex: 1 1 auto; max-width: 100%; }   /* a link for keyboard and Ctrl-click, styled as the title it is */

.post-tags { display: flex; gap: 5px; flex-wrap: wrap; align-items: center; flex-shrink: 0; min-width: 0; max-width: 100%; }
.tag-pill {
  /* Muted, not accent: a pill painted like a wikilink sat beside real links
     in the same orange, and the card's one accent was spent on metadata. The
     accent comes back on hover, where the pill is the thing being pointed at. */
  font-size: 10px; padding: 2px 7px; border-radius: 3px;
  background: transparent; color: var(--muted); cursor: pointer;
  border: 1px solid var(--border); letter-spacing: 0.02em;
  transition: color 0.12s, border-color 0.12s;
}
.tag-pill:hover { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 50%, transparent); }
/* A long "via …" is one unbreakable run: cap + ellipsis it so it can never
   push the header (and with it the card's 1fr track) wider than the card. */
.post-source {
  font-size: 10px; color: var(--accent-2); text-decoration: none;
  display: inline-block; min-width: 0; max-width: 100%; vertical-align: bottom;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* The reading scale: 15 body, 18 section, 22 title (13 is the interface's own
   size). Headings, tables and code keep the interface face — they are
   structure and data, scanned and copied rather than read line after line. */
.post-body { font-family: var(--font-read); color: var(--body); line-height: 1.65; font-size: 15px; overflow-wrap: anywhere; }
.post-body h1, .post-body h2, .post-body h3 { color: var(--text); margin: 1.4em 0 0.4em; font-weight: 600; line-height: 1.3; }
.post-body h1 { font-size: 22px; }
.post-body h2 { font-size: 18px; }
.post-body h3 { font-size: 15px; color: var(--body-strong); }
.post-body :is(h1, h2, h3, h4, h5, h6, table, code, pre, kbd) { font-family: var(--font-ui); }
.post-body hr { border: none; border-top: 1px solid var(--border); margin: 14px 0; }
.post-body p         { margin: 6px 0; }
/* 2.2em holds a two-digit marker ("10.") inside the box — 18px clipped "1." to
   "." in feed cards and hung the numbers left of the modal's reading column. */
.post-body ul, .post-body ol { padding-left: 2.2em; margin: 6px 0; }
.post-body li        { margin: 3px 0; }
.post-body a         { color: var(--accent); text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 2px; }
.post-body a:hover   { text-decoration-style: solid; }
.post-body a.wikilink { color: var(--accent); text-decoration: none; border-bottom: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); cursor: pointer; }
.post-body a.wikilink:hover { border-bottom-color: var(--accent); }
.post-body .wikilink.broken { color: var(--muted); text-decoration: none; border-bottom: 1px dashed var(--muted); cursor: help; }
/* Block margin only: in the post modal every direct child is centred in the
   reading column by `margin-inline: auto` (see --measure), and a `margin: 0.5rem 0`
   shorthand here reset that and pinned images to the panel's left edge, outside
   the column the text sits in. */
.post-body img.attachment { max-width: 100%; height: auto; border-radius: 6px; display: block; margin-block: 0.5rem; }
.pm-body .post-body > img.attachment { margin-inline-start: max(0px, calc((100% - var(--measure)) / 2)); }
.post-body a.attachment-link { color: var(--accent); text-decoration: none; border-bottom: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); }
/* A drawn paperclip on currentColor (masked SVG), not the 📎 emoji — see icons.js. */
.post-body a.attachment-link::before {
  content: ""; display: inline-block; width: 0.9em; height: 0.9em; margin-right: 0.3em; vertical-align: -0.1em;
  background: currentColor;
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M13 7.4l-5.1 5.1a3 3 0 0 1-4.3-4.3l5.3-5.3a2 2 0 0 1 2.8 2.8l-5.1 5.1a1 1 0 0 1-1.4-1.4L10 4.6'/%3E%3C/svg%3E") center / contain no-repeat;
}
.post-body a.attachment-link.broken { color: var(--muted); border-bottom-color: var(--muted); }
.pm-backlinks { margin: 18px 0 4px; padding-top: 12px; border-top: 1px solid var(--border); }
.pm-backlinks h4 { color: var(--text); font-family: var(--font-ui); font-size: 12px; font-weight: 600; margin: 0 0 8px; }
.pm-backlinks ul { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; }
.pm-backlinks a { color: var(--accent); cursor: pointer; font-size: 12px; }
.pm-backlinks .bl-id { color: var(--muted); margin-right: 6px; }
.post-body code      { background: var(--bg); padding: 1px 5px; border-radius: 3px; font-size: 0.85em; border: 1px solid var(--border); -webkit-box-decoration-break: clone; box-decoration-break: clone; }
.post-body pre       { position: relative; background: var(--bg); padding: 10px 12px; border-radius: 4px; overflow-x: auto; margin: 8px 0; border: 1px solid var(--border); }
.post-body pre code  { background: none; padding: 0; border: none; font-size: 12px; }
.code-copy {
  position: absolute; top: 6px; right: 6px;
  background: var(--surface); border: 1px solid var(--border); border-radius: 3px;
  color: var(--muted); font-family: inherit; font-size: 10px; font-weight: 600;
  padding: 2px 7px;
  cursor: pointer; opacity: 0; transition: opacity 0.15s, color 0.15s, border-color 0.15s;
  line-height: 1.6;
}
.post-body pre:hover .code-copy { opacity: 1; }
.code-copy:hover { color: var(--text); }
.code-copy.copied { color: var(--accent); border-color: var(--accent); opacity: 1; }
/* Body-coloured, not --muted: a quote is often the point of a note, and muted
   text on --surface is below the contrast floor the body text is held to. The
   accent rule and the italic already mark it as quoted. */
.post-body blockquote { border-left: 2px solid var(--accent); padding-left: 12px; color: var(--body); font-style: italic; margin: 8px 0; }
.post-body strong    { color: var(--text); font-weight: 600; }
.post-body em        { font-style: italic; color: var(--body-strong); }
/* Tables size their columns to their content (automatic layout): a "#" column
   stays narrow and a description column takes the room. Words never break
   mid-word — a column is never narrower than its longest word — except URLs,
   which may wrap anywhere so one long link can't claim the whole width.
   A table that still doesn't fit scrolls inside `.table-scroll` (main.js wraps
   every rendered table, feed and modal alike), whose edge shadows show there is
   more to either side: iOS hides scrollbars, and an unmarked overflow reads as a
   table that simply ends.

   This replaced `table-layout: fixed`, which split every table into equal
   columns — a 166px "#" beside a crushed description on desktop, one letter per
   line in a ten-column table on a phone — and middle-aligned cells, which left
   short cells looking empty beside a tall one. */
.post-body table     { border-collapse: collapse; border-color: var(--border); width: 100%; margin: 8px 0; font-size: 12px; }
/* `overflow-wrap: normal` undoes `.post-body`'s `anywhere`, which let every cell
   shrink below its longest word. */
.post-body th, .post-body td { border: 1px solid var(--border); padding: 5px 10px; text-align: left; vertical-align: top; overflow-wrap: normal; }
/* Links only: a URL has no natural break and would claim the whole width. Code
   wraps at spaces and hyphens like prose — `anywhere` split `fleet-health.sh`
   into four one-syllable pills. */
.post-body td a { overflow-wrap: anywhere; }
.post-body table.prose-table { font-family: var(--font-read); }   /* see render.js floorProseColumns */
.post-body th        { background: var(--bg); color: var(--text); font-weight: 600; }
.post-body tr:nth-child(even) td { background: var(--wash-raise); }
.table-scroll {
  overflow-x: auto; margin: 8px 0;
  /* Scroll shadows: the two `local` covers travel with the content and hide
     the fixed shadows beneath them whenever that edge is already reached. */
  background:
    linear-gradient(to right, var(--surface) 40%, transparent) left / 28px 100% no-repeat local,
    linear-gradient(to left, var(--surface) 40%, transparent) right / 28px 100% no-repeat local,
    radial-gradient(farthest-side at 0 50%, rgba(var(--scrim-rgb), 0.45), transparent) left / 14px 100% no-repeat scroll,
    radial-gradient(farthest-side at 100% 50%, rgba(var(--scrim-rgb), 0.45), transparent) right / 14px 100% no-repeat scroll;
}
.table-scroll table  { margin: 0; }

.post-footer {
  display: flex; align-items: center; justify-content: space-between;
  margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border);
}
/* Never break a stamp mid-phrase — an orphaned "ago" on its own line was
   the ugliest part of a wrapped footer. Chunks wrap whole or not at all. */
.post-time { font-size: 10px; color: var(--muted); letter-spacing: 0.02em; white-space: nowrap; }
/* Who wrote the current version. A button — it filters the feed by them — but
   set as part of the meta line, not as another pill beside the tags. */
.post-author {
  background: none; border: none; padding: 0; cursor: pointer; font-family: inherit;
  font-size: 10px; color: var(--muted); letter-spacing: 0.02em;
  max-width: 22ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  text-decoration: underline dotted; text-underline-offset: 2px;
}
.post-author:hover { color: var(--accent); }
.author-mark {
  display: inline-block; width: 7px; height: 7px; border-radius: 2px;
  margin-right: 5px; vertical-align: 0.05em; background: var(--accent);
}
.author-mark[data-hue="1"] { background: var(--accent-2); }
.author-mark[data-hue="2"] { background: var(--accent-3); }
.pm-time .post-author { max-width: none; }
.post-actions { display: flex; gap: 6px; opacity: 0; transition: opacity 0.15s; flex-shrink: 0; }
.post:hover .post-actions { opacity: 1; }

.btn-delete {
  background: none; border: 1px solid color-mix(in srgb, var(--red) 25%, transparent); color: var(--red);
  padding: 3px 9px; border-radius: 3px; cursor: pointer;
  font-family: inherit; font-size: 11px;
  transition: background 0.15s, border-color 0.15s;
}
.btn-delete:hover { background: color-mix(in srgb, var(--red) 10%, transparent); border-color: var(--red); }

.btn-edit {
  background: none; border: 1px solid var(--border); color: var(--muted);
  padding: 3px 9px; border-radius: 3px; cursor: pointer;
  font-family: inherit; font-size: 11px;
  transition: border-color 0.15s, color 0.15s;
}
.btn-edit:hover { border-color: var(--accent); color: var(--accent); }
/* Drawn icons (icons.js) beside a label: centred on the text, not its baseline. */
.btn-edit, .btn-delete, .btn-attach, .btn-restore, .post-media-count { display: inline-flex; align-items: center; gap: 5px; }
.btn-edit .icon, .btn-delete .icon, .btn-attach .icon, .btn-restore .icon, .post-media-count .icon { flex-shrink: 0; }

/* ── Toast (js/toast.js) ─────────────────────────────── */
.toast {
  position: fixed; z-index: 300; left: 50%; transform: translateX(-50%);
  bottom: calc(20px + env(safe-area-inset-bottom, 0px));
  display: flex; align-items: center; gap: 12px;
  max-width: min(560px, calc(100vw - 32px));
  background: var(--surface); color: var(--text);
  border: 1px solid var(--border); border-radius: 6px;
  padding: 10px 10px 10px 14px; font-size: 12px;
  box-shadow: var(--shadow-modal);
  animation: toastIn 0.18s ease backwards;
}
/* Its own keyframes, no `to`: modalIn ends on `transform: none`, which would
   drop the centring translate for the length of the entrance. */
@keyframes toastIn { from { opacity: 0; transform: translate(-50%, 8px); } }
.toast-error { border-color: var(--red); }
.toast-text { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.toast-action {
  background: none; border: 1px solid color-mix(in srgb, var(--accent) 50%, transparent);
  color: var(--accent); border-radius: 4px; padding: 4px 10px; cursor: pointer;
  font-family: inherit; font-size: 12px; font-weight: 600; flex-shrink: 0;
}
.toast-action:hover { border-color: var(--accent); }
.toast-close {
  background: none; border: none; color: var(--muted); cursor: pointer;
  font-family: inherit; font-size: 16px; line-height: 1; padding: 2px 4px; flex-shrink: 0;
}
.toast-close:hover { color: var(--text); }

/* ── Edit / Compose form ─────────────────────────────── */
.edit-form { display: flex; flex-direction: column; gap: 10px; }
.edit-form input, .edit-form textarea, .edit-form select, .ef-content-backdrop {
  background: var(--bg); border: 1px solid var(--border); color: var(--text);
  padding: 7px 10px; border-radius: 4px; font-size: 12px; font-family: inherit; width: 100%;
  transition: border-color 0.15s;
}
.edit-form textarea       { resize: vertical; min-height: 120px; line-height: 1.65; }
.ef-content-backdrop      { line-height: 1.65; }
.edit-form input:focus,
.edit-form textarea:focus,
.edit-form select:focus   { outline: none; border-color: var(--accent); }
.edit-form label {
  font-size: 11px; color: var(--muted); margin-bottom: 4px; display: block;
}
.edit-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 2px; }

/* Broken-link highlighting (edit-form.js's buildEditForm): a textarea can't
   style individual substrings, so `.ef-content-backdrop` is a same-sized
   <pre> behind it — this rule gives both layers the identical box model
   (padding/border/font/line-height) the alignment depends on, box-sizing:
   border-box (inherited from `*` presumably elsewhere, kept explicit here
   since it's load-bearing) so the border width is included the same way on
   both. Layering and the two layers' own colour/interaction differences
   (background is one paints the other hides, text is only ever drawn once)
   are below, next to `.ef-content-highlight`. */
.ef-content-highlight { position: relative; flex: 1; min-height: 0; display: flex; }
.ef-content-highlight .ef-content-backdrop,
.ef-content-highlight .ef-content {
  position: absolute; inset: 0; box-sizing: border-box; margin: 0;
  white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word;
}
.ef-content-backdrop {
  z-index: 0; color: transparent; overflow: hidden; pointer-events: none;
  border-color: transparent;
}
.ef-content-highlight .ef-content { z-index: 1; background: transparent; }
.ef-broken-link {
  background: color-mix(in srgb, var(--red) 35%, transparent);
  border-radius: 2px;
}

.btn-save {
  background: var(--accent); border: none; color: var(--on-accent);
  padding: 5px 14px; border-radius: 3px; cursor: pointer;
  font-family: inherit; font-size: 12px; font-weight: 600;
  transition: background 0.15s;
}
.btn-save:hover    { background: var(--accent-dim); }
.btn-save:disabled { opacity: 0.55; cursor: not-allowed; background: var(--accent); }

/* --body, not --muted: a secondary action, not a disabled one — muted text beside
   the bright primary button read as unavailable. */
.btn-cancel {
  background: none; border: 1px solid var(--border); color: var(--body);
  padding: 5px 14px; border-radius: 3px; cursor: pointer;
  font-family: inherit; font-size: 11px;
  transition: border-color 0.15s, color 0.15s;
}
.btn-cancel:hover { border-color: var(--text); color: var(--text); }

.attach-row { display: flex; align-items: center; gap: 10px; margin-top: 6px; }
.btn-attach {
  background: none; border: 1px solid var(--border); color: var(--muted);
  padding: 4px 10px; border-radius: 3px; cursor: pointer;
  font-family: inherit; font-size: 11px;
  transition: border-color 0.15s, color 0.15s;
}
.btn-attach:hover { border-color: var(--accent); color: var(--accent); }
.attach-status { font-size: 11px; color: var(--muted); }
.attach-status.error { color: var(--red); }
/* Scope-gating message (relay #198, B-8) — a Save/Publish button's disabled
 * reason, in both the compose panel and buildEditForm's shared form. Muted
 * when empty (no layout jump — always present, just empty), red once it
 * actually explains why the button is disabled. */
.ef-gate-msg { font-size: 12px; color: var(--muted); }
.ef-gate-msg:not(:empty) { color: var(--red); margin-top: 2px; }
.ef-attachments { margin-top: 6px; display: flex; flex-direction: column; gap: 3px; }
.ef-attach-head { font-size: 11px; font-weight: 600; color: var(--muted); margin-bottom: 2px; }
.ef-attach-item { display: flex; align-items: center; gap: 8px; font-size: 12px; }
.ef-attach-name { color: var(--text); overflow-wrap: anywhere; }
.ef-attach-size { color: var(--muted); font-size: 11px; margin-left: auto; white-space: nowrap; }
.ef-attach-del { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 15px; line-height: 1; padding: 0 4px; }
.ef-attach-del:hover { color: var(--red); }

/* ── Writing aids (js/editor.js) ─────────────────────── */
/* Fixed, placed under the caret by script: inside a scrolling form an absolute
   menu would be clipped by the sheet's own overflow. */
.ac-menu {
  position: fixed; z-index: 250; list-style: none;
  min-width: 200px; max-width: 280px; max-height: 240px; overflow-y: auto; padding: 4px;
  background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
  box-shadow: var(--shadow-modal);
}
.ac-item, .sw-item {
  display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
  padding: 6px 9px; border-radius: 4px; cursor: pointer;
  font-size: 12px; color: var(--text);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* --accent on --tag-bg: an accent-chip pairing the contrast tests already hold. */
.ac-item[aria-selected="true"], .sw-item[aria-selected="true"], .ac-item:hover, .sw-item:hover {
  background: var(--tag-bg); color: var(--accent);
}
:is(.ac-item, .sw-item)[aria-selected="true"] .ac-hint { color: inherit; }
.ac-hint { flex-shrink: 0; font-size: 10px; color: var(--muted); font-variant-numeric: tabular-nums; }
.ef-preview {
  border: 1px solid var(--border); border-radius: 4px; padding: 10px 12px;
  overflow-y: auto; max-height: 60vh;
}
.ef-preview > :first-child { margin-top: 0; }
.ef-preview a.wikilink { cursor: default; }   /* inert here — see main.js */
.ef-preview-btn[aria-pressed="true"], #cpPreview[aria-pressed="true"] { color: var(--accent); border-color: var(--accent-dim); }

/* ── Attachment gallery ─────────────────────────────── */
.attachments-view { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 4px 20px 40px; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
.attachments-view::-webkit-scrollbar { width: 4px; }
.attachments-view::-webkit-scrollbar-track { background: transparent; }
.attachments-view::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.att-folder-head { font-size: 12px; font-weight: 600; color: var(--text); margin: 18px 2px 8px; }
.att-folder-head:first-child { margin-top: 4px; }
.att-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; }
.att-card { position: relative; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; background: color-mix(in srgb, var(--accent) 4%, transparent); }
.att-thumb { height: 110px; display: flex; align-items: center; justify-content: center; overflow: hidden; background: rgba(var(--scrim-rgb), 0.15); cursor: pointer; }
.att-thumb img { width: 100%; height: 100%; object-fit: cover; }
.att-thumb .att-ext { font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent-2); }
.att-thumb .att-ext::before {
  content: ""; display: block; width: 26px; height: 26px; margin: 0 auto 6px;
  background: currentColor;
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9.5 1.8H4.2v12.4h7.6V4.1z'/%3E%3Cpath d='M9.5 1.8v2.3h2.3'/%3E%3C/svg%3E") center / contain no-repeat;
}
.att-meta { padding: 6px 8px; display: flex; flex-direction: column; gap: 2px; }
.att-name { font-size: 12px; overflow-wrap: anywhere; color: var(--text); }
.att-sub { font-size: 10px; color: var(--muted); display: flex; justify-content: space-between; }
.att-del { position: absolute; top: 4px; right: 4px; width: 22px; height: 22px; border: none; border-radius: 4px;
  background: rgba(var(--scrim-rgb), 0.55); color: var(--on-scrim); cursor: pointer; font-size: 15px; line-height: 1; opacity: 0; transition: opacity 0.15s; }
.att-card:hover .att-del { opacity: 1; }
.att-del:hover { background: var(--red); color: var(--on-scrim); }
.att-empty { color: var(--muted); font-size: 13px; padding: 20px 2px; }

.lightbox { position: fixed; inset: 0; background: rgba(var(--scrim-rgb), 0.85); z-index: 100; display: flex; align-items: center; justify-content: center; padding: 24px; cursor: pointer; }
.lightbox-inner { max-width: 92vw; max-height: 92vh; display: flex; flex-direction: column; align-items: center; gap: 10px; }
.lightbox img { max-width: 92vw; max-height: 82vh; object-fit: contain; border-radius: 4px; }
.lightbox-cap { color: var(--on-scrim); font-size: 12px; }
.edit-form textarea.drag-over { border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent); }

/* ── Recovery browser (inside the status panel) ───────── */
/* Not a sidebar view: Tags/Tree/Files are ways to browse what *exists*, and
   this is a different corpus. It renders into `.sm-body`, so it inherits the
   panel's scroll shell and 560px measure rather than owning either. */
.sm-back {
  align-self: flex-start; background: none; border: none; cursor: pointer;
  font: inherit; font-size: 11px; color: var(--muted); padding: 0;
  transition: color 0.15s;
}
.sm-back:hover { color: var(--accent); }
.del-filters { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 4px; }
.del-filter {
  background: none; cursor: pointer; font: inherit; font-size: 10px;
  padding: 3px 8px;
  border: 1px solid var(--border); border-radius: 3px; color: var(--muted);
  transition: color 0.15s, border-color 0.15s;
}
.del-filter:hover { color: var(--text); }
.del-filter.active { color: var(--accent); border-color: var(--accent); }
.del-card {
  border: 1px solid var(--border); border-radius: 6px;
  padding: 12px 14px; margin-bottom: 10px;
  background: color-mix(in srgb, var(--red) 4%, transparent);
}
.del-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; }
.del-title { font-size: 13px; font-weight: 600; color: var(--text); min-width: 0; overflow-wrap: anywhere; }
/* The reason a post went away decides how alarming it should look: a TTL sweep
   is routine, a delete is deliberate, and an editor removing the file is the
   case this view exists for. */
.del-reason {
  font-size: 11px;
  padding: 1px 7px; border-radius: 3px; flex-shrink: 0;
  color: var(--accent-3); background: color-mix(in srgb, var(--accent-3) 10%, transparent);
}
.del-reason-deleted { color: var(--red); background: color-mix(in srgb, var(--red) 10%, transparent); }
.del-reason-external { color: var(--accent-2); background: color-mix(in srgb, var(--accent-2) 10%, transparent); }
.del-meta { font-size: 10px; color: var(--muted); overflow-wrap: anywhere; margin-bottom: 8px; }
.del-actions { display: flex; gap: 6px; }
.del-failed { color: var(--red); margin: 8px 0 0; }
.btn-restore {
  background: none; border: 1px solid color-mix(in srgb, var(--green) 35%, transparent);
  color: var(--green); padding: 3px 9px; border-radius: 3px; cursor: pointer;
  font-family: inherit; font-size: 11px;
  transition: background 0.15s, border-color 0.15s;
}
.btn-restore:hover:not(:disabled) { background: color-mix(in srgb, var(--green) 12%, transparent); border-color: var(--green); }
.btn-restore:disabled { opacity: 0.6; cursor: default; }
.del-body {
  margin: 10px 0 0; padding: 10px 12px; max-height: 260px; overflow: auto;
  background: var(--bg); border: 1px solid var(--border); border-radius: 4px;
  font-family: inherit; font-size: 12px; line-height: 1.55; color: var(--body);
  white-space: pre-wrap; overflow-wrap: anywhere;
  scrollbar-width: thin; scrollbar-color: var(--border) transparent;
}
@media (max-width: 768px) {
  /* `.sm-body .btn-edit` reaches the Recovery section's own button too — it is
     not inside `.del-actions`, and the sheet touch-floor smoke caught it at
     132x23 rather than my noticing. */
  .del-actions button, .del-filter, .sm-back, .sm-body .btn-edit, .lm-filter, .lm-open {
    min-height: 44px; display: inline-flex; align-items: center;
  }
  /* `.hm-rev`'s own two-line content (message + meta) clears 44px on its own;
     a lint finding row is shorter (one short rule label, one short sub line),
     so it needs the floor stated explicitly rather than relying on content. */
  .lm-finding { min-height: 44px; justify-content: center; }
}

/* ── Vault lint modal (`#lintModal`) ───────────────────── */
/* Its own modal, stacked over the status modal — not a drill-down replacing
   that panel's body. A fixed two-pane shell (list left, pane right — mirrors
   `.hm-layout`/`.hm-list`/`.hm-pane`, the post-history modal's own shape)
   keeps the finding and the post it is about on screen together. The pane
   hosts the editor directly (`.lm-editor`, `buildEditForm` from
   ./js/edit-form.js) rather than a read-only preview — the point of this
   panel is fixing what a finding flags, not just reading about it, and a
   preview plus a button leading to a *different* modal (an earlier version)
   cost you your place in the list every time you actually acted on one.
   "Open post" (`.lm-open`) survives as a smaller, secondary action for what
   the inline editor doesn't cover — Delete, History, backlinks. Distinct
   `.lm-*` names rather than reusing `.hm-*` outright: `.hm-inner` (declared
   further down, in the Post history panel section) is asserted bare/unscoped
   in a couple of tests (`.bounding_box()` expects exactly one match), and
   this modal coexisting with history in the DOM would break that.

   `.lm-inner`'s own size rule lives further down, next to `.hm-inner`'s —
   not here, next to everything else lint-specific. `.sm-inner`'s base rule
   (`max-width: 560px`, the default status-panel size) is declared *after*
   this point in the file at equal specificity, so a `.lm-inner` override
   placed here would lose to it on source order — the exact `.em-inner`/
   `.hm-inner` trap this file's own history block already documents. Placing
   it after `.sm-inner` instead, where `.hm-inner`/`.em-inner` already sit,
   is the actual fix; a rule stated here would look right and render wrong. */
#lintModal .sm-body { flex: 1; min-height: 0; overflow: hidden; display: flex; flex-direction: column; }
.lm-filters { flex-shrink: 0; margin-bottom: 10px; }
.lint-skipped {
  font-size: 11px; color: var(--muted); margin-bottom: 8px;
  padding: 6px 8px; border: 1px solid var(--border); border-radius: 4px;
}
.lm-chips, .del-filters { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 4px; }
.lm-filter, .del-filter {
  background: none; cursor: pointer; font: inherit; font-size: 10px;
  padding: 3px 8px;
  border: 1px solid var(--border); border-radius: 3px; color: var(--muted);
  transition: color 0.15s, border-color 0.15s;
}
.lm-filter:hover, .del-filter:hover { color: var(--text); }
.lm-filter.active, .del-filter.active { color: var(--accent); border-color: var(--accent); }

.lm-layout {
  display: grid;
  grid-template-columns: minmax(260px, 340px) 1fr;
  gap: 0;
  flex: 1;
  min-height: 0;
}
.lm-list {
  display: flex; flex-direction: column; gap: 2px;
  overflow-y: auto; min-height: 0; padding-right: 12px;
}
/* `overflow-y: auto`, same as `.lm-list` right above: `#lintModal .sm-body`
   turns off the body-level scroll every other `.sm-*` modal gets for free
   (the base `.sm-body` rule) so the two panes can scroll independently
   instead of one scrolling both out of sync. `.lm-list` got that treatment;
   this one didn't, and the editor form (title/tags/source/expires/content/
   attachments/Save-Cancel) is tall enough to overflow the pane's allotted
   share of a mobile sheet — with nothing here to scroll it, the excess was
   just clipped by `.sm-body`'s `overflow: hidden` and unreachable. */
.lm-pane {
  display: flex; flex-direction: column; min-height: 0; min-width: 0;
  border-left: 1px solid var(--border); padding-left: 16px; gap: 10px;
  overflow-y: auto;
}
/* `.hm-rev`'s row shell (padding, hover/active state) fits a finding row as-is
   — scoped by `#hmBody` everywhere it is asserted, so an `.lm-finding` row
   living under `#lmBody` instead cannot collide with those. */
.lm-finding-head { display: flex; align-items: center; gap: 6px; }
.lm-finding .hm-meta { color: var(--muted); font-size: 10.5px; }

/* The selected finding's own rule + detail, pinned above the post preview so
   the reason this pane is open never scrolls out of view. Same visual shape
   as a lint finding got in the flat-list design this replaced, just one at a
   time instead of many. */
.lint-card {
  border: 1px solid var(--border); border-radius: 6px;
  padding: 10px 14px; flex-shrink: 0;
}
.lint-card.lint-error { background: color-mix(in srgb, var(--red) 4%, transparent); }
.lint-card.lint-warning { background: color-mix(in srgb, var(--accent) 4%, transparent); }
.lint-rule { font-size: 11px; font-weight: 600; color: var(--text); }
.lint-detail { font-size: 11.5px; margin-top: 4px; }
/* Secondary now that the pane hosts the editor directly — Save/Cancel down in
   the form are the primary actions, this is only for what they don't cover
   (Delete, History, backlinks). Same look as `.sm-back`'s breadcrumb text. */
.lm-open {
  align-self: flex-start; background: none; border: none;
  color: var(--muted); cursor: pointer; font-family: inherit;
  font-size: 11px; padding: 0; flex-shrink: 0; transition: color 0.15s;
}
.lm-open:hover { color: var(--accent); }

/* The editor itself (buildEditForm, ./js/edit-form.js) — same treatment
   `#editModal .edit-form` gives it, just against `.lm-pane`'s geometry
   instead of the standalone modal's. No footer bar breaking out to full
   bleed here: `.lm-pane` doesn't own its own edge-to-edge padding the way
   `.sm-body` does in the standalone modal, so Save/Cancel just sit inline at
   the form's own width with a top border for separation instead. */
.lm-editor { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.lm-editor .edit-form { display: flex; flex-direction: column; flex: 1; min-height: 0; gap: 10px; }
.lm-editor .edit-form > .ef-content-wrap { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.lm-editor .edit-form .ef-content-highlight { flex: 1; min-height: 100px; }
.lm-editor .edit-form textarea.ef-content { resize: none; }
.lm-editor .edit-form .ef-attachments { max-height: 16vh; overflow-y: auto; }
.lm-editor .edit-actions { border-top: 1px solid var(--border); padding-top: 10px; }

@media (max-width: 860px) {
  .lm-inner { height: 88vh; }
  .lm-layout { grid-template-columns: 1fr; grid-template-rows: minmax(0, 40%) 1fr; }
  .lm-list { padding-right: 0; padding-bottom: 10px; }
  .lm-pane { border-left: none; border-top: 1px solid var(--border); padding-left: 0; padding-top: 12px; }
}

/* ── Search bar ─────────────────────────────────────── */
.search-bar {
  display: flex; align-items: center; gap: 8px;
  padding: 8px 20px; border-bottom: 1px solid var(--border);
  background: var(--surface); flex-shrink: 0;
}
.search-field { position: relative; flex: 1; display: flex; min-width: 0; }
.search-bar input {
  flex: 1; min-width: 0; background: var(--bg); border: 1px solid var(--border);
  color: var(--text); padding: 5px 10px; border-radius: 4px;
  font-size: 12px; font-family: inherit; transition: border-color 0.15s;
}
.search-bar input:focus { outline: none; border-color: var(--accent); }
.search-bar input::placeholder { color: var(--muted); }
.search-clear {
  background: none; border: none; color: var(--muted); cursor: pointer;
  font-family: inherit; font-size: 18px; line-height: 1; padding: 0 4px;
  transition: color 0.15s; display: none;
  /* Inside the field rather than after it, so the field can take a whole row
     on a phone without stranding its own × on the next one. */
  position: absolute; right: 4px; top: 50%; transform: translateY(-50%);
}
/* The active tag/folder filter (main.js syncSearchScope). */
.search-scope {
  flex-shrink: 0; max-width: 16em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  background: var(--tag-bg); color: var(--accent); cursor: pointer;
  border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); border-radius: 4px;
  font-family: inherit; font-size: 11px; padding: 4px 8px;
}
.search-scope:hover { border-color: var(--accent); }
.search-clear:hover { color: var(--text); }
.search-bar.active input { border-color: var(--accent); padding-right: 26px; }
.search-bar.active .search-clear { display: block; }
.search-label {
  font-size: 11px; color: var(--accent); flex-shrink: 0; display: none;
}
.search-bar.active .search-label { display: block; }

.view-toggle { display: flex; flex-shrink: 0; }
.vt-btn {
  background: none; border: 1px solid var(--border); color: var(--muted);
  cursor: pointer; font-size: 13px; line-height: 1; padding: 5px 9px;
  font-family: inherit; transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.vt-btn:first-child { border-radius: 4px 0 0 4px; }
.vt-btn:last-child  { border-radius: 0 4px 4px 0; border-left: none; }
.vt-btn.active { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 40%, transparent); background: var(--tag-bg); }
.vt-btn:hover:not(.active) { color: var(--text); }

.sort-control { display: flex; flex-shrink: 0; }
.sort-select, .mode-select {
  appearance: none; -webkit-appearance: none;
  background-color: var(--bg); border: 1px solid var(--border); color: var(--text);
  cursor: pointer; font-size: 12px; font-family: inherit;
  padding: 4px 22px 4px 8px; border-radius: 4px;
  transition: border-color 0.15s;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='7' viewBox='0 0 10 7' fill='none' stroke='%238a8a8a' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M1 1l4 4 4-4'/%3E%3C/svg%3E");
  background-repeat: no-repeat; background-position: right 7px center;
}
.sort-select { border-radius: 4px 0 0 4px; }
.sort-select:hover, .mode-select:hover { border-color: var(--muted); }
.sort-select:focus, .mode-select:focus { outline: none; border-color: var(--accent); }
.sort-select option, .mode-select option { background: var(--surface); color: var(--text); }
.sort-dir { border-radius: 0 4px 4px 0; border-left: none; min-width: 30px; }
/* Off (embedding_enabled=false server-side, the default) until confirmed via
   GET /status — main.js reveals it, never CSS, so there is no theme/media
   query to keep in sync here. */
.mode-select { flex-shrink: 0; }
/* Separate the sort group from the list/grid view group. */
.search-bar .view-toggle { margin-left: 6px; padding-left: 12px; border-left: 1px solid var(--border); }

/* New-posts pill — shown when live posts arrive but the current sort
   wouldn't place them at the top (so we don't insert them out of order). */
.new-posts-pill {
  position: sticky; top: 8px; z-index: 20;
  display: block; width: fit-content; margin: 8px auto -4px; padding: 6px 16px;
  background: var(--accent); color: var(--on-accent); border: none; border-radius: 999px;
  font-family: inherit; font-size: 12px; font-weight: 600; cursor: pointer;
  box-shadow: var(--shadow-pill); transition: opacity 0.15s, transform 0.15s;
}
.new-posts-pill:hover { transform: translateY(-1px); }
.new-posts-pill .np-arrow { font-weight: 700; }

/* ── Load more / states ──────────────────────────────── */
/* The end of the feed (main.js feedEnd): a status line inside the scrolling
   list, never a bar pinned under it. min-height keeps it a target the
   observer can see while it has nothing to say. */
.feed-end {
  min-height: 1px; flex-shrink: 0; text-align: center;
  font-size: 11px; color: var(--muted); padding: 6px 0 12px;
}
.feed-end:empty { padding: 0; }
.feed.grid > .feed-end { grid-column: 1 / -1; }
.feed-end-retry {
  background: none; border: none; padding: 0 2px; cursor: pointer;
  font: inherit; color: var(--accent); text-decoration: underline; text-underline-offset: 2px;
}

.empty {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  color: var(--muted); padding: 64px 16px; gap: 14px; flex: 1;
}
.empty svg { opacity: 0.25; }
.empty p   { font-size: 12px; text-align: center; max-width: 42ch; line-height: 1.6; }
.empty-action {
  background: none; border: 1px solid var(--border); color: var(--text); cursor: pointer;
  font-family: inherit; font-size: 12px; padding: 6px 14px; border-radius: 4px;
}
.empty-action:hover { border-color: var(--accent); color: var(--accent); }

.auth-prompt {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  color: var(--muted); padding: 64px 16px; gap: 8px;
}
.auth-prompt p        { font-size: 12px; }
.auth-prompt p + p    { font-size: 11px; opacity: 0.6; margin-top: 4px; }

/* ── Responsive ──────────────────────────────────────── */

/* iOS dynamic viewport height */
@supports (height: 100dvh) { body { height: 100dvh; } }

/* Hamburger button — desktop hidden */
.menu-btn {
  display: none; background: none; border: none; cursor: pointer;
  flex-direction: column; justify-content: space-between;
  width: 18px; height: 12px; padding: 0; flex-shrink: 0;
}
.menu-btn span {
  display: block; width: 100%; height: 1.5px;
  background: var(--muted); border-radius: 1px; transition: background 0.15s;
}
.menu-btn:hover span, .menu-btn.active span { background: var(--accent); }

/* Sidebar overlay */
.sidebar-overlay {
  display: none; position: fixed; inset: 0; z-index: 49;
  background: rgba(var(--scrim-rgb), 0.6);
  -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px);
}
.sidebar-overlay.visible { display: block; }

@media (max-width: 768px) {
  /* Header */
  header { padding: 0 14px; gap: 10px; }
  /* The drawer opens from the left, so its button leads the bar — and is a
     thumb-sized target, not the 18x12 of its three bars. */
  .menu-btn { display: flex; order: -1; box-sizing: content-box; padding: 14px 11px; margin: 0 -11px; }
  /* Drawer model on mobile — desktop collapse is meaningless here */
  .collapse-btn { display: none !important; }
  aside.collapsed { width: 260px; opacity: 1; }
  .brand-sep { display: none; }
  .live-label { display: none; }
  .key-wrap input { width: 140px; }
  /* "+" alone, the same square as the icon buttons beside it; the words stay
     for screen readers. */
  .btn-new-post { width: 40px; height: 40px; padding: 0; justify-content: center; font-size: 18px; }
  .np-label {
    position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden;
    clip: rect(0, 0, 0, 0); white-space: nowrap;
  }

  /* Sidebar → slide-in drawer */
  aside {
    position: fixed; top: 48px; left: 0; bottom: 0; z-index: 50;
    width: 260px;
    transform: translateX(-100%);
    transition: transform 0.24s cubic-bezier(0.4,0,0.2,1);
    box-shadow: var(--shadow-drawer);
  }
  aside.open { transform: translateX(0); }

  /* Search bar. `min-width: 0` alone (the previous fix here) let the input
     give up space under flex, but with nothing stopping it, four siblings —
     mode select, sort select, sort direction, two view-toggle buttons — could
     still squeeze it down to a few unusable pixels on a real phone width.
     `input, select { font-size: 16px !important }` above (iOS zoom guard,
     @media (hover: none)) also means the 11px this block used to set on
     .sort-select/.mode-select never actually applied on a touch device —
     shrinking those was never doing anything there. The real levers on a
     touch device are padding and each control's own rendered width, not
     font-size: tighter padding throughout, and a capped, ellipsis-truncated
     width on the two <select>s (their closed-state label — "Keyword",
     "Updated" — otherwise sizes the box to fit the longest option in full).
     The input's own min-width is the floor that actually matters: it can
     still shrink below its content size for a long placeholder, just not
     below something you can read and type into. */
  /* wrap is a safety net, not the expectation: mode-select (opt-in, off by
     default everywhere) plus an active search's "×" plus the narrowest
     supported phone (320px, the @media (max-width: 400px) tier) is the one
     combination where the floors above can still add up past the viewport.
     Wrapping to a second line beats silently clipping controls off-screen. */
  .search-bar { gap: 5px; padding: 8px 14px; flex-wrap: wrap; }
  .search-bar input { min-width: 72px; }
  .search-bar .search-label { display: none !important; }
  .sort-select, .mode-select {
    padding: 4px 20px 4px 6px; max-width: 66px;   /* 20px: the label used to touch its arrow */
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }
  .sort-dir { min-width: 24px; padding: 5px 5px; }
  .vt-btn { padding: 5px 6px; font-size: 12px; }
  .search-bar .view-toggle { margin-left: 3px; padding-left: 6px; }

  /* Feed — full width, tighter spacing */
  .feed { padding: 12px; gap: 8px; }
  .post { padding: 12px; }
  /* Per-card Edit/Delete are hazardous in a touch scroll feed — drive
     edits/deletes from the post detail view (tap a card to open it). */
  .post-actions { display: none; }
}

/* Touch devices: always show post actions (no hover) */
@media (hover: none) {
  .post-actions { opacity: 1; }
  /* Prevent iOS auto-zoom on input focus (triggered when font-size < 16px).
     `.ef-content-backdrop` is a <pre>, not matched by `textarea` — it needs
     the same bump explicitly or its text stops lining up with the real
     textarea's the moment this rule fires and only that one's font grows. */
  input, textarea, select, .ef-content-backdrop { font-size: 16px !important; }
}

@media (max-width: 400px) {
  header { gap: 8px; }
  .key-wrap input { width: 110px; font-size: 11px; }
  .btn-connect { padding: 5px 8px; }
}

/* ── Post ID pill ────────────────────────────────────── */
.post-id-pill {
  font-size: 10px; color: var(--accent-2);
  background: color-mix(in srgb, var(--accent-2) 10%, transparent);
  padding: 1px 6px; border-radius: 3px;
  letter-spacing: 0.02em; font-variant-numeric: tabular-nums; flex-shrink: 0;
}

/* ── Master document card ────────────────────────────── */
.post.master-doc {
  border-color: color-mix(in srgb, var(--accent) 45%, transparent);
  background: color-mix(in srgb, var(--accent) 2.5%, transparent);
}
.post.master-doc:hover {
  border-color: var(--accent);
  box-shadow: 0 4px 28px color-mix(in srgb, var(--accent) 12%, transparent);
}
.post.pinned {
  border-color: var(--accent);
  box-shadow: inset 3px 0 0 var(--accent);
}
.master-badge {
  display: inline-flex; align-items: center; gap: 5px; font-family: inherit;
  font-size: 11px; font-weight: 600; color: var(--accent);
  background: color-mix(in srgb, var(--accent) 10%, transparent); border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
  padding: 2px 8px; border-radius: 3px; margin-bottom: 10px;
}
/* Inside its card the badge is the card's own label: the card already carries
   the accent frame, so a second box around the words only doubled it. */
.post.master-doc .master-badge { background: none; border: none; padding: 0; cursor: pointer; }
.accordion-chevron {
  display: inline-flex; margin-left: 2px;
  transition: transform 0.28s cubic-bezier(0.4,0,0.2,1);
}
.post.master-doc.accordion.collapsed .accordion-chevron { transform: rotate(-90deg); }

/* ── Master document accordion ───────────────────────── */
/* Collapsed it is one line — its badge — so it never costs a phone its first
   screen. Open, the whole note shows, unclamped. */
.post.master-doc.accordion.collapsed { padding-block: 10px; }
.post.master-doc.accordion.collapsed > :not(.master-badge) { display: none; }
.post.master-doc.accordion.collapsed .master-badge { margin-bottom: 0; }

/* ── Post footer left group ──────────────────────────── */
.post-footer-left { display: flex; align-items: center; gap: 8px; }

/* ── Post expires label ──────────────────────────────── */
/* --accent, not --accent-dim: -dim is tuned as a hover *fill* under --on-accent
   (its text contrast is not a theme's concern), and as 10px text it fell to
   2.93:1 on Solarized Light. --accent's text contrast is held by
   test_small_text_roles_clear_aa. */
.post-expires {
  font-size: 10px; color: var(--accent); letter-spacing: 0.02em;
  margin-left: 8px;
}

/* ── Tag config inline form ──────────────────────────── */
/* The row is a flex container, so its child's automatic minimum is min-content —
   and `datetime-local` has a wide one (the mm/dd/yyyy, --:-- -- placeholder plus
   the picker icon). Without min-width:0 the form refused to shrink and spilled
   out of the sidebar, taking its controls off-screen with it: the same automatic
   minimum that bites the grid tiles. `tag-editing` drops the row out of flex
   layout entirely while a form is in it. */
/* Last-resort clip: even if some future control has an intrinsic minimum wider
   than the sidebar, it is scrolled rather than painted over the feed. */
.tag-list { overflow-x: hidden; }
.tag-item.tag-editing {
  display: block; padding: 4px 12px 10px; cursor: default;
  background: var(--wash-raise);
}
.tag-config-form {
  display: flex; flex-direction: column; gap: 5px;
  min-width: 0; max-width: 100%;
}
.tc-label {
  font-size: 11px;
  color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.tag-config-form input {
  background: var(--bg); border: 1px solid var(--accent); color: var(--text);
  border-radius: 3px; font-size: 11px; padding: 4px 6px;
  width: 100%; min-width: 0; max-width: 100%;
  outline: none; font-family: inherit;
}
/* What a keep-last limit will expire, said before Save (main.js startTagConfig). */
.tc-note { font-size: 11px; line-height: 1.4; color: var(--text); overflow-wrap: anywhere; }
.tc-note:empty { display: none; }
.tc-actions { display: flex; flex-wrap: wrap; gap: 6px; min-width: 0; }
.tc-actions .tc-remove { flex-basis: 100%; }
.tc-actions .tc-remove:hover { color: var(--red); border-color: var(--red); }
.tc-actions button {
  flex: 1; min-width: 0;
  background: none; border: 1px solid var(--border); border-radius: 3px;
  color: var(--muted); font-family: inherit; font-size: 10px;
  padding: 4px 6px; cursor: pointer;
  transition: color 0.12s, border-color 0.12s;
}
.tc-actions .tc-save { color: var(--accent); border-color: var(--accent-dim); }
.tc-actions button:hover { color: var(--text); border-color: var(--accent); }
/* The rename input lives inside the row's flex layout and needs the same escape. */
.tag-rename-input { min-width: 0; max-width: 100%; }
/* .tag-config-btn is styled with .tag-rename above — same shape, same behaviour. */

/* ── Card truncation ─────────────────────────────────── */
.post { cursor: pointer; }
.post-body-wrap { overflow: hidden; }

/* ── Post modal ──────────────────────────────────────── */
/* Status / about modal. Reuses .pm-backdrop and the modalIn keyframes; its own
   shell because it is a narrow read-only panel, not the post viewer. */
.status-modal { display: none; position: fixed; inset: 0; z-index: 110; align-items: center; justify-content: center; }
.status-modal.open { display: flex; }
.sm-inner {
  position: relative; z-index: 1;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: 8px; width: 94%; max-width: 560px; max-height: 92vh;
  display: flex; flex-direction: column; overflow: hidden;
  box-shadow: var(--shadow-modal);
  animation: modalIn 0.18s ease backwards;
}
.sm-head { display: flex; align-items: baseline; gap: 8px; padding: 18px 20px 0; flex-shrink: 0; }
.sm-name { font-size: 15px; font-weight: 600; color: var(--text); }
.sm-version { font-size: 12px; color: var(--accent); font-variant-numeric: tabular-nums; }
.sm-body {
  padding: 16px 20px 18px; overflow-y: auto;
  display: flex; flex-direction: column; gap: 18px;
  /* One label column for the whole panel. Every section used to size its own —
     `auto 1fr` per grid — so Vault's values started at one x and Server's at
     another, while Health pushed its values to the far right edge entirely.
     Three alignments down one 560px panel is what "unbalanced" was. Widest
     label is "Full-text search" at ~120px plus the 15px status-dot gutter. */
  --sm-label: 150px;
}
.sm-section-title {
  font-size: 12px; font-weight: 600; color: var(--text); margin-bottom: 8px;
}
/* The value column may wrap — a long vault path must not widen the panel (same
   min-width:0 lesson as the grid tiles). */
.sm-rows {
  display: grid; grid-template-columns: minmax(0, var(--sm-label)) 1fr;
  gap: 6px 14px; font-size: 12.5px; margin: 0;
}
.sm-rows dt { color: var(--muted); }
.sm-rows dd { margin: 0; color: var(--text); min-width: 0; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
/* Health is the same grid; the row is `contents` so the dot+label pair and the
   note fall into those two columns rather than forming a flex row of their own. */
.sm-health { gap: 7px 14px; align-items: start; }
.sm-feat { display: contents; }
.sm-feat-name { display: flex; align-items: center; gap: 8px; color: var(--muted); min-width: 0; }
.sm-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.sm-dot.ok   { background: var(--green); }
.sm-dot.warn { background: var(--accent); }
.sm-dot.bad  { background: var(--red); }
.sm-dot.off  { background: var(--muted); }
.sm-feat-note { color: var(--text); min-width: 0; overflow-wrap: anywhere; }
.sm-recovery-line, .lint-summary-line { font-size: 12.5px; color: var(--text); margin-bottom: 10px; }
.sm-error { font-size: 12.5px; color: var(--red); }
.sm-embed-controls { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.sm-feat-note .sm-embed-controls { margin-top: 6px; }
.sm-activity { list-style: none; display: flex; flex-direction: column; gap: 8px; font-size: 12.5px; }
.sm-act { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.sm-act-title {
  align-self: flex-start; max-width: 100%; color: var(--text);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
button.sm-act-title { background: none; border: none; padding: 0; font: inherit; cursor: pointer; text-align: left; }
button.sm-act-title:hover { color: var(--accent); }
.sm-act-meta, .sm-act-note { font-size: 11px; color: var(--muted); }
.sm-embed-controls .btn-edit:disabled { opacity: 0.5; cursor: default; }
.sm-embed-controls .btn-edit:disabled:hover { border-color: var(--border); color: var(--muted); }

.post-modal { display: none; position: fixed; inset: 0; z-index: 100; align-items: center; justify-content: center; }
.post-modal.open { display: flex; }

.pm-backdrop {
  position: absolute; inset: 0;
  background: rgba(var(--scrim-rgb), 0.72);
  backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px);
  cursor: pointer;
}
.pm-inner {
  position: relative; z-index: 1;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: 8px; width: 94%; max-width: calc(var(--measure) + 440px); max-height: 92vh;
  display: flex; flex-direction: column; overflow: hidden;
  box-shadow: var(--shadow-modal);
  animation: modalIn 0.18s ease backwards;
}
@keyframes modalIn {
  from { opacity: 0; transform: translateY(10px) scale(0.98); }
  to   { opacity: 1; transform: none; }
}
.pm-header {
  display: flex; align-items: flex-start; gap: 12px;
  padding: 18px 20px 0; flex-shrink: 0;
}
.pm-title { flex: 1; min-width: 0; font-size: 22px; font-weight: 600; color: var(--text); line-height: 1.3; overflow-wrap: anywhere; }
.pm-back {
  display: none; background: none; border: none;
  color: var(--muted); cursor: pointer; font-family: inherit;
  font-size: 12px; font-weight: 500;
  padding: 0; flex-shrink: 0; transition: color 0.15s;
}
.pm-back:hover { color: var(--accent); }
/* When navigating between notes the breadcrumb names the previous one; on
   desktop the header flips to a column so it has its own row. */
.pm-inner.has-back .pm-back {
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
@media (min-width: 769px) {
  .pm-inner.has-back .pm-header {
    flex-direction: column;
    gap: 4px;
  }
  .pm-inner.has-back .pm-back {
    max-width: calc(100% - 40px); /* leave room for the absolute-positioned × */
  }
}
.pm-close, .sm-close {
  background: none; border: none; color: var(--muted); cursor: pointer;
  font-size: 22px; line-height: 1; padding: 0; flex-shrink: 0;
  font-family: inherit; transition: color 0.15s;
}
.pm-close:hover, .sm-close:hover { color: var(--text); }
/* Pinned to the panel, not to the header. The header is a centred column now,
   so a close button inside its flow lands in the middle of the panel rather
   than in its corner. `.pm-inner` is already `position: relative`. */
.pm-close {
  position: absolute; top: 16px; right: 18px; z-index: 3;
}
.sm-close { margin-left: auto; }
.pm-meta { padding: 12px 20px 0; display: flex; flex-direction: column; gap: 8px; flex-shrink: 0; }
.pm-meta .post-tags { row-gap: 6px; }
.pm-time { font-size: 10px; color: var(--muted); letter-spacing: 0.02em; }
.pm-body-wrap { flex: 1; min-height: 0; position: relative; display: flex; flex-direction: column; }
.pm-body {
  flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; padding: 16px 20px;
}
.pm-body, .sm-body, .hm-list, .hm-body-text, .lm-list, .lm-pane {
  scrollbar-width: thin; scrollbar-color: var(--border) transparent;
}
.pm-body::-webkit-scrollbar, .sm-body::-webkit-scrollbar,
.hm-list::-webkit-scrollbar, .hm-body-text::-webkit-scrollbar, .lm-list::-webkit-scrollbar,
.lm-pane::-webkit-scrollbar { width: 4px; }
.pm-body::-webkit-scrollbar-thumb, .sm-body::-webkit-scrollbar-thumb,
.hm-list::-webkit-scrollbar-thumb, .hm-body-text::-webkit-scrollbar-thumb, .lm-list::-webkit-scrollbar-thumb,
.lm-pane::-webkit-scrollbar-thumb {
  background: var(--border); border-radius: 2px;
}
/* One reading column, and everything lives in it — prose, headings, rules,
   tables, code, images. Wide blocks used to break out of it: first centred
   (which put a table ~300px left of every paragraph, aligned with nothing) and
   then anchored to the column's left edge and spilling right (better, still
   unbalanced — the panel had one ragged edge). Constraining them costs some row
   height on a wide table and buys a page with two straight sides.

   `.table-scroll` and `pre` keep their own `overflow-x`, so anything that still
   cannot fit scrolls inside the column rather than out of it.

   ⚠️ **The measure is `rem`, never `ch`.** `ch` resolves against each element's
   *own* font and size, so blocks set differently (headings, code) got different
   widths, and once the column was centred each landed on a different left edge. `rem` resolves against the root size whatever the element is
   set in. 40rem is 520px at the root's 13px: about 75 characters of prose in
   the 15px reading face, the comfortable line. */
/* The panel (`.pm-inner`'s max-width) is this column plus a ~200px gutter each
   side — room for the outline — not the 1320px it was for a 104-character
   mono measure. */
.pm-inner, .lm-inner { --measure: 40rem; }
.pm-body .post-body > * { max-width: var(--measure); margin-inline: auto; }
/* Backlinks belong to the post, so they sit in its reading column too. */
.pm-body .pm-backlinks { max-width: var(--measure); margin-inline: auto; }
/* An image that failed to load becomes a link (main.js); give it the
   column the image would have had instead of the panel's left edge. */
.pm-body .post-body > a.attachment-link { display: block; width: fit-content; margin-inline: auto 0; margin-inline-start: max(0px, calc((100% - var(--measure)) / 2)); }

/* The title and meta ride the same column, or centring the prose alone just
   moves it away from everything else — the header stays hard left and the text
   floats in the middle, which is worse than the gap it was meant to fix. With
   all three centred the panel reads as one article column and the wide blocks
   break out of it on both sides. The footer is chrome, not content, so it keeps
   the full width. */
/* `--measure + 40px` so the *content* box is exactly the measure once the 20px
   side padding is taken off — the same box `.pm-body`'s prose is centred in,
   which is what makes the title and the first paragraph share a left edge.
   Asymmetric padding here put them 16px apart, which is precisely visible
   enough to look wrong. */
.pm-header, .pm-meta { max-width: calc(var(--measure) + 40px); margin-inline: auto; width: 100%; }
/* The close button is pinned to the panel, so reserve room in the title rather
   than in the header — at narrow widths the column fills the panel and a long
   ellipsised title would otherwise run underneath it. */
.pm-title { padding-right: 30px; }

.pm-body-fade {
  position: absolute; bottom: 0; left: 0; right: 0; height: 52px;
  background: linear-gradient(to bottom, transparent, var(--surface));
  pointer-events: none; transition: opacity 0.2s;
}
.pm-body-fade.hidden { opacity: 0; }
.pm-footer {
  display: flex; gap: 8px; justify-content: flex-end;
  padding: 12px 20px; border-top: 1px solid var(--border); flex-shrink: 0;
  background: var(--surface);
}
/* ── Wikilink hover preview ─────────────────────────────────── */
.link-preview {
  position: fixed; z-index: 200; pointer-events: none;
  background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
  padding: 10px 13px; width: 280px; max-width: calc(100vw - 24px);
  box-shadow: var(--shadow-modal);
  animation: modalIn 0.12s ease backwards;
}
.lp-title { font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 5px; }
.lp-tags  { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 6px; }
.lp-tag   { font-size: 10px; color: var(--muted); background: var(--bg); border: 1px solid var(--border); border-radius: 3px; padding: 1px 5px; }
.lp-body  { font-family: var(--font-read); font-size: 12px; color: var(--muted); line-height: 1.5; }

/* ── Quick switcher (js/switcher.js) ─────────────────────────── */
/* Near the top, like any command palette: the list grows downward from where
   the eye already is, and the panel does not jump as results change. A phone
   keeps the bottom sheet every other panel is. */
@media (min-width: 769px) {
  #switcherModal.open { align-items: flex-start; padding-top: 12vh; }
}
.sw-inner { max-width: 520px; }
.sw-inner .sm-body { gap: 10px; }
#swInput {
  width: 100%; min-height: 44px; padding: 8px 10px; border-radius: 4px; outline: none;
  background: var(--bg); border: 1px solid var(--border); color: var(--text);
  font-family: inherit; font-size: 13px;
}
#swInput:focus { border-color: var(--accent); }
.sw-list { list-style: none; display: flex; flex-direction: column; gap: 1px; max-height: 50vh; overflow-y: auto; }
.sw-empty { padding: 6px 9px; font-size: 12px; color: var(--muted); }

/* ── Post outline (main.js renderOutline) ──────────────────── */
/* In the gutter left of the reading column, which only exists on a wide
   screen: the panel is 94% of the viewport up to --measure + 440px (960px),
   so by 1180px the gutter is its full ~200px. */
.pm-outline { display: none; }
@media (min-width: 1180px) {
  .pm-outline:not([hidden]) {
    display: flex; flex-direction: column; gap: 2px;
    position: absolute; z-index: 2; top: 16px; left: 20px; bottom: 16px; overflow-y: auto;
    width: calc((100% - var(--measure)) / 2 - 44px);
    scrollbar-width: thin; scrollbar-color: var(--border) transparent;
  }
}
.pm-outline-item {
  background: none; border: none; text-align: left; cursor: pointer;
  padding: 3px 0; font-family: inherit; font-size: 11px; line-height: 1.4; color: var(--muted);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 0;
}
.pm-outline-item.h3 { padding-left: 12px; }
.pm-outline-item:hover { color: var(--accent); }

/* ── Keyboard shortcuts modal ───────────────────────────────── */
.kb-inner { max-width: 360px; }
.kb-inner .sm-body { gap: 20px; }
.kb-group { display: flex; flex-direction: column; gap: 7px; }
.kb-group-label { font-size: 12px; font-weight: 600; color: var(--text); padding-bottom: 2px; border-bottom: 1px solid var(--border); }
.kb-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; font-size: 13px; color: var(--body); }
.kb-row > span:first-child { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
kbd {
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--bg); border: 1px solid var(--border); border-bottom-width: 2px;
  border-radius: 4px; padding: 1px 7px; font-family: inherit; font-size: 11px;
  font-weight: 600; color: var(--text); min-width: 24px; white-space: nowrap;
}

@media (max-width: 768px) {
  .link-preview { display: none; } /* touch devices don't hover */
}

@media (max-width: 768px) {
  .post-modal.open { align-items: flex-end; }
  .status-modal.open { align-items: flex-end; }
  .sm-inner { width: 100%; max-width: none; }
  /* `backwards`, and no `to` keyframe — both deliberate, and together they are
     what lets a drag move the sheet. A forwards fill outranks inline styles, so
     `both` plus an explicit `to { transform: none }` pinned the panel at the
     origin for the whole gesture: the swipe handler wrote transforms nothing
     ever painted. An omitted `to` resolves against the element's own style,
     which is exactly where the drag writes. */
  .pm-inner, .sm-inner { animation: modalInMobile 0.22s ease backwards; }
  @keyframes modalInMobile {
    from { opacity: 0; transform: translateY(20px); }
  }

  /* Every sheet is draggable, so every sheet shows the same handle. It hangs
     off the panel rather than sitting in the header's flow because .sm-head is
     a baseline row (name · version · close) that stacking would break, while
     .pm-header is a column — one rule has to serve both. */
  .pm-inner, .sm-inner { border-radius: 12px 12px 0 0; }
  .pm-inner::before, .sm-inner::before {
    content: ""; position: absolute; z-index: 2; pointer-events: none;
    top: 8px; left: 50%;
    width: 40px; height: 4px; margin-left: -20px; border-radius: 999px;
    background: var(--border);
    transition: background-color 0.18s ease, width 0.18s ease, margin-left 0.18s ease;
  }
  /* The handle answers the thumb: it darkens on contact and turns accent once
     you are past the point where letting go dismisses. Without that the only
     feedback is distance travelled, which you cannot judge mid-gesture. */
  .pm-inner.sheet-dragging::before, .sm-inner.sheet-dragging::before { background: var(--muted); }
  .pm-inner.sheet-armed::before, .sm-inner.sheet-armed::before {
    background: var(--accent); width: 56px; margin-left: -28px;
  }
  @media (prefers-reduced-motion: reduce) {
    .pm-inner::before, .sm-inner::before { transition: none; }
  }

  /* Room for the handle above the first row of each header. */
  .pm-header {
    flex-direction: column; align-items: stretch; gap: 6px;
    padding: 20px 16px 0;
  }
  .sm-head { padding-top: 22px; }
  .pm-back {
    display: inline-flex; align-items: center; align-self: flex-start;
    margin-left: -2px; padding: 6px 10px 6px 0; min-height: 44px;
  }
  /* No pinned close button here — back is the dismissal — so the title needs no
     room reserved for it, and the column cap is a no-op at this width anyway. */
  .pm-title { font-size: 18px; line-height: 1.3; padding-right: 0; }
  .pm-close { display: none; }
  .pm-meta { padding-left: 16px; padding-right: 16px; }
  .pm-body { padding-left: 16px; padding-right: 16px; }
  .pm-footer { padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px)); }
}

/* ── Post history panel ───────────────────────────────────── */
/* Fixed height and a two-pane grid, both deliberate. The panel used to be sized
   by its content, so selecting a revision collapsed it to the loading state and
   re-inflated when the body arrived — a visible jump on every click. Panes that
   exist up front and scroll internally cannot do that. The extra width is for
   reading an old version, which is the actual task here. */
.hm-inner {
  max-width: 1280px;
  width: 94%;
  height: min(92vh, 1000px);
}
#historyModal .sm-body { flex: 1; min-height: 0; overflow: hidden; }

/* `.lm-inner`'s size rule, not up with the rest of the Vault lint modal's CSS —
   see the comment there. Must stay after `.sm-inner`'s base rule (above) to
   win the cascade: equal specificity, so source order decides, and a copy of
   this placed earlier in the file renders at 560px regardless of what it says.
   Matches `.pm-inner`'s width exactly (94% up to the measure + 440px, the
   shared rule below), not a cap of its own —
   an uncapped, full-width first version made the post modal visibly shrink
   every time "Open post" led there, and shrink back on the way "← Vault
   lint" returns. Same width, no resize, whichever direction you go. */
.lm-inner {
  width: 94%;
  max-width: calc(var(--measure) + 440px);
  height: min(92vh, 1000px);
}

.hm-layout {
  display: grid;
  grid-template-columns: minmax(260px, 340px) 1fr;
  gap: 0;
  height: 100%;
  min-height: 0;
}
.hm-list {
  display: flex; flex-direction: column; gap: 2px;
  overflow-y: auto; min-height: 0; padding-right: 12px;
}
.hm-pane {
  display: flex; flex-direction: column; min-height: 0; min-width: 0;
  border-left: 1px solid var(--border); padding-left: 16px;
}
.hm-rev {
  display: flex; flex-direction: column; align-items: stretch; gap: 3px; width: 100%;
  background: none; border: 1px solid transparent; border-radius: 4px;
  padding: 7px 9px; cursor: pointer; text-align: left; flex-shrink: 0;
  font-family: inherit; font-size: 12.5px; color: var(--text);
  transition: background 0.12s, border-color 0.12s;
}
.hm-meta { display: flex; align-items: center; gap: 7px; min-width: 0; }
.hm-badge {
  color: var(--accent); border: 1px solid var(--accent-dim); border-radius: 3px;
  padding: 0 4px; font-size: 10px;
  flex-shrink: 0;
}
.hm-rev:hover { border-color: var(--border); background: var(--accent-glow); }
.hm-rev.active { border-color: var(--accent); background: var(--accent-glow); }
.hm-sha { color: var(--accent); font-size: 10.5px; font-variant-numeric: tabular-nums; flex-shrink: 0; }
/* The message wraps to two lines rather than ellipsizing on one: it is the only
   thing that says what a revision *was*. min-width:0 still keeps it from widening
   the column. */
.hm-msg {
  min-width: 0; line-height: 1.35;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
  overflow: hidden; overflow-wrap: anywhere;
}
.hm-when { color: var(--muted); font-size: 10.5px; flex-shrink: 0; }
.hm-author { color: var(--muted); font-size: 10.5px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.hm-pane-head { flex-shrink: 0; margin-bottom: 8px; }
.hm-body-text {
  flex: 1; min-height: 0; overflow: auto; margin: 0;
  padding: 12px;
  background: var(--bg); border: 1px solid var(--border); border-radius: 4px;
  font-family: inherit; font-size: 12.5px; line-height: 1.55; color: var(--text);
  white-space: pre-wrap; overflow-wrap: anywhere;
}
.hm-view-toggle { display: flex; flex-shrink: 0; margin-bottom: 8px; }
.hm-view-toggle .vt-btn { font-size: 10px; padding: 3px 9px; }
/* Inline word-level diff (relay #198, N-1): changed runs are <span>s inside
   the same `pre` shell the Body pane uses, unchanged text is plain text
   nodes at normal reading contrast — the accent/strikethrough treatment on
   the changed spans is what should draw the eye, not a dimmed backdrop. */
.hm-d-add {
  color: var(--accent);
  background: color-mix(in srgb, var(--accent) 16%, transparent);
  text-decoration: underline;
  text-decoration-color: color-mix(in srgb, var(--accent) 45%, transparent);
  border-radius: 2px;
}
.hm-d-del { color: var(--muted); text-decoration: line-through; }
.hm-restore { width: 100%; margin-top: 10px; flex-shrink: 0; justify-content: center; }
/* Placeholder and loading share the pane's centre so neither changes its size. */
.hm-placeholder {
  flex: 1; display: flex; align-items: center; justify-content: center;
  color: var(--muted); font-size: 12.5px; text-align: center; padding: 20px;
}

@media (max-width: 860px) {
  .hm-inner { height: 88vh; }
  .hm-layout { grid-template-columns: 1fr; grid-template-rows: minmax(0, 40%) 1fr; }
  .hm-list { padding-right: 0; padding-bottom: 10px; }
  .hm-pane { border-left: none; border-top: 1px solid var(--border); padding-left: 0; padding-top: 12px; }
}

/* ── Edit modal ───────────────────────────────────────────── */
/* Editing used to happen inside the post card. In grid view that meant a ~200px
   column: the textarea was a few words wide and long notes were unusable. The
   form is the same, it just gets the room the reading modal already had. */
.em-inner {
  max-width: calc(64rem + 40px);   /* the reading measure: lines past ~120 characters are hard to edit */
  width: 94%;
  height: min(92vh, 1040px);
}
:is(#editModal, #composePanel) .sm-body { flex: 1; min-height: 0; display: flex; }
:is(#editModal, #composePanel) .edit-form {
  display: flex; flex-direction: column; gap: 10px;
  flex: 1; min-height: 0; min-width: 0;
}
/* The content field takes whatever is left, so a long note gets the whole panel
   rather than a fixed few rows. */
:is(#editModal, #composePanel) .edit-form > .ef-content-wrap { display: flex; flex-direction: column; flex: 1; min-height: 0; }
:is(#editModal, #composePanel) .edit-form .ef-content-highlight { flex: 1; min-height: 140px; }
:is(#editModal, #composePanel) .edit-form textarea.ef-content { resize: none; }
/* Compose's body field has no highlight backdrop, so it fills the column itself. */
#composePanel .edit-form textarea#cpContent { flex: 1; min-height: 140px; resize: none; }
:is(#editModal, #composePanel) .edit-form .ef-attachments { max-height: 22vh; overflow-y: auto; }
:is(#editModal, #composePanel) .edit-actions {
  margin: 4px -20px -18px; padding: 12px 20px; flex-shrink: 0;
  border-top: 1px solid var(--border); background: var(--surface);
}
@media (max-width: 860px) {
  .em-inner { height: 92vh; }
}


/* ── Mobile sheets: geometry, reach, safe areas ──────────────────────────── */
/* ⚠️ `--sm-label` belongs in *this* block, not the ≤768px one further up: the
   base `.sm-body` rule is declared after that media query at equal specificity,
   so the override lost on source order and the phone sheet kept the full 150px
   column — measured, not noticed. Same trap as `.em-inner`/`.hm-inner` below.
   A 150px label column of a ~350px sheet leaves the vault path wrapping every
   few characters. */
@media (max-width: 768px) {
  .sm-body { --sm-label: 108px; }
}

/* This block sits at the end of the file deliberately. `.em-inner`/`.hm-inner`
   are declared *after* the modal media block above and carry equal specificity,
   so the `width: 100%` up there lost on source order — the edit and history
   sheets sat 23px narrower than the post and status ones, which nobody chose.
   Position is the entire fix: both panels also carry `.sm-inner`, so one rule
   placed after their declarations covers them. Naming `.em-inner`/`.hm-inner`
   here would read as though the selector were doing the work. */
@media (max-width: 768px) {
  .pm-inner, .sm-inner { width: 100%; max-width: none; }

  /* The post modal used to be full-bleed at 100vh: its rounded top and grab
     handle were pressed against the screen edge (under the notch on a real
     phone) and there was no backdrop left to tap. Leaving a strip makes it read
     as a sheet like the other three, and gives back the tap-outside dismissal. */
  /* The inset keeps the tallest sheet clear of the notch. It belongs here,
     on the one panel that comes close to the top, rather than on every header. */
  .pm-inner { max-height: calc(94vh - env(safe-area-inset-top, 0px)); }
  .em-inner { height: 92vh; }
  .hm-inner, .lm-inner { height: 88vh; }
  /* dvh, not vh. `vh` is the *large* viewport and ignores the software
     keyboard, so focusing the edit sheet's textarea pushed Save and Cancel
     underneath the keyboard with no way to reach them. Lint's pane hosts the
     same editor (`.lm-pane`'s own `overflow-y: auto`, above, is what actually
     keeps Save/Cancel reachable when the form doesn't fit — dvh here just
     keeps the two panels sized the same rather than diverging for no reason). */
  @supports (height: 100dvh) {
    .pm-inner { max-height: calc(94dvh - env(safe-area-inset-top, 0px)); }
    .em-inner { height: 92dvh; }
    .hm-inner, .lm-inner { height: 88dvh; }
  }

  /* Reaching the end of a modal's scroll handed the gesture to the page behind
     it, which on iOS shows as the whole sheet rubber-banding. */
  .pm-body, .sm-body, .hm-list, .hm-body-text, .lm-list, .lm-pane { overscroll-behavior: contain; }

  /* Touch targets. Measured before this: the close button was 13x22 on three of
     the four sheets, the post footer's actions 22px tall, Save/Cancel 29px.
     44px is the floor; negative margins keep each glyph optically where it was
     so only the hit area grows. */
  /* `.sm-close` only — the post sheet hides its "x" and dismisses via "back",
     and a blanket `display: inline-flex` here silently un-hid it, dropping a
     stray glyph into the middle of that header. */
  .sm-close {
    min-width: 44px; min-height: 44px;
    display: inline-flex; align-items: center; justify-content: center;
    margin-top: -10px; margin-right: -12px; margin-left: auto;
  }
  .pm-footer button,
  :is(#editModal, #composePanel) .edit-actions button,
  #lintModal .edit-actions button,
  .btn-attach {
    min-height: 44px; display: inline-flex; align-items: center; justify-content: center;
  }
  /* Delete sits next to Edit; a wider gap is cheap insurance now that both are
     comfortable to hit. Same reasoning for Save/Cancel in the lint pane's
     inline editor (edit-form.js's buildEditForm, shared with #editModal). */
  .pm-footer, :is(#editModal, #composePanel) .edit-actions, #lintModal .edit-actions { gap: 12px; }
  .sm-body { padding-bottom: calc(18px + env(safe-area-inset-bottom, 0px)); }
  :is(#editModal, #composePanel) .edit-actions {
    padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
    margin-bottom: calc(-18px - env(safe-area-inset-bottom, 0px));
  }

  /* Roomier fields, and the 44px floor. Font size is deliberately not set
     here: `@media (hover: none)` already forces 16px on every input to stop
     iOS zooming the page on focus, and that keys on the device rather than on
     a narrow window, which is the better test. */
  .edit-form input, .edit-form textarea, .edit-form select, .ef-content-backdrop { padding: 10px 12px; }
  .edit-form input, .edit-form select { min-height: 44px; }

  /* The attachment list's delete × was 17x15 — the touch floor never reached it
     because it only exists when the post *has* an attachment, and no test had
     ever created one. It surfaced the moment a fixture did. Negative margins
     grow the hit area without stretching the row, the same shape the sidebar's
     tag controls use. */
  .ef-attach-del {
    min-width: 44px; min-height: 44px;
    display: inline-flex; align-items: center; justify-content: center;
    margin: -12px -8px -12px 0;
  }
  /* Text-sized buttons in a meta line (the author, an activity row's title):
     a thumb-sized hit area that overlaps its neighbours rather than pushing
     the line apart. */
  .post-author, button.sm-act-title {
    min-height: 44px; display: inline-flex; align-items: center; margin-block: -14px;
  }
  .post-author { min-width: 44px; }

  /* List-mode cards keep the single-row header (title + tags side by side)
     at any width otherwise, so a handful of tags squeeze the title down to a
     hard ellipsis of a couple characters. Grid mode already stacks the header
     unconditionally (`.feed.grid .post-header`, above) — give list mode the
     same treatment, but only on phones, where the row is too narrow to share. */
  .post-header { flex-direction: column; align-items: stretch; gap: 6px; }
  .post-title {
    white-space: normal; overflow: hidden; text-overflow: clip;
    display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
  }
}

/* Below two grid columns (minmax(250px) + gap) a grid tile has no row
   neighbours to match: size to content with the list view's clamp. */
@media (max-width: 560px) {
  .feed.grid > .post:not(.pinned) { height: auto; grid-template-rows: none; }
  .feed.grid > .post:not(.pinned) .post-body-wrap { height: auto; }
}

/* Phones: the search field takes its own row, so its placeholder reads in
   full and the menus say "Updated"/"Semantic" instead of "Up…"/"Hy…"; filter
   chip, mode, sort and view controls share the row below, view toggle pinned
   right. */
@media (max-width: 560px) {
  .search-bar .search-field { flex: 1 1 100%; }
  .search-bar :is(.sort-select, .mode-select) { max-width: none; }
  .search-bar .view-toggle { margin-left: auto; }
}
