/* ── Fonts ────────────────────────────────────────────────────────────────────
   Three VARIABLE faces, not a pile of static instances. Each carries a live
   `wght` axis over 400..700, so 400/600/700 — and anything between — come out of
   one file instead of needing one download per weight.

   NB the naive version of this is a REGRESSION, not a saving: a variable font
   ships interpolation deltas for its whole design space, so the stock Noto Sans
   VF is 513 KB against 181 KB for a single static instance. Two things pay for
   it, and both are load-bearing:
     · the axes are cut to what we use — `wdth` pinned out entirely, `wght`
       clamped to 400..700 instead of 100..900;
     · the astral plane is subset away (mathematical alphanumerics, phonetic
       extensions). Every BMP codepoint the old fonts had survives — all Cyrillic
       and Greek, punctuation, arrows, math, and U+0301 COMBINING ACUTE, which is
       what ЧГК stress accents are made of. Dropping that would corrupt questions.
   Net: 416 KB for three faces vs 362 KB for the old two, and we gain real
   italics (previously the browser SYNTHESISED them by shearing the roman) and a
   real mono. Regenerate with the recipe in DESIGN.md if a face is ever rebuilt. */
@font-face {
  font-family: "Noto Sans";
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url("/static/fonts/noto-sans-var.woff2") format("woff2");
}

@font-face {
  font-family: "Noto Sans";
  font-style: italic;
  font-weight: 400 700;
  font-display: swap;
  src: url("/static/fonts/noto-sans-var-italic.woff2") format("woff2");
}

@font-face {
  font-family: "JetBrains Mono";
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url("/static/fonts/jetbrains-mono-var.woff2") format("woff2");
}

:root {
  color-scheme: light;

  /* ── THE PALETTE ───────────────────────────────────────────────────────────
     uchu (uchu.style) is the whole colour vocabulary. Both variants ship in
     full below, generated from palette/uchu.json — `go generate ./palette`.

     Do NOT add a colour to this file. A role names a RUNG, and a role that
     wants a shade the ladder does not have is a role that wants a different
     rung. That constraint is the feature: it is what makes "one step darker"
     arithmetic instead of a trip to a colour picker, and it is what lets the
     high-contrast blocks below be an OFFSET rather than a fourth palette.

     The ladder is monotone per theme and its contrast ratios are asserted —
     palette/palette_test.go, which reads this file. */
  /* ---- BEGIN GENERATED uchu-ramps (go generate ./palette) ---- */
  --uchu-yang: oklch(0.994 0 0);
  --uchu-yin: oklch(0.1438 0.007 256.88);

  --uchu-blue-1: oklch(0.8966 0.046 260.67);
  --uchu-blue-2: oklch(0.8017 0.091 258.88);
  --uchu-blue-3: oklch(0.7094 0.136 258.06);
  --uchu-blue-4: oklch(0.6239 0.181 258.33);
  --uchu-blue-5: oklch(0.5487 0.222 260.33);
  --uchu-blue-6: oklch(0.5115 0.204 260.17);
  --uchu-blue-7: oklch(0.4736 0.185 259.89);
  --uchu-blue-8: oklch(0.4348 0.17 260.2);
  --uchu-blue-9: oklch(0.3953 0.15 259.87);

  --uchu-gray-1: oklch(0.9557 0.003 286.35);
  --uchu-gray-2: oklch(0.9204 0.002 197.12);
  --uchu-gray-3: oklch(0.8828 0.003 286.34);
  --uchu-gray-4: oklch(0.8468 0.002 197.12);
  --uchu-gray-5: oklch(0.8073 0.002 247.84);
  --uchu-gray-6: oklch(0.7503 0.002 247.85);
  --uchu-gray-7: oklch(0.6901 0.003 286.32);
  --uchu-gray-8: oklch(0.6312 0.004 219.55);
  --uchu-gray-9: oklch(0.5682 0.004 247.89);

  --uchu-green-1: oklch(0.9396 0.05 148.74);
  --uchu-green-2: oklch(0.8877 0.096 147.71);
  --uchu-green-3: oklch(0.8374 0.139 146.57);
  --uchu-green-4: oklch(0.7933 0.179 145.62);
  --uchu-green-5: oklch(0.7523 0.209 144.64);
  --uchu-green-6: oklch(0.7003 0.194 144.71);
  --uchu-green-7: oklch(0.6424 0.175 144.92);
  --uchu-green-8: oklch(0.5883 0.158 145.05);
  --uchu-green-9: oklch(0.5277 0.138 145.41);

  --uchu-orange-1: oklch(0.9383 0.037 56.93);
  --uchu-orange-2: oklch(0.8837 0.072 55.8);
  --uchu-orange-3: oklch(0.8356 0.107 56.49);
  --uchu-orange-4: oklch(0.7875 0.141 54.32);
  --uchu-orange-5: oklch(0.7461 0.171 51.56);
  --uchu-orange-6: oklch(0.6933 0.157 52.18);
  --uchu-orange-7: oklch(0.638 0.142 52.1);
  --uchu-orange-8: oklch(0.5828 0.128 52.2);
  --uchu-orange-9: oklch(0.5249 0.113 51.98);

  --uchu-pink-1: oklch(0.958 0.023 354.27);
  --uchu-pink-2: oklch(0.9214 0.046 352.31);
  --uchu-pink-3: oklch(0.889 0.066 354.39);
  --uchu-pink-4: oklch(0.8543 0.09 354.1);
  --uchu-pink-5: oklch(0.8223 0.112 355.33);
  --uchu-pink-6: oklch(0.7637 0.101 355.37);
  --uchu-pink-7: oklch(0.7023 0.092 354.96);
  --uchu-pink-8: oklch(0.6411 0.084 353.91);
  --uchu-pink-9: oklch(0.5768 0.074 353.14);

  --uchu-purple-1: oklch(0.891 0.046 305.24);
  --uchu-purple-2: oklch(0.7868 0.091 305);
  --uchu-purple-3: oklch(0.685 0.136 303.78);
  --uchu-purple-4: oklch(0.5847 0.181 302.06);
  --uchu-purple-5: oklch(0.4939 0.215 298.31);
  --uchu-purple-6: oklch(0.4611 0.198 298.4);
  --uchu-purple-7: oklch(0.4277 0.181 298.49);
  --uchu-purple-8: oklch(0.3946 0.164 298.29);
  --uchu-purple-9: oklch(0.3601 0.145 298.35);

  --uchu-red-1: oklch(0.8898 0.052 3.28);
  --uchu-red-2: oklch(0.7878 0.109 4.54);
  --uchu-red-3: oklch(0.6986 0.162 7.82);
  --uchu-red-4: oklch(0.6273 0.209 12.37);
  --uchu-red-5: oklch(0.5863 0.231 19.6);
  --uchu-red-6: oklch(0.5441 0.214 19.06);
  --uchu-red-7: oklch(0.4995 0.195 18.34);
  --uchu-red-8: oklch(0.458 0.177 17.7);
  --uchu-red-9: oklch(0.4117 0.157 16.58);

  --uchu-yellow-1: oklch(0.9705 0.039 91.2);
  --uchu-yellow-2: oklch(0.95 0.07 92.39);
  --uchu-yellow-3: oklch(0.9276 0.098 92.58);
  --uchu-yellow-4: oklch(0.9092 0.125 92.56);
  --uchu-yellow-5: oklch(0.89 0.146 91.5);
  --uchu-yellow-6: oklch(0.8239 0.133 91.5);
  --uchu-yellow-7: oklch(0.7584 0.122 92.21);
  --uchu-yellow-8: oklch(0.6914 0.109 91.04);
  --uchu-yellow-9: oklch(0.6229 0.097 91.9);

  --uchu-yin-1: oklch(0.9187 0.003 264.54);
  --uchu-yin-2: oklch(0.8461 0.004 286.31);
  --uchu-yin-3: oklch(0.7689 0.004 247.87);
  --uchu-yin-4: oklch(0.6917 0.004 247.88);
  --uchu-yin-5: oklch(0.6101 0.005 271.34);
  --uchu-yin-6: oklch(0.5279 0.005 271.32);
  --uchu-yin-7: oklch(0.4387 0.005 271.3);
  --uchu-yin-8: oklch(0.3502 0.005 236.66);
  --uchu-yin-9: oklch(0.2511 0.006 258.36);

  --uchu-pastel-blue-1: oklch(0.9 0.028 261);
  --uchu-pastel-blue-2: oklch(0.8 0.048 260);
  --uchu-pastel-blue-3: oklch(0.7 0.072 259);
  --uchu-pastel-blue-4: oklch(0.6 0.096 259);
  --uchu-pastel-blue-5: oklch(0.51 0.124 262);
  --uchu-pastel-blue-6: oklch(0.48 0.112 261);
  --uchu-pastel-blue-7: oklch(0.44 0.1 261);
  --uchu-pastel-blue-8: oklch(0.41 0.096 262);
  --uchu-pastel-blue-9: oklch(0.37 0.084 261);

  --uchu-pastel-gray-1: oklch(0.96 0.004 287);
  --uchu-pastel-gray-2: oklch(0.92 0.004 195);
  --uchu-pastel-gray-3: oklch(0.88 0.004 287);
  --uchu-pastel-gray-4: oklch(0.85 0.004 195);
  --uchu-pastel-gray-5: oklch(0.81 0.004 243);
  --uchu-pastel-gray-6: oklch(0.75 0.004 245);
  --uchu-pastel-gray-7: oklch(0.69 0.008 286);
  --uchu-pastel-gray-8: oklch(0.63 0.004 215);
  --uchu-pastel-gray-9: oklch(0.57 0.004 269);

  --uchu-pastel-green-1: oklch(0.94 0.032 149);
  --uchu-pastel-green-2: oklch(0.88 0.056 148);
  --uchu-pastel-green-3: oklch(0.83 0.076 147);
  --uchu-pastel-green-4: oklch(0.78 0.1 146);
  --uchu-pastel-green-5: oklch(0.73 0.116 145);
  --uchu-pastel-green-6: oklch(0.68 0.108 145);
  --uchu-pastel-green-7: oklch(0.63 0.1 145);
  --uchu-pastel-green-8: oklch(0.57 0.088 146);
  --uchu-pastel-green-9: oklch(0.51 0.08 146);

  --uchu-pastel-orange-1: oklch(0.94 0.02 55);
  --uchu-pastel-orange-2: oklch(0.88 0.04 56);
  --uchu-pastel-orange-3: oklch(0.83 0.056 57);
  --uchu-pastel-orange-4: oklch(0.78 0.076 55);
  --uchu-pastel-orange-5: oklch(0.73 0.092 53);
  --uchu-pastel-orange-6: oklch(0.68 0.084 54);
  --uchu-pastel-orange-7: oklch(0.62 0.076 54);
  --uchu-pastel-orange-8: oklch(0.57 0.068 54);
  --uchu-pastel-orange-9: oklch(0.51 0.06 53);

  --uchu-pastel-pink-1: oklch(0.96 0.016 353);
  --uchu-pastel-pink-2: oklch(0.92 0.028 351);
  --uchu-pastel-pink-3: oklch(0.89 0.036 355);
  --uchu-pastel-pink-4: oklch(0.85 0.048 353);
  --uchu-pastel-pink-5: oklch(0.82 0.06 354);
  --uchu-pastel-pink-6: oklch(0.76 0.056 354);
  --uchu-pastel-pink-7: oklch(0.7 0.048 355);
  --uchu-pastel-pink-8: oklch(0.64 0.044 354);
  --uchu-pastel-pink-9: oklch(0.58 0.04 353);

  --uchu-pastel-purple-1: oklch(0.89 0.028 304);
  --uchu-pastel-purple-2: oklch(0.79 0.052 307);
  --uchu-pastel-purple-3: oklch(0.68 0.072 305);
  --uchu-pastel-purple-4: oklch(0.57 0.1 304);
  --uchu-pastel-purple-5: oklch(0.46 0.128 303);
  --uchu-pastel-purple-6: oklch(0.43 0.116 303);
  --uchu-pastel-purple-7: oklch(0.4 0.108 302);
  --uchu-pastel-purple-8: oklch(0.37 0.1 303);
  --uchu-pastel-purple-9: oklch(0.34 0.084 302);

  --uchu-pastel-red-1: oklch(0.89 0.032 2);
  --uchu-pastel-red-2: oklch(0.78 0.06 3);
  --uchu-pastel-red-3: oklch(0.68 0.088 5);
  --uchu-pastel-red-4: oklch(0.59 0.12 8);
  --uchu-pastel-red-5: oklch(0.52 0.136 14);
  --uchu-pastel-red-6: oklch(0.49 0.128 13);
  --uchu-pastel-red-7: oklch(0.45 0.116 13);
  --uchu-pastel-red-8: oklch(0.41 0.104 12);
  --uchu-pastel-red-9: oklch(0.37 0.092 11);

  --uchu-pastel-yellow-1: oklch(0.97 0.024 94);
  --uchu-pastel-yellow-2: oklch(0.95 0.036 92);
  --uchu-pastel-yellow-3: oklch(0.92 0.052 92);
  --uchu-pastel-yellow-4: oklch(0.9 0.068 91);
  --uchu-pastel-yellow-5: oklch(0.88 0.084 92);
  --uchu-pastel-yellow-6: oklch(0.82 0.076 92);
  --uchu-pastel-yellow-7: oklch(0.75 0.068 92);
  --uchu-pastel-yellow-8: oklch(0.69 0.064 91);
  --uchu-pastel-yellow-9: oklch(0.62 0.056 90);

  --uchu-pastel-yin-1: oklch(0.9187 0.003 264.54);
  --uchu-pastel-yin-2: oklch(0.8461 0.004 286.31);
  --uchu-pastel-yin-3: oklch(0.7689 0.004 247.87);
  --uchu-pastel-yin-4: oklch(0.6917 0.004 247.88);
  --uchu-pastel-yin-5: oklch(0.6101 0.005 271.34);
  --uchu-pastel-yin-6: oklch(0.5279 0.005 271.32);
  --uchu-pastel-yin-7: oklch(0.4387 0.005 271.3);
  --uchu-pastel-yin-8: oklch(0.3502 0.005 236.66);
  --uchu-pastel-yin-9: oklch(0.2511 0.006 258.36);
  /* ---- END GENERATED ---- */

  /* ── NEUTRAL ROLES ─────────────────────────────────────────────────────────
     Light reads DOWN the ramps: paper at the top, ink at the bottom. Fills come
     off `gray` and inks off `yin`, which is uchu's own division of labour.

     Five fills and lines, where there used to be eight: --grid shares a rung
     with --action-bg (a table rule and a button fill never abut, so the pair
     never had to be told apart), and page/surface-tint/hover-bg all resolve to
     --structure. The old set put --grid 0.916, --action-bg 0.934 and
     --action-hover-bg 0.901 inside one 0.033 band — three values that read as
     one gray. Rungs cannot land that close: the tightest gap here is 0.035. */
  --paper: var(--uchu-yang);
  --structure: var(--uchu-gray-1);
  --grid: var(--uchu-gray-2);
  --grid-dark: var(--uchu-gray-5);
  --muted-light: var(--uchu-gray-7);
  --muted: var(--uchu-yin-6);
  --text-strong: var(--uchu-yin-8);
  --text: var(--uchu-yin-9);
  --blue: #1a73e8;
  --green: #69a882;
  --green-dark: #206836;
  --red: #ecc5c9;
  --red-dark: #9b3f48;
  --success: #16a34a;
  --danger: #dc2626;

  /* ── THE THREE WASHES ──────────────────────────────────────────────────────
     A wash is a tinted background that says what just happened to a row, a cell
     or a field. There are exactly three things it can say — this went well, this
     went wrong, this is the one you are on — so there are three tokens, named
     for the meaning rather than for the hue.

     There used to be seven (--red-soft, --danger-soft, --success-soft,
     --accent-soft, --blue-tint, --warning-soft, --blue-soft), sitting inside a
     0.05 lightness band and interleaved with the structural grays: two pairs
     were 0.004 and 0.009 apart, and three were dead. --warning-soft was the
     sharp end — named for warning, and blue at hue 260.

     Each is a mix of THIS THEME'S paper with a rung, so a wash sits on whatever
     surface the theme has instead of being re-picked against it. That is also
     what buys the equal weight the old set never had: all three land at L
     0.94–0.96 here, where the seven spread from 0.929 to 0.982. */
  --wash-positive: color-mix(in oklab, var(--surface) 88%, var(--uchu-green-6));
  --wash-negative: color-mix(in oklab, var(--surface) 88%, var(--uchu-red-5));
  --wash-emphasis: color-mix(in oklab, var(--surface) 88%, var(--uchu-blue-5));
  /* ── WARNING ───────────────────────────────────────────────────────────────
     A fill and its ink — the unnumbered-teams banner, the fuzzy-match badge,
     the pending sync dot. Two tokens, not the four (--amber-bg, --amber-border,
     --amber-text-strong, --amber-text) that used to occupy FOUR hue families
     between them: hue 96, 70, 66 and 38, drifting from yellow through orange
     into red-brown, all named amber.

     The drift was hiding a real constraint rather than being sloppy. Yellow
     cannot carry text on a light surface — uchu's darkest yellow rung reaches
     only 3.54:1 against yang, short of AA — so a warning FILL wants yellow and
     warning INK wants orange, and the old values had drifted toward exactly
     that without saying so. Now it is said: the split is by role, and one ink
     serves both grounds (orange-9 is 4.87:1 on the fill and 5.52:1 on paper,
     asserted in palette_test.go).

     --amber-border is gone. The Kit's outlines are off in the regular themes;
     the banner separates by fill like everything else. */
  --warn-bg: var(--uchu-yellow-2);
  --warn-text: var(--uchu-orange-9);
  /* Diagnostic chip (download-log button): stays dark in every theme, so it
     names the ink rung directly rather than a role that moves. */
  --diag-bg: oklch(from var(--uchu-yin-9) l c h / 0.82);
  --diag-fg: var(--uchu-yang);
  /* Transparent twins for gradient endpoints: a fade must end on the alpha-0
     version of ITS OWN color — `transparent` (black) or a hardcoded light value
     tints the ramp on the other themes. Derived from the role now, so a twin
     can no longer drift away from the colour it is the fade of. */
  --paper-transparent: oklch(from var(--paper) l c h / 0);
  --structure-transparent: oklch(from var(--structure) l c h / 0);
  /* Semantic aliases onto existing theme-aware tokens (theme-aware through
     their referents, so no dark-block override needed). */
  --accent: var(--success);
  --text-muted: var(--muted);
  --blue-strong: #174ea6;
  --blue-bright: #1d4ed8;
  --danger-text: #9f1239;

  /* Aliases, not rungs: these three carried no distinction of their own even
     when they had their own hexes. Dark re-points --page below, where the steps
     ARE the elevation. */
  --page: var(--structure);
  --surface: var(--paper);
  --surface-tint: var(--structure);
  --hover-bg: var(--structure);

  /* ── Element outlines are OFF ──────────────────────────────────────────
     Buttons, menus, popovers, cards, modals and inputs separate themselves by
     FILL (+ shadow for floating layers), never by a contrast outline. The four
     outline tokens below are therefore `transparent` in the regular themes, and
     only the [data-contrast=high] blocks give them real colors — that mode
     exists precisely to carry separation on displays that cannot resolve the
     fills, so it is the one place an outline earns its keep.

     They stay `transparent` rather than being deleted for three reasons: the 1px
     box is still reserved, so nothing reflows; state rules that only set
     `border-color` (`.input:focus` → --blue, `.btn-danger`) still paint; and
     high contrast re-enables everything by re-pointing four variables.

     STRUCTURAL rules are a different thing and are NOT tokens: table cell grids,
     the topbar divider, pane splitters, dashed "add"/page-break affordances and
     dope's keypad key-seams all reference --grid / --grid-dark directly, and are
     unaffected by anything here. Do not route them back through --border. */
  --border: transparent;
  --border-soft: transparent;
  --action-border: transparent;
  --action-disabled-border: transparent;

  /* THE control fill. It carries the whole affordance now that the outlines are
     gone, so it must read against BOTH --paper and --structure — the two
     backgrounds controls actually sit on. Buttons and fields share it in light
     (shape and typography already tell them apart; a 2% gray difference told
     nobody anything); dark keeps them split below, where recessed-field vs
     lifted-button is real elevation. */
  --action-bg: var(--uchu-gray-2);
  --action-hover-bg: var(--uchu-gray-3);
  --action-fg: var(--text);
  --field-bg: var(--action-bg);

  /* Ghost = the low-emphasis button. It is TRANSLUCENT on purpose: a ghost sits
     on --surface, --structure and --page alike, and any opaque fill picked to
     read on one of them disappears into another. A wash of the text color works
     against all three. It must still read as a button — with the outlines gone,
     a ghost with no fill at all is just a word (which is what it had become).
     A wash OF --text, so it stays in the ink's hue family by construction; the
     old pair hardcoded rgb(32,33,36) and needed a paragraph explaining why. */
  --action-ghost-bg: oklch(from var(--text) l c h / 0.06);
  --action-ghost-hover-bg: oklch(from var(--text) l c h / 0.12);

  --action-disabled-bg: var(--structure);
  --action-disabled-fg: var(--muted-light);
  --action-disabled-opacity: 0.72;
  --cursor-action: pointer;
  --cursor-disabled: not-allowed;

  --space-0: 0;
  --space-0-5: 2px;
  --space-1: 4px;
  --space-1-5: 6px;
  --space-2: 8px;
  --space-2-5: 10px;
  --space-3: 12px;
  --space-3-5: 14px;
  --space-4: 16px;
  --space-5: 24px;
  --space-5-5: 28px;
  --space-6: 32px;
  --space-8: 48px;

  --radius-xs: 3px;
  --radius-sm: 4px;
  --radius-sm-plus: 5px;
  --radius-md: 6px;
  --radius-lg: 8px;
  --radius-pill: 24px;
  --radius-full: 50%;

  --text-2xs: 10px;
  --text-xs-tight: 11px;
  --text-xs: 12px;
  --text-xs-plus: 13px;
  --text-sm: 14px;
  --text-base: 15px;
  --text-md: 16px;
  --text-lg: 18px;
  --text-lg-xl: 21px;
  --text-xl: 22px;
  --text-xl-plus: 24px;
  --text-2xl: 30px;

  /* THE CONTROL HEIGHT SCALE. Everything that can share a row with a field —
     buttons, icon buttons, selects, the file field — resolves to one of these, so
     a row of mixed controls lines up by construction instead of by luck. It used
     to be luck, and it lost: a card header held a 38px select, a 30px button and
     a 34px icon button, three heights in one row, and nothing agreed with
     anything. Replaced elements (input[type=color], input[type=file]) can't be
     stretched by flexbox at all, so they MUST be told a height — hence a token
     rather than a guess at each call site. */
  --control-h: 38px;
  --control-h-sm: 30px;
  /* the micro tier: buttons that live INSIDE a table cell or a text line */
  --control-h-xs: 20px;

  --leading-tight: 1;
  --leading-snug: 1.25;
  --leading-relaxed: 1.55;

  --fw-regular: 400;
  --fw-semibold: 600;
  --fw-bold: 700;

  /* --shade is the colour every scrim and shadow is a wash OF. One token, so
     dark re-points it once (to near-black, where a yin-9 scrim would vanish)
     instead of restating eight rgba() literals. */
  --shade: var(--uchu-yin-9);
  --shadow-sm: 0 1px 2px oklch(from var(--shade) l c h / 0.12);
  --shadow-popover: 0 8px 20px oklch(from var(--shade) l c h / 0.18);
  --shadow-popover-up: 0 -8px 20px oklch(from var(--shade) l c h / 0.18);
  --shadow-card: 0 8px 24px oklch(from var(--shade) l c h / 0.18);
  --shadow-modal: 0 18px 48px oklch(from var(--shade) l c h / 0.22);
  --shadow-scroll-inset: inset -24px 0 18px -22px oklch(from var(--shade) l c h / 0.45);
  --focus-ring-none: 0;
  --focus-outline-offset-tight: calc(var(--space-0-5) * -1);

  --backdrop: oklch(from var(--shade) l c h / 0.35);

  --z-dropdown: 30;
  --z-popover: 35;
  --z-modal: 80;
  /* A dropdown opened from INSIDE a modal. --z-dropdown would paint under the
     modal it belongs to; --z-toast is a different concern entirely. */
  --z-modal-dropdown: 85;
  --z-toast: 120;
  --z-tooltip: 999;

  /* page chrome padding tiers */
  --page-pad-x: 24px;
  --page-pad-x-tight: 16px;
  --page-pad-y-tight: 8px;

  /* the ONE header height — every top bar (host, compact game/board, public)
     is this tall, and the mains/overlays that lay out against the header
     (calc(100dvh - …), sticky offsets) read the same token. */
  --header-h: 44px;

  /* scroll-fade utility */
  --scroll-fade-size: 24px;
  --scroll-fade-color: oklch(from var(--shade) l c h / 0.18);
  --inline-fade-width: 34px;
  --inline-fade-bg: linear-gradient(to right, var(--paper-transparent), var(--paper));

  --font-sans: "Noto Sans", Arial, sans-serif;
  /* JetBrains Mono is shipped now (it was system-mono roulette before), but the
     stack keeps its fallbacks: the subset drops astral codepoints, so anything
     exotic in the 4s editor still lands on a system mono rather than tofu. */
  --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}

