/* ==========================================================================
   SSnotes design tokens
   --------------------------------------------------------------------------
   The single place colour, type, spacing, radius, elevation and depth are
   decided. Nothing here is HedgeDoc's: the inherited look was never chosen,
   it just arrived with the fork.

   The language is DENSE AND STRUCTURED — closer to an IDE or Linear than to a
   blog. That is why the type scale starts at 13px rather than 16px, why the
   spacing scale has 2px steps at the bottom, and why the radii are small: at
   this information density, generous padding and soft corners stop reading as
   "calm" and start reading as "wasteful".

   THEMING
   Every themed value is declared exactly once, as light-dark(light, dark).
   That function resolves against the `color-scheme` of the element that USES
   the value — not the element that declared it — so a subtree can be pinned to
   one theme by setting `color-scheme` on it alone. The three switches are:

     1. no data-theme      -> follow the OS (`color-scheme: light dark`)
     2. :root[data-theme]  -> explicit override, beats the OS
     3. a subtree's own    -> beats both; this is how the workspace's existing
                              `hd-theme` toggle keeps winning (see workspace.css)

   Consumers must therefore only ever read tokens through var(); re-deriving a
   colour by hand re-introduces the coupling this file exists to remove.
   ========================================================================== */

/* The faces the families below name. font.css imports this same file and is on
   every webpack-built page, so this line exists for the ONE surface that loads
   no webpack CSS at all: the signed-out sign-in page, which links tokens.css
   directly. Keeping the @font-face rules in their own file rather than copying
   them into both is what stops the two declarations drifting apart. */
@import './pretendard.css';