/* ── scroll-fade utilities ────────────────────────────────
   Pure-CSS scroll shadows (Lea Verou trick): a soft fade appears
   at the edge the user CAN scroll toward, and disappears when
   they've reached that end. Background covers move with content
   (background-attachment: local) so they reveal the static
   shadows underneath as scrolling progresses.

   Two utilities on the overflow:auto element itself: .scroll-fade-y
   (vertical) and .scroll-fade-xy (all four edges). The kit's own scroll
   surfaces (.table-scroll, .sheet-frame) are in the groups below; an app
   container composes the utility class and overrides --scroll-fade-bg
   where its background isn't --surface. */
.scroll-fade-y,
.scroll-fade-xy,
.table-scroll,
.sheet-frame {
  --scroll-fade-bg: var(--surface);
}

.scroll-fade-y {
  background-image:
    linear-gradient(var(--scroll-fade-bg) 30%, transparent),
    linear-gradient(transparent, var(--scroll-fade-bg) 70%),
    radial-gradient(farthest-side at 50% 0, var(--scroll-fade-color), transparent),
    radial-gradient(farthest-side at 50% 100%, var(--scroll-fade-color), transparent);
  background-position: center top, center bottom, center top, center bottom;
  background-repeat: no-repeat;
  background-size:
    100% var(--scroll-fade-size),
    100% var(--scroll-fade-size),
    100% calc(var(--scroll-fade-size) * 0.5),
    100% calc(var(--scroll-fade-size) * 0.5);
  background-attachment: local, local, scroll, scroll;
}

.scroll-fade-xy,
.table-scroll,
.sheet-frame {
  background-image:
    linear-gradient(var(--scroll-fade-bg) 30%, transparent),
    linear-gradient(transparent, var(--scroll-fade-bg) 70%),
    linear-gradient(to right, var(--scroll-fade-bg) 30%, transparent),
    linear-gradient(to left, var(--scroll-fade-bg) 30%, transparent),
    radial-gradient(farthest-side at 50% 0, var(--scroll-fade-color), transparent),
    radial-gradient(farthest-side at 50% 100%, var(--scroll-fade-color), transparent),
    radial-gradient(farthest-side at 0 50%, var(--scroll-fade-color), transparent),
    radial-gradient(farthest-side at 100% 50%, var(--scroll-fade-color), transparent);
  background-position:
    center top, center bottom,
    left center, right center,
    center top, center bottom,
    left center, right center;
  background-repeat: no-repeat;
  background-size:
    100% var(--scroll-fade-size),
    100% var(--scroll-fade-size),
    var(--scroll-fade-size) 100%,
    var(--scroll-fade-size) 100%,
    100% calc(var(--scroll-fade-size) * 0.5),
    100% calc(var(--scroll-fade-size) * 0.5),
    calc(var(--scroll-fade-size) * 0.5) 100%,
    calc(var(--scroll-fade-size) * 0.5) 100%;
  background-attachment: local, local, local, local, scroll, scroll, scroll, scroll;
}

/* per-container background tint to keep covers matching the bg */
.sheet-frame { --scroll-fade-bg: var(--structure); }

* {
  box-sizing: border-box;
}

html,
body {
  margin: 0;
  min-height: 100%;
  font-family: var(--font-sans);
  color: var(--text);
  /* WebKit inflates text inside wide blocks on real iOS devices unless told
     not to — and it inflates the TEXT while CSS-px column widths stay put, so
     fixed-width tables overflow on a phone in ways no desktop device emulation
     reproduces (neither Chrome's nor Firefox's applies the autosizer). Our type
     scale is already sized for small screens, so opt out and render what the
     stylesheet says. */
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
}

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

[hidden] {
  display: none !important;
}

/* ── design system: surfaces ─────────────────────────────── */

.list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}

.list-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-3);
  padding: var(--space-3) var(--space-4);
  border-radius: var(--radius-md);
  background: var(--surface);
  text-decoration: none;
  color: inherit;
}

a.list-row:hover { background: var(--hover-bg); }

.list-row-title { font-weight: var(--fw-semibold); }

.muted {
  color: var(--muted);
  font-size: var(--text-sm);
}

.table-scroll {
  overflow: auto;
}

.data-table {
  width: 100%;
  min-width: 420px;
  border-collapse: collapse;
  background: var(--surface);
  font-size: var(--text-sm);
}

.data-table th,
.data-table td {
  padding: 8px 10px;
  border-bottom: 1px solid var(--grid);
  text-align: left;
  vertical-align: top;
}

.data-table th {
  color: var(--muted);
  font-weight: var(--fw-semibold);
}

.data-table input:not([type="checkbox"]),
.data-table select {
  width: 100%;
  min-height: 34px;
  padding: 6px 8px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--field-bg);
  color: var(--text);
}

/* ── design system: form controls ────────────────────────── */

.field {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  font-size: var(--text-sm);
}

/* Filled fields: --field-bg is what makes an input an input now that the outline
   is transparent — on --surface it reads as recessed, and it stays distinct from
   --structure too (forms sit on both). The transparent 1px border is kept so the
   :focus rule below still has a box to paint blue. */
.input,
.field > input,
.field > textarea,
.field > select {
  font-size: var(--text-base);
  min-height: var(--control-h);
  padding: 8px 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--field-bg);
  color: var(--text);
  outline: 0;
}

.input:focus,
.field > input:focus,
.field > textarea:focus,
.field > select:focus {
  border-color: var(--blue);
}

.field > textarea {
  resize: vertical;
  min-height: 96px;
  font-family: inherit;
}

/* A <select> sizes itself from its own UA box and lands 2px over --control-h on
   a bare min-height, which is exactly the 1px-off look in a header where it sits
   beside buttons. Pin the height; the padding has to go to 0 or the text clips. */
select.input,
.field > select {
  height: var(--control-h);
  padding-top: 0;
  padding-bottom: 0;
}

.checkbox {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  font-size: var(--text-sm);
}

/* Native checkboxes/radios draw their own 1px box, which no token of ours can
   reach — appearance:none is the only way to stop them, and once it's off we owe
   them the whole widget (fill, tick, focus ring). The ring is --grid-dark and
   NOT the (transparent) --action-border: for an empty checkbox the outline IS
   the widget — an 18px pale circle with no edge reads as nothing at all on the
   page gray. A graphic, like the sync spinner's ring. */
input[type="checkbox"],
input[type="radio"] {
  appearance: none;
  -webkit-appearance: none;
  flex: none;
  display: inline-grid;
  place-content: center;
  width: 18px;
  height: 18px;
  margin: 0;
  padding: 0;
  border: 1px solid var(--grid-dark);
  border-radius: var(--radius-sm);
  background: var(--field-bg);
  cursor: var(--cursor-action);
}

input[type="radio"] { border-radius: var(--radius-full); }

input[type="checkbox"]::before,
input[type="radio"]::before {
  content: "";
  width: 10px;
  height: 10px;
  transform: scale(0);
  background: var(--uchu-yang);
  /* a tick, drawn rather than typed: a glyph would depend on the font stack */
  clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}

input[type="radio"]::before {
  width: 8px;
  height: 8px;
  border-radius: var(--radius-full);
  clip-path: none;
}

input[type="checkbox"]:checked,
input[type="radio"]:checked {
  background: var(--blue);
  border-color: var(--blue);
}

input[type="checkbox"]:checked::before,
input[type="radio"]:checked::before { transform: scale(1); }

input[type="checkbox"]:disabled,
input[type="radio"]:disabled {
  background: var(--action-disabled-bg);
  opacity: var(--action-disabled-opacity);
  cursor: var(--cursor-disabled);
}

/* appearance:none also removes the UA focus ring — put a real one back, or the
   box becomes unreachable-looking for keyboard users. */
input[type="checkbox"]:focus-visible,
input[type="radio"]:focus-visible {
  outline: 2px solid var(--blue);
  outline-offset: 2px;
}

/* The file field's "Browse…" is a UA-drawn button with its own border. Style the
   ::file-selector-button into an ordinary small button so the control stops
   looking like a stray piece of 1998 and matches --control-h. */
.input[type="file"] {
  height: var(--control-h);
  padding: 0;
  padding-right: var(--space-3);
  overflow: hidden;
}

.input[type="file"]::file-selector-button {
  height: 100%;
  margin-right: var(--space-3);
  padding: 0 var(--space-3);
  border: 0;
  border-radius: var(--radius-md) 0 0 var(--radius-md);
  /* one step down from the field fill (they share --action-bg now), so the
     button keeps a visible seam inside the field */
  background: var(--action-hover-bg);
  color: var(--text);
  font: inherit;
  font-size: var(--text-sm);
  font-weight: var(--fw-semibold);
  cursor: var(--cursor-action);
}

.input[type="file"]:hover::file-selector-button { filter: brightness(0.96); }

/* The shadow carries the modal's elevation; the border paints only in high
   contrast (--border is transparent elsewhere). */
.modal-dialog {
  width: min(560px, calc(100vw - var(--space-6)));
  padding: var(--space-4);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  background-color: var(--surface);
  color: var(--text);
  box-shadow: var(--shadow-modal);
}

.modal-dialog::backdrop {
  background: var(--backdrop);
}

.modal-dialog h2 {
  margin: 0;
  font-size: var(--text-lg);
}

/* the right-aligned button row a dialog ends with */
.modal-actions {
  display: flex;
  justify-content: flex-end;
  gap: var(--space-2);
}

/* ── design system: buttons & links ───────────────────────────────────────────
   THE BUTTON KINDS. The vocabulary has always declared four (primary / ghost /
   danger / secondary, see kit/vocab.json) but the stylesheet only ever painted
   two of them, so `secondary` and `primary` silently rendered as a plain .btn.
   All four are defined here now, and they separate by FILL WEIGHT — there are no
   outlines to separate them with:

     primary    strongest — an accent fill. One per view, the thing to do next.
     .btn       default (= secondary): a tonal fill. The everyday button.
     ghost      quietest — a translucent wash. Still a button, still clickable.
     danger     a red fill. Destructive, and it should look it.

   Peer actions must share a kind: a filled button next to a ghost one reads as
   "this one is real and that one is a label", which is a lie when both do the
   same class of thing. */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* --control-h, so a button sitting in a row with a select or a field shares
     its height instead of being 2px short of it */
  min-height: var(--control-h);
  padding: 0 var(--space-4);
  border: 1px solid var(--action-border);
  border-radius: var(--radius-md);
  background: var(--action-bg);
  color: var(--text);
  /* semibold, not bold: every button on every page shouting at 700 is what made
     the UI feel heavy. Weight is for emphasis, and if all of it is emphasised
     none of it is. */
  font-weight: var(--fw-semibold);
  cursor: var(--cursor-action);
  text-decoration: none;
}

/* Hover is a fill step, not an outline. (It used to promote border-color to
   --text, which is exactly the "contrast border" this pass removes.) */
.btn:hover { background: var(--action-hover-bg); }

.btn.btn-primary {
  background: var(--blue);
  color: var(--uchu-yang);
}

.btn.btn-primary:hover { background: var(--blue); filter: brightness(1.08); }

/* Ghost: the quiet kind. It carries a faint wash rather than nothing at all —
   with the outlines gone, a fill-less ghost is indistinguishable from body text,
   and «Удалить карточку» / «Переместить» stopped reading as buttons entirely.
   Lives in the kit (not an app layer): it is one of the core button kinds. */
.btn.btn-ghost {
  background: var(--action-ghost-bg);
  color: var(--muted);
  font-weight: var(--fw-regular);
}

.btn.btn-ghost:hover {
  background: var(--action-ghost-hover-bg);
  color: var(--text);
}

/* Danger reads through its fill too. The old near-white wash relied on a red
   outline to be legible as a danger button; --red is the actual tinted fill, and
   --red-dark is its designated on-color in every theme. */
.btn.btn-danger {
  color: var(--red-dark);
  background: var(--red);
}

.btn.btn-danger:hover { background: var(--red); filter: brightness(0.94); }

/* The micro button: composes .btn (fill, fill-step hover, disabled states) at
   in-cell scale. Peers of table content, not of fields — hence its own tier. */
.btn.btn-xs {
  min-height: var(--control-h-xs);
  padding: 0 var(--space-1-5);
  border-radius: var(--radius-sm-plus);
  font-size: var(--text-xs);
  font-weight: var(--fw-regular);
  line-height: var(--leading-tight);
  white-space: nowrap;
}

.btn:disabled,
.btn[aria-disabled="true"] {
  border-color: var(--action-disabled-border);
  background: var(--action-disabled-bg);
  color: var(--action-disabled-fg);
  cursor: var(--cursor-disabled);
  opacity: var(--action-disabled-opacity);
}

/* :hover also matches disabled buttons — re-assert the disabled fill after it. */
.btn:disabled:hover,
.btn[aria-disabled="true"]:hover {
  background: var(--action-disabled-bg);
}

.action-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* square, on the same scale as every other control — it was 34px against a
     38px select and a 30px button in the very same card header */
  width: var(--control-h);
  height: var(--control-h);
  padding: 0;
  border: 1px solid var(--action-border);
  border-radius: var(--radius-md);
  background: var(--action-bg);
  color: var(--text);
  font-size: 17px;
  font-weight: var(--fw-bold);
  cursor: pointer;
  text-decoration: none;
}

.action-icon:hover { background: var(--action-hover-bg); }

.disclosure > summary {
  width: max-content;
  list-style: none;
}

.disclosure > summary::-webkit-details-marker {
  display: none;
}

.disclosure > form {
  margin-top: var(--space-3);
}

/* ── public + host common page chrome ────────────────────── */

.host {
  overflow: hidden;
  background: var(--structure);
}

.host-top {
  position: relative;
  z-index: var(--z-dropdown);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-5);
  height: var(--header-h);
  padding: var(--space-1-5) var(--page-pad-x);
  border-bottom: 0;
  background: var(--structure);
}

/* Compact chrome — game/board pages where the content below the header is the
   point: tighter gaps and side padding (the height is the shared token). */
.host-compact .host-top {
  gap: var(--space-2-5);
  padding: var(--space-1-5) var(--page-pad-x-tight);
}

.host h1 {
  margin: 0;
  /* one title size on every top bar — sheet pages don't shout louder than
     board/game pages */
  font-size: var(--text-xl);
  line-height: var(--leading-tight);
  letter-spacing: 0;
}

/* Breadcrumbs — the shared header path (🏠 / фест / игра). Every crumb is a
   navigable prefix of the URL; the last is the page you are on, so it is plain
   text. The trail is one line that ellipsises rather than wrapping: the header
   is a fixed height, and a second row would push the content down. */
.host-brand {
  display: flex;
  flex: 1 1 auto;
  align-items: center;
  min-width: 0;
}

.crumbs {
  display: flex;
  flex: 1 1 auto;
  align-items: baseline;
  gap: var(--space-2);
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  font-size: var(--text-xl);
  line-height: var(--leading-snug);
}

.crumb {
  flex: 0 1 auto;
  min-width: 0;
  overflow: hidden;
  color: var(--muted);
  text-decoration: none;
  text-overflow: ellipsis;
  /* The overflow:hidden that yields the ellipsis clips at the padding edge, so
     a line-height bump alone still shaved bold Cyrillic descenders (у, щ, р).
     Give the clip box explicit bottom room so descenders survive. */
  padding-bottom: 0.2em;
}

.crumb:hover {
  text-decoration: underline;
}

/* The home crumb is an icon: it should not shrink, ellipsise, or dim. */
.crumb-home {
  flex: 0 0 auto;
  overflow: visible;
  font-size: 18px;
  line-height: 1;
}

/* The page you are on is the one that should read as the title. */
.crumb-current {
  flex: 1 1 auto;
  color: var(--text-strong);
  font-weight: var(--fw-bold);
}

.crumb-current:hover {
  text-decoration: none;
}

.crumb-sep {
  flex: 0 0 auto;
  color: var(--muted);
}

/* Ancestors are the first to give up room; the current page keeps its share. */
@media (max-width: 640px) {
  .crumbs {
    font-size: var(--text-base);
    gap: var(--space-1-5);
  }

  .crumbs .crumb:not(.crumb-current):not(.crumb-home) {
    max-width: 6ch;
  }
}

.host-actions {
  display: flex;
  flex: 0 0 auto;
  align-items: center;
  gap: var(--space-2);
  position: relative; /* anchors .notif-panel under the 🔔 bell */
}


.sync-status {
  display: inline-grid;
  place-items: center;
  width: 28px;
  height: 28px;
  color: var(--blue);
}

.sync-status::before {
  content: "";
  display: block;
  box-sizing: border-box;
}

/* The spinner ring and the offline/pending dots below are DRAWN out of their
   border — it is the graphic, not an outline — so they take --grid-dark
   directly and must never be routed through the (transparent) --action-border. */
.sync-status:not([data-state="saved"])::before {
  width: 16px;
  height: 16px;
  border: 2px solid var(--grid-dark);
  border-top-color: currentColor;
  border-radius: var(--radius-full);
  animation: sync-spin 700ms linear infinite;
}

.sync-status[data-state="error"] {
  color: var(--red-dark);
}

.sync-status[data-state="saved"]::before {
  width: 14px;
  height: 8px;
  border-left: 2.5px solid var(--green-dark);
  border-bottom: 2.5px solid var(--green-dark);
  transform: translateY(-2px) rotate(-45deg);
}

/* Offline (no connection) and pending (queued offline edits) are steady states,
   not spinners — override the generic non-saved spinner. */
.sync-status[data-state="offline"]::before,
.sync-status[data-state="pending"]::before {
  width: 12px;
  height: 12px;
  border: 2px solid var(--grid-dark);
  border-radius: var(--radius-full);
  animation: none;
  transform: none;
}

.sync-status[data-state="offline"] {
  color: var(--text-muted);
}

.sync-status[data-state="pending"] {
  color: var(--warn-text);
}

.sync-status[data-state="pending"]::before {
  background: currentColor;
  border-color: currentColor;
}

@keyframes sync-spin {
  to {
    transform: rotate(360deg);
  }
}

.match-main {
  height: calc(100vh - var(--header-h));
  height: calc(100dvh - var(--header-h));
  min-height: 360px;
  padding: var(--space-3-5);
  overflow: hidden;
  background: var(--structure);
}

.sheet-frame {
  position: relative;
  width: 100%;
  height: 100%;
  overflow: auto;
  border-top: 0;
  background-color: var(--structure);
}

.import-frame {
  padding: var(--space-4);
}

/* the message primitive: a status/result line that preserves line breaks. The
   min-height reserves the line so text appearing doesn't shift the layout; when
   empty it collapses instead of leaving a dead gap at the end of a dialog. */
.message {
  min-height: 24px;
  margin: 0;
  color: var(--muted);
  white-space: pre-wrap;
}

.message:empty {
  min-height: 0;
  display: none;
}

/* the section primitive: a titled column of page content.
   Inputs use .input, submit buttons use .btn — see design system above. */
.section {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
}

/* on sheet pages (login, register, profile stages) a section is a form/step
   column and keeps the narrow column measure */
.sheet-frame .section {
  max-width: 420px;
}

/* stacked sections must not glue together (two one-button sections otherwise
   render as one visual block) */
.section + .section {
  margin-top: var(--space-4);
}

/* the hint primitive: muted helper text under a field or section */
.hint {
  margin: 0;
  color: var(--muted);
  font-size: var(--text-sm);
}

/* danger variant: a warning that must not read as quiet helper text */
.hint-danger {
  color: var(--danger-text);
}

/* the empty primitive: an "nothing here yet" placeholder line */
.empty {
  margin: 0;
  color: var(--muted);
}

@media (max-width: 760px) {
  :root {
    --header-h: 48px;
  }

  .host-top {
    gap: 8px;
    padding: 7px 10px;
  }

  .host h1 {
    font-size: var(--text-lg-xl);
  }

  .match-main {
    min-height: 0;
    padding: 4px;
  }

  .import-frame {
    padding: 6px;
  }
}

/* ════════════════════════════════════════════════════════════════════════
   APPEARANCE: dark theme + high-contrast mode
   Two independent axes set by menu.js on <html>:
     data-theme    = light | dark
     data-contrast = regular | high
   Default (no overrides) is the light/regular palette in :root above. Each
   block below only re-points existing color variables, so the whole app
   re-themes through the variables it already uses. The high-contrast blocks
   are scoped per theme ([data-theme=light/dark][data-contrast=high]) so they
   layer on top of the active theme with higher specificity than the bare
   dark block — order-independent.
   Palette rationale (Material dark theme + WCAG): no pure black; depth via
   lighter surfaces ~3-4 L apart, not shadows; off-white (not #fff) text;
   lightened/desaturated accents; high-contrast separates adjacent surfaces
   with stronger, text-colored borders and darker grays.
   ════════════════════════════════════════════════════════════════════════ */