:root {
  color-scheme: light dark;

  /* --- Type families ----------------------------------------------------
     The notes are Korean and English, usually in the same sentence. A Latin-only
     face (Inter, SF, Segoe) leaves Hangul to whatever the OS substitutes, and
     the substitute rarely shares a weight axis or x-height with the Latin next
     to it — the mismatch is the single most obvious "unfinished" tell in a
     bilingual UI. Pretendard leads because it covers BOTH scripts from one
     family; the rest of the stack is a per-OS pairing that degrades sanely
     (SF Pro + Apple SD Gothic Neo, Segoe UI + Malgun Gothic, Roboto + Noto KR)
     rather than falling back to an arbitrary system serif.

     Pretendard IS bundled now, subset to this corpus — see pretendard.css for
     provenance, licence and how the character set was derived. The names below
     are unchanged: 'Pretendard Variable' is what the self-hosted @font-face
     declares, and plain Pretendard behind it still picks up a local install
     for anything outside the subset. */
  --font-ui:
    'Pretendard Variable', Pretendard,
    -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo',
    'Segoe UI', 'Malgun Gothic',
    Roboto, 'Noto Sans KR', 'Helvetica Neue', Arial,
    sans-serif;
  /* The Latin half is unchanged on purpose — the editor resolves its own font
     picker against these exact names. (The editor's default is Nordwand Mono
     now, in --font-editor below; this stack is the readouts' face.)
     The Hangul tail is new: none of the programming faces cover Korean, so
     before this the browser substituted something arbitrary, usually a
     PROPORTIONAL face. Mixed-width Hangul in a monospace editor destroys the
     column alignment that is the only reason to use one. D2Coding is the usual
     Korean choice and is NOT self-hosted (unlike Pretendard, which now is —
     D2Coding is a monospace Korean face and would cost another ~300KB for the
     editor alone), with Nanum/Noto/Malgun behind it for machines without it. */
  /* NO IoskeleyMono at the head of it, since 2026-10-20. It was named here and
     declared nowhere this stack is used, so every readout drew Source Code Pro;
     declaring it for the editor's picker would have fetched 481KB for the
     sidebar's counts and changed their face. Source Code Pro is what they have
     always drawn, now said rather than fallen into. */
  --font-mono:
    'Source Code Pro', Consolas, monaco,
    'D2Coding', 'Nanum Gothic Coding', 'Noto Sans Mono CJK KR', 'Malgun Gothic',
    monospace;

  /* Rendered note text, as opposed to interface chrome (--font-ui) or the
     editor's own face (--font-editor). Three separate axes because they answer
     three different questions, and a settings surface exposes each one. JS
     overrides these per user; the values here are what an anonymous reader and
     a user who has never opened Settings both get. */
  --font-content: var(--font-ui);
  /* Nordwand Mono, the editor default (HJ, 2026-10-20) — its own stack rather
     than var(--font-mono), which the readouts below derive from and which
     should not change face because the editor did. */
  --font-editor:
    'Nordwand Mono', 'Source Code Pro', Consolas, monaco,
    'D2Coding', 'Nanum Gothic Coding', 'Noto Sans Mono CJK KR', 'Malgun Gothic',
    monospace;

  /* THE INSTRUMENT VOICE, and the fourth axis is a role rather than a taste.
     Everything in this UI is either DATA — note titles, folder names, project
     names, prose, all of it Korean as often as not — or a READOUT: the counts
     beside a folder, the Modified column, the stamps in Recent's lead gutter.
     A readout is a NUMBER, never a word and never a language, and it wants the
     opposite of what data wants: figures on one advance so a column of them
     lines up, and a shape that says "reading" rather than "read me".
     WORDS ARE NOT READOUTS. "Modified" and "Name" head the same table and are
     set in the same face as each other; the cells under them are not. Getting
     that wrong is how a UI ends up in two typefaces by accident rather than on
     purpose — see .hd-x-colhead in explorer.css, which had to say so.
     A mono face gives both, and it costs nothing: --font-mono is already in this
     product, already self-hosted, already on every page. The app is not being
     given a new voice — it is being given the one the editor already speaks, in
     the few places where it means something.
     DERIVED, NOT DECLARED. One mono family, not two — and it is --font-mono
     rather than --font-editor deliberately: --font-editor is the user's to
     change in Settings, and somebody who picked Fira Code for their source has
     not asked the sidebar's counts to follow them.
     THE FACE IS SOURCE CODE PRO, and says so. --font-mono once named
     IoskeleyMono first while nothing on the workspace declared it, so this
     resolved to Source Code Pro by falling through. Closed 2026-10-20 by naming
     what it draws: the editor's faces are now declared on every page
     (editor-fonts.css via font.css), and keeping IoskeleyMono here would have
     fetched its 481KB for counts and timestamps. Source Code Pro is
     self-hosted at 300/400/500, so nothing is synthesised. */
  --font-label: var(--font-mono);

  /* --- Type scale -------------------------------------------------------
     Base is 13px, the density this UI actually needs: rows, menu items and
     buttons all sit at --text-base, so 16px would cost roughly one visible
     row per screenful in the sidebar and note list. Steps are ~1px at the
     small end (where the metadata lives and 2px would flatten the hierarchy)
     and widen towards the display sizes. */
  --text-2xs: 10px;   /* priority pills, share badges */
  --text-xs: 11px;    /* metadata: timestamps, counts, tag chips */
  --text-sm: 12px;    /* section labels, secondary controls */
  --text-base: 13px;  /* default UI text */
  --text-md: 14px;    /* inputs and emphasised body */
  --text-lg: 15px;    /* card and modal titles */
  --text-xl: 18px;    /* page title / paper banner */
  --text-2xl: 22px;   /* empty-state display */
  /* Off the scale on purpose. iOS Safari zooms the page in when a form field
     smaller than 16px takes focus and never zooms back out, which leaves the
     page scrolling sideways for the rest of the session. That threshold is a
     browser constant, not a design decision, so it does not get a scale step —
     but it does need a name, because a form that states it inline is a form
     the next one will not copy. Any full-width text field a phone can reach. */
  --text-field: 16px;

  /* The two sizes that are a user's to choose. They sit outside the scale above
     because they are not UI chrome: rendered prose and editor source want their
     own size, and reading distance is personal in a way a toolbar is not. */
  --text-content: 15px;
  --text-editor: 14px;

  /* --- Heading ramp -----------------------------------------------------
     ONE ladder, in em, so it multiplies whatever body size it lands on:
     --text-content in a rendered note, --text-editor in the Live editor. A px
     ramp would have to be restated per surface and would ignore the reader's
     own size preference.

     THESE ARE GITHUB-MARKDOWN-CSS'S MULTIPLIERS, and they are here because the
     desktop Read pane renders with them directly — 2 / 1.5 / 1.25 / 1 / .875 /
     .85 are what .markdown-body h1..h6 say in the vendored file. Nothing wired
     Read to these tokens, so for as long as they disagreed the same heading was
     30px in Read and 27.03px in Live and the document changed size with the
     tab. Asked and answered: Read wins.

     WHAT THIS REPLACED, because the reasoning is still true and may yet win:
     1.125 / 1.266 / 1.424 / 1.602 / 1.802, every rung a power of 1.125, which
     is Obsidian's scale — an even ratio is what makes a ramp read as a ramp,
     and h5/h6 held at 1.125 because below that a heading is distinguished by
     weight and colour rather than size (the corpus agrees loudly: 339 notes
     contain ZERO h5 or h6 and only 10 use h4). To go back to it, these six
     values move and Read has to be wired to them — which means overriding
     github-markdown-css's own heading rules in ui/rendered-note.css, a file
     that deliberately adds no sizes of its own today.

     Either way they must not drift apart again: test/smoke/live-vs-read.cjs
     measures all six rungs in both modes and fails on a pixel.

     The PHONE ramp is unaffected and still ours — .hd-ph redefines all six
     below, and on a phone BOTH modes already read them (Live through
     .cm-lp-h*, Read through ui/mobile/phone-note.css), so the two have never
     disagreed there. */
  --h1-size: 2em;
  --h2-size: 1.5em;
  --h3-size: 1.25em;
  --h4-size: 1em;
  --h5-size: 0.875em;
  --h6-size: 0.85em;

  --leading-none: 1;
  --leading-tight: 1.25;  /* single-line rows and headings */
  --leading-snug: 1.4;    /* dense multi-line UI text */
  --leading-normal: 1.55; /* running prose (help text, descriptions) */
  /* RULES §3.13. A rendered NOTE is not the same kind of text as a help string:
     it is paragraphs of Korean with Latin terms in them, and 1.55 is a Latin
     value. Hangul syllable blocks fill the em box and have neither ascenders
     nor descenders to open the line up, so Korean at 1.55 sets solid. Its own
     token rather than a literal in rendered-note.css, because the published
     view and the export will want the same number. */
  --leading-prose: 1.75;  /* rendered note body */

  --weight-normal: 400;
  --weight-medium: 500;
  --weight-semibold: 600;
  --weight-bold: 700;

  /* Small caps-ish section labels need loosening; display sizes need the
     opposite or they look gappy. */
  --tracking-label: 0.04em;
  --tracking-normal: 0;
  --tracking-tight: -0.011em;

  /* --- Space ------------------------------------------------------------
     A 2px base, not 4px or 8px. Most of this UI lives between 2px and 8px
     (row padding, icon gaps, chip insets) and a 4px floor would force every
     one of those decisions to round to double what it wants. Above 8px the
     steps widen to 4px, then 8px, because container padding does not need
     that resolution. */
  /* DENSITY. The "compact" preference multiplies the container steps — and only
     the container steps. Below 8px the scale is icon gaps and chip insets,
     where a 0.8 factor rounds to a sub-pixel and buys nothing but blur; the
     rows and panels above it are where a screenful is actually won or lost.
     Scaling one variable beats restating padding per component, which is how a
     density option turns into a second stylesheet. */
  --density-scale: 1;
  --row-h: 28px;

  --space-1: 2px;
  --space-2: 4px;
  --space-3: 6px;
  --space-4: 8px;
  --space-5: calc(12px * var(--density-scale));
  --space-6: calc(16px * var(--density-scale));
  --space-7: calc(20px * var(--density-scale));
  --space-8: calc(24px * var(--density-scale));
  --space-9: calc(32px * var(--density-scale));
  --space-10: calc(40px * var(--density-scale));

  /* --- Radius -----------------------------------------------------------
     Deliberately tighter than the inherited 10-12px. Large radii read as
     "card", and a dense tool wants surfaces that read as "panel". */
  --radius-xs: 3px;
  --radius-sm: 4px;
  --radius-md: 6px;
  --radius-lg: 8px;
  --radius-xl: 10px;
  --radius-pill: 999px;

  /* --- Layout -----------------------------------------------------------
     Named so the geometry that other rules have to compute against (the CFP
     rail's max-height, for one) can be derived instead of re-counted by hand. */
  --shell-topbar-h: calc(44px * var(--density-scale));
  --shell-benchhead-h: calc(44px * var(--density-scale));
  --shell-sidebar-w: 264px;
  /* THE APP BAR'S HEIGHT, read by the shell grid and by the phone drawer that
     has to start below it. 42px against the website's 48px (cut by a fifth from
     48 to 38 alongside the site's 60 -> 48, then given a tenth back): the site's
     navbar is a page header over prose, this one sits above an editor where
     every row of chrome is a row of text somebody does not get to read.
     sspapers' top bar is the same 42px. The mark keeps the site's PROPORTION —
     28px in 42 is 32px in 48 — and every control in the bar is 26px. */
  --bar-h: 42px;
  --shell-rail-w: 300px;

  /* Reading measure. Applies to running text only — descriptions, empty
     states, help. Anything tabular ignores it, because a data row is not
     prose and truncating it to 68 characters would hide the data. */
  --measure: 68ch;
  /* The dashboard column. Wider than --measure because its content is rows and
     charts; it is a layout ceiling, not a reading one. */
  --measure-panel: 760px;

  /* THE DOCUMENT MEASURE — the note itself, in Live and in Read. Not --measure
     above: that one is scoped to UI running text (descriptions, empty states,
     help) and is read by settings.css and explorer.css, which have no opinion
     about notes.
     WHY 65ch, measured rather than felt. Summing real glyph advances from the
     shipped public/fonts/pretendard-latin.woff2 over all 33,683 prose lines of
     the production corpus gives 6.45px per character at 15px. Read was 758px =
     118 characters; Live, unconstrained, was ~171 at a 1440px window. The
     typographic optimum is 45-75 and the tolerable ceiling ~90, so the target
     is 90 characters = 580px at 15px.
     IN ch, NOT px, because --text-content is a real user preference. Pretendard's
     `0` advance is 0.5957em, so 580px at 15px is 64.9ch. A fixed 580px would
     drift to 96 characters at a 14px content font and 75 at 18px; 65ch holds 90
     across the whole 12-24px range the preference allows.
     ONE TOKEN, TWO max-widths: note-view.css puts it on .markdown-body and
     cm6.css puts it on .cm-line, and both centre with auto margins. Nothing
     computes with it, so the preference's full-width value is simply `none`.

     WHY round(…, 2px) AND NOT A BARE 65ch — measured, after a run that failed.
     Pretendard's `0` advance is 0.6em as Chromium resolves it (65ch came out at
     exactly 585px at 15px, not the 580.8 a 0.5957em advance predicts), and 585
     is ODD. A centred column of odd width inside an even pane starts on a HALF
     PIXEL — Read landed at x=559.5 and Live's padding at 295.5 — and every
     glyph in the note is then resampled rather than rasterised on the grid.
     That is the exact defect test/smoke/rendered-crispness.cjs exists for, and
     it failed all four of its dpr-1 cases on a bare 65ch.
     Snapping to an EVEN multiple keeps `margin: 0 auto` and the padding calc
     landing on whole pixels, for the same reason the 758px it replaced did.
     It costs 1px of measure (586 rather than 585, about 0.15 of a character),
     and it is what makes the ch unit usable here at all: 65ch is a whole even
     number at some font sizes and not others, and round() removes the question.
     preferences.js applyTokens() overrides this from the `noteWidth` setting. */
  --measure-note: round(65ch, 2px);

  /* --- Motion -----------------------------------------------------------
     Short enough to feel mechanical rather than animated — this is a tool. */
  --dur-fast: 80ms;
  --dur-base: 140ms;
  --ease: cubic-bezier(0.4, 0, 0.2, 1);

  /* --- Depth ------------------------------------------------------------
     Every stacking level the workspace uses, in order. Values match what the
     shell already stacked at, so the ordering is a rename and not a re-layer. */
  --z-base: 0;
  --z-resizer: 5;
  --z-dropdown: 10;
  --z-menu: 30;
  --z-shell: 1000;      /* #workspace-root over the legacy page */
  --z-overlay: 1100;    /* click-catcher under a context menu */
  --z-ctxmenu: 1101;
  --z-popover: 1200;    /* task panel, scope switcher */
  --z-drag: 2000;       /* pointer capture during a sidebar resize */
  --z-modal: 3000;
  --z-modal-menu: 3100; /* a menu opened from inside a modal */

  /* ======================================================================
     COLOUR
     A near-neutral ramp with a slight cool cast, one accent, and low-chroma
     status hues. Every pair below is checked against WCAG — text at 4.5:1 on
     the surfaces it is used on, icons and the selection bar at 3:1 — and it is
     CHECKED rather than claimed: test/tokens-contrast.js reads this file, works
     out both themes, and measures the pairs. It also compares the two
     hand-maintained copies of this palette (the @supports block below and the
     .hd-ph block at the head of phone.css) against the originals, because a
     copy nobody compares is a copy that is wrong.
     Do not lighten --fg-muted or --fg-subtle without running it; they are the
     two that were already at the limit.
     ====================================================================== */

  /* Surfaces, back to front */
  --bg-canvas:   light-dark(#ece8df, #04080f); /* behind everything */
  --bg-surface:  light-dark(#f4f1ea, #070f1a); /* panels: bars, sidebar, cards */
  --bg-raised:   light-dark(#f9f7f3, #0e1620); /* menus, modals, popovers */
  --bg-inset:    light-dark(#e8e4db, #03060b); /* search fields, tracks, code */
  --bg-hover:    light-dark(#e0dcd4, #131b25);
  --bg-active:   light-dark(#d4d1c9, #222933);
  --bg-selected: light-dark(#e0e1dd, #132635); /* the current row */
  /* THE GROUND THE LAB MARK SITS ON, and it is NOT themed.
     images/sslab-logo.png is a single blue, #36A0F3. Measured against the
     surfaces it could land on: 6.25:1 on the dark page, 5.57:1 on the dark
     panel — and 2.30:1 on the light parchment, 2.49:1 on the light panel. So in
     the light theme it has no legible ground of its own, and the website solves
     that the same way: its navbar is black in both themes and the mark lives
     there. One dark chip, both themes, 6.25:1. */
  /* The site's dark accent line and its glow, straight from main.scss. Not
     themed: they exist only in the dark half, which is where the Tron look
     lives. Nothing in ssnotes uses the glow yet — it is here so the next thing
     that wants it takes the site's value rather than inventing one. */
  --neon: #37a3f8;
  --neon-glow: rgba(55, 163, 248, 0.45);
  /* THE NAVBAR BLACK, and it is the website's literal value rather than our
     darkest token. main.scss: `.navbar { background-color: #000 }`, unthemed —
     the site keeps its bar black on the parchment page too, because the mark is
     one blue (#36A0F3) that measures 2.30:1 on light and has no legible ground
     of its own there. Ours was #04080f, the dark page ground, which was close
     enough while it was a 44px chip in a corner; as a bar across the whole
     window it should be the same black the site uses. Six digits, not #000:
     bin/assert-release.sh reads this as a six-digit hex. */
  --logo-ground: #000000;
  --bg-backdrop: light-dark(rgba(16, 21, 28, 0.42), rgba(2, 4, 8, 0.62));

  /* THE DRAWING BOARD, DARK IN BOTH THEMES — AND THAT IS THE DECISION, not an
     oversight to be tidied later.

     The canvas cannot follow the app theme: a sketch is authored on white and
     the note renders it on white (lib/drawings/svg.js), so an editing surface
     that went dark at night would be a surface that lies about the result. A
     white artboard then needs a ground to sit on, and the scrim behind a
     full-screen board has to be dark to read as a scrim at all.

     That settles the bar too. A light bar between a dark scrim and a white
     canvas has nothing to separate it from the canvas — it would read as part
     of the paper. Every drawing tool recedes its chrome for this reason. So the
     board's chrome is one appearance in both themes, and these are UNTHEMED
     like --logo-ground above.

     What was actually wrong is that this decision lived in thirteen hard-coded
     hexes inside drawing.css with nowhere to change it. Contrast is held by
     test/tokens-contrast.js against --draw-bar, not assumed. */
  --draw-scrim: rgba(16, 19, 23, 0.82);
  --draw-bar: #1c2128;
  --draw-bar-edge: #2b323b;
  --draw-fg: #c8d1da;          /* 9.09:1 on --draw-bar */
  --draw-fg-strong: #ffffff;
  --draw-fg-dim: #8b96a1;      /* 4.84:1 on --draw-bar */
  --draw-hover: #2b323b;
  /* THE PRESSED TOOL'S GROUND. Was #34506e, which measured 1.94:1 against the
     bar — the fill said almost nothing and the whole of "which tool am I
     holding" rested on a 1px ring. That is thin from across a room, and a wall
     display is the case this board exists for. #436f96 clears 3:1 on the bar and
     still carries white at 5.31:1. */
  --draw-on: #436f96;
  --draw-accent: #4a90d9;      /* the focus ring and the pressed tool's border */
  --draw-primary: #3d6fa8;     /* Done */
  /* HOVER BRIGHTENS, BUT NOT PAST THE LABEL. #4a83c4 was lighter than Done's own
     ground and took white down to 3.94:1, so pointing at the button made its
     word harder to read. #4478b4 is still a lift (lum .180 against .152) with
     white at 4.57:1. */
  --draw-primary-hover: #4478b4;
  --draw-paper: #ffffff;       /* the artboard; see above on why it is not themed */
  /* THE RESTING OUTLINE ON AN EMBEDDED SKETCH, where there is no hover to reveal
     one (mobile.css). A composite rather than an alpha: mobile.css drew the old
     accent at 50%, which over the light page came to 2.16:1 — an affordance you
     cannot see is not one. These are that blend at 82%, computed here so
     test/tokens-contrast.js can measure them as colours rather than trusting an
     alpha nothing can check. 3.61:1 light, 7.05:1 dark. THEMED, unlike
     everything above it: this one is drawn on the NOTE, not on the board.

     82%, not the 72% I first wrote: that came to 3.04:1 against a page I had
     guessed at (#f4f1ea) and 2.93:1 against the one the light theme actually
     uses (#ece8df). A margin this thin is worth having, since the blend is
     fixed here and the page it lands on can be retoned. */
  --draw-embed-hint: light-dark(#397bbf, #4fa1d4);

  /* Text, strongest to faintest */
  --fg-strong:    light-dark(#2a2a2a, #e9f4ff); /* titles */
  --fg:           light-dark(#4a463f, #b8d3ee); /* default */
  --fg-secondary: light-dark(#5a564c, #94b4d6); /* labels */
  /* One step down from where it shipped, in both themes, and the step is
     measured. This is the token a field PLACEHOLDER uses, and a placeholder
     sits on --bg-inset rather than on a panel: 4.31:1 light and 4.24:1 dark,
     which is under AA on the one surface it was never checked against. */
  --fg-muted:     light-dark(#6b6559, #6f95bd); /* metadata, placeholders */
  --fg-subtle:    light-dark(#777166, #6689ae); /* icons, carets — 3:1, not 4.5 */
  /* ON A SOLID FILL — and it stopped being white when the palette changed.
     It was a single #ffffff for both themes, which worked only because the old
     dark accent was DARK. The SSLab dark accent is a light blue (#5ba7ff) and
     white on it measures 2.50:1, so the label on a primary button would have
     been unreadable in dark. The site already solved this: main.scss carries
     `--tag-text: #0b1b2e` in its dark palette for exactly this reason, and this
     is that value. The same logic makes --danger-solid a LIGHT red in dark —
     on a light fill the text is the dark half of the pair. */
  --fg-on-accent: light-dark(#ffffff, #04121f); /* on any solid accent/status fill */

  /* Lines ------------------------------------------------------------------
     THE LIGHT RAMP WAS INVISIBLE AND THE DARK ONE WAS NOT, which is what the
     owner reported as "light theme overall has too low contrast and hard to see
     UI components". Measured against --bg-canvas, the darkest of the four light
     grounds and therefore the worst case:

                        light              dark
       --border-subtle  1.10 -> 1.60      1.19 (unchanged)
       --border         1.22 -> 3.06      1.58 -> 3.06
       --border-strong  1.45 -> 3.80      3.84 (unchanged)

     --border is the one that mattered: it draws the search field, the buttons,
     the kbd and workspace chips, the menus, the switcher and the column's own
     edge — 55 uses, and every one of them a thing the owner called a UI
     component. At 1.22:1 none of them had a visible boundary, and WCAG 1.4.11
     asks 3:1 of exactly that: "visual information required to identify user
     interface components". So 3:1 it is.

     THIS FILE USED TO ARGUE THE OPPOSITE, and the argument is worth keeping
     next to its refutation: test/tokens-contrast.js said these were left out of
     the gate deliberately, "1.5-1.7:1 and always have been, and that is the
     design rather than an oversight", on the grounds that nothing here is
     identified by its outline alone — an input also changes its background, a
     button also carries a label. That holds for a button. It did not hold for
     the light theme as a whole, because the DARK theme quietly had a strong
     line all along (--border-strong is the neon #1f6fb8 at 3.84:1) and the
     light one had nothing above 1.45. One theme had structure and the other
     was a wash; the asymmetry is the bug, not the absolute number.

     EACH THEME KEEPS ITS OWN HUE. The light values are the ratios of the site's
     --ink-muted #6b6559 darkened — the page's own greys, not a neutral grey
     dropped on a warm palette. The dark one is the ratios of #1f6fb8, which is
     the site's --panel-border and is already what dark's --border-strong is, so
     the dark ramp is one blue getting brighter rather than two colours.

     DARK WAS FIXED TOO, AND NOT BECAUSE IT WAS REPORTED. Only light was. But
     the gate written for light asks the question of both themes, and dark's
     --border answered 1.58:1 — the same 55 components, the same defect, in the
     theme the owner spends most of their time in. Gating one and not the other
     would have been half a standard. Solved against --bg-surface, the LIGHTER
     of the two dark grounds and so the worse case for a blue line.

     --border-subtle is deliberately left low in both: it is a divider inside a
     list rather than the boundary of anything, and a 3:1 hairline between every
     pair of rows in a dense tree is a heavy-outlined UI nobody asked for. See
     the note in test/tokens-contrast.js, which keeps the argument this change
     refuted next to the half of it that survived. */
  --border:        light-dark(#8b8374, #1b62a3); /* panel edges, field and button outlines */
  --border-subtle: light-dark(#c3b8a2, #0b1e33); /* dividers inside a list */
  --border-strong: light-dark(#7b7466, #1f6fb8); /* emphasis edges, the phone's grab handle */

  /* Rendered markdown surfaces — the table, the code panel, the inline chip.
     These are what public/css/ui/rendered-note.css assigns to
     github-markdown-css's own --bgColor-* / --borderColor-* variables, so this
     block is where the rendered note is themed. Renaming one here retimes
     GitHub's stylesheet without forking it.

     TRANSLUCENT AND DERIVED FROM THE INK, not a light-dark() pair, and that is
     the whole point. A rendered table has FOUR backdrops: the Read pane
     (--bg-surface), the Split pane, the phone's read pane, and the CodeMirror
     editor when live preview draws the same table as a block widget — and that
     last one is whichever palette the `editorTheme` preference names, so it is
     #1e2126 for one-dark, #272822 for Monokai, #2e3440 for Nord, #ffffff for
     the light one. A fixed pair cannot be right on all four; a tint of whatever
     is behind it is right on all four by construction. It is also why the
     widget looked wrong to begin with: those surfaces were hard-coded #fff by
     the github-extract.css these replaced.

     currentColor RATHER THAN A NEUTRAL GREY, which is the one thing that
     changed when github-markdown-css came in. A 127-grey lightens a dark
     surface and darkens a light one by the same amount, so it goes muddy at
     both ends and washes out at neither — the dark code panel it produced was
     LIGHTER than the canvas around it by more than GitHub's own is. A
     percentage of currentColor instead moves the surface towards the TEXT,
     whichever direction that is, which is what GitHub itself does: its
     --bgColor-muted is lighter than its canvas in the dark theme and darker
     than it in the light one. Inside a block widget currentColor is the editor
     palette's foreground, so the tint follows the palette too.

     Percentages are matched to GitHub's own pairs against our canvas: 6% of
     --fg over dark --bg-canvas lands on rgb(26,27,32) where GitHub dark's
     --bgColor-muted is #151b23, and over light lands on rgb(240,241,242) where
     GitHub light's is #f6f8fa. --fg on a --md-tint-panel panel reads 11.5:1
     dark and 12.4:1 light, both far past AA; the crispness gate measures it. */
  --md-tint-row:    color-mix(in srgb, currentColor 4%, transparent);  /* a diff line, a callout */
  --md-tint-panel:  color-mix(in srgb, currentColor 6%, transparent);  /* code block, alternate table row */
  --md-tint-chip:   color-mix(in srgb, currentColor 12%, transparent); /* inline code */
  /* THE GRIDLINES WENT UP WITH THE BORDERS, for the same report and by the same
     measurement. A sweep of every painted edge in a light note found the table
     hairline at 1.51:1 and the panel edge at 1.25:1 — the two faintest lines on
     the page once --border was fixed, and sixteen of the former in one small
     table. 25% -> 30% and 14% -> 24%, and BOTH numbers were argued down from a
     first attempt by a check. Two different checks, in opposite directions.

     35% FAILED THE CRISPNESS GATE, which asks that a themed border sit on the
     same side of the light/dark divide as the surface it encloses — GitHub's own
     are #d1d9e0 on white and #3d444d on #0d1117. Inside a LIVE block widget
     currentColor is the editor palette's foreground, and for the light default
     that is #000000, so the same percentage draws a far darker line there than
     in the Read pane: 35% landed on rgb(159,157,152), luminance 0.338, just over
     the 0.35 divide and classified dark-on-light. 30% lands at 0.397, with
     margin.

     THE GATE IS NOT BEING BENT TO FIT. It was written for a real bug — a
     light-grey Bootstrap hairline drawn round a dark panel — and the obvious
     softening, "a line need only contrast with its surface in the theme's own
     direction", would have let that exact bug through, because #ccc on a dark
     panel satisfies it. So the tint gives way instead.

     NOT 3:1 EITHER. These are content, not controls: a gridline is not something
     you click, and the percentage that would put light at 2.2 puts DARK at 3.3 —
     a bright grid through every table in the app. GitHub's light table border is
     1.29:1 for comparison; this is still the darker choice.

     AND 20% WAS TOO FAR THE OTHER WAY. Correcting --md-rule down from 35, this
     took --md-rule-subtle from 22% to 20% as well — which nothing had asked for:
     the crispness gate's objection was to a table BORDER, and this token does
     not draw one. At 20% a rendered panel's edge measured 1.38:1 and fell under
     the 1.4 floor the legibility gate holds, so the over-correction failed a
     check of its own. 24% puts it at about 1.47 with margin.

     The ramp between the two is narrower than it was (24 and 30, from 14 and
     25). That is the honest consequence of both ends being pinned by a
     measurement rather than chosen: a row separator and a table hairline were
     never very different, and now both of them clear the bar. */
  --md-rule:        color-mix(in srgb, currentColor 30%, transparent); /* table hairlines */
  --md-rule-subtle: color-mix(in srgb, currentColor 24%, transparent); /* row separators */
  --md-fg-muted:    color-mix(in srgb, currentColor 70%, transparent); /* blockquotes, gutters */

  /* Syntax highlighting, as TOKENS rather than as a vendor stylesheet.
     The preview used to load highlight.js/styles/github.css, which is a LIGHT
     theme: it pins .hljs to #24292e on #ffffff, so a fenced block in the dark
     app was dark ink on a white slab. Its dark twin exists (github-dark.css)
     but a second <style> cannot be scoped to a theme the way a variable can,
     and this app's theme is a stored preference rather than the OS's.

     So both halves of GitHub's own syntax palette live here as pairs.
     rendered-note.css assigns them to github-markdown-css's
     --color-prettylights-syntax-* variables and maps highlight.js's .hljs-*
     class names onto those, which is why nothing here has to know that the
     highlighter and the stylesheet come from different projects. Vendor values,
     not invented ones: the light column is exactly what the app already
     shipped, and the dark column is GitHub's own. */
  /* No --code-fg. Plain text inside a fence is not a syntax token, and giving it
     a colour of its own was how it ended up disagreeing with the ink around it:
     github-markdown-css sets `pre { color: var(--fgColor-default) }`, which
     rendered-note.css points at currentColor, so a code block reads in the same
     ink as the note — or as the editor palette, inside a live block widget. */
  --code-keyword:  light-dark(#d73a49, #ff7b72);
  --code-entity:   light-dark(#6f42c1, #d2a8ff);
  --code-constant: light-dark(#005cc5, #79c0ff);
  --code-string:   light-dark(#032f62, #a5d6ff);
  --code-variable: light-dark(#e36209, #ffa657);
  --code-comment:  light-dark(#6a737d, #8b949e);
  --code-tag:      light-dark(#22863a, #7ee787);
  --code-heading:  light-dark(#0550ae, #1f6feb);
  --code-list:     light-dark(#3b2300, #f2cc60);
  --code-inserted-fg: light-dark(#22863a, #7ee787);
  --code-deleted-fg:  light-dark(#b31d28, #ffa198);

  /* Accent. A desaturated indigo rather than the inherited bootstrap blue —
     the point of this slice is that the palette was chosen. */
  /* ACCENT — 207°, WHICH IS THE SSLab MARK'S OWN HUE.
     This slice used to be a desaturated indigo, and its note said "the point of
     this slice is that the palette was chosen". That was true and it was not
     enough: chosen for coherence is not the same as chosen for us, and what got
     chosen was indistinguishable from Linear's or Primer's.

     THE EVIDENCE. public/img/sslab-logo.png quantises to #3398e9 over 12,835 of
     its pixels; sslab-logo-large.png to #3398e9, sslab-logo-only.png to #3396e5,
     apple-touch-icon.png to #3295e5 with shadow tones #246ca7 and #143d5d. Every
     one of those is hue 207 ± 1. That single azure is the entire SSLab palette —
     the mark is the only artefact in this project that has ever carried a brand
     colour, and the wordmark, the favicons, the touch icons and the two sibling
     services (sspapers, sschulcheck) all share the same byte-identical files.
     And it appeared in NO stylesheet: grep 3398e9 across public/css found
     nothing before this commit. The logo sat in the top bar in one blue while
     the interface underneath it was another, which is most of what "it doesn't
     feel like ours" is describing.

     WHY THESE VALUES AND NOT #3398e9 ITSELF. The mark is a light azure — 3.08:1
     against white — so it cannot carry white button text, and --fg-on-accent is
     white on every solid accent fill in the product. So the HUE is held at 207
     and each token is the lightness that hue has to be to clear its own job:
     the fills are dark enough for white text (5.37:1 light, 4.59:1 dark), the
     text token is dark/light enough for the three surfaces it lands on, and the
     subtle pair is the same hue at the top and bottom of the ramp. Nothing here
     is eyeballed — test/tokens-contrast.js measures all of it. */
  --accent:           light-dark(#1263b8, #5fc3ff);
  --accent-hover:     light-dark(#0c4a8f, #a6e0ff);
  --accent-fg:        light-dark(#1263b8, #5fc3ff); /* accent-coloured TEXT */
  --accent-subtle-bg: light-dark(#e9eae8, #102131);
  --accent-border:    light-dark(#99b5d0, #365578);
  /* The focus ring is the one place the mark's own #3398e9 appears untouched:
     it sits behind nothing and carries no text, so it has no contrast job to
     do and can be the literal brand colour. */
  --accent-ring:      light-dark(rgba(18, 99, 184, 0.30), rgba(91, 167, 255, 0.45));

  /* Status. Used for meaning, never decoration. */
  --danger:           light-dark(#b3261e, #ff8a80);
  --danger-subtle-bg: light-dark(#e7d7ce, #22181d);
  --danger-border:    light-dark(#d9a69d, #72443e);
  --warn:             light-dark(#7d5c07, #e8b53a);
  --warn-subtle-bg:   light-dark(#e6dcc6, #1f1d14);
  --warn-border:      light-dark(#d7c18a, #614c16);
  --success:          light-dark(#1c6b3a, #6ed39a);
  --success-subtle-bg: light-dark(#d9ddd0, #112020);
  --success-border:   light-dark(#a5bea7, #3b6048);
  /* A red that carries WHITE, which --danger cannot: in dark it is a light red
     for text on a dark surface, and the destructive confirmation button filled
     itself with it and put #fff on top — 2.64:1, the worst pair in the product
     and the only outright AA failure the new contrast test found. A fill and a
     text colour are two jobs and the dark theme cannot do both with one value. */
  --danger-solid:     light-dark(#b3261e, #ff8a80);
  /* Pinned/starred. Amber reads as "marked by a person" rather than "wrong". */
  --marker: light-dark(#7d5c07, #e8b53a);

  /* Projects. A FIXED set, assigned by hash — not a generated hue.
     `hsl(<hue> 52% 45%)` with white on it was the previous answer, and a fixed
     HSL lightness is not perceptually flat: the same lightness that gives 11:1
     at blue gives 2.35:1 at yellow, so a project whose name happened to hash into
     the yellows got an unreadable chip. Six pairs, each a subtle background
     plus the text colour that belongs on it, each measured at AA in BOTH
     themes (lowest is project-6 at 5.42:1 light). Six because that is enough for
     three projects to stay distinct and few enough that every pair could be
     checked by hand; a seventh would have to be checked before it is added.
     The background is deliberately subtle rather than saturated — a project chip
     is an identifier in a dense row, not a status. */
  /* project-1 IS the accent pair, deliberately — the house colour is a project's
     colour like any other, and two near-identical blues in one row would be a
     palette with a mistake in it rather than six distinct chips. */
  --project-1-bg: light-dark(#e7ecf1, #0d1a28); /* SSLab azure */
  --project-1-fg: light-dark(#1b6099, #64b5f7);
  --project-2-bg: light-dark(#e2eee8, #0b1d1e); /* teal */
  --project-2-fg: light-dark(#0e6357, #5ecfc0);
  --project-3-bg: light-dark(#f7eedf, #1d1812); /* amber */
  --project-3-fg: light-dark(#8a5300, #e0a422);
  --project-4-bg: light-dark(#f7e9e9, #1d111a); /* rose */
  --project-4-fg: light-dark(#ab2c4c, #f08aa4);
  --project-5-bg: light-dark(#f0e9f1, #181428); /* violet */
  --project-5-fg: light-dark(#6c37a5, #c39cf0);
  --project-6-bg: light-dark(#e8f0e9, #0c1b17); /* green */
  --project-6-fg: light-dark(#116a46, #4ec98a);

  /* Chart marks: progress fills and Gantt bars. Separate from status because
     they encode a schedule, not a severity, and they sit on a track rather
     than behind text. */
  --track:       light-dark(#d7d3cb, #353230);
  /* Same 207° as the accent. A schedule bar in one blue beside a selected row
     in another is two accents, and the eye reads the difference as meaning. */
  --bar-planned: light-dark(#a9c8e6, #2c4a68);
  --bar-active:  light-dark(#2f80c4, #3a8fd0);
  --bar-future:  light-dark(#c8cedb, #333c49);
  --bar-done:    light-dark(#5aab84, #3f9e70);
  --bar-over:    light-dark(#e08b8b, #a44b4b);

  /* Elevation. Two layers so the shadow reads as a lift rather than a blur,
     and tinted with the canvas hue rather than pure black. */
  --shadow-weak:   light-dark(rgba(16, 21, 28, 0.06), rgba(0, 0, 0, 0.32));
  --shadow-mid:    light-dark(rgba(16, 21, 28, 0.10), rgba(0, 0, 0, 0.44));
  --shadow-strong: light-dark(rgba(16, 21, 28, 0.16), rgba(0, 0, 0, 0.58));

  --shadow-xs: 0 1px 1px var(--shadow-weak);
  --shadow-sm: 0 1px 2px var(--shadow-weak), 0 1px 3px var(--shadow-weak);
  --shadow-md: 0 2px 4px var(--shadow-weak), 0 6px 16px var(--shadow-mid);
  --shadow-lg: 0 4px 8px var(--shadow-mid), 0 16px 40px var(--shadow-strong);
  --ring: 0 0 0 3px var(--accent-ring);
}

/* --------------------------------------------------------------------------
   THE SAME LADDER, TWO RUNGS DOWN, on a phone.

   Not a different scale — the identical 1.125 ratio shifted down two steps, so
   the phone's h1 is the desktop's h3 and every gap between levels is unchanged.
   That is how Obsidian's mobile type stays recognisably Obsidian's: the ramp is
   compressed, not redrawn.

   It lives beside the ramp it modifies rather than in phone.css, because a
   scale split across two files is a scale nobody can read in one go — the same
   argument this file makes about the palette copies.

   WHY COMPRESSED AT ALL, measured rather than felt. At the old 26px, 33% of the
   412 real h1 headings on this server wrapped onto a second line in the 350px
   of column a 390px phone actually offers; at 1.424em of a 15px body (21.4px)
   that falls to 21%. Korean is why the felt size runs ahead of the number:
   Hangul syllable blocks fill the em box with no ascenders or descenders, so a
   ramp tuned on Latin reads a size larger and a weight heavier in this corpus.

   h4-h6 flatten to body size here and carry their weight instead. Ten notes in
   the corpus use h4 and none use h5 or h6, so three more visible steps would be
   three sizes bought for nothing on the narrowest screen in the product.
   -------------------------------------------------------------------------- */
.hd-ph {
  --h1-size: 1.424em;
  --h2-size: 1.266em;
  --h3-size: 1.125em;
  --h4-size: 1em;
  --h5-size: 1em;
  --h6-size: 1em;
}

/* An explicit choice beats the OS, in both directions. */
:root[data-theme='light'] { color-scheme: light; }
:root[data-theme='dark'] { color-scheme: dark; }

/* --------------------------------------------------------------------------
   Legibility floor for browsers without light-dark() (pre-2024). Custom
   properties parse it happily and then fail at use time, which would leave the
   shell with no background and no text colour at all — so pin the structural
   tokens to the light theme rather than degrade to nothing. Themed status and
   chart colours are omitted deliberately: they are already legible on a light
   surface, and this block is a floor, not a second palette to maintain.
   -------------------------------------------------------------------------- */
@supports not (color: light-dark(#000, #fff)) {
  :root {
    --bg-canvas: #ece8df;
    --bg-surface: #f4f1ea;
    --bg-raised: #f9f7f3;
    --bg-inset: #e8e4db;
    --bg-hover: #e0dcd4;
    --bg-active: #d4d1c9;
    --bg-selected: #e0e1dd;
    --bg-backdrop: rgba(16, 21, 28, 0.42);
    --fg-strong: #2a2a2a;
    --fg: #4a463f;
    --fg-secondary: #5a564c;
    --fg-muted: #6b6559;
    --fg-subtle: #777166;
    --border: #8b8374;
    --border-subtle: #c3b8a2;
    --border-strong: #7b7466;
    --accent: #1263b8;
    --accent-hover: #0c4a8f;
    --accent-fg: #1263b8;
    --accent-subtle-bg: #e9eae8;
    --accent-border: #99b5d0;
    --accent-ring: rgba(18, 99, 184, 0.30);
    /* The only --draw-* token that is themed; the rest are plain hexes and
       need no restating here. */
    --draw-embed-hint: #397bbf;
    --danger: #b3261e;
    --danger-solid: #b3261e;
    --danger-subtle-bg: #e7d7ce;
    --danger-border: #d9a69d;
    --warn: #7d5c07;
    --warn-subtle-bg: #e6dcc6;
    --warn-border: #d7c18a;
    --success: #1c6b3a;
    --success-subtle-bg: #d9ddd0;
    --success-border: #a5bea7;
    --marker: #7d5c07;
    /* Not "already legible on a light surface" like the status colours below —
       a project chip paints its own background, so without these the chip loses
       its fill and the text keeps a colour chosen for that fill. */
    --project-1-bg: #e7ecf1; --project-1-fg: #1b6099;
    --project-2-bg: #e2eee8; --project-2-fg: #0e6357;
    --project-3-bg: #f7eedf; --project-3-fg: #8a5300;
    --project-4-bg: #f7e9e9; --project-4-fg: #ab2c4c;
    --project-5-bg: #f0e9f1; --project-5-fg: #6c37a5;
    --project-6-bg: #e8f0e9; --project-6-fg: #116a46;
    --track: #d7d3cb;
    --bar-planned: #a9c8e6;
    --bar-active: #2f80c4;
    --bar-future: #c8cedb;
    --bar-done: #5aab84;
    --bar-over: #e08b8b;
    --shadow-weak: rgba(16, 21, 28, 0.06);
    --shadow-mid: rgba(16, 21, 28, 0.10);
    --shadow-strong: rgba(16, 21, 28, 0.16);
  }
}

/* --------------------------------------------------------------------------
   RESOLVED: Pretendard is self-hosted, and the answer was the one this note
   predicted — a unicode-range subset in public/fonts, declared in its own
   stylesheet that both this file and font.css import. The costs it worried
   about came out at 41KB for a Latin-only page and 321KB for a Korean one,
   against the 2.0MB variable or 766KB-per-weight it was comparing with.
   The host still has no fonttools; the subsetting ran in a throwaway container
   (bin/subset-pretendard.sh) because the output is a static file and the tool
   does not need to persist. See public/css/pretendard.css for provenance, the
   OFL licence, why the weight axis is clamped to 400..700, and how the
   character set was derived from the real note corpus.
   -------------------------------------------------------------------------- */