/* ── Dark theme ─────────────────────────────────────────────────────────── */
:root[data-theme="dark"] {
  /* let native controls (select dropdown arrows, scrollbars, the date picker)
     render with the dark UA palette — otherwise the <select> arrows stay black
     and vanish against dark fills */
  color-scheme: dark;
  /* ── THE CAST ──────────────────────────────────────────────────────────────
     A rung fixes LIGHTNESS. It does not have to fix hue, and dark's neutrals
     were never meant to be dead neutral — they carried a blue cast at chroma
     0.014–0.024 around hue 261, and losing it made the whole theme read
     differently even though every step was in the right place.

     So the cast is its own dial. `oklch(from <rung> l …)` keeps the rung's
     lightness — the ladder, its monotonicity, its gaps, everything the tests
     assert — and replaces only chroma and hue. Two numbers steer the entire
     dark palette; there is nothing to keep in sync.

     Light does not declare a cast and names its rungs bare, because uchu's gray
     ramp is already the neutral it wants. */
  --tint-c: 0.018;
  --tint-h: 261;

  /* Dark reads UP the yin ramp: page darkest, paper/cards lifted, controls
     lifted again — the steps ARE the elevation here, unlike light.
     Five rungs where there were ten. yin's rungs are the constraint that
     produced the reduction: between L 0.14 and 0.53 the ramp offers exactly
     yin, yin-9, yin-8, yin-7 and yin-6, so the old ladder's ten surfaces cannot
     be expressed. Two of its pairs were 0.003 and 0.007 apart anyway.
     The -transparent twins and the ghost washes are NOT restated here: they
     derive from --paper / --structure / --text, which this block re-points, so
     they follow on their own. */
  --page: oklch(from var(--uchu-yin) l var(--tint-c) var(--tint-h));
  --paper: oklch(from var(--uchu-yin-9) l var(--tint-c) var(--tint-h));
  --surface: var(--paper);
  --structure: oklch(from var(--uchu-yin-8) l var(--tint-c) var(--tint-h));
  --hover-bg: var(--structure);
  --grid: oklch(from var(--uchu-yin-8) l var(--tint-c) var(--tint-h));
  --grid-dark: oklch(from var(--uchu-yin-7) l var(--tint-c) var(--tint-h));
  --shade: var(--uchu-yin);
  /* outlines off (see the :root block) — high contrast turns them back on */
  --border: transparent;
  --border-soft: transparent;
  --action-border: transparent;
  --action-disabled-border: transparent;
  /* lifted clear of --structure and --paper: with no outline the fill is the
     only thing that makes a control a control. The field stays recessed to
     --structure, which is the split light deliberately does not make. */
  --action-bg: oklch(from var(--uchu-yin-7) l var(--tint-c) var(--tint-h));
  --action-hover-bg: oklch(from var(--uchu-yin-6) l var(--tint-c) var(--tint-h));
  --field-bg: var(--structure);
  --action-disabled-bg: var(--paper);

  /* text — off-white high-emphasis, dimmer for muted (avoids halation). Read up
     the yin ramp; --text-strong borrows gray-1 because yin tops out at 0.92 and
     the strong tier has to clear --text.
     The two ink tiers take the cast (the old --muted sat at chroma 0.015, same
     family as the surfaces); --text and --text-strong do NOT — they were 0.006
     and 0.003 before, near-neutral, and tinted white reads as a colour cast
     rather than as paper. */
  --text: var(--uchu-yin-1);
  --text-strong: var(--uchu-gray-1);
  --muted: oklch(from var(--uchu-yin-3) l var(--tint-c) var(--tint-h));
  --muted-light: oklch(from var(--uchu-yin-5) l var(--tint-c) var(--tint-h));

  /* accents — lighter + a touch desaturated so they don't vibrate on dark */
  --blue: #5b9bf5;
  --blue-strong: #8fbaf8;
  --blue-bright: #5e8ff2;

  /* The washes take the SAME form as light — a mix of this theme's paper with a
     rung — and only two things change: the rung goes to the dark end of its
     ramp, and the paper's share drops. That is the whole dark wash block. */
  --wash-positive: color-mix(in oklab, var(--surface) 72%, var(--uchu-green-9));
  --wash-negative: color-mix(in oklab, var(--surface) 72%, var(--uchu-red-9));
  --wash-emphasis: color-mix(in oklab, var(--surface) 72%, var(--uchu-blue-9));

  /* status — re-picked for dark, not desaturated light values */
  --green: #33814b;
  /* --green-dark is a *foreground* on dark: green text on dark paper, and the
     question-number digit over the --green cell fill. #2b8446 sat too close to
     both surfaces in luminance to read — lift it to a legible light green. */
  --green-dark: #7fd29b;
  --red: #9a353c;
  --red-dark: #f0a0aa;
  --success: #2ecc6f;
  --danger: #f25555;
  --danger-text: #f7a6b4;

  /* warning — the fill takes the wash form (this theme's paper mixed with the
     same dark orange rung light uses as its ink); the ink climbs the orange ramp
     until it reads on both that fill and bare paper */
  --warn-bg: color-mix(in oklab, var(--surface) 72%, var(--uchu-orange-9));
  --warn-text: var(--uchu-orange-4);

  /* overlays — deepen the scrims (--shade is near-black here); shadows are a
     weak cue on dark, so only the alphas move */
  --backdrop: oklch(from var(--shade) l c h / 0.6);
  --scroll-fade-color: oklch(from var(--shade) l c h / 0.5);
}

:root[data-theme="dark"] body {
  background: var(--page);
}

/* The detailed team number is muted (secondary) in light, but on dark a muted
   gray drops below the names/scores and disappears on poor displays — the very
   monitors this feature targets. Promote it to the primary text color. */
:root[data-theme="dark"] .match-table.od-detailed .od-detailed-team-number {
  color: var(--text);
}

/* ── High contrast, light theme ─────────────────────────────────────────────
   Motivating case: a monitor that can't separate white from light gray.
   Darken the structural grays well clear of white and strengthen borders so
   cell/row boundaries carry separation regardless of fill. Push text toward
   AAA. */
:root[data-theme="light"][data-contrast="high"] {
  /* Same roles, same ramp — the ladder STRETCHES. Regular light walks gray in
     single steps (yang, 1, 2, 3); here it takes two at a time (yang, 3, 5, 7),
     so every adjacent gap roughly triples: 0.035 becomes 0.076 at the tightest.
     That is the whole block. High contrast is a step size, not a fourth palette,
     so there is nothing here to keep in step with :root by hand.
     Reading it as a stride is also what caught the bug this replaces:
     --action-bg used to move the WRONG WAY (#e9e9ea → #e9edf2, lighter, against
     a page that got darker) — invisible as a hex, obvious as a backwards step. */
  --structure: var(--uchu-gray-3);
  --page: var(--uchu-gray-2);
  --surface-tint: var(--uchu-gray-2);
  --hover-bg: var(--uchu-gray-4);
  --grid: var(--uchu-gray-7);
  --grid-dark: var(--uchu-gray-9);
  /* Outlines back ON — the fills alone are what a bad display cannot resolve, so
     this mode re-points every outline token the regular themes zero out. */
  --action-bg: var(--uchu-gray-5);
  --action-hover-bg: var(--uchu-gray-7);
  /* The field stays paper-white on purpose: here it separates by its OUTLINE,
     which is the one mode where an outline earns its keep. */
  --field-bg: var(--uchu-yang);
  --border: var(--grid-dark);
  --border-soft: var(--uchu-gray-6);
  --action-border: var(--uchu-gray-8);
  --action-disabled-border: var(--grid-dark);

  --text: var(--uchu-yin);
  --text-strong: var(--uchu-yin);
  --muted: var(--uchu-yin-8);
  --muted-light: var(--uchu-yin-6);

  --blue-strong: #103d86;
  --green-dark: #1f6e44;
  --red-dark: #8a2e37;
  --danger-text: #7a0c2e;

  /* answer-cell fills (right/wrong). The default pastel 'wrong' reads as gray
     on the high-contrast gray page. Saturate both, and separate them in
     LUMINANCE as well as hue (dark green vs light coral) so they stay
     distinguishable for red-green colour-blindness, not by hue alone. */
  --green: #3d8f5b;
  --red: #ec8585;
}

/* ── High contrast, dark theme ─────────────────────────────────────────────
   Brighten borders and text, and widen the surface lightness gaps so adjacent
   panels stay distinct on a poor display. */
:root[data-theme="dark"][data-contrast="high"] {
  /* Dark does NOT stretch its fills, and that is the interesting part. Regular
     dark already walks yin in ~0.09 steps — nearly three times light's stride —
     so adjacent fills are not what a poor display fails on. Climbing them would
     actively hurt: yin's steps get SMALLER toward the light end (0.099 at yin-9,
     0.077 by yin-3), so a stretch compresses, and the fills would then be moving
     toward the ink, which this mode brightens to yang. Light text on a lifted
     mid-gray button is the worse bug.
     So the fills stay put and the LINES do the work — grid climbs two rungs and
     grid-dark four, which is what a display that cannot resolve fills needs.
     --header and --toolbar used to be declared here and nowhere else, styling
     nothing in either app; they are gone rather than re-pointed. */
  /* The cast carries over from the dark block — this mode changes which rungs
     the roles name, never the hue family they sit in. */
  --page: oklch(from var(--uchu-yin) l var(--tint-c) var(--tint-h));
  --paper: oklch(from var(--uchu-yin-9) l var(--tint-c) var(--tint-h));
  --grid: oklch(from var(--uchu-yin-5) l var(--tint-c) var(--tint-h));
  --grid-dark: oklch(from var(--uchu-yin-3) l var(--tint-c) var(--tint-h));
  /* Outlines back ON — see the light high-contrast block. */
  --border: var(--grid-dark);
  --border-soft: var(--uchu-yin-5);
  --action-border: var(--uchu-yin-4);
  --action-disabled-border: var(--grid-dark);
  /* recessed to the page, so the brightened ink has the darkest possible ground */
  --field-bg: oklch(from var(--uchu-yin) l var(--tint-c) var(--tint-h));

  --text: var(--uchu-yang);
  --text-strong: var(--uchu-yang);
  --muted: oklch(from var(--uchu-yin-2) l var(--tint-c) var(--tint-h));
  --muted-light: oklch(from var(--uchu-yin-4) l var(--tint-c) var(--tint-h));

  --blue: #7fb0f8;
  --green-dark: #9be0b3;
  --red-dark: #f6bcc3;
}

/* ── ☰ menu (Appearance item, edit/view jump, downloads, account) ───────── */
.menu {
  position: relative;
  display: inline-flex;
}

.menu-floating {
  position: fixed;
  top: var(--space-2);
  right: var(--space-2);
  z-index: var(--z-dropdown);
}

.menu-trigger svg {
  display: block;
}

/* In a .public-top bar, pin the menu to the right edge. */
.menu-public {
  margin-left: auto;
}

.menu-floating .menu-trigger {
  background: var(--surface);
  border: 1px solid var(--border);
  box-shadow: var(--shadow-popover);
}

/* ── popover base ─────────────────────────────────────────────────────────
   Every floating surface — the ☰ dropdown, hover name popovers, suggestion
   lists, notification panels — shares one silhouette: paper + the popover
   shadow (outlines are OFF; the 1px --border box paints only in high
   contrast). App emitters compose by adding this class next to their
   positioning class; .menu-dropdown gets it here (it is emitted in too many
   places to retrofit). */
.popover,
.menu-dropdown {
  background-color: var(--paper);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-popover);
  z-index: var(--z-dropdown);
}

/* The dropdown sizes itself to its longest item (width: max-content) instead of
   forcing every label to wrap inside a fixed 184px column — menu labels are
   single actions and must each read on ONE line. min-width keeps short menus
   from collapsing to a sliver; max-width keeps a long label from running off a
   narrow screen (it ellipsises there rather than wrapping). */
.menu-dropdown {
  position: absolute;
  top: calc(100% + var(--space-1));
  right: 0;
  width: max-content;
  min-width: 184px;
  max-width: min(360px, calc(100vw - var(--space-4)));
  display: flex;
  flex-direction: column;
  padding: var(--space-1);
}

/* flex + gap gives the emoji a real column of its own, so the labels align down
   the menu instead of starting wherever the previous glyph happened to end. */
.menu-item {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  width: 100%;
  text-align: left;
  padding: var(--space-2) var(--space-2-5);
  border: 0;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--text);
  font: inherit;
  font-size: var(--text-base);
  line-height: var(--leading-snug);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  text-decoration: none;
  cursor: var(--cursor-action);
}

.menu-item:hover,
.menu-item:focus-visible {
  background: var(--hover-bg);
}

/* ── Appearance modal ───────────────────────────────────────────────────── */
.appearance-modal-overlay {
  position: fixed;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-4);
  background: var(--backdrop);
  z-index: var(--z-modal);
}

.appearance-modal {
  width: min(420px, 100%);
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  padding: var(--space-5);
  background: var(--surface);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-modal);
}

/* modal variant="wide": for dialogs that show content rather than a few
   controls — the 360px default turns prose and images into a column of scraps. */
.modal-wide { width: min(720px, 100%); text-align: left; }

.appearance-modal-title {
  margin: 0;
  font-size: var(--text-lg);
  font-weight: var(--fw-semibold);
}

.appearance-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
}

.appearance-row-label {
  font-size: var(--text-sm);
  color: var(--muted);
}

.appearance-modal-done {
  align-self: flex-end;
  padding: var(--space-2) var(--space-4);
  border: 1px solid var(--action-border);
  border-radius: var(--radius-md);
  background: var(--action-bg);
  color: var(--action-fg);
  font: inherit;
  font-size: var(--text-sm);
  font-weight: var(--fw-semibold);
  cursor: var(--cursor-action);
}

/* === ui layout utilities (internal/ui render.go) ============================
   The closed set of flex utilities the v2 UI primitives (col/row/spacer)
   compile to. Gap tokens map onto the existing --space-* scale, calibrated so
   form layouts keep their rhythm (auth-form/auth-actions used --space-2 = sm).
   Do not extend ad hoc: add a token here only when a primitive emits it. */
.u-col { display: flex; flex-direction: column; }

.u-row { display: flex; flex-direction: row; }

.u-row.u-wrap { flex-wrap: wrap; }

.u-grow { flex: 1 1 auto; min-width: 0; }

.u-spacer { flex: 1 1 auto; }

.u-gap-xs { gap: var(--space-1); }

.u-gap-sm { gap: var(--space-2); }

.u-gap-md { gap: var(--space-3); }

.u-gap-lg { gap: var(--space-4); }

.u-gap-xl { gap: var(--space-6); }

.u-align-start { align-items: flex-start; }

.u-align-center { align-items: center; }

.u-align-end { align-items: flex-end; }

.u-justify-center { justify-content: center; }

.u-justify-end { justify-content: flex-end; }

.u-justify-between { justify-content: space-between; }

/* === core widget rules promoted from the xy layer ==========================
   Base styling for classes emitted by core primitives (unreaddot, label,
   bigcode, subhead, tabs/tab/tabpanel, sliderrow) — they belong to the shared
   design system so any app using these primitives is styled. App-specific
   overrides (e.g. xy's mobile .section-label margin) stay in the app layer,
   which is concatenated after this file. */
.unread-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--blue); vertical-align: middle; }

/* iconbtn badgeid: the dot pinned to the button's corner, ringed with the
   button's own fill so it reads as a cutout */
.action-icon.has-badge { position: relative; }

.unread-dot-badge {
  position: absolute;
  top: 3px;
  right: 3px;
  border: 1.5px solid var(--action-bg);
}

/* the floating variant: pinned to the top-right of a positioned card/tile */
.unread-dot-corner {
  position: absolute;
  top: var(--space-1-5);
  right: var(--space-1-5);
}

/* Section labels read in the case they were authored in ("Метки", not "МЕТКИ").
   The uppercase + tracked-out + 700 treatment is three separate emphasis devices
   stacked on a word whose whole job is to be quiet scaffolding; muted color and
   a small size already say "label". Same reasoning for .fld-label in xy.

   PROXIMITY: a label must sit much closer to the thing it labels than to the
   block above it, or it reads as floating between the two and you cannot tell
   what it refers to. It was 20px above / 16px below — near-equidistant, so it
   bound to nothing. The margin-bottom is NEGATIVE on purpose: these labels live
   in flex columns with a `gap`, which applies equally on both sides, and pulling
   the bottom back in is the only way to beat it. Ratio is now ~32 above / ~4-12
   below. */
.section-label {
  font-size: var(--text-sm);
  font-weight: var(--fw-semibold);
  color: var(--muted);
  margin-top: var(--space-5);
  margin-bottom: calc(var(--space-1) * -1);
}

/* The dash IS the affordance here (a tear-off code box), so it takes --grid-dark
   directly rather than the transparent --border. */
.bigcode { font-family: var(--font-mono); font-size: var(--text-2xl); font-weight: var(--fw-bold); letter-spacing: 0.08em; text-align: center; padding: var(--space-3); border: 1px dashed var(--grid-dark); border-radius: var(--radius-md); }

/* Click-to-copy inline token (login: bot handle + code). The dashed underline
   marks it actionable; .copied (set by JS for ~1s) floats a confirmation. */
.copyable { font-family: var(--font-mono); font-weight: var(--fw-bold); cursor: pointer; border-bottom: 1px dashed var(--grid-dark); position: relative; }
.copyable::after { content: "скопировано"; position: absolute; left: 50%; bottom: calc(100% + var(--space-1)); transform: translateX(-50%); padding: var(--space-0-5) var(--space-1); font-family: var(--font-sans); font-size: var(--text-xs); font-weight: var(--fw-regular); white-space: nowrap; color: var(--surface); background: var(--text-strong); border-radius: var(--radius-sm); opacity: 0; pointer-events: none; z-index: var(--z-tooltip); }
.copyable.copied::after { opacity: 1; }

.subhead { margin: 0; font-size: var(--text-md); font-weight: var(--fw-semibold); color: var(--text-strong); }

/* ── segmented control ────────────────────────────────────────────────────
   THE either/or switch: the tabs primitive (the card-detail view switch), the
   appearance modal's theme/contrast rows, xy's editor toggles. A recessed
   track holding flat buttons; the active one lifts to a surface card
   (.active, toggled by the page's JS). .seg-grow splits the row equally. */
.seg {
  display: inline-flex;
  gap: var(--space-0-5);
  padding: var(--space-0-5);
  background: var(--structure);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
}

.seg-grow { display: flex; }

.seg-grow .seg-btn { flex: 1 1 0; }

.seg-btn {
  padding: var(--space-1-5) var(--space-3);
  border: 0;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--muted);
  font: inherit;
  font-size: var(--text-sm);
  cursor: var(--cursor-action);
}

.seg-btn:hover { color: var(--text); }

.seg-btn.active {
  background: var(--surface);
  color: var(--text-strong);
  font-weight: var(--fw-semibold);
  box-shadow: var(--shadow-sm);
}

.tabpanel { margin-top: var(--space-2); }

.sizes-row { display: flex; flex-direction: column; gap: var(--space-1); }

.sizes-row-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-2); }

.sizes-value { font-size: var(--text-sm); color: var(--muted); font-variant-numeric: tabular-nums; }

.sizes-hint { margin: 0; font-size: var(--text-xs); color: var(--muted); }

.sizes-row input[type="range"] { width: 100%; accent-color: var(--blue-strong); cursor: var(--cursor-action); }

/* ============================================================
   xy — board / card / kanban (built on the shared design tokens)
   ============================================================ */

/* Kanban depth is carried by fill, not borders: the canvas, the column and the
   card are three luminance steps. Light: pure white canvas, gray column panels,
   white cards back on top (canvas == card is fine — they never touch, a card
   always sits on a column). The kit's structure canvas made all three grays sit
   within a couple of L of each other and the board read as dirty. Dark keeps
   the kit canvas: panel/card luminance is remapped below for the same reason. */
:root { --canvas: var(--paper); --kanban-col: var(--structure); --kanban-card: var(--surface); --board-tile: var(--structure); }
:root[data-theme="dark"] { --canvas: var(--structure); --kanban-col: var(--surface); --kanban-card: var(--structure); --board-tile: var(--surface); }

/* The app shell (header + the section under it) sits on the canvas, not on the
   kit's structure gray. */
.host,
.host-top,
.match-main,
.sheet-frame { background: var(--canvas); }

.sheet-frame { --scroll-fade-bg: var(--canvas); }

/* .btn-ghost now lives in the kit (core.css) — it is one of the core button
   kinds, not an xy invention, and it needs a fill there to read as a button. */
.btn-small { min-height: var(--control-h-sm); padding: 0 var(--space-3); }

/* /admin/users has few enough columns to fit a phone, but the kit's 420px floor
   on .data-table holds it wider than a 393px screen — which pushed the last
   column's sort button off the edge. This table may shrink; its cells wrap. */
#adminUsers { min-width: 0; }

/* board list. .match-main is a fixed-height app surface and the page can't
   scroll (see .board-main below), so the grid is the scroller — otherwise an
   account with more boards than fit one screen can't reach the rest. */
.list-page .match-main { display: flex; flex-direction: column; }
.list-page .board-grid { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
.board-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  /* the grid is also the page's flex-filling scroller (above); without this,
     align-content:stretch inflates a lone row of tiles to the viewport height */
  align-content: start;
  gap: var(--space-4);
  padding: var(--space-4) 0;
}
/* Board tiles carry depth by fill, not shadow — the same two-luminance-step
   scheme as the kanban (--board-tile flips against --canvas in dark). Hover is
   --action-bg: the one fill picked to read against structure and paper alike. */
.board-card {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  min-height: 90px;
  padding: var(--space-4);
  border-radius: var(--radius-md);
  background: var(--board-tile);
  text-decoration: none;
  color: var(--text);
  position: relative; /* anchors the unread dot and the title popover */
}
.board-card:hover { background: var(--action-bg); }
/* dope's truncated-cell fade+popover: a long title stays on one line, clipped
   (no ellipsis) with a right-edge fade; the .board-card-name-truncated flag (set
   by index.js only when it overflows) turns the fade on, and index.js floats the
   full name below on hover/focus. The fade is a mask on the text so it needs no
   bg-matched gradient and survives the tile/hover/dark fills paper rows never see. */
.board-card-name-wrap { position: relative; display: block; min-width: 0; }
.board-card-name {
  display: block;
  font-weight: var(--fw-semibold);
  font-size: var(--text-lg);
  overflow: hidden;
  text-overflow: clip;
  white-space: nowrap;
}
.board-card-name-truncated .board-card-name {
  -webkit-mask-image: linear-gradient(to right, var(--paper) calc(100% - var(--inline-fade-width)), transparent);
  mask-image: linear-gradient(to right, var(--paper) calc(100% - var(--inline-fade-width)), transparent);
}
/* One shared node, appended to <body> and positioned by index.js (position:fixed),
   so the grid's scroll clip and neighbouring tiles never crop it. */
.board-card-name-popover {
  position: fixed;
  z-index: var(--z-tooltip);
  display: none;
  max-width: min(420px, calc(100vw - var(--space-4)));
  padding: var(--space-1) var(--space-2);
  font-weight: var(--fw-semibold);
  white-space: normal;
  overflow-wrap: anywhere;
  pointer-events: none;
}
.board-card-name-popover.visible { display: block; }
.board-card-role { font-size: var(--text-xs); color: var(--muted); }
/* blue "unread" dot — a board has changes by another member the user hasn't
   read. The kit's .unread-dot(-corner); a touch bigger on the large tile. */
.board-card-unread {
  top: var(--space-3);
  right: var(--space-3);
  width: 10px;
  height: 10px;
}

/* trello import */
.import-form { max-width: 760px; margin: 0 auto; }
.import-form .card-desc { min-height: 320px; }

/* kanban — the board is an SPA: the header is fixed and never scrolls, the
   lower section (.board-main) fills the rest of the viewport and only scrolls
   *inside* itself (the kanban scrolls horizontally, each list vertically), so
   the page as a whole never scrolls or rubber-bands. */
.board-page .board-main {
  height: calc(100dvh - var(--header-h));
  overflow: hidden;
  padding: var(--space-4) var(--page-pad-x);
}
/* The board page uses the kit's compact chrome (44px, .host-compact on body).
   z-index: the card modal (--z-card-overlay) covers the lower section, but the
   fixed header — and its ☰ dropdown — must stay above it (the settings/unlock
   modals at z-modal still win over both). */
.board-page .host-top { z-index: var(--z-board-header); }
/* The header breadcrumb (.crumbs) is the kit's now — both apps share it. */

/* Blue "unread" dots are the kit's .unread-dot: .unread-dot-corner on kanban
   cards and board tiles, .unread-dot-badge on the 🔔 bell, plain in the card
   detail head / timeline label / bell panel rows. */
.notif-panel {
  position: absolute;
  top: calc(100% + var(--space-1));
  right: 0;
  width: min(320px, 90vw);
  max-height: 60vh;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
}
.notif-panel-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-2-5);
  border-bottom: 1px solid var(--border);
  font-size: var(--text-sm);
  font-weight: var(--fw-semibold);
}
.notif-empty { padding: var(--space-3); color: var(--muted); font-size: var(--text-sm); text-align: center; }
.notif-row {
  display: flex;
  gap: var(--space-2);
  align-items: flex-start;
  padding: var(--space-2);
  cursor: pointer;
  text-align: left;
  border: 0;
  background: transparent;
  color: var(--text);
  font: inherit;
}
.notif-row:hover { background: var(--action-bg); }
.notif-row .unread-dot { margin-top: 6px; flex: 0 0 auto; }
.notif-row-body { min-width: 0; flex: 1 1 auto; }
.notif-row-meta { font-size: var(--text-xs); color: var(--muted); }
.notif-row-preview { font-size: var(--text-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* import & other .board-main pages: the main area is their scroller. It can't be
   the page — body.host is overflow:hidden, and per CSS's viewport-propagation
   rule that value lands on the viewport itself, so the document never scrolls.
   (.board-page overrides this back to overflow:hidden above: the kanban scrolls
   its columns instead.) */
.board-main {
  height: calc(100dvh - var(--header-h));
  overflow-y: auto;
  padding: var(--space-4) var(--page-pad-x) var(--space-6);
}
.kanban {
  display: flex;
  gap: var(--space-3);
  align-items: flex-start;
  height: 100%;
  overflow-x: auto;
  padding-bottom: var(--space-2);
  /* On a wide monitor a full-bleed board strands the reader at the screen edge,
     so the workspace is a centred column with a user-set cap («Изменить размеры»).
     max-width, not width: the box still scrolls internally once the lists
     outgrow it. none = full bleed, the old behaviour. */
  max-width: var(--kanban-max-w, none);
  margin-inline: auto;
}
/* Workspace width, list width and card line count are user-tunable («Изменить
   размеры» in the ☰ menu, board.js) — the modal overrides these vars on <html>. */
:root {
  --kanban-max-w: none;   /* none = board spans the full viewport width */
  /* board stacking: card overlay < fixed header < --z-modal; the timer floats
     above ALL of it (modals and toasts included) — it is dragged anywhere on
     screen and must stay visible while a question is being played. */
  --z-card-overlay: 50;
  --z-timer: 130;
  --z-board-header: 60;
  --klist-w: 280px;
  --kcard-lines: none;    /* none = card shows the whole question, unclamped */
  --kcard-font: 14px;     /* card text size — smaller packs more questions on screen */
}
.klist {
  flex: 0 0 var(--klist-w);
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  max-height: 100%;
  padding: var(--space-2);
  border-radius: var(--radius-md);
  background: var(--kanban-col);
}
.klist.dragging { opacity: 0.5; }
.klist-head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-2); padding: var(--space-1) var(--space-2); }
.klist-title { font-weight: var(--fw-semibold); }
/* title + question count share a text BASELINE (centring would drop the smaller
   count's baseline below the title's), and the count wraps to its own line when
   a long title leaves it no room; the ⋯ menu stays pinned top-right. */
.klist-headmain { display: flex; flex-wrap: wrap; align-items: baseline; column-gap: var(--space-2); flex: 1 1 auto; min-width: 0; }
.klist-count { font-size: var(--text-xs); color: var(--muted); white-space: nowrap; }
.kadd {
  border: none;
  background: transparent;
  color: var(--muted);
  font-size: var(--text-lg);
  line-height: 1;
  cursor: var(--cursor-action);
}
.kadd:hover { color: var(--text); }
/* the SVG glyphs (app.js strokeIcon: +, ✓) size with the surrounding text and
   inherit their color via currentColor — the reason they replaced the emoji */
.stroke-ico { width: 1em; height: 1em; display: inline-block; vertical-align: -0.125em; }
.kcards { display: flex; flex-direction: column; gap: var(--space-2); overflow-y: auto; min-height: var(--space-6); padding: var(--space-1); }
.kcard {
  position: relative; /* anchors .kcard-unread */
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-sm);
  background: var(--kanban-card);
  cursor: pointer;
}
.kcard:hover { background: var(--hover-bg); }
.kcard.dragging { opacity: 0.4; }
/* The node holds the card's whole text; -webkit-line-clamp cuts it at N lines
   *with an ellipsis* (a plain max-height would slice a line in half). `none`
   clamps nothing, so the card grows to fit the question. The -webkit- prefixed
   pair is still the only thing that works cross-browser; `line-clamp` is the
   standard alias, harmless where unsupported. */
.kcard-title {
  font-size: var(--kcard-font);
  white-space: pre-wrap;
  word-break: break-word;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: var(--kcard-lines);
  line-clamp: var(--kcard-lines);
  overflow: hidden;
}
/* The alias sits below the view panels, between Метки and Вложения: it belongs
   to the card rather than to any one view, so it stays put as the tabs change. */
#cardAliasRow { margin-bottom: var(--space-2); gap: 0; }
/* The alias input and its save button are one fused control — a visual cue that
   the alias saves on its own, apart from the card's «Сохранить». Both are
   control-h, so they meet flush; the shared edge is a single divider. */
#cardAlias { border-top-right-radius: 0; border-bottom-right-radius: 0; }
#cardAliasSave {
  border-top-left-radius: 0;
  border-bottom-left-radius: 0;
  border-left: 1px solid var(--border);
  white-space: nowrap;
}

/* An alias is the card's name; it reads like any other card title (no special
   weight). It's already short, so the --kcard-lines clamp has nothing to do. */
.kcard-title-alias {
  -webkit-line-clamp: none;
  line-clamp: none;
}
/* auto/directive question number reads as scaffolding, not content */
.kcard-num { color: var(--muted); }
/* Every unit on this row is exactly --chip-h tall — a bare label, a lone 🧪, a
   test group with three verdicts in it. Widths differ and must: a test group
   holds however many verdicts it holds. Heights differing is what read as
   broken, and it happened by default — the row is a flex line, so `stretch`
   grew the bare 🧪 to match the tallest item while the labels, having an
   explicit height, stayed short and hung off the top. */
.kcard-labels {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-1);
  margin-bottom: var(--space-1);
  --chip-h: 12px;
}
/* A card that isn't a question isn't a tile: no fill, so the list shows through
   and the eye can tell scaffolding (headings, meta) from the questions. */
.kcard-heading { background: transparent; padding-bottom: var(--space-1); }
.kcard-heading:hover { background: transparent; }
.kcard-heading .kcard-title {
  font-weight: var(--fw-bold);
  letter-spacing: 0.02em;
  color: var(--muted);
}
.kcard-meta .kcard-title { color: var(--muted); font-style: italic; }
/* No fill, so no visible box: zero the vertical padding to line the input's
   top edge up with the list panels' top edge. */
.klist-add { background: transparent; padding-block: 0; }
/* the ghost "new list" tile is an input, but it stands in the row of list
   columns — take their fill, not the form-field one (a subtly different grey) */
.klist-add .input { background: var(--kanban-col); }
.klist-add .kadd-form { color: var(--muted); }
.klist-add:hover .kadd-form { color: var(--text); }
.kadd-form { width: 100%; }
/* the ✓ that creates the list (the Android soft keyboard has no Enter): a
   control-height touch target in the topbar tick's green, not the muted
   hover-reveal of the other .kadd buttons — on touch there is no hover */
.kadd-ok {
  min-width: var(--control-h);
  min-height: var(--control-h);
  font-size: var(--text-xl);
}
.klist-add .kadd-form .kadd-ok, .klist-add:hover .kadd-form .kadd-ok { color: var(--green-dark); }
/* The Тип row under the new-list field: breathe away from the field, and the
   select shrinks to the row's xs type scale instead of full .input chrome.
   min-height too — .input's --control-h floor is what kept the select
   full-size in Firefox/Chromium (Safari's native select ignores it). */
.kadd-form .attach-lossless { margin-top: var(--space-2); }
.kadd-form select.input {
  width: auto;
  height: auto;
  min-height: 0;
  padding: var(--space-1) var(--space-2);
  font-size: var(--text-xs);
}

/* linked lists (list_of_lists) leave the board layout alone: each member just
   carries a small tag naming its group, right under the title (the negative
   margin eats most of the column's gap so the tag reads as a subtitle). */
.klist-group-tag {
  /* overflow:hidden zeroes the automatic min-height, letting a full column's
     flex squash the tag under the first card — pin it */
  flex: none;
  font-size: var(--text-xs);
  color: var(--muted);
  padding: 0 var(--space-2);
  margin-top: calc(-1 * var(--space-1-5));
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* labels */
/* A label is a DOT. The pill shape is reserved for the test group below — that
   is the one thing on the row that contains other things, so letting it be the
   only elongated shape is what makes the row parse at a glance: circles are
   labels, a capsule is a test, and the circles inside a capsule are what the
   testers said. A dot is also the narrowest a colour can be, which is what keeps
   a card carrying several tests on one line. */
.label-chip {
  display: inline-block;
  width: var(--chip-h, 12px);
  height: var(--chip-h, 12px);
  border-radius: var(--radius-full);
  background: var(--muted);
}

/* card detail overlay — occupies the whole lower section (below the fixed
   header), fully obscuring the kanban; the panel itself scrolls internally so
   the page never scrolls. */
.card-overlay {
  position: fixed;
  top: var(--header-h);
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  align-items: stretch;
  justify-content: center;
  background: var(--backdrop);
  overflow: hidden;
  z-index: var(--z-card-overlay);
}
.card-detail {
  width: min(680px, 100%);
  height: 100%;
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  padding: var(--space-5);
  overflow-y: auto;
  background: var(--surface);
  box-shadow: var(--shadow-modal);
}
/* The detail is a fixed-height scrolling flex column; pin every child at its
   natural size (flex: none) so nothing is compressed — iOS WebKit otherwise
   shrinks native <select>s below their min-height and clips the value text. */
.card-detail > * { flex: none; }
/* type selector, copy button and close × share one line */
.card-detail-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); }
.card-kind-select { flex: 0 1 auto; width: auto; min-width: 0; }
/* Test-card session heading (shown instead of the kind selector). */
.card-detail-title { flex: 1 1 auto; min-width: 0; margin: 0; font-size: var(--text-base); font-weight: var(--fw-semibold); color: var(--text-strong); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card-head-actions { display: flex; align-items: center; gap: var(--space-2); flex: 0 0 auto; margin-left: auto; }
/* Copy-for-test feedback sits right under the button (top-right of the panel). */
.card-copy-msg { align-self: flex-end; margin-top: calc(var(--space-1) * -1); font-size: var(--text-xs); color: var(--green); }
.card-copy-msg[data-err] { color: var(--red); }
/* The card view no longer needs to flatten anything by hand: borders are off
   system-wide and fields carry --field-bg from the kit. The old override here
   (border-color: transparent + background: --page) was written back when fields
   were boxed, and it now CONTRADICTS the design system — it made every field in
   the card RECESSED (--page is darker than --surface in dark) while every field
   everywhere else is a step LIGHTER. Same widget, opposite direction, one screen
   apart. Deleted; the editor just picks up the shared field fill below. */
.card-desc {
  width: 100%;
  min-height: 260px;
  padding: var(--space-3);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--field-bg);
  color: var(--text);
  font-family: var(--font-mono);
  font-size: var(--text-sm);
  resize: vertical;
}
.card-desc:focus { border-color: var(--blue); }
.comment-input { min-height: 52px; }

/* label picker */
.label-picker { display: flex; flex-wrap: wrap; gap: var(--space-2); }
/* Assigned labels render as filled colour pills. Both the fill AND the ink are
   set via the CSSOM in paintLabels — the ink follows the fill's luminance, so
   #fff here is only the fallback for a label with no colour of its own (which
   lands on --muted). The pill is inert; its "×" is the only remove affordance,
   so brushing a label cannot take it off the card. */
.label-pick {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  padding: var(--space-0-5) var(--space-2);
  border: 1px solid transparent;
  border-radius: var(--radius-pill);
  background: var(--muted);
  color: #fff;
  font-size: var(--text-xs);
}
.label-pick-name { overflow-wrap: anywhere; }
.label-pick-x {
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  line-height: 1;
  padding: 0 var(--space-0-5);
  cursor: var(--cursor-action);
  opacity: 0.75;
}
.label-pick-x:hover, .label-pick-x:focus-visible { opacity: 1; }
.label-pick.is-on { font-weight: var(--fw-bold); }
/* No horizontal padding: this sits at the left edge of the labels row and its
   text has to line up with the block above and below it, not sit indented 8px
   into open space. */
.label-empty { font-size: var(--text-xs); color: var(--muted); padding: var(--space-1) 0; }
/* position:relative anchors the custom add-label popup (.menu-dropdown) below
   the trigger button. */
.label-add-row { position: relative; display: flex; gap: var(--space-2); align-items: center; }
.label-add-row .input { flex: 1; min-width: 0; }

/* Custom add-label popup: filter field, scrollable sorted label list, and the
   create-new-label form at the foot. It hangs off the ➕ button (a ~180px anchor)
   rather than a full-width row now, so it sizes itself instead of stretching to
   the anchor — it has to hold a name field + colour + submit. */
.label-add-popup {
  left: 0;
  right: auto;
  width: max-content;
  min-width: 280px;
  max-width: min(420px, calc(100vw - var(--space-4)));
  gap: var(--space-1);
}
.label-add-filter { margin-bottom: var(--space-1); }
.label-add-list { display: flex; flex-direction: column; max-height: 240px; overflow-y: auto; }
.label-add-item { display: flex; align-items: center; gap: var(--space-2); }
.label-swatch { flex: none; width: 12px; height: 12px; border-radius: var(--radius-sm); background: var(--muted); }
.label-add-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* label colour picker (colorpick.js); --control-h shares the field's baseline */
.color-field { position: relative; display: inline-flex; flex: none; }
.color-field-btn {
  width: 38px; height: var(--control-h); padding: 0;
  border: 1px solid var(--border); border-radius: var(--radius-sm);
  background: var(--surface); cursor: var(--cursor-action);
}
.menu-dropdown.color-pop { min-width: 0; width: max-content; gap: var(--space-2); padding: var(--space-2); }
.menu-dropdown.menu-fixed.color-pop { z-index: var(--z-modal-dropdown); }
.color-grid { display: grid; grid-template-columns: repeat(4, 26px); gap: var(--space-1); }
.color-cell { width: 26px; height: 26px; padding: 0; border: 1px solid var(--border); border-radius: var(--radius-sm); cursor: var(--cursor-action); }
.color-cell.is-on { outline: 2px solid var(--text); outline-offset: 1px; }
.color-hex { width: 100%; font-family: var(--font-mono); }
.color-hex.is-bad { border-color: var(--danger); background: var(--wash-negative); }

/* timeline */
.timeline { display: flex; flex-direction: column; gap: var(--space-2); max-height: 320px; overflow-y: auto; }
.tl-event { padding: var(--space-2) var(--space-3); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); background: var(--page); }
/* wraps: on a phone the author·time, the «в ответ» link and the actions cannot
   share a line, and unwrapped the pieces stretched apart around a stranded «·» */
.tl-meta { font-size: var(--text-2xs); color: var(--muted); display: flex; align-items: center; gap: var(--space-1); flex-wrap: wrap; }
.tl-comment { white-space: pre-wrap; font-size: var(--text-sm); margin-top: var(--space-1); }
/* copy-link affordance on a comment; revealed on hover/focus to stay quiet */
.tl-link { border: none; background: none; cursor: pointer; padding: 0; font-size: var(--text-2xs); line-height: 1; opacity: 0; transition: opacity 0.1s; }
.tl-event:hover .tl-link, .tl-link:focus-visible { opacity: 0.7; }
.tl-link:hover { opacity: 1; }
/* touch devices have no hover — keep the link visible there */
@media (hover: none) { .tl-link { opacity: 0.55; } }
/* Comment controls, pinned to the top-right corner of the event: the 🔗 stays
   quiet (hover-revealed), but ⋯ is the entry point to edit/delete/выписка and is
   always visible, at a real touch-sized target — hunting for a hover-only sliver
   after a variable-width timestamp is exactly the friction to avoid. */
.tl-actions { margin-left: auto; display: flex; align-items: center; gap: var(--space-1); }
.tl-menu {
  border: none; background: none; cursor: var(--cursor-action); color: var(--muted);
  padding: 0 var(--space-1); min-width: 24px; min-height: 24px;
  font-size: var(--text-lg); line-height: 1;
}
.tl-menu:hover { color: var(--text); }
/* a comment flagged as a выписка — tinted so it reads as reference material
   rather than discussion while scanning the лента */
.tl-event.tl-excerpt { background: var(--structure); border-color: var(--border); }
.tl-badge { font-size: var(--text-2xs); color: var(--muted); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 0 var(--space-1); }
/* in-place comment editor (board.js#startCommentEdit) */
.tl-editbox { display: flex; flex-direction: column; gap: var(--space-2); margin-top: var(--space-1); }
.tl-editrow { display: flex; gap: var(--space-2); }
/* Replies stay in the flat лента, visually identical to any other comment — the
   «↳ в ответ X» mark in the meta line carries it. No indent, no rule: the лента
   is a flat history, and decorating replies made it read as a tree it is not. */
.tl-sep { color: var(--muted); }
.tl-replyto {
  border: none; background: none; padding: 0; cursor: var(--cursor-action);
  font: inherit; font-size: var(--text-2xs); color: var(--blue);
}
.tl-replyto:hover { text-decoration: underline; }
/* «карточка создана» — the лента's floor, quieter than the events above it */
.tl-event.tl-born { background: transparent; border-style: dashed; }
/* a comment whose text was deleted but whose thread survives */
.tl-event.tl-deleted { color: var(--muted); font-style: italic; }
.tl-thread {
  margin-top: var(--space-1); border: none; background: none; padding: 0;
  cursor: var(--cursor-action); font: inherit; font-size: var(--text-2xs); color: var(--blue);
}
.tl-thread:hover { text-decoration: underline; }
/* thread modal: one exchange, oldest first — the root set off from its replies */
.thread { display: flex; flex-direction: column; gap: var(--space-2); max-height: 55vh; overflow-y: auto; }
.thread-item { padding: var(--space-2) var(--space-3); border-radius: var(--radius-sm); background: var(--page); }
.thread-item.thread-root { background: var(--structure); }
/* flash a comment when arrived at via a direct link */
.tl-event.tl-highlight { animation: tl-flash 2.5s ease-out; }
@keyframes tl-flash { 0%, 40% { background: var(--wash-positive); border-color: var(--success); } 100% { background: var(--page); } }

/* members / sharing modal */
.members-list { display: flex; flex-direction: column; gap: var(--space-1); max-height: 320px; overflow-y: auto; }
.member-row { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-1) 0; }
.member-name { flex: 1; min-width: 0; font-size: var(--text-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.member-role { font-size: var(--text-2xs); color: var(--muted); }
.member-del { font-size: var(--text-lg); }

/* attachments (card detail) */
.attachments { display: flex; flex-direction: column; gap: var(--space-1); }

/* A mount with nothing in it is still a flex ITEM, and a flex item still eats a
   `gap` on each side — so an empty .attachments/.timeline (0px tall, invisible)
   silently pushed its section 8px apart, which is why «Вложения» and «Лента» sat
   12px off the thing they label while «Метки» (whose mount is never empty) sat
   at 4px. Collapse them out of the layout when they hold nothing. */
.attachments:empty,
.timeline:empty { display: none; }
.attach-row { display: flex; align-items: center; gap: var(--space-2); }
.attach-name { flex: 1; text-align: left; border: none; background: transparent; color: var(--blue); cursor: var(--cursor-action); font-size: var(--text-sm); }
.attach-name:hover { text-decoration: underline; }
.attach-size { font-size: var(--text-2xs); color: var(--muted); }
.attach-del { border: none; background: transparent; color: var(--muted); cursor: var(--cursor-action); font-size: var(--text-lg); line-height: 1; }
.attach-del:hover { color: var(--red-dark); }
.attach-lossless { display: inline-flex; align-items: center; gap: var(--space-1); font-size: var(--text-xs); color: var(--muted); }

/* выписки modal: the card's excerpts in one scrollable column. Images are shown
   inline at readable size (click → the lightbox below) rather than as links —
   they are screenshots of a source, and the point is to glance at them. */
.excerpt-count { font-size: var(--text-sm); color: var(--muted); }
.excerpts { display: flex; flex-direction: column; gap: var(--space-3); max-height: 65vh; overflow-y: auto; }
.excerpt { display: flex; flex-direction: column; gap: var(--space-1); padding: var(--space-2) var(--space-3); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); background: var(--page); }
.excerpt-meta { font-size: var(--text-2xs); color: var(--muted); }
.excerpt-text { white-space: pre-wrap; font-size: var(--text-sm); }
/* .excerpt is a flex column, so the default align-items:stretch was forcing the
   image to the container's width and squashing it — a replaced element with an
   intrinsic size still stretches on the cross axis. align-self + height:auto
   keep the aspect ratio. */
.excerpt-img { align-self: flex-start; max-width: 100%; height: auto; border-radius: var(--radius-sm); cursor: zoom-in; }

/* Image lightbox: a full-screen viewer for image attachments. Above the modals
   it may be opened over, below the timer (which must stay usable). The <img> is
   a real element on the blob URL, so right-click → «Сохранить как…» still works. */
.img-lb {
  position: fixed;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-4);
  background: var(--backdrop);
  z-index: 125;
  overflow: hidden;
  touch-action: none;
}
.img-lb[hidden] { display: none; }
.img-lb-img {
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
  cursor: zoom-in;
  will-change: transform;
  user-select: none;
  -webkit-user-drag: none;
}
/* Keep the max-width/height fit cap even when zoomed: the transform scales the
   fit-sized layout box, so the zoom math (lbBaseW/H) stays consistent. */
.img-lb-img.img-lb-zoomed { cursor: grab; }
.img-lb-close {
  position: absolute;
  top: var(--space-3);
  right: var(--space-3);
  width: 2rem;
  height: 2rem;
  border: none;
  border-radius: var(--radius);
  background: var(--surface);
  color: var(--text);
  font-size: var(--text-xl);
  line-height: 1;
  cursor: var(--cursor-action);
}
.img-lb-close:hover { filter: brightness(0.96); }
/* selects must never push past their container on narrow screens */
.card-detail select.input { max-width: 100%; }

/* per-list "⋯" menu: the shared .menu-dropdown floats on <body> at a fixed,
   viewport-clamped spot (board.js popupMenu) — anchored inside the kanban
   scroll container it was clipped at the work area's edge. The wrap only
   keeps the trigger button inline. */
.klist-menu-wrap { position: relative; display: inline-flex; }
/* A body-mounted fixed popup must outrank whatever it was opened from, and the
   comment/attachment ⋯ menus are opened from INSIDE the card overlay — at the
   inherited --z-dropdown (30) they painted under it (50) and read as a dead
   button. Above the overlay and the board header, still below --z-modal. */
.menu-dropdown.menu-fixed { position: fixed; top: auto; right: auto; z-index: 70; }
/* a toggle row in a popup menu: .menu-item is already the flex row, this only
   makes the <label> behave like the <button> siblings it sits among */
.menu-item-check { cursor: var(--cursor-action); user-select: none; }

/* краткий diff: one flowing line, old and new inline, the untouched bulk of the
   question replaced by … — the whole point is that it costs a couple of lines */
.tl-brief {
  margin-top: var(--space-1); font-size: var(--text-sm);
  white-space: pre-wrap; word-break: break-word; line-height: 1.5;
}
.tl-gap { color: var(--muted); }
/* The two-pane view colours a change by which PANE it is in; inline there are no
   panes, so the tokens carry the colour themselves — red struck-through for what
   went, green for what came. */
.tl-brief del.tl-chg { background: var(--red); text-decoration: line-through; }
.tl-brief ins.tl-chg { background: var(--green); text-decoration: none; }
/* the whitespace that used to separate them was absorbed into the cluster
   (clusterChanges), so the gap between the old chunk and the new one is drawn */
.tl-brief .tl-chg + .tl-chg { margin-left: 0.35em; }

/* the expanded лента: the same events flowed into columns. The container has a
   definite height, so the columns fill top-to-bottom and continue sideways —
   hence the horizontal scroll rather than a vertical one. */
/* full width, unlike the 820px reading column the other doc overlays want —
   the whole point is to spend the screen on columns */
/* both classes: .preview-doc's own width rule is later in this file and would
   otherwise win on equal specificity */
.preview-doc.feed-doc { width: 100%; }
/* .section-label is a heading for the block BENEATH it and carries a top margin
   to prove it; used as an inline caption beside a control it drags itself off
   the shared centre line. Same fix as .handouts-doc's. */
.card-detail .u-row > .section-label { margin: 0; }
/* Feed head: both labelled selects on ONE line, close button pushed to the far
   right. No «Лента» title — after «Развернуть» the surface is unmistakable.
   Compact padding: this is chrome above the content, not content. */
.feed-doc .preview-head {
  align-items: center;
  padding: var(--space-3) var(--space-4);
}
.feed-doc .preview-head-actions {
  flex: 1 1 auto;
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-1) var(--space-2);
}
.feed-doc .preview-head-actions .section-label { margin: 0; }
.feed-doc #feedClose { margin-left: auto; } /* far right, on the first line */
/* On a phone these dropdowns dwarfed the content — bigger font than the feed
   itself. Shrink them to roughly half (smaller type, ~26px tall — note the kit
   hard-sets select HEIGHT, not min-height, so it must be overridden) so they
   read as chrome. The × pins to the top-right corner instead of wrapping onto a
   row of its own; the controls wrap in the space left of it. */
@media (max-width: 560px) {
  .feed-doc .preview-head {
    position: relative;
    padding: var(--space-2) var(--space-3);
    padding-right: 42px;
  }
  .feed-doc .preview-head-actions .section-label { font-size: var(--text-2xs); }
  .feed-doc .preview-head-actions select {
    font-size: var(--text-2xs);
    height: 26px;
    min-height: 0;
    padding: 0 16px 0 6px;
  }
  .feed-doc #feedClose {
    position: absolute;
    top: var(--space-2);
    right: var(--space-3);
    margin: 0;
    height: 26px; min-height: 26px; width: 26px; min-width: 26px;
    padding: 0;
    font-size: var(--text-sm);
  }
}
.feed-grid {
  flex: 1; min-height: 0; overflow: auto;
  column-width: 340px; column-gap: var(--space-4);
  padding: var(--space-3) var(--space-5);
}
.feed-grid .tl-event { break-inside: avoid; margin-bottom: var(--space-2); }
/* one column on a phone: 340px columns in a 375px viewport leave no margin, and
   scrolling a feed sideways is worse than scrolling it down */
@media (max-width: 560px) {
  .feed-grid { column-width: auto; column-count: 1; padding: var(--space-3); }
}
/* a column-broken .tl-diff turns into two unreadable slivers */
.feed-grid .tl-diff { grid-template-columns: 1fr; }

/* two-pane before/after diff (timeline desc_edit), with the word-level changes
   highlighted inside each pane */
.tl-diff { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-2); margin-top: var(--space-1); font-family: var(--font-mono); font-size: var(--text-2xs); }
.tl-before, .tl-after { padding: var(--space-2); border-radius: var(--radius-xs); white-space: pre-wrap; word-break: break-word; line-height: 1.5; }
.tl-before { background: var(--wash-negative); }
.tl-after { background: var(--wash-positive); }
.tl-chg { border-radius: var(--radius-xs); color: #fff; padding: 0 1px; }
.tl-before .tl-chg { background: var(--red); text-decoration: line-through; }
.tl-after .tl-chg { background: var(--green); text-decoration: none; }


/* card create mode: show only the type chooser + description editor */
.card-detail.creating [data-edit-only] { display: none !important; }
.card-detail:not(.creating) [data-create-only] { display: none !important; }

/* list preview ("à la docx export", rendered client-side) — a wide, scrollable
   reading view layered over the board like the card detail. */
.preview-doc {
  width: min(820px, 100%);
  height: 100%;
  display: flex;
  flex-direction: column;
  background: var(--surface);
  box-shadow: var(--shadow-modal);
}
.preview-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  flex: none;
  padding: var(--space-4) var(--space-5);
  border-bottom: 1px solid var(--border);
}
.preview-title {
  margin: 0;
  font-size: var(--text-lg);
  font-weight: var(--fw-semibold);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.preview-head-actions { display: flex; align-items: center; gap: var(--space-3); flex: none; }
.preview-screen-toggle { white-space: nowrap; cursor: var(--cursor-action); }
.preview-body {
  flex: 1;
  overflow-y: auto;
  padding: var(--space-5) var(--space-6);
  font-size: var(--text-base);
  line-height: 1.55;
  color: var(--text);
}
/* Cap the reading column to a comfortable measure (~66 characters) and centre it
   — full-width lines on a wide modal are tiring to read. */
.preview-body > * { margin: 0 auto var(--space-3); max-width: 66ch; }
/* docx-like rhythm: the gap below each question matches the preview's own top
   padding (space-5) so the spacing reads even top-to-bottom; a small gap between
   the question text and its answer block; and no gap between the answer/zachet/
   comment/source/author lines (they read as one tight block). */
.pv-q { margin-bottom: var(--space-5); }
/* per-card ✏️ jump-to-editor button, rendered inline as a small bordered pill
   right before the block's leading label ("✏️ Вопрос 1.") so it stays clearly
   separated from the text and never covers it — including on mobile, where an
   overlaid pencil used to obscure the question. */
.pv-edit {
  display: inline-block;
  border: 1px solid var(--action-border);
  background: var(--action-bg);
  border-radius: var(--radius-sm);
  padding: 0.05em 0.35em;
  margin-right: 0.4em;
  font-size: 0.8em;
  line-height: 1.4;
  cursor: var(--cursor-action);
  vertical-align: middle;
}
.pv-edit:hover, .pv-edit:focus-visible { background: var(--action-hover-bg); }
/* preserve authored line breaks inside multi-line question/answer/meta text */
.pv-q-text, .pv-field, .pv-meta, .pv-list-body { white-space: pre-wrap; }
.pv-q-text { margin-bottom: var(--space-2); }
.pv-field { margin-bottom: 0; }
.pv-label { font-weight: var(--fw-bold); }
/* numbered list (sources, blitz/duplet questions & answers): the "N." marker and
   the item body are separate boxes so wrapping a long unbreakable value (e.g. a
   bare URL) inside the body can never orphan the marker onto its own line. The
   numbers are plain weight (only field labels are bold). */
.pv-list { margin-top: var(--space-1); }
.pv-list-item { display: flex; gap: 0.4em; margin-bottom: var(--space-0-5); }
.pv-list-num { flex: 0 0 auto; font-weight: var(--fw-regular); }
.pv-list-body { min-width: 0; flex: 1; overflow-wrap: anywhere; }
.pv-handout { color: var(--muted); }
/* sources/authors set smaller, matching the exports' 12pt → 10pt. The block after
   a body-size one starts one BODY line below, not one small line: the lost height
   is 1.55 × (2/12) body-em = 0.31 of the small block's own em. */
.pv-small { font-size: calc(10em / 12); }
:not(.pv-small) + .pv-small { margin-top: 0.31em; }
.pv-heading { font-size: var(--text-xl); font-weight: var(--fw-bold); margin: var(--space-5) 0 var(--space-3); }
.pv-section { font-size: var(--text-lg); font-weight: var(--fw-semibold); margin: var(--space-4) 0 var(--space-2); }
.pv-empty { color: var(--muted); }
/* break long URLs at any character so they never force horizontal scroll on
   narrow screens (links are often unbreakable runs with no spaces). */
.pv-link { color: var(--blue); overflow-wrap: anywhere; word-break: break-word; }
.pv-sc { font-variant: small-caps; }
/* the dash IS the page-break mark, so it takes --grid-dark, not --border */
.pv-pagebreak { border: 0; border-top: 1px dashed var(--grid-dark); margin: var(--space-4) 0; }
.pv-img {
  display: block;
  max-width: min(100%, 480px);
  max-height: 12em;
  width: auto;
  height: auto;
  margin: var(--space-2) 0;
  border-radius: var(--radius-sm);
  cursor: zoom-in; /* click opens the lightbox */
}
/* block, like the image it stands in for — renderRich swallows the source
   newline after an (img …), so an inline placeholder would glue to the text */
.pv-img-missing { display: block; color: var(--muted); font-style: italic; }

/* Touch / small screens: shrink the type to fit more questions on screen (the
   same densification ~/dope applies on mobile). */
@media (max-width: 760px) {
  /* tighter board header so more of the board is visible on phones */
  .board-page .host-top { padding: 6px 10px; gap: 6px; }
  .board-page h1 { font-size: var(--text-lg); }
  .board-page .action-icon { width: 30px; height: 30px; font-size: 15px; }

  .board-main { padding: var(--space-2) var(--space-2); }
  .klist { flex-basis: 86vw; max-width: 320px; }
  .klist-title { font-size: var(--text-sm); }
  .kcard { padding: var(--space-1) var(--space-2); }
  /* phones already densify to 12px; a smaller user-chosen card font still wins */
  .kcard-title { font-size: min(var(--kcard-font), var(--text-xs)); }
  /* keep the label pills off the card's top edge */
  .kcard-labels { margin-top: var(--space-1); }
  .card-detail { padding: var(--space-4) var(--space-3); }
  .preview-head { padding: var(--space-3) var(--space-4); }
  .preview-body { padding: var(--space-4) var(--space-4); font-size: var(--text-sm); }
  .card-desc { font-size: var(--text-xs-plus); min-height: 200px; }
  .section-label { margin-top: var(--space-2); }
  .tl-comment { font-size: var(--text-xs-plus); }
  .label-pick { font-size: var(--text-2xs); }
}

/* ЧГК play timer — floats bottom-right by default, above everything
   (--z-timer). Toggled by the ⏰ header button; draggable anywhere on screen
   (timer.js sets left/top and remembers them in localStorage). */
.timer-overlay {
  position: fixed;
  right: var(--space-4);
  bottom: var(--space-4);
  z-index: var(--z-timer);
  cursor: grab;
  touch-action: none;
  user-select: none;
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  width: 240px;
  padding: var(--space-3);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  background: var(--surface);
  box-shadow: var(--shadow-card);
}
.timer-overlay[hidden] { display: none; }
/* controls stay fully interactive inside the draggable surface */
.timer-overlay :is(input, select, button) { touch-action: auto; user-select: auto; -webkit-user-select: auto; }
.timer-overlay.timer-moved { right: auto; bottom: auto; }
.timer-overlay.timer-dragging { cursor: grabbing; }
.timer-row, .timer-custom { display: flex; }
.timer-preset, .timer-custom-input { width: 100%; }
.timer-display {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-1);
  padding: var(--space-2) 0;
}
.timer-time {
  font-variant-numeric: tabular-nums;
  font-weight: var(--fw-bold);
  font-size: 56px;
  line-height: 1;
  color: var(--text-strong);
}
.timer-time.timer-urgent { color: var(--danger); }
.timer-time.timer-answer { color: var(--accent); }
.timer-label {
  font-size: var(--text-xs);
  color: var(--muted);
  min-height: 1em;
}
.timer-actions { display: flex; gap: var(--space-2); justify-content: center; }
.timer-actions .btn { flex: 1; display: flex; align-items: center; justify-content: center; }
.timer-ico { width: 16px; height: 16px; display: block; }

@media (max-width: 760px) {
  .timer-overlay { right: var(--space-2); bottom: var(--space-2); width: min(220px, calc(100vw - 2 * var(--space-2))); padding: var(--space-2); }
  .timer-time { font-size: 44px; }
  .timer-display { padding: var(--space-1) 0; }
  /* let the three control buttons shrink to share the row instead of overflowing
     the box (flex items default to min-width:auto = their text width) */
  .timer-actions { gap: var(--space-1); }
  .timer-actions .btn { min-width: 0; padding-left: var(--space-2); padding-right: var(--space-2); }
}

/* API tokens page (tokens.html) — built on the auth/sheet layout above.
   .subhead (the subhead primitive) lives in core.css. */
.token-value {
  flex: 1 1 auto;
  min-width: 0;
  padding: var(--space-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--surface);
  font-family: var(--font-mono);
  font-size: var(--text-sm);
  word-break: break-all;
  user-select: all;
}
.token-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}
.token-row {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: center;
  gap: var(--space-1) var(--space-3);
  padding: var(--space-3);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--surface);
}
.token-row-inactive { opacity: 0.55; }
.token-row-main {
  grid-column: 1;
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
  flex-wrap: wrap;
}
.token-row-label { font-weight: var(--fw-semibold); color: var(--text-strong); }
.token-row-status { font-size: var(--text-xs); color: var(--muted); }
.token-row-meta {
  grid-column: 1;
  font-size: var(--text-xs);
  color: var(--muted);
}
.token-revoke {
  grid-column: 2;
  grid-row: 1 / span 2;
  white-space: nowrap;
}

/* card detail: 3-view switch (Просмотр / Поля / Текст) — the tabs/tab/tabpanel
   primitives; the .seg/.seg-btn/.tabpanel base lives in core.css. */
/* preview view inside the card: a bounded, scrollable docx-style render */
.card-preview-screen { display: inline-flex; align-items: center; gap: var(--space-1); cursor: var(--cursor-action); margin-bottom: var(--space-2); }
.card-preview-body {
  padding: var(--space-3) var(--space-4);
  background: var(--page);
  border-radius: var(--radius-md);
  max-height: 52vh;
  overflow-y: auto;
  cursor: text;
}
.card-preview-body > * { max-width: none; }
/* The card-detail preview shows a single question, so the inter-question
   bottom margin (.pv-q) is just a trailing gap — drop it; the container's own
   padding already gives symmetric top/bottom breathing room. */
.card-preview-body .pv-q { margin-bottom: 0; }

/* ── structured fields (Поля) ─────────────────────────────────────────────── */
/* Density (user request): absent fields are pills that FLOW TOGETHER on shared
   wrap rows, and a present field spans the full row with its label BESIDE the
   control, not above it — the stacked label/input made any filled card scroll. */
.card-fields { display: flex; flex-flow: row wrap; gap: var(--space-2); }
.fld { display: flex; flex-direction: column; }
.fld-wide, .fld-present { flex: 1 1 100%; }
.fld-present {
  display: grid;
  /* as narrow as the longest unbreakable label («Комментарий» at text-xs)
     allows — multi-word labels wrap instead of widening the column */
  grid-template-columns: 5.5em 1fr;
  column-gap: var(--space-2);
  align-items: start;
}
/* label column: × next to the label (not flushed to the far edge of the card),
   label dropped onto the input's first text line */
.fld-present > .fld-head { justify-content: flex-start; padding-top: 7px; gap: var(--space-1); }
.fld-present .fld-label { font-size: var(--text-xs); }
.fld-present .fld-rm { padding: 0 2px; }
.fld-present > .fld-body { margin-top: 0; }
/* the dashed ring is this button's whole identity ("add a field") — --grid-dark,
   not the transparent --border */
.fld-add {
  align-self: flex-start;
  border: 1px dashed var(--grid-dark);
  background: var(--page);
  color: var(--muted);
  font: inherit;
  font-size: var(--text-sm);
  font-weight: var(--fw-regular);
  padding: var(--space-1) var(--space-3);
  border-radius: 999px;
  cursor: var(--cursor-action);
}
.fld-add:hover { color: var(--text); border-color: var(--muted); }
.fld-muted .fld-add { opacity: 0.75; }
.fld-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); }
.fld-label { font-size: var(--text-sm); font-weight: var(--fw-semibold); color: var(--muted); }
.fld-rm, .fld-row-rm, .fld-tag-rm {
  border: none; background: transparent; color: var(--muted);
  font-size: var(--text-base); line-height: 1; cursor: var(--cursor-action);
  padding: 0 var(--space-1);
}
.fld-rm:hover, .fld-row-rm:hover, .fld-tag-rm:hover { color: var(--red); }
.fld-body { margin-top: var(--space-1); }
.fld-input { width: 100%; }
/* rows=1 + no floor: a one-line answer takes one line; autoGrow adds the rest */
textarea.fld-input { min-height: 0; padding-block: var(--space-2); }
/* desktop: shave the 38px control floor inside Поля — it reads airy repeated
   over nine fields; phones keep the full touch target */
@media (min-width: 761px) {
  .card-fields .input { min-height: 32px; padding-block: 5px; }
  .card-fields select.input { height: 32px; }
}
/* phones have no width to spare for a label column — stack label above input
   again (this block must FOLLOW the .fld-present grid rules to win the tie) */
@media (max-width: 760px) {
  /* align-items too: the grid rule's `start` would shrink the stacked children
     to content width */
  .fld-present { display: flex; flex-direction: column; align-items: stretch; }
  .fld-present > .fld-head { justify-content: space-between; padding-top: 0; }
  .fld-present > .fld-body { margin-top: var(--space-1); }
}
.fld-rows { display: flex; flex-direction: column; gap: var(--space-1); }
.fld-row { display: flex; align-items: center; gap: var(--space-1); }
.fld-row-input { flex: 1; }
.fld-add-row { align-self: flex-start; font-size: var(--text-sm); margin-top: var(--space-1); cursor: var(--cursor-action); padding: var(--space-1) var(--space-2); width: auto; }
/* hand-drawn autocomplete (suggestWrap in board.js — <datalist> never opens on
   iOS Safari, so the dropdown is ours) */
.suggest-wrap { position: relative; flex: 1 1 auto; min-width: 0; }
.suggest-wrap > .input { width: 100%; }
.suggest-menu {
  position: absolute;
  top: calc(100% + 2px);
  left: 0;
  right: 0;
  z-index: var(--z-dropdown);
  display: flex;
  flex-direction: column;
  max-height: 40vh;
  overflow-y: auto;
  background: var(--surface);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-popover);
}
.suggest-item {
  border: none;
  background: transparent;
  color: var(--text);
  font: inherit;
  font-size: var(--text-sm);
  text-align: left;
  padding: var(--space-1-5) var(--space-2-5);
  cursor: var(--cursor-action);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.suggest-item:hover, .suggest-item.active { background: var(--hover-bg); }

/* handout text/image toggle (.seg lives in the kit) */
.fld-body > .seg { margin-bottom: var(--space-1); }
/* test-card tester rows: игрок/команда toggle sits inline with the name input */
.tester-seg { flex: none; }
/* author tags */
.fld-tags { display: flex; flex-wrap: wrap; gap: var(--space-1); margin-bottom: var(--space-1); }
.fld-tag {
  display: inline-flex; align-items: center; gap: var(--space-1);
  background: var(--page); border: 1px solid var(--border); border-radius: 999px;
  padding: 2px var(--space-2); font-size: var(--text-sm);
}

/* ── handouts generation modal ────────────────────────────────────────────── */
.handouts-doc { width: min(1100px, 100%); }
.handouts-body { flex: 1; display: flex; min-height: 0; }
.handouts-pane { flex: 1 1 50%; display: flex; flex-direction: column; min-width: 0; padding: var(--space-4) var(--space-5); overflow-y: auto; }
/* pane splitter — a structural rule, so --grid (like the table cell grids) */
.handouts-src { border-right: 1px solid var(--grid); }
.handouts-textarea { flex: 1; min-height: 320px; width: 100%; }
/* The two panes are mirrored columns: header row, then the big area, spaced by
   the pane's gap (not by per-widget margins, which is what glued the label /
   buttons to the textarea). The section-label's own margins are for flowing
   forms, not headers — zeroed here; the header rows reserve a small-button
   height so «Скачать» appearing doesn't push the PDF down out of line. */
.handouts-doc .handouts-pane { gap: var(--space-3); }
.handouts-doc .section-label { margin: 0; }
.handouts-doc .handouts-pane > .u-row:first-child { min-height: var(--control-h-sm); }
/* The frame fills its box via absolute inset, not flex + height:100% — a
   percentage height inside a flex-sized box resolves against an indefinite
   height in some engines, which cropped the preview to a fraction of the box. */
.handouts-pdf { flex: 1; background: var(--field-bg); border-radius: var(--radius-md); min-height: 320px; position: relative; }
.handouts-pdf-frame { position: absolute; inset: 0; width: 100%; height: 100%; border: none; border-radius: var(--radius-md); }
/* Safari-standalone/iOS fallback: no inline PDF there, offer a real navigation */
.handouts-pdf-fallback { position: absolute; inset: 0; display: flex; flex-direction: column; gap: var(--space-3); align-items: center; justify-content: center; padding: var(--space-4); text-align: center; }
.handouts-pdf-note { font-size: var(--text-xs); color: var(--muted); }

@media (max-width: 760px) {
  .handouts-body { flex-direction: column; }
  .handouts-src { border-right: none; border-bottom: 1px solid var(--grid); }
  .card-preview-body { max-height: 40vh; }
}

/* ── import verification modal ─────────────────────────────────────────────────
   Reuses the handouts split-pane shell: parsed 4s source on the left, the same
   preview the list preview renders on the right. */
.import-doc { width: min(1100px, 100%); }
.import-textarea { font-family: var(--font-mono); font-size: var(--text-sm); line-height: 1.5; }
.import-count { color: var(--muted); font-size: var(--text-sm); white-space: nowrap; }
.import-preview > * { margin: 0 0 var(--space-3); }

/* ── sizes modal (list width / card height) ───────────────────────────────── */
.sizes-modal { width: min(420px, 100%); text-align: left; }
/* The .sizes-row/.sizes-value/.sizes-hint slider rows are the sliderrow
   primitive; their base rules live in core.css. */

/* The pseudo-board preview in the sizes dialog (profile.js#renderPreview):
   a to-scale wireframe of a monitor — the board column centres inside the
   "screen", lists are --pv-list-w wide, cards are stacks of "text line" bars
   cut by the card-height clamp. Proportions only, hence bars, not text. */
.sizes-preview {
  height: 150px;
  border-radius: var(--radius-md);
  overflow: hidden;
}
.pvb-screen {
  display: flex;
  justify-content: center;
  height: 100%;
  background: var(--structure);
  overflow: hidden;
}
.pvb-board {
  display: flex;
  align-items: flex-start;
  gap: 3px;
  width: 100%;
  max-width: var(--pv-board-w, none);
  padding: 5px;
  overflow: hidden;
}
.pvb-list {
  flex: 0 0 var(--pv-list-w, 70px);
  display: flex;
  flex-direction: column;
  gap: 3px;
  padding: 3px;
  border-radius: 3px;
  background: var(--action-bg);
}
.pvb-title { height: 4px; width: 55%; border-radius: 2px; background: var(--grid-dark); margin: 1px 0 2px; }
.pvb-card {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 3px;
  border-radius: 2px;
  background: var(--surface);
}
.pvb-line { height: var(--pvb-line-h, 3px); border-radius: 1.5px; background: var(--grid-dark); }
.pvb-line-last { width: 60%; }

/* ── lists management modal ────────────────────────────────────────────────── */
.lists-manage { width: min(560px, 100%); text-align: left; }
.lists-move-pos { width: 4.5rem; }
.lists-manage-rows {
  display: flex; flex-direction: column; gap: var(--space-2);
  max-height: 55vh; overflow-y: auto; padding: var(--space-1);
}
.lm-unit { border-radius: var(--radius-md); background: var(--structure); }
.lm-unit.dragging { opacity: 0.5; }
.lm-row { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-1) var(--space-2); }
.lm-check { display: inline-flex; align-items: center; }
.lm-pos { color: var(--muted); font-size: var(--text-sm); min-width: 2.2rem; }
.lm-handle { color: var(--muted); cursor: grab; font-size: var(--text-lg); line-height: 1; }
.lm-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lm-group-title { font-weight: var(--fw-semibold); color: var(--blue-strong); white-space: normal; }
.lm-icon {
  border: none; background: transparent; color: var(--muted);
  cursor: var(--cursor-action); font-size: var(--text-sm); padding: 0 var(--space-1);
}
.lm-icon:hover { color: var(--text); }
.lm-move { display: flex; align-items: center; gap: var(--space-1); }
.lm-move-pos { width: 3.5rem; }
.lm-members {
  display: flex; flex-direction: column; gap: var(--space-1);
  padding: 0 var(--space-2) var(--space-2) calc(var(--space-5) + var(--space-2));
}
.lm-member { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-0-5) var(--space-2); border-left: 2px solid var(--blue-strong); }
.lm-member.dragging { opacity: 0.5; }

/* PWA html/body overrides — xy layer only (dope must NOT inherit these).
   iOS: don't bump font sizes on rotate, don't rubber-band/zoom; the app is a
   fixed shell whose inner sections scroll (see menu.js gesture guards). */
html,
body {
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
  overscroll-behavior: none;
  touch-action: manipulation;
}

/* ---- test sessions: the Тесты panel, the label editor, the mark template ----
   All three are list-shaped modals, so they share one row skeleton. */
.sess-row {
  display: flex; flex-wrap: wrap; gap: var(--space-2);
  align-items: center; justify-content: space-between;
  padding: var(--space-2) 0; border-bottom: 1px solid var(--border-soft);
}
.sess-row:last-child { border-bottom: 0; }
.sess-head { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: baseline; min-width: 0; flex: 1 1 16rem; }
.sess-title { font-weight: var(--fw-semibold); }
.sess-meta { color: var(--muted); font-size: var(--text-sm); }
/* A copied session says how stale it might be: its testers froze at transfer
   time, and that list is what «Видели» reads (ADR-0003). */
.sess-origin { color: var(--muted); font-size: var(--text-sm); font-style: italic; }
.sess-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; }
.sess-cities { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; }
.sess-invite-box { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; margin: var(--space-3) 0; }
.sess-invite { flex: 1 1 18rem; margin: 0; font-size: var(--text-base); }
/* Two lines in the tester list, so they stack and the copy button sits beside */
.sess-invite-lines { flex: 1 1 18rem; display: flex; flex-direction: column; gap: var(--space-1); }
.sess-invite-lines .sess-invite { flex: none; }

/* A test on a board card: the flask alone when the testers recorded nothing,
   the flask plus their verdicts' colours in a thin border when they did. Inside
   the group the verdicts are DOTS, not pills — a card can carry several tests,
   and pills there ate the row. */
/* border-box, so the border and padding come OUT of --chip-h rather than adding
   to it — that is what used to make a test group 16px beside a 12px label. */
.kcard-test {
  display: inline-flex;
  align-items: center;
  box-sizing: border-box;
  height: var(--chip-h);
  gap: 2px;
}
.kcard-test.has-labels {
  border: 1px solid var(--grid-dark);
  border-radius: var(--radius-pill);
  padding: 0 3px;
}
/* A verdict is the same dot as a label, at the size the capsule leaves it: the
   group is --chip-h tall and 1px of that is border, top and bottom. */
.kcard-test .label-chip { width: 8px; height: 8px; }
/* An emoji's glyph overflows a line-height of 1, so the flask sat on the group's
   bottom border. Centre it in a box tall enough to hold it. */
.kcard-test-icon {
  font-size: 9px;
  line-height: 1;
  display: inline-flex;
  align-items: center;
  height: 10px;
}
/* The handout mark: same emoji-in-a-box trick as the flask, but at the row's
   own height — this one stands on the row rather than inside a capsule. */
.kcard-handout {
  font-size: 11px;
  line-height: 1;
  display: inline-flex;
  align-items: center;
  height: var(--chip-h);
}

/* «Видели»: derived from the sessions a card is tagged with, stored nowhere. */
#cardSeen { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: baseline; }
.seen-label { color: var(--muted); font-size: var(--text-sm); }
.seen-names { flex: 1 1 12rem; }
.seen-copy { flex: 0 0 auto; }

/* The person directory's dropdown: this board's names first and unlabelled,
   then names from other unlocked boards tagged with where they came from. */
/* position:absolute with no offsets leaves the popup at its STATIC position —
   inline after the input, i.e. off to the right of the row. It has to be pinned
   to the anchor's bottom-left to read as a dropdown. */
.suggest-pop {
  position: absolute;
  z-index: 30;
  max-height: 12rem;
  overflow-y: auto;
}
.suggest-hint { color: var(--muted); font-size: var(--text-sm); margin-left: var(--space-2); }

/* A tester row is seg + name + ×. The name SHRINKS rather than claiming a line
   of its own — giving it 100% pushed the × onto a third line at every width, not
   just on a phone — so all three stay on one row down to ~200px, below which the
   row wraps by itself. */
#sessionForm .tester-row { flex-wrap: wrap; }
#sessionForm .tester-row .fld-row-input { flex: 1 1 8rem; min-width: 0; }

/* These modals hold more than a screenful, so their BODY scrolls — the shape
   .lists-manage-rows already uses, which keeps the title and the «Готово»
   button pinned either side of the scrolling part. */
#sessionForm, #sessionsList, #labelsEditor, #marksEditor {
  max-height: 65vh;
  overflow-y: auto;
  padding-right: var(--space-1);
}

/* The session's лента reuses the card's timeline markup (.tl-item / .tl-meta /
   .tl-body), so a note reads exactly like a comment rather than being a second,
   slightly-different comment UI. Only the container needs a rule. */
.sess-feed { display: flex; flex-direction: column; gap: var(--space-2); }
/* The tester rows and the «+ тестер» button under them are one block; without a
   gap it sat flush against the Сохранить row below. */
.sess-testers { display: flex; flex-direction: column; gap: var(--space-2); margin-bottom: var(--space-3); }
.sess-notes { max-height: 14rem; overflow-y: auto; }

/* A Playing: one test this question was played at, with its own labels. The name,
   its ×, the chips and the add button are ONE wrapping row — stacking them put a
   lone ＋ on a line of its own under every test. */
.playing {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2);
  padding: var(--space-2) 0; border-bottom: 1px solid var(--border-soft);
}
.playing:last-child { border-bottom: 0; }
.playing-head { display: flex; align-items: center; gap: var(--space-1); }
.playing-name { font-weight: var(--fw-semibold); font-size: var(--text-sm); }
/* position:relative anchors the add-label popup to THIS row. Without it the
   popup positions against the page and lands over the board. */
.playing-labels {
  position: relative;
  display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center;
}
#cardPlayings { display: flex; flex-direction: column; }
/* The add-label button inside a playing sits among chips, so it matches them. */
.playing-add { padding: var(--space-0-5) var(--space-2); font-size: var(--text-xs); }
#playingAddRow { position: relative; display: flex; gap: var(--space-2); align-items: center; }
/* The popup is anchored to a chip row, which is narrow; keep it readable. */
.label-add-popup { min-width: 16rem; max-width: min(24rem, 90vw); }

/* The town field sits inside the chip row, so it must not stretch to fill it. */
.sess-city-add { width: 12rem; max-width: 100%; }
/* Any input with an autocomplete under it: the popup is absolute, so its parent
   has to be the positioning context. */
.suggest-anchor { position: relative; }

/* A city is not a coloured label, so it does not wear the label pill — that
   painted it grey-on-white and made both the name and its offset unreadable.
   Surface + border, text in the normal ink. */
.city-chip {
  display: inline-flex; align-items: center; gap: var(--space-1);
  padding: var(--space-0-5) var(--space-2);
  border: 1px solid var(--grid-dark); border-radius: var(--radius-pill);
  background: var(--surface); color: var(--text); font-size: var(--text-xs);
}
.city-chip-name { font-weight: var(--fw-semibold); }
/* Which zone the town resolved to: a wrong guess is visible before the invite
   line goes out, not after. Muted against the SURFACE, so it stays legible. */
.city-chip-zone { color: var(--muted); font-size: var(--text-2xs); }
.city-chip-x {
  border: 0; background: none; color: var(--muted); font: inherit;
  line-height: 1; padding: 0 var(--space-0-5); cursor: var(--cursor-action);
}
.city-chip-x:hover, .city-chip-x:focus-visible { color: var(--text); }

/* A section heading belongs to what is UNDER it, so it needs air above and
   little below — «Лента» was sitting nearer the delete button than its own
   feed. */
#sessionForm .fld:first-child { margin-top: 0; }
#sessionForm .fld-head { margin-top: var(--space-4); margin-bottom: var(--space-1); }
