/* Indizoku — dark premium theme */
:root {
  --bg: hsl(0 0% 3.9%);
  --bg-soft: hsl(0 0% 7%);
  /* Drei Stufen derselben Leiter, streng steigend. `--panel-faint` ist die
     leiseste: sie stand achtmal als rohes `hsl(0 0% 100% / 0.03)` in der Datei,
     ohne Namen und ohne Zusammenhang untereinander — acht Stellen, die dasselbe
     meinten und nichts voneinander wussten. Wer die Stufe jetzt verschiebt,
     verschiebt alle acht.
     NICHT eingemeindet und bewusst nicht: `.dossier-summary:hover` faehrt 0.035.
     Ob das Absicht oder Ausrutscher ist, ist ungeklaert — und eine Regel mit
     ungeklaertem Gegenbeispiel ist eine Beobachtung, kein Gesetz. */
  --panel-faint: rgba(255, 255, 255, 0.03);
  --panel: rgba(255, 255, 255, 0.045);
  --panel-strong: rgba(255, 255, 255, 0.08);
  /* Opaque counterparts. Native controls (select popup, scrollbars, dialog
     backdrop compositing) hand their colour to the OS, which has no page
     behind it to blend with — translucency turns unreadable there. */
  --surface-1: hsl(0 0% 9%);
  --surface-2: hsl(0 0% 13%);
  --surface-3: hsl(0 0% 18%);
  /* 0.10 liest 1.25 gegen die Seite und 1.32 gegen ein Panel — beides weit
     unter den 3:1, die 1.4.11 für grafische Objekte verlangt, und beides
     Absicht. Diese Kante sagt nichts aus: ein ruhender Knopf nennt sich über
     sein Textlabel und seine Fläche, ein Kasten über seinen Grund. Ein Review
     hat hier schon einmal 0.34 verlangt — die Zahl stimmt (sie erreicht 3.0
     gegen den Seitengrund), die Regel greift nur nicht, und der Preis wäre
     jede Haarlinie des Hauses dreieinhalbfach aufgehellt gewesen.
     Wo eine Kante wirklich etwas AUSSAGT, steht ein anderes Token: --pick-line
     für „in der Hand", die Kante von .account-submit für den Rang. Die haben
     ihre eigene Schranke, und zwar eine, die rot werden kann —
     `node test/contrast.test.mjs` misst beide gegen innen UND außen.
     Wer diesen Wert hebt, ändert Dekoration; wer eines der beiden anderen
     senkt, nimmt einer Aussage ihren Träger. Nur das zweite ist ein Fehler. */
  --border: rgba(255, 255, 255, 0.10);
  --border-strong: rgba(255, 255, 255, 0.22);
  --text: hsl(0 0% 98%);
  /* 78, and the road here is the whole lesson. 64 was measured against a bare
     panel and read 7.26; through the page's radial washes the same pixel is
     6.72, so it went to 68 (7.46 on panel). Then .chip-quiet appeared, because
     68 only reads 5.92 once a .chip stacks a second translucent layer on the
     panel — and the exception hid the fact that the SAME shortfall exists on
     .clue, .clue:hover, .trace-entry, .trace-entry:hover and .dossier. Nine
     surfaces, one exception written for one of them.
     Secondary text lands on every nested surface in the house, so the token has
     to clear the floor on the heaviest of them, not on the emptiest: 78% reads
     7.65 on .trace-entry:hover, its worst, and 9.85 on a bare panel. It also
     retires .chip-quiet — the correct fix removed an exception instead of
     adding eight more. --text stays at 98%, so the hierarchy is intact.
     test/contrast.test.mjs measures all nine and runs in `npm test`. */
  --muted: hsl(0 0% 78%);
  /* Accent means danger and nothing else: victim, conflict, refuted clue,
     accusation. Every other "this is active" state used to borrow it too, which
     left red meaning nine things and therefore nothing. */
  --accent: hsl(0 72% 52%);
  /* The one token here that carries text, and it was the darkest: 4.71 through
     the wash, where --ok sits at 8.70 and --warn at 7.70. Those three colour the
     same three states of the same status line, so the worst news in the app was
     rendered in the least readable of them.
     76 fixed the status line and missed the place this colour is actually read:
     the ✗ on a refuted clue, which sits on .clue-bad's own red tint. Accent text
     on an accent tint is the trap this house has walked into across five dark
     themes, and it is invisible to any check that only measures against neutral
     panels — 76% reads 7.47 on a panel and 6.52 on .clue-bad:hover. 80% reads
     7.50 there. The tint keeps its strength; the text moves, because the tint is
     decoration and the text is content.
     It does three other jobs — status text, focus ring, and the strike-through
     bar on a refuted clue — and lightening is safe in all of them: none puts
     text ON it (the bar is 2px of hairline, not a plate), the ring measures 9.43
     against the panel against a 3:1 bar, and the brand mark (an icon, so also
     3:1) measures 5.83 on its own 32% accent tint. */
  --accent-soft: hsl(0 72% 80%);
  --accent-deep: hsl(0 72% 45%);
  /* "Armed" — a picked-up suspect, an engaged mode, the cell inviting the drop.
     Deliberately shared by all three: they are one idea, not three. */
  --pick-line: hsl(0 0% 100% / 0.72);
  --pick-fill: hsl(0 0% 100% / 0.13);
  /* The room under the cursor, traced on its own floor. Deliberately NOT
     --pick-line: "armed" is a thing the player did and has to undo, while this is
     the board answering a question the player only asked by looking. Brass says
     "surveyed" everywhere else in this house, and it never competes with the red
     of an accusation. Measured against all ten room tints on both floor mixes it
     can meet — the resting 34% and the 44% under the cursor — it spans 3.81 (the
     rose carpet at hover tint) to 6.78 (the armoury at rest), over the 3:1 a
     non-text mark owes and short of shouting.
     62% is kept, not re-derived: the floors below need only 55% to clear 3:1
     everywhere, and the brass identity is worth more than the 7% of headroom.
     The number that moves when the floors move is the SPAN, not the token. */
  --room-edge: hsl(38 55% 62% / 0.9);
  /* 60, not 55. Green marks a confirmed clue and a taken statement, and both of
     those plates are tinted green — same-hue text on same-hue tint again, the
     third token in this block to walk into it. 55% read 7.05 on .is-taken and
     7.24 on .clue:hover; 60%, with that tint thinned to 0.05, reads 8.00 and
     7.76. Still clearly the darkest of the three status colours, which is right:
     green is the quiet one. Run `node test/contrast.test.mjs --matrix` to see
     every surface behind these two figures. */
  --ok: hsl(151 60% 60%);
  /* Pushed to orange so it cannot be mistaken for --brass, which sits five
     hue steps away and carries the opposite meaning (reward, not caution). */
  /* 68, not 62, and unlike --ok this one could not be fixed by thinning its
     plate. --warn also paints text on a tint of its own hue (.note-warn), and
     that plate is already at the 0.08 the others use: dropping it to 0.03 still
     only reaches 7.22, so the plate was never the lever here — the colour was
     simply too dark to carry small text on anything but bare panel. 68% reads
     7.48 on the tint and 8.58 on a panel. It ends up lighter than --brass at
     62%, which is the right way round: brass is chrome, warn is a warning. */
  --warn: hsl(28 92% 68%);
  /* The plate --warn paints ON, and a separate token because it is a separate
     decision: the text was lifted to 68% to clear AA, but a tint and a border
     are not text and lifting them with it would wash the plate out until the
     chip stopped reading as a plate at all. Every warn surface in the file was
     already hand-writing this same 62% triplet — five sites, none of them
     named — so the number was a convention held together by copy-paste, which
     is how the text colour and its plate drift apart. Named here, they move
     together or not at all. */
  --warn-plate: 28 92% 62%;
  --brass: hsl(45 72% 62%);

  /* Corner radii — three steps, named for the role a corner plays rather than
     for its size. The sheet used to carry a single radius token at 16px with
     two uses, while setting border-radius in twenty-two distinct values across
     sixty declarations; twelve of those stood as a raw 10px and eleven as a raw
     12px, both more common than the token itself. A token that does not name
     what the file actually does gets ignored. That is the same stall the first
     spacing scale ran into further down, and it gets the same answer here: the
     scale learns what the sheet already does, instead of bending the sheet.

     Role names, not sizes, because that is how this file names things
     (--panel-strong, --warn-plate, --cell-touch, --room-edge), and because --r
     and --bx-r are already taken — an --r-* namespace would have been
     ambiguous at first glance.

     What this scale deliberately does NOT do is unify radii. The 8/9/10/13px
     on the fixed-format square tiles are not drift, they are a proportion: they
     run with the tile's side length (26 to 8, 30 to 9, 34 to 10, 44 to 13, so
     about 0.30 of the side). Snapping those to one step would break a visible
     family to make one number smaller. .legend-dot (26px side, 4px radius)
     contradicts that rule, which is why it stands here as an observation and
     not as a check — a rule with a known counterexample is a finding, not a law.

     Off the radius scale — a list of exemptions, not of drift. Two sorts of
     entry, told apart by which side of the colon carries the selector.

     Exempt by KIND, shapes a box scale has nothing to say about: a circle is
     not an oversized box, and forcing it into one would make this scale speak
     about forms it does not mean.
     `50%`: `circle` · `999px`: `pill` · `inherit`: `inherited` · `0`: `edge`

     Exempt one at a time, each with the value it is meant to hold:
     `.brand-mark`: `14px` · `.menu-mark`: `14px` · `.rank-icon`: `9px`
     `.grid`: `5px` · `.sill`: `1px` · `.legend-dot`: `4px` · `.clue-name`: `4px`
     `.clue-struck .clue-text::after`: `2px` · `.avatar-culprit`: `13px`
     `.suspect-mark`: `8px` · `.profile-pick:focus-visible`: `8px`
     `.cipher-key summary:focus-visible`: `6px`
     `.bx-top, .bx-front`: `var(--r, var(--bx-r))`
     `.pawn-foot`: `34% 34% 26% 26% / 66% 66% 26% 26%`
     `.pawn-stem`: `30% 30% 22% 22% / 26% 26% 14% 14%`
     `.pawn-coat`: `46% 46% 18% 18% / 62% 62% 16% 16%`
     `.suspect-row .suspect`: `var(--radius-card) var(--radius-card) 4px 4px`
     `.suspect-withdraw`: `4px 4px var(--radius-card) var(--radius-card)`

     test/rundung.test.mjs reads this list in both directions, so an entry that
     nobody sets any more is as loud as a rounding that nobody wrote down. */
  --radius-inner: 10px;    /* what sits INSIDE a box — field, chip, entry */
  --radius-card: 12px;     /* the box itself — card, panel row, button */
  --radius-surface: 16px;  /* the surface underneath — panel, modal */

  /* WCAG 2.5.5 asks for 44px. A board cell needs 50 to deliver it, and the
     extra six are not padding: the board is tilted AND in perspective, so a cell
     is presented smaller than it is laid out, and the far row is presented
     smallest of all. Measured, not derived — at 48px the top row still only
     offered 43.6px. The number lives here because it was written as a literal in
     three separate places (the cell floor, the overflow board's min-width, the
     short-landscape cap), and when the cell floor was raised to 50 the other two
     were left behind at a bare 44 — the layout size that MEASURES 40. Nothing
     announced that: the two stragglers still read fine, still computed fine, and
     nobody edits three rules at once on purpose. A constant that carries a
     measurement has to be stated once or it stops being the same measurement. */
  --cell-touch: 50px;

  /* Und die zweite Achse derselben Messung. Beide Zahlen oben — die 44 und die
     50 — sind auf der BREITE genommen. Die Buehne ist aber gekippt, und
     rotateX(26deg) unter perspective:1700px staucht die Tiefe staerker als die
     Breite: dieselbe hinterste Kachel erscheint 47.9px breit, aber nur 41.9px
     hoch (gemessen 1185x826, 8x8, Brett 400px, Spurbreite 49.75 — das Brett
     traegt 2px Rahmen, die Planzelle ist nie glatt 50).

     Die Zahl steht hier als gemessene, nicht als gerechnete — die Perspektive
     haengt an Kippwinkel UND Fluchtweite, und eine Formel daraus waere
     praeziser, als die Messung verdient. Aendert sich einer der beiden, wird
     hier neu gemessen.

     Gemessen woran, ist dabei die erste Fangfrage: die Stauchung ist NICHT
     konstant. Ein groesseres Brett reicht weiter von der Kamera weg, seine
     hinterste Reihe staucht staerker. Bei Spur 49.75 sind es 0.842, bei 64.75
     nur noch 0.825 — und weil dieses Token die Brettgroesse mitbestimmt, ist es
     sein eigener Eingang. Es ist also ein Fixpunkt, kein Messwert: gesucht ist
     das Brett, auf dem die hinterste Reihe GENAU 44 misst, und das ist 421.4px
     (Spur 52.4). 352/421.4 = 0.835, abgerundet 0.83 — abgerundet, nicht
     aufgerundet, denn eine zu grosse Stauchung kostet die hinterste Reihe.

     Und die zweite Fangfrage, die diesen Wert schon einmal um 14% verfehlt hat:
     WANN gemessen wird. Der erste Anlauf las 36.6px und schrieb 0.72 — gemessen
     an einer Kachel, die noch im Eintritt stand (styles.css:4029
     `@keyframes cell-enter`). Dessen `from`-Frame traegt `scale(0.86)`, und
     `backwards` haelt ihn ueber die ganze Verzoegerung von bis zu 63 x 24ms
     fest; in einer gedrosselten Vorschau friert er ganz. 0.829 x 0.86 = 0.713
     — die fehlende Zahl war nie die Perspektive, sondern die Animation. Vor dem
     Messen gehoert deshalb `document.getAnimations().forEach(a => a.finish())`
     davor, sonst misst man den Auftritt und nicht das Brett. Die 0.72 hoben den
     Komfortboden auf 489px und schoben damit die unterste Reihe auf 1280x720
     unter die Falz — der Messfehler kam als Layoutfehler zurueck. */
  --rear-shrink: 0.83;
  /* Was eine Zelle gelegt messen muss, damit sie hinten noch getroffen wird.
     Der Tastboden oben ist der Boden der Breitenachse; dies ist der der Hoehe,
     und weil die Hoehe staerker staucht, ist er der groessere von beiden. Er
     gehoert NICHT an die Zelle selbst: 8 x 53.0 waeren 424px Mindestbreite und
     damit auf jedem 375px-Telefon Dauer-Querscrollen. Er gehoert an die
     HOEHENgrenze des Bretts (--board-fit weiter unten), wo er nur den Deckel
     anhebt und die Breite unberuehrt laesst. */
  --cell-comfort: calc(44px / var(--rear-shrink));

  /* Elevation. Three steps only — a fourth would flatten the difference. */
  --lift-1: 0 1px 2px hsl(0 0% 0% / 0.4);
  --lift-2: 0 12px 28px -14px hsl(0 0% 0% / 0.75);
  --lift-3: 0 34px 72px -30px hsl(0 0% 0% / 0.95);
  --inset-hair: inset 0 1px 0 hsl(0 0% 100% / 0.07);

  /* No webfonts: Google Fonts is a GDPR liability in the EU and this app ships
     without a build step or vendored binaries. The serif is the case-file
     voice, the sans is the interface voice — both from the system stack.
     Inter used to lead this list, which quietly contradicted the sentence above
     it: Inter ships on no operating system, so the stack was a system stack for
     most visitors and something else for anyone who had installed Inter locally
     — designers and developers, i.e. exactly the people who review it. Two
     consequences, one cosmetic and one load-bearing. Inter carries a real 800
     cut, so the measurement recorded on .room-plate below describes a family
     that is only the fallback. And Inter's wider advances run against overhang
     budgets that were measured on Segoe metrics. A font stack that renders
     differently for the reviewer than for the player is not a stack, it is a
     coin toss. */
  --font-ui: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
  --font-file: Georgia, 'Iowan Old Style', 'Palatino Linotype', 'Times New Roman', serif;
  /* Ciphers only. A morse row, a digit row and a roman numeral have to align
     glyph for glyph, which a proportional face refuses to do. */
  --font-cipher: ui-monospace, 'SFMono-Regular', 'Cascadia Mono', 'Consolas', monospace;

  /* Type scale — six steps at a 1.2 ratio on a 16px root. The steps have to
     stay far enough apart to be told apart: an earlier scale packed most of the
     interface into an 11.5–14px band, where "smaller" stopped reading as
     "quieter" and the hierarchy collapsed.

     Off-scale by design — and this list said "two one-offs" while the sheet
     carried five. The undercount mattered in one direction: it let a reader
     believe --fs-xs was the floor. It is not, and three rules say so, because
     none of them measures the document. They measure the board, so they hang on
     --board and fall below the scale rather than choosing a step in it:
     .room-plate (9px), .room-plate.pl-tight (8px) and .cell-name (10px). Each
     number is a floor, not a size — the clamp above it does the sizing.
     The other two answer to a device, not a layout: .select (16px) and
     .input (16px) hold that value because iOS Safari zooms the page on focus
     below it, which is also why it reappears inside the coarse-pointer block.
     That --fs-xs is not the smallest type in the sheet is the whole content of
     this paragraph. test/typescale.test.mjs reads the floors out of the rules
     and the names out of these lines, so the count cannot drift apart again. */
  --fs-xs: 0.6875rem;  /* 11px — 13 rules set caps AND tracking, 10 set neither, .compass is caps by content; test/versalien.test.mjs */
  --fs-sm: 0.8125rem;  /* 13px — meta, captions, secondary */
  --fs-md: 0.9375rem;  /* 15px — body and every interactive label */
  --fs-lg: 1.125rem;   /* 18px — subheads, totals */
  --fs-xl: clamp(1.375rem, 3.2vw, 1.75rem);
  --fs-2xl: clamp(1.625rem, 4.2vw, 2.375rem);
  --fs-file: 1rem;     /* the serif runs a step larger — Georgia sets small */

  /* Spacing scale — the counterpart to the type scale, and added later than it.
     Gaps and padding had grown to twenty distinct pixel values, among them 5, 7,
     9, 11 and 13px: neighbours no eye can tell apart, so the rhythm read as
     improvised next to a type scale that is deliberate.

     The first version of this scale was six steps on a 4px grid, and it stalled
     at 23 adoptions out of 144 spacing declarations. The reason is written into
     what it left behind: the holdouts cluster hard on 10px and 14px, and
     snapping those to 12 and 16 would have loosened some forty boxes by 2px
     each. This interface is meant to be dense — board, case file and suspect
     tray side by side — so "half a step tighter than the grid" was never drift,
     it was the actual rhythm, and a six-step scale simply had no name for it. A
     scale that cannot name what the file already does is decoration, and gets
     ignored accordingly.

     So the ramp now carries the half steps it kept being asked for. The `-5`
     suffix reads as "half": --space-2-5 is the step between --space-2 and
     --space-3. Below 16px they are not exceptions but the working rhythm — that
     band genuinely runs on 2px. Above 16px they are rare.

     Gaps, padding, margins and structural offsets are on this scale. What sits
     outside it is not rhythm: negatives, which pull something back over an edge
     rather than hold anything apart; absolute nudges that seat a label against
     artwork; and clearances measured against something that is not a
     neighbouring box at all — a piece of drawn art, or the viewport edge, as
     under .modal. Border widths answer to neither — they are strokes, and keep
     their own literals. Corner radii stood in that same sentence until they
     got a scale of their own, further up: they are shapes, so they answer to
     the radius scale and not to this one.

     Below the scale — where the 2px step is NOT. `--space-0-5` is set at six
     places, and at none of them does it stand between a line of text and the
     line under it. That is the outcome of a move, not an accident, and it is
     worth the paragraph because this file spent a long time doing the opposite.

     It used to sit between two lines at six further places, and there it was
     never the distance — it was a minority share of it. At `line-height: 1.5`
     every line carries half its leading above and half below, so ink sat from
     ink at halfLeading(upper) + step + halfLeading(lower), and the step
     claimed about a fifth of that. Doubling the token there bought a fifth of
     what the number suggested and halving it bought nothing anyone could see,
     so those six moved onto the lever that actually owns the distance:
     `line-height`. The measured percentages are not repeated here on purpose —
     the sites they described are gone, so nothing in this file could contradict
     them any more, and a number without a contradiction is decoration. They are
     kept as a finding in ROADMAP.md, which is where dead measurements belong.

     What the move costs is not what it looks like. Ink-to-ink between two
     stacked lines is lineHeight(upper) + lineHeight(lower) + gap, less each
     line's own slack between contour and line box: the gap enters ONCE and
     each line-height enters ONCE as well. Dropping a 2px gap therefore costs
     +1px of leading on EACH of the two lines — not +2px on one line, and not
     +1px shared between them. Every entry below is that trade, and
     test/durchschuss-render.test.mjs measured the picture on both sides of it.

     Where the leading went. Site · the rules that now declare it. The carriers
     are named and not only the sites, because `calc(1.5em + 1px)` computes to
     a LENGTH on the element that declares it and then inherits as that fixed
     length — declared one level too high, every child below gets leading meant
     for a different size.
     `.trace-meta` · `.trace-meta strong` + `.trace-meta .muted`
     `.cipher-key p` · `.cipher-key summary` + `details.cipher-key p`
     `.score-sheet` · `.score-row`
     `.menu-head p` · `.menu-head h2` + `.menu-head p`
     `.profile-pick` · `.profile-name` + `.profile-meta`
     `.fact-grid` · `.fact-grid dt` + `.fact-grid dd`

     The carriers that are not the rule the eye would pick get a line each — no
     count in front of them, because a count of exceptions is a number nothing
     in this file could refute. `.score-row` stands once for both lines because both lines ARE score rows. The cipher
     paragraph is carried by `details.cipher-key p` and not by `.cipher-key p`,
     because `.modal-inner p` further down this file has the same specificity
     and wins on source order — the shorter selector reads like it sets that
     paragraph's size and leading and sets neither. And `.profile-name` takes
     its `em` from `html, body`: neither it nor any ancestor declares a size,
     and `.profile-pick` spends `font: inherit` to overrule the button default
     it would otherwise inherit.

     And the six that remain, each with the reason it is not a pair of lines:
     `.dossier-summary::after`: `margin-left` — horizontal; the caret's offset from the text beside it.
     `.cell`: `gap` — inside a board tile, between a digit and its notes grid.
     `.cell`: `padding` — the tile's own inset against its border, holding nothing apart.
     `.cipher-key`: `margin-top` — the grid above already spends --space-2, so this is a nudge on an existing step.
     `.cipher-key summary`: `padding` — the vertical half is --space-1; --space-0-5 is the horizontal one.
     `.morse-key`: `margin` — the --space-0-5 is the BOTTOM margin, and its neighbour is the box edge.

     test/durchschuss.test.mjs reads both lists against the file in both
     directions: a site that stops being listed is as loud as an entry whose
     site is gone, and a carrier that stops declaring a leading is as loud as
     one of the six taking the token back. */
  --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-4-5: 20px;
  --space-5: 24px;
  --space-5-5: 28px;
  --space-6: 32px;

  /* The three measurements that decide how wide the BOARD ends up, named here
     because two places have to agree on them: the layout that spends them, and
     .grid-wrap, which subtracts them from the window to work out what is left
     over for the plan. Both carried their own literals, which is exactly the
     pair that drifts — widen the side column and the room labels would go on
     being sized for a board that no longer exists, silently. */
  --app-max: 1240px;
  --app-pad: clamp(var(--space-3), 2.4vw, var(--space-5));
  --side-w: 350px;

  /* Room colour is no longer a free palette: a room's hue is the colour of the
     material it is actually made of, so hue reservation had to move from hue to
     SATURATION. Every signalling token sits at 60% saturation or above (accent
     72, ok 60, warn 92, brass 72); every room material is capped at 34%. A
     walnut floor may therefore sit at hue 24 without ever being mistaken for the
     victim's red — one is a lit signal, the other is a dark surface. The
     per-room definitions live next to .cell, where they are read. */

  /* How much of the room hue bleeds through the dark cell base.
     34, up from 22: at 22 the ten floors sat 0.17 ΔE apart under Deuteranopie —
     two rooms were literally the same colour for a deuteranope, and no amount of
     hue choosing fixes that when the mix is too thin to carry the hue at all.
     Raising the mix is what buys the separation; the hues below only spend it.
     The board stays dark because the tints themselves are dark: composited, the
     ten floors land at L* 10.1 … 25.5, all of them under the lit crest of the
     partition facing the camera (L* 33.9). Not under every wall face — the side
     walls run parallel to the light and are deliberately darker (L* 24.5), which
     is what draws the corners; the brightest floor passes them by design. Der
     Satz stand hier lange als „all of them under the walls" und war in dieser
     Allgemeinheit falsch. Nachgerechnet wird er jetzt in
     test/contrast.test.mjs. */
  --cell-base: hsl(0 0% 8%);
  --cell-tint: 34%;
  --cell-tint-key: 62%;   /* legend swatches: hue must survive at 26px */
  /* Die Antwort des Zeigers ist eine AUFHELLUNG, kein zweiter Raumton. Hier
     stand bis eben --cell-tint-hover: 44%, also derselbe Hebel wie oben nur
     weiter aufgedreht — und genau daran ist er gescheitert. Wie weit ein
     Prozentpunkt Mischung einen Boden bewegt, hängt daran, wie weit dieser
     Boden vom --cell-base entfernt ist, und diese Distanz schwankt über die
     zehn Räume um das Sechsfache. Bei 44% bewegte sich die Waffenkammer um
     ΔE 1.08 — unter der Unterscheidungsschwelle 2.3, also gar nicht. Der
     Spieler wartete deshalb nicht auf eine LANGSAME Antwort (verzögert wird
     nichts, und 71% des Übergangs stehen nach 50 ms), sondern auf eine, die
     nie deutlich genug wurde. Um die Waffenkammer über die Schwelle zu heben,
     hätte der Wert auf 56% gemusst — dort flammt der hellste Raum um ΔE 14.65
     auf. Ein einziger Skalar kann das nicht leisten.
     Weiss kann es: es liegt zu jedem Boden gleich weit, also antwortet jeder
     Raum gleich stark (ΔE 5.2 … 6.6), und der DUNKELSTE gewinnt am meisten,
     weil er den meisten Kopfraum hat. Dieselbe Redewendung benutzt die
     durchsuchte Zelle weiter unten schon. Wie hoch sie steigen darf, sagt der
     Wandkamm bei L* 33.9, und die Grenze ist gemessen, nicht geschätzt: 12%
     treiben den Musikzimmerboden auf L* 35.5 und damit über seine eigene
     Mauer. 10% landen rechnerisch gerade eben darunter, 9% behalten 0.8 L*
     Luft — beides liegt unter der Schwelle, ab der ein L*-Unterschied
     überhaupt sichtbar wird (rund 1.0), und wäre ein Kamm nur auf dem Papier.
     8% ist der grösste Wert, der mit L* 32.2 sichtbare 1.7 L* Luft behält:
     ein Drittel mehr Antwort unter dem Zeiger als die 6% vorher, ohne dass ein
     Boden seine eigene Mauer überstrahlt und damit aufhört, ein Raum zu sein.
     Nachgerechnet in test/contrast.test.mjs → „auch unter dem Zeiger". */
  --cell-lift-hover: 8%;
}

* { box-sizing: border-box; }

/* Class-level display rules must not defeat the hidden attribute */
[hidden] { display: none !important; }

.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
}

html, body {
  margin: 0;
  min-height: 100%;
  background:
    radial-gradient(1100px 600px at 12% -10%, hsl(0 60% 18% / 0.35), transparent 60%),
    radial-gradient(900px 500px at 100% 0%, hsl(258 60% 22% / 0.30), transparent 55%),
    var(--bg);
  color: var(--text);
  font-family: var(--font-ui);
  /* 100% statt 16px — und das ist eine Reparatur, keine Umgewichtung.
     Der alte Kommentar begruendete die 16 gegen eine frueher hier stehende 15
     („a 15px root silently shrank every step"). Das Argument stimmt und trifft
     die falsche Groesse: eine feste Pixelzahl schiebt nicht nur eine 15 auf
     eine 16, sie setzt JEDE Einstellung des Nutzers ausser Kraft. Wer im
     Browser „Sehr gross" (32 px) waehlt, bekam gemessen Wert fuer Wert dieselbe
     Darstellung wie bei 16 — Wurzel 16, Knopftext 15, Statuszeile 13, Chip 11.
     Die Gegenprobe lief im selben Lauf: dieselbe 32-px-Einstellung mit
     `font-size: 100%` zur Laufzeit ergibt Wurzel 32, Knopf 30, Status 26,
     Chip 22. Die Einstellung ist also lebendig, und genau diese eine
     Deklaration hielt sie auf. WCAG 1.4.4 nennt das beim Namen.
     `100%` und nicht der Verzicht auf die Zeile: der Standardwert IST 100 %,
     aber die Zeile sagt jetzt ausdruecklich, dass die Wurzel dem Nutzer
     gehoert — und der naechste Leser, der hier eine Pixelzahl eintragen will,
     liest zuerst, warum das schon einmal falsch war.
     Die rem-Skala bleibt unveraendert: sie rechnet relativ zur Wurzel, und
     genau das soll sie. Bei einem 16-px-Standard aendert sich kein einziger
     gemessener Wert (Kontrolllauf: 15/15/13/11/18 vor und nach der
     Ueberschreibung) — deshalb bleiben auch die Typografie-Waechter gruen. */
  font-size: 100%;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}

/* ------------------------------------------------------------------- icons */

/* Every icon is an inline <svg> that inherits currentColor. Sizing therefore
   belongs to the box it sits in, not to font-size — the old emoji contract. */
.icon {
  display: block;
  width: 100%;
  height: 100%;
  /* Strokes were authored at 1.5 on a 24 grid, which thins out at chip sizes. */
  stroke-width: 1.6;
  overflow: visible;
}

.app {
  max-width: var(--app-max);
  margin: 0 auto;
  padding: clamp(var(--space-3), 2.4vw, var(--space-5-5)) var(--app-pad) var(--space-6);
  padding-left: max(var(--app-pad), env(safe-area-inset-left));
  padding-right: max(var(--app-pad), env(safe-area-inset-right));
  padding-bottom: calc(var(--space-6) + env(safe-area-inset-bottom));
}

/* ------------------------------------------------------------------ topbar */

.topbar {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3);
  align-items: center;
  justify-content: space-between;
  margin-bottom: var(--space-4);
}

.brand { display: flex; align-items: center; gap: var(--space-3); }

.brand-mark {
  display: grid;
  place-items: center;
  flex: 0 0 auto;
  width: 44px;
  height: 44px;
  padding: var(--space-2-5);
  border-radius: 14px;
  color: var(--accent-soft);
  background: linear-gradient(158deg, hsl(0 72% 52% / 0.32), hsl(0 0% 100% / 0.05));
  border: 1px solid var(--border);
  box-shadow: var(--inset-hair), var(--lift-1);
}

/* `--fs-xl` ist hier keine Restgröße, sondern der Boden: die Marke daneben ist
   fest 44px, und der Wortzeichen-Block misst mit dieser Stufe 28px Zeile plus
   11px `.tagline` = 58.5px. Eine Stufe tiefer (`--fs-lg`) fiele er auf ~43.5px,
   und die Marke wäre das größere Objekt — genau der Fehler, den `.menu-head`
   weiter unten für dieselbe Paarung protokolliert hat.
   Damit steht dieses h1 auf der Spielszene gleichauf mit `#case-title`, und das
   ist Absicht: getrennt wird die beiden nicht die Größe, sondern die Schrift
   (UI-Grotesk gegen `--font-file`). Die Rechnung dazu steht bei `.scene-head h2`
   — dort ist auch belegt, warum kein Ausweg über die Größe trägt. */
.brand h1 {
  margin: 0;
  font-size: var(--fs-xl);
  font-weight: 700;
  letter-spacing: -0.02em;
}

.tagline {
  margin: 0;
  color: var(--muted);
  font-size: var(--fs-xs);
  text-transform: uppercase;
  letter-spacing: 0.14em;
}

/* ------------------------------------------------------------------- rank */

/* The career badge. It is the only place in the chrome that carries brass, so
   a promotion is visible without reading a word. */
.rank {
  display: flex;
  align-items: center;
  gap: var(--space-2-5);
  padding: var(--space-2) var(--space-3-5) var(--space-2) var(--space-2-5);
  border-radius: 999px;
  border: 1px solid var(--border);
  background: var(--panel-strong);
  box-shadow: var(--inset-hair);
}

.rank-icon {
  display: grid;
  place-items: center;
  flex: 0 0 auto;
  width: 30px;
  height: 30px;
  padding: var(--space-1-5);
  border-radius: 9px;
  color: var(--brass);
  background: hsl(45 72% 62% / 0.14);
}

.rank-meta { display: grid; gap: var(--space-1); min-width: 148px; }
.rank-meta strong { font-size: var(--fs-sm); font-weight: 600; letter-spacing: -0.01em; }

/* A <span>, so it needs an explicit box before height can apply. */
.rank-bar {
  display: block;
  height: 4px;
  border-radius: 999px;
  background: hsl(0 0% 100% / 0.10);
  overflow: hidden;
}

.rank-fill {
  display: block;
  height: 100%;
  min-width: 0;
  border-radius: inherit;
  background: linear-gradient(90deg, var(--accent-deep), var(--brass));
  /* Slower than a UI transition wants to be, and that is the point: this bar
     moves exactly once per solved case, and it is the only place the player can
     watch progress toward the next rank actually happen. At 0.35s it was over
     before the eye returning from the win modal found it. The ease decelerates
     hard so the last few percent — the part that matters when a promotion is
     close — are the part that is legible. */
  transition: width 0.9s cubic-bezier(0.22, 1, 0.36, 1);
}

.rank-note { font-size: var(--fs-xs); }

.topbar-actions { display: flex; align-items: flex-end; gap: var(--space-2); flex-wrap: wrap; }

.field { display: flex; flex-direction: column; gap: var(--space-1); }
.field-label { font-size: var(--fs-xs); color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; }

.select {
  appearance: none;
  /* The popup list is drawn by the OS, and Chromium forwards this background-color
     to it. A translucent value composites over white OS chrome and swallows the
     near-white text — so the control keeps an OPAQUE colour and the caret rides
     in background-image, not in the shorthand. */
  background-color: var(--surface-2);
  background-image:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath fill='%23a3a3a3' d='M1 1.5 6 6.5l5-5'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 0.75rem center;
  background-size: 11px 7px;
  color-scheme: dark;
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: var(--radius-inner);
  /* The right inset is the only padding here that is not rhythm: it is
     clearance for the caret drawn as a background image above, measured
     against that caret's inset (0.75rem) and width (11px) rather than against
     the spacing scale. Left literal rather than written as a calc of those
     three, because the caret art is stable and a lone number that visibly does
     not belong to the scale is the clearer signal that it answers to
     something else. */
  padding: var(--space-2) 2.1rem var(--space-2) var(--space-3-5);
  font-size: 16px; /* below 16px iOS Safari auto-zooms on focus */
  font-family: inherit;
  min-height: 44px;
  cursor: pointer;
  /* Insurance, not a fix for a measured defect: at 320px the longest label,
     „Düster (ab 18)", takes 100px of a 117px content box, so it fits today. It
     fits by 17px though, and the font is a stack — the first entry that answers
     on an unfamiliar device may be wider. Without this a <select> clips
     mid-glyph and shows no sign anything is missing, which on this control means
     an age rating that ends after „ab". */
  text-overflow: ellipsis;
}

/* Explicit, opaque option colours — inheritance does not reach the OS popup. */
.select option {
  background-color: var(--surface-2);
  color: var(--text);
}

.select:hover { border-color: var(--border-strong); }
.select:focus-visible { outline: 2px solid var(--accent-soft); outline-offset: 2px; }

/* The house had no text input until the menu needed one for a profile name, so
   this is written to match `.select` rather than to introduce a second control
   idiom: same ground, same border, same radius, same 16px floor, same focus
   ring. What it does NOT copy is the caret art and the right inset that clears
   it — those belong to a control that opens a list.
   `background-color` rather than the shorthand for the same reason as above: a
   translucent value would composite over whatever sits behind the menu card. */
.input {
  appearance: none;
  background-color: var(--surface-2);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: var(--radius-inner);
  padding: var(--space-2) var(--space-3-5);
  font-size: 16px; /* below 16px iOS Safari auto-zooms on focus */
  font-family: inherit;
  min-height: 44px;
  min-width: 0; /* a flex child's default min-width:auto would refuse to shrink */
}

.input::placeholder { color: var(--muted); }
/* `:not(:disabled)`, because the name field IS disabled once the cabinet is full
   (`renderMenu`), and a dead field that brightens under the cursor promises a
   click it will not honour. */
.input:hover:not(:disabled) { border-color: var(--border-strong); }
.input:focus-visible { outline: 2px solid var(--accent-soft); outline-offset: 2px; }

/* The same dashed idiom as `.btn:disabled`, and deliberately the same one: in
   this sheet a dashed border means „not yet" — it is what `.trace-slot` wears
   for a clue nobody has uncovered. Written out rather than left to the UA sheet
   because the author declarations above win against it, so without this a full
   cabinet's field is pixel-identical to an empty one's.
   `background-color` for the reason given above the block: the shorthand would
   reset the layer this control composites onto. */
.input:disabled {
  cursor: not-allowed;
  color: var(--muted);
  background-color: transparent;
  border-style: dashed;
}
.input:disabled::placeholder { color: var(--border-strong); }

/* The 16px floor exists only to stop iOS auto-zooming on focus, and iPads do
   that at any width — so the compact size is gated on the pointer, not the
   viewport. Mouse and trackpad get the tighter control, every touch device
   keeps the floor.
   `pointer: fine` describes only the PRIMARY device, which is the query the
   pointer gate near the end of this sheet exists to condemn: a touchscreen
   laptop answers „fine" and lands here, so the menu's three selects and its
   name field would ship at 40 while every button beside them had been lifted to
   44 — one row of controls out of step with the card around it. The floor is
   therefore RESTATED under `any-pointer: coarse` below, which is later in the
   sheet and at equal specificity, so it wins there. Stated there rather than
   narrowed here for a second reason: the geometry suite audits the floors
   inside that block, and a floor merely dropped outside it is invisible to the
   guard written for exactly this defect. */
@media (min-width: 561px) and (pointer: fine) {
  .select,
  .input { font-size: var(--fs-md); min-height: 40px; }
}

/* ------------------------------------------------------------------ layout */

.layout {
  display: grid;
  grid-template-columns: minmax(0, 1fr) var(--side-w);
  gap: var(--space-4);
  align-items: start;
}

.panel {
  background: var(--panel);
  backdrop-filter: blur(18px);
  -webkit-backdrop-filter: blur(18px);
  border: 1px solid var(--border);
  border-radius: var(--radius-surface);
  padding: var(--space-4);
}

.side { display: grid; gap: var(--space-4); }

/* Zwei Spalten, zwei Aufgaben: links wird gespielt, rechts wird nachgeschlagen.
   Sobald die Fallakte hier drin steht, ist diese Spalte länger als das Fenster —
   und ohne eigenen Scrollbereich würde die ganze SEITE scrollen, also das Brett
   mit nach oben aus dem Bild, sobald jemand eine Zeugenaussage nachliest. Genau
   das war der Grund, das Brett überhaupt in die Höhe passen zu lassen; es hier
   wieder zu verspielen wäre die halbe Arbeit.

   `sticky` und ein eigener Überlauf: die Spalte bleibt stehen und rollt in sich.
   Nur ab der Zweispaltigkeit; darunter stapelt das Layout, und eine in sich
   scrollende Box mitten in einer Telefonseite ist eine Falle.

   Above the stacking breakpoint the two columns are a pair the player reads
   together: a clue names a room, the room is on the board. On a 6x6 case the
   column is shorter than the board and this does nothing. On an 8x8 it is not —
   the clue list grows with the case, the Spuren panel unhides once anything has
   been searched, and the column ends up taller than the viewport. Scrolling down
   to read hint nine then took the board off screen, which is the one thing the
   side-by-side layout exists to prevent.

   Bis 2026-08-02 standen hier ZWEI `@media (min-width: 941px)`-Blöcke mit
   demselben Selektor untereinander, und der zweite überschrieb die
   `max-height` des ersten. Die Rechnung war in beiden dieselbe — `--space-6`
   ist 32px und damit exakt `2 * --space-4` —, verschieden war nur die Einheit,
   und genau die war das Argument des ersten Blocks. Ein Kommentar, der `svh`
   begründet, über einer Regel, die `dvh` liefert, ist schlimmer als kein
   Kommentar. Zusammengelegt und die Einheit entschieden, statt sie zweimal zu
   schreiben. */
@media (min-width: 941px) {
  .side {
    position: sticky;
    top: var(--space-4);
    /* `--space-6`, nicht `--space-4`: derselbe Abstand, der die Spalte oben
       vom Rand hält, bleibt unten stehen, sodass das letzte Panel dort endet,
       wo das erste beginnt. Diese Invariante ist der Grund, warum hier Token
       und keine Zahl steht — sie ist im Abstands-Vokabular formuliert, also
       muss sie darin buchstabiert werden, sonst bricht ein Nachziehen der
       Skala die bündige Kante still.

       `svh` aus demselben Grund wie beim Brett: die kleine Fensterhöhe ist
       die, die immer da ist. `dvh` wäre hier sogar der schlechtere Wert —
       eine in sich scrollende Spalte, deren Deckel sich beim Ein- und
       Ausblenden der Browserleiste mitbewegt, verschiebt ihren eigenen
       Inhalt unter dem Finger.

       Die `vh`-Zeile ist keine Dopplung: ein Browser ohne `svh` verwirft die
       Zeile darunter beim Parsen, und ohne Rückfall hätte die Spalte gar
       keine `max-height` und käme nie an ihr eigenes Ende. */
    max-height: calc(100vh - var(--space-6));
    max-height: calc(100svh - var(--space-6));
    /* Sonst klebt der Fokusring eines gerade angesprungenen Bedienelements am
       Rand des Überlaufs statt frei zu stehen. */
    scroll-padding: var(--space-2);
    /* Am unteren Rand sind 8px zu wenig, und zwar messbar: dort steht das
       angeheftete Blatt (`.side > .actions`, gleich unten), es ist
       undurchsichtig und je nach Meldung 123 bis 166px hoch. Ein Bedienelement,
       das die Tab-Taste ans Ende des Überlaufs holt — `#note-mode`,
       `#clear-notes`, `#reset`, `#hint`, `#reveal-name`, eine
       Verdächtigen-Marke — landet mit Fokusring KOMPLETT darunter. Das ist WCAG
       2.2 SC 2.4.11: der Fokus darf nicht von eigenem Inhalt verdeckt werden.

       Hier stand bis 2026-08-04 eine ausgeschriebene Summe, und sie war DREIMAL
       zu kurz, jedes Mal aus demselben Grund. Erst `1.2rem` für die Meldung,
       weil `#status` „ja nur eine Untergrenze" brauche — `min-height` ist aber
       ein BODEN, kein Deckel. Dann `calc(var(--fs-sm) * 3)`, begründet mit
       „alle geprüften Meldungen brechen auf zwei Zeilen um" — geprüft worden
       war eine Stichprobe, deren Platzhalter mit Möbelnamen aus `js/data.js`
       gefüllt waren (längster damals (Stand 2026-08-04) `Kupferkessel`,
       12 Zeichen) statt mit den Namen
       aus `js/scenarios.js` (längste Person `Handschriftenkundler Dr. Ivo
       Rasmussen`, 38). Dann `* 6`, begründet mit einer im Browser gezählten
       dritten Zeile plus einer Zeile Luft — die gezählte Zeile stammte aus
       einer Sonde, die zwei Zweige eines Ternärs zusammenklebte, und der
       wirklich längste Text stand ganz woanders: `withdrawWord` in `js/ui.js`
       setzt ZWEI Personennamen in einen Satz, 176 Zeichen.

       Dreimal dieselbe Fehlerklasse — jemand zählt Zeichen, und wer zählt,
       irrt in die knappe Richtung. Die Reserve wird deshalb nicht mehr
       geschrieben, sondern GEMESSEN: `observeFootHeight()` in `js/ui.js` hängt
       einen `ResizeObserver` an das Blatt und schreibt dessen gerenderte Höhe
       als `--fuss-h` hierher. Damit ist die reservierte Höhe per Konstruktion
       die tatsächliche — bei jedem Text, jedem Schriftgrad, jeder
       Spaltenbreite, auch bei solchen, die es heute noch nicht gibt.

       `--space-2` obendrauf ist der Fokusring: 2px Versatz plus 2px Breite
       liegen ausserhalb der Randbox und werden von `offsetHeight` nicht
       mitgezählt. 8px statt 4px, weil zu viel Rand hier nichts kostet — er
       verschiebt nur, wo `scrollIntoView` landet, nicht wo etwas gezeichnet
       wird.

       Der Rückfall darunter gilt, solange kein JS lief (der erste Tastendruck
       vor `init()`) und auf Browsern ohne `ResizeObserver`. Er ist die einzige
       Stelle, an der noch eine Obergrenze BEHAUPTET wird, und trägt deshalb den
       grosszügigen Fall: 44px Knopf (der grobe Zeiger, für den dieser Zweig
       gebaut ist) und vier Meldungszeilen (176 Zeichen bei 301,33px Textbreite
       und 13px ≈ 48 Zeichen je Zeile). Dass er die Glieder trifft und mehr
       Meldung trägt als der Boden von `.status`, prüft
       `node test/quer.test.mjs`; dass die Messung darüber überhaupt existiert,
       ebenfalls. */
    --fuss-rueckfall: calc(var(--space-4) * 2 + 44px + var(--space-2-5) + calc(var(--fs-sm) * 6) + 2px);
    scroll-padding-bottom: calc(var(--fuss-h, var(--fuss-rueckfall)) + var(--space-2));
    /* Subtracting the header height instead — calc(100dvh - 32px - 88px) — was
       the obvious way to stop the column overhanging the fold before the page
       has been scrolled. It is wrong at every width but one: .topbar wraps, so
       its height changes at 941px and again at 561px, and a constant tuned to
       one of those states is short by the other two — always in the direction
       that steals height the column needs. anchor-size() would read the real
       height, but it has no Firefox or Safari baseline. So the overhang stays:
       it lasts until the first scroll, and the sticky foot below is what makes
       that acceptable — one gesture, and the verdict button is pinned for the
       rest of the case. */
    overflow-y: auto;
    /* Naming one axis computes the other to auto, which would hand the column a
       horizontal scrollbar it has no content for. This says clip but COMPUTES to
       hidden — the spec forbids clip opposite a scrolling axis (CSS Overflow 3
       §3.1). Written as the intent, kept because the outcome is the same one the
       column needs: no visible scrollbar, nothing painted outside. */
    overflow-x: clip;
  }

  /* The column scrolls, and on a long case the two things that must never be
     out of reach were the two that ended up furthest down it: „Anklage erheben"
     — the only way to finish — sat 681px below the fold on a 1440x900 laptop,
     and #status, the aria-live region every setStatus() writes to, sat 781px
     below. A live region that announces to a screen reader while being off
     screen for everyone else is feedback the sighted player never receives.

     Pinned to the foot of the scroller rather than pulled out of it, so the
     panel keeps its place in the reading order and in the tab order. The
     background must be opaque: --panel is a white film that lets the clue list
     scroll through it, and a verdict button you can read the hints through is
     worse than one you have to scroll to. */
  .side > .actions {
    position: sticky;
    bottom: 0;
    z-index: 1;
    background: var(--surface-1);
    /* .panel sets backdrop-filter: blur(18px), which has nothing to blur once
       the background above is opaque — but the filter is still declared, and a
       declared backdrop-filter makes the element a backdrop root: the engine
       snapshots what is behind it every frame. On the one element that moves
       against its scroller for the whole case, that is a cost with no pixel to
       show for it. */
    backdrop-filter: none;
    /* Only ever painted upward, so the panel reads as a lid over the scrolling
       content instead of a card floating above the page. */
    box-shadow: 0 -12px 24px -18px hsl(0 0% 0% / 0.9);
    /* Und weil es ein Blatt ist und keine Karte, hört seine Karten-Grammatik
       nach unten auf. `.panel` zeichnet eine Haarlinie rundum und 16px Radius
       an allen vier Ecken; zwei runde Unterecken über der Kante der Spalte
       sagen „Karte, die stehen geblieben ist" — genau die Lesart, die der
       Kommentar oben bestreitet, und der Deckel-Schatten kämpfte dagegen an.
       Unten offen trägt der Schatten allein, und zwar das, was er sagen soll:
       hier hört der Lauf auf. Kostet null Höhe. */
    border-bottom-color: transparent;
    border-bottom-left-radius: 0;
    border-bottom-right-radius: 0;
  }
}

/* An eyebrow, not a heading: it is smaller than the items it introduces, so it
   needs a second cue to still read as their parent. The hairline gives it the
   full width of the panel and turns the size difference into a deliberate rank
   instead of an inversion. */
.panel-title {
  display: flex;
  align-items: center;
  /* Measured at 320px: without this the row cannot move a label down, so it
     takes the only other way out and breaks one INSIDE — "(8 + 2 in der" over
     "Fallakte)" over a two-line count, three ragged lines where two clean ones
     were available. The labels beside the eyebrow are whole phrases; a phrase
     that has to go somewhere should go to the next line, not come apart. */
  flex-wrap: wrap;
  gap: var(--space-2-5);
  margin: 0 0 var(--space-3);
  font-size: var(--fs-xs);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.14em;
  color: var(--muted);
}
/* The live count next to the label, and the one thing in this row that is not
   chrome. Inheriting the eyebrow's treatment gave "3/7" the same weight, the
   same tracking and the same colour as the word "Hinweise", so the row read as
   two labels of equal rank and the number — the only part that ever changes —
   was the harder of the two to find. Dropping the tracking and the caps also
   fixes the arithmetic: 0.14em applies to the slash and to every digit, which
   is a lot of air inside a three-character value. Tabular figures keep the
   hairline from stepping sideways each time a digit changes width. */
.panel-title .muted {
  /* The step up was argued from a contract that did not hold. `--fs-xs` used to
     claim "uppercase eyebrows only" (styles.css:266 `--fs-xs:`), and seven of
     its twenty rules were lowercase while it said so. What DOES hold there is
     caps and tracking travelling together, measured and now guarded — and this
     run drops both on the next lines, so it never owed that debt. The size step
     keeps its own reason, which was always the better one: the count stops being
     the same height as the word beside it. Rank reads from size soonest. */
  font-size: var(--fs-sm);
  font-weight: 600;
  letter-spacing: 0;
  text-transform: none;
  font-variant-numeric: tabular-nums;
  /* The tabular figures above are there so a changing digit cannot move the
     hairline sideways, and they are worth nothing if the phrase around them can
     be cut in half. Atomic here, wrapping on the row: the count travels whole. */
  white-space: nowrap;
}

/* The player's own mark on the list, held apart from the count beside it —
   its own kind of fact, and the only one in the row a click can take back.
   Same standing as the count: neither is allowed to be broken to make room,
   because the row can drop one to the next line instead. */
.panel-title__aside {
  /* Eigene Zeile, und zwar die LETZTE. Vorher teilte sich der Lauf die Zeile
     mit der Haarlinie (`.panel-title::after`), die als dehnbares Element den
     Rest fuellt — sobald der Lauf da war, brach die Linie um und lag als
     armseliger Stummel unter der Ueberschrift. Die drei in der Roadmap
     vorgeschlagenen Auswege (Linie kuerzen, Lauf kuerzen, Umbruch verbieten)
     laufen alle auf dieselbe Frage hinaus — „passt es bei Breite W in eine
     Zeile?" — und die kann hier NIEMAND beantworten: es gibt keinen Renderer
     im Baum, also auch keine Sonde, die eine breitenabhaengige Zusage
     widerlegen koennte. `order` macht die Frage gegenstandslos: der Lauf geht
     hinter die Linie, nimmt eine ganze Zeile, und die Linie bleibt in JEDER
     Breite bei der Augenbraue.
     Der Preis, ehrlich benannt: bei 375px kostet das eine zweite Zeile — aber
     nur dann, wenn ueberhaupt ein Hinweis zurueckgezogen wurde. Ist der Lauf
     leer, greift `:empty { display: none }` darunter und es kostet nichts. */
  order: 1;
  flex: 0 0 100%;
  white-space: nowrap;
  /* Same step and for the same reason as the count above: lowercase does not
     belong at the eyebrow's size. */
  font-size: var(--fs-sm);
  color: var(--warn);
  font-weight: 600;
  letter-spacing: 0;
  text-transform: none;
}
.panel-title__aside:empty { display: none; }

.panel-title::after {
  content: '';
  /* A floor, because this hairline is the thing that makes the eyebrow read as
     the parent of the list under it. Without one it is the only flexible item
     in the row, so it pays for every character the labels beside it grow by —
     and a two-pixel remnant does not say "this heading spans the panel", it
     says a border was cut off. Sized to the smallest run that still reads as a
     deliberate line rather than a dash. */
  min-width: var(--space-6);
  flex: 1 1 auto;
  height: 1px;
  background: linear-gradient(90deg, var(--border-strong), transparent);
}

.muted { color: var(--muted); }

/* ------------------------------------------------------------------- scene */

.scene-head {
  display: flex;
  gap: var(--space-3);
  justify-content: space-between;
  align-items: flex-start;
  flex-wrap: wrap;
  margin-bottom: var(--space-3-5);
}

.scene-title { min-width: 0; }

.case-kicker {
  display: block;
  font-size: var(--fs-xs);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.16em;
  color: var(--accent-soft);
}

/* The serif is the case-file voice. It carries the dossier body, and it also
   carries the case's own name, its verdict and its closing screen — so the
   typeface marks "this is the case" everywhere, instead of appearing in exactly
   one paragraph style and reading as an accident. */
/* `--fs-xl` und nicht `--fs-2xl`: der Falltitel steht ÜBER dem Brett, also in
   genau der Höhe, die dem Brett fehlt. Auf 1185x826 lief er bei 38px über zwei
   Zeilen (85px statt 43) und schob die Kopfzeile auf 170px auf — 54px, die
   unten die hinterste Reihe kosteten. Auf 1440x900 kostet dieselbe Stufe die
   Reihe erneut (Brettunterkante 907.7 gegen 900). Das Vorbild steht schon in
   dieser Datei: der Querformat-Block setzt `#case-title` aus demselben Grund
   eine Stufe herunter.

   Was hier früher stand — der Titel sei „der lauteste Text der Seite" — ist
   falsch, und die Zählung zeigt es: bei 28px ist er GLEICHAUF mit dem Wortzeichen
   `.brand h1` in der Kopfleiste; alles andere auf dem Bildschirm liegt bei 16px
   und darunter. Der Gleichstand bleibt trotzdem stehen, weil beide Auswege
   gemessen schlechter sind als er:

   Höher geht nicht weit genug. Der längste der 30 Titel ist „Die letzte Stunde
   von Landhaus Wolkenstein" (42 Zeichen), und auf 1185 endet die Einzeiligkeit
   zwischen 32px (719.9 von 722 verfügbar) und 33px (zweizeilig, Brettunterkante
   819.1). 32px hielte also mit 2.1px Rest — eine Grenze, die nicht der nächste
   Commit reißt, sondern der nächste Falltitel. Der sichere Deckel wäre 30.4px,
   und der ginge am Wortzeichen mit 2.4px vorbei; für genau solche Abstände hat
   diese Datei bei `.menu-head` schon entschieden, dass ein paar Pixel kein Rang
   sind.

   Tiefer geht auch nicht: das Wortzeichen steht neben einer 44px-Marke, sein
   Block misst 28+11 = 58.5px. Auf `--fs-lg` fiele er auf ~43.5px und die Marke
   wäre das größere Objekt — der Fehler, den `.menu-head` unten ausdrücklich
   protokolliert.

   Also trennt hier nicht die Größe, sondern die Schrift: das Wortzeichen läuft
   in der UI-Grotesk, der Falltitel in `--font-file`. Das ist konsistent mit der
   Rangregel weiter unten (`.menu-head`) — Rang folgt dem, WORÜBER eine
   Überschrift steht, und App-Name und Fall-Name stehen über Verschiedenem. */
.scene-head h2 {
  margin: var(--space-1) 0 0;
  font-family: var(--font-file);
  font-size: var(--fs-xl);
  font-weight: 700;
  letter-spacing: -0.012em;
  line-height: 1.12;
  text-wrap: balance;
}
.scene-head p { margin: var(--space-1) 0 0; font-size: var(--fs-sm); }

/* Wraps, because a fourth chip arrived with the liar mode. Chips are short,
   self-contained labels, so a second line costs nothing while an overflow costs
   the label — at 320px the four of them ran 15px past the panel and cut
   "0/5 platziert" off mid-word.

   `flex-wrap` alone did NOT buy that, which is why this row read as fixed for as
   long as it did: the wrap is on the inner container, but the shrinking has to
   happen to it as an ITEM of .scene-head, and `flex-shrink: 0` pinned it at its
   full unwrapped width — so the line box never got narrow enough for the wrap to
   fire, and the overflow went to the page instead. `min-width: 0` is the other
   half: a flex item's automatic minimum size is its content, so it would refuse
   to shrink past the widest chip even once shrinking is allowed. They stay flush
   right so a wrapped line sits under the row it broke from rather than drifting
   to the opposite edge. */
.scene-meta {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: var(--space-2);
  min-width: 0;
}

/* ---------------------------------------------------------------- dossier */

/* The case file reads as paper, not as chrome: warmer surface, a serif voice
   and a rule down the left edge. The visual break is the point — a witness
   statement must not look like another button in the interface. */
.dossier {
  margin-bottom: var(--space-4);
  border-radius: var(--radius-card);
  border: 1px solid var(--border);
  border-left: 3px solid hsl(45 72% 62% / 0.55);
  background: linear-gradient(180deg, hsl(38 22% 12% / 0.55), hsl(0 0% 100% / 0.02));
  box-shadow: var(--inset-hair);
  overflow: hidden;
}

.dossier-summary {
  display: flex;
  align-items: center;
  gap: var(--space-2-5);
  padding: var(--space-3) var(--space-4);
  cursor: pointer;
  list-style: none;
  min-height: 44px;
  user-select: none;
}
.dossier-summary::-webkit-details-marker { display: none; }
.dossier-summary:hover { background: hsl(0 0% 100% / 0.035); }
.dossier-summary:focus-visible { outline: 2px solid var(--accent-soft); outline-offset: -3px; }

.dossier-icon {
  display: grid;
  place-items: center;
  flex: 0 0 auto;
  width: 20px;
  height: 20px;
  color: var(--brass);
}

.dossier-label {
  font-size: var(--fs-sm);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.12em;
}

/* Pushed to the far end so the open-state chevron below has a fixed anchor.

   Kein `min-width`, und das ist eine Messung, keine Auslassung. Ein Review
   verlangte hier eine Schranke gegen Stauchung — nachgestellt im laufenden
   Bogen fand sich keine: auf 941x560, dem SCHMALSTEN Fenster, in dem der
   Querformat-Zweig überhaupt zwei Spalten baut, ist der Streifen 477px breit
   und der Vermerk 144.97px bei einer Inhaltsbreite von 145px. Er steht also auf
   seiner natürlichen Breite, mit rund 180px Luft davor. Unter 941px klappt der
   Bogen ohnehin einspaltig, und dort trägt der Vermerk schon
   `flex-basis: 100%` — eine eigene Zeile ist die stärkere Schranke, als eine
   Mindestbreite es wäre.
   Gestaucht werden könnte er auch dann nicht: sein `min-width` ist `auto`, der
   Vorgabewert für Flex-Kinder, und der ist bereits die Zusicherung „nicht unter
   den Inhalt". Der drei möglichen Texte (`js/ui.js`, `renderDossier()`) ist
   keiner länger als „alle Aussagen ausgewertet". Eine Schranke ohne Fall, den
   sie hält, ist keine Absicherung, sondern eine zweite Behauptung, die später
   jemand nachprüfen muss. */
.dossier-note { margin-left: auto; font-size: var(--fs-xs); text-align: right; }

/* Rotating caret, drawn in CSS so the summary keeps its single-line markup. */
.dossier-summary::after {
  content: '';
  flex: 0 0 auto;
  width: 8px;
  height: 8px;
  margin-left: var(--space-0-5);
  border-right: 2px solid var(--muted);
  border-bottom: 2px solid var(--muted);
  transform: rotate(45deg) translate(-2px, -2px);
  transition: transform 0.18s ease;
}
.dossier[open] .dossier-summary::after { transform: rotate(-135deg) translate(-2px, -2px); }

/* Die Fallakte ist die einzige Fläche der App, die mit dem Fall MITWÄCHST: mehr
   Zeugen, mehr Indizien, mehr Absätze. Ohne eigenen Scroll-Besitzer schiebt sie
   das Brett vor sich her — gemessen 1440x900: `#grid` beginnt bei y=1332, also
   vollständig unter der Falz, während dieselbe Seite auf 375px besser dasteht,
   weil das Skript die Akte dort zuklappt. Ein `max-height` macht aus dem
   wachsenden Bereich einen begrenzten und gibt dem Brett den ersten Blick
   zurück; `overscroll-behavior: contain` verhindert, dass das Weiterscrollen am
   Ende der Akte die ganze Seite mitreißt.

   Hier stand bis 2026-08-02, der andere Weg — `open` aus dem Markup nehmen —
   sei bewusst NICHT gegangen worden, weil die Akte beim Ankommen lesbar sein
   solle. Das stimmt seit N-20 nicht mehr: das Markup trägt kein `open`, und
   `js/ui.js` klappt die Akte in `startCase()`, in `init()` und beim Drehen
   ausdrücklich zu. Der Deckel hier bleibt trotzdem richtig — er begrenzt die
   GEÖFFNETE Akte, und die kann immer noch mitwachsen. Falsch war nur der
   Nachsatz, der einen Weg für ungegangen erklärte, den die App inzwischen
   geht. Die Begründung für das Zuklappen steht dort, wo es passiert, und ist
   die bessere: zusammengeklappt bleiben Lese-, Fokus- und Sehreihenfolge
   dasselbe, während `order` sie auseinanderzöge (WCAG 2.4.3). Ein Kommentar,
   der eine Entscheidung beschreibt, die anderswo umgekehrt wurde, ist
   gefährlicher als gar keiner: er nimmt dem nächsten Leser den Anlass,
   nachzusehen. */
.dossier-body {
  display: grid;
  gap: var(--space-3);
  padding: var(--space-1) var(--space-4) var(--space-4);
  border-top: 1px solid var(--border);
  max-height: 46vh;
  overflow-y: auto;
  overscroll-behavior: contain;
}

/* Der Falltitel als Überschrift des Dokuments, das man liest — und zugleich
   die einzige Stelle, an der er OHNE Hover vollständig zu haben ist. Im
   Tablet-Querformat schneidet `#case-title` mit Ellipse ab, damit Uhr und
   Fortschritt ganz bleiben; die dort dokumentierte Deckung war das
   `title`-Attribut, also ausgerechnet eine Zeigergeste auf der Geräteklasse,
   für die dieser Zweig überhaupt existiert. Ein Tipp auf „Fallakte" ist kein
   Hover.

   `--font-file` und nicht die UI-Grotesk: die Akte ist Fließtext einer anderen
   Stimme, und der Titel gehört zu ihr, nicht zur Oberfläche. Er kostet keine
   Brettpixel — `.dossier-body` hat mit `max-height` und `overflow-y` einen
   eigenen Scroll-Besitzer, und im Querformat ist die Akte ohnehin zu, sodass
   der Posten `--q-streifen: 44px` unberührt bleibt. */
.dossier-case {
  margin: var(--space-1) 0 0;
  font-family: var(--font-file);
  font-size: var(--fs-lg);
  font-weight: 600;
  line-height: 1.3;
  color: hsl(40 20% 94%);
  text-wrap: balance;
}

.dossier-text {
  margin: 0;
  /* The one long-form reading surface in the app, and the only one that needed a
     measure: at 1440px the case file ran the full width of the scene panel, past
     100 characters a line, where the eye loses the return sweep and re-reads the
     line it just left. 66ch is the conventional ceiling for continuous prose and
     is stated in ch rather than px so it tracks the file font, which is not the
     UI font and is the whole reason this paragraph is set apart. */
  max-width: 66ch;
  font-family: var(--font-file);
  font-size: var(--fs-file);
  line-height: 1.7;
  color: hsl(40 14% 90%);
  text-wrap: pretty;
}

/* A witness paragraph carries information the clue list does not have yet, so
   it is lifted out of the running prose with brass and its own surface. */
.dossier-par.is-witness {
  padding: var(--space-3) var(--space-3-5);
  border-radius: var(--radius-inner);
  border-left: 2px solid hsl(45 72% 62% / 0.7);
  background: hsl(45 60% 55% / 0.07);
}
.dossier-par.is-witness .dossier-text { font-style: italic; }

.dossier-extract {
  margin-top: var(--space-2-5);
  display: inline-flex;
  align-items: center;
  gap: var(--space-1-5);
  min-height: 40px;
  padding: var(--space-2) var(--space-3);
  border-radius: 999px;
  border: 1px solid hsl(45 72% 62% / 0.45);
  background: hsl(45 72% 62% / 0.12);
  /* --text, not --brass, and this is the one place in the house where the fix
     was the label rather than the token. The pill is brass text on a brass fill
     inside the warm .dossier gradient — three layers of the same hue under a
     colour that has to stand out from it. At the 0.20 hover fill it reads 5.76,
     the worst number in the stylesheet, and no lightening of --brass survives it
     without turning brass into straw: 74% still only reaches 6.90.
     So the border and the fill keep the brass, which is what carries the
     file-folder look, and the label goes white: 9.60 on the hover fill. Same
     answer this house has reached on five dark themes — a tinted fill wants a
     neutral label, not a label in its own hue. */
  color: var(--text);
  font-family: var(--font-ui);
  font-size: var(--fs-sm);
  font-weight: 600;
  cursor: pointer;
  transition: background 0.12s ease, border-color 0.12s ease;
}
.dossier-extract:hover { background: hsl(45 72% 62% / 0.2); border-color: hsl(45 72% 62% / 0.7); }
.dossier-extract:focus-visible { outline: 2px solid var(--brass); outline-offset: 2px; }
/* Taken statements stay on the page as a record, so they must remain legible
   rather than fade to the usual disabled grey. */
.dossier-extract.is-taken {
  border-color: hsl(151 55% 50% / 0.4);
  /* 0.05, and the two steps down from 0.10 measure the same mistake twice. The
     text here is --ok on a tint of --ok's own hue, so the tint lightens exactly
     the ground the colour must stand out from. 0.10 read 7.17 and 0.08 read
     7.46 — but both were measured against a bare panel, and this pill sits
     inside the .dossier gradient, one layer further up. Through that layer 0.08
     is 7.05, not 7.46. 0.05, with --ok raised to 60%, reads 8.00. A tint is
     decoration and the text is content, so the decoration gives way; the green
     border carries the state. */
  background: hsl(151 60% 55% / 0.05);
  color: var(--ok);
  cursor: default;
}

.chip {
  display: inline-flex;
  align-items: center;
  padding: var(--space-1) var(--space-2-5);
  border-radius: 999px;
  border: 1px solid var(--border);
  background: var(--panel-strong);
  font-size: var(--fs-sm);
  font-variant-numeric: tabular-nums;
}

/* Deliberately no colour of its own any more. This carried hsl(0 0% 76%) for
   one round, as a local repair for --muted being unreadable on a .chip — and
   the repair was the bug: the same shortfall existed on .clue, .trace-entry and
   .dossier, and an exception on the one surface that had been measured made the
   other eight look fine. --muted is 78% now and clears the floor on all nine,
   so the exception is gone rather than multiplied. The class stays because the
   markup names it and it is the hook for anything genuinely chip-local. */
.chip-quiet { color: var(--muted); }

/* Accent-on-accent again — the fourth token in this file to meet that trap, and
   it is answered the same way the other three were: the tint keeps its strength
   and the TEXT moves. --accent at 52% would read ~3.3 on this fill; --accent-soft
   at 80% is the token already measured against .clue-bad's identical red tint at
   7.50, so this surface inherits a figure rather than inventing one. */
.chip-adult {
  color: var(--accent-soft);
  border-color: hsl(0 72% 52% / 0.34);
  background: hsl(0 72% 52% / 0.12);
}

/* Warn, not accent, and the two chips can be on screen at once — an adult-tone
   liar case shows both. Red is already spoken for twice over on this surface
   (the victim card, the refuted clue), so a second red chip beside the age
   rating would read as one two-part warning rather than two facts. --warn on its
   own 0.08 plate is the pair the token block measured at 7.48; the alpha is the
   figure, so it is not nudged to match .chip-adult's 0.12. */
.chip-liar {
  color: var(--warn);
  border-color: hsl(var(--warn-plate) / 0.34);
  background: hsl(var(--warn-plate) / 0.08);
}

/* Same hue as the liar chip, and a separate block rather than a second selector
   on that one. Two reasons, and the first is the weaker: contrast.test.mjs reads
   a chip's plate through `rule('.chip-x', 'background')`, so one block per chip
   keeps each measurement pointed at its own declaration — which is the whole
   argument the comment above makes for not folding the backgrounds together.

   The second is why the hue may repeat at all. The block above refuses red
   because .chip-adult is already red and the two CAN be on screen together, so a
   matching pair would read as one two-part warning. That does not apply here:
   MODES has one row per case and startCase() passes exactly that row, so a case
   is a liar case or an accomplice case and never both. Same slot, same voice,
   different word — which is what the chip is carrying anyway. */
.chip-accomplice {
  color: var(--warn);
  border-color: hsl(var(--warn-plate) / 0.34);
  background: hsl(var(--warn-plate) / 0.08);
}

/* The two warning chips share a voice and differ only in colour, so the voice is
   written once. Grouped rather than folded into the blocks above because each
   chip's `background` has to stay the only declaration of its kind in a block of
   its own: contrast.test.mjs resolves the measured plate through
   rule('.chip-liar', 'background'), and a shared block declaring a background
   would give that lookup a second candidate to choose between. */
.chip-adult,
.chip-liar,
.chip-accomplice {
  letter-spacing: 0.05em;
  text-transform: uppercase;
  font-size: var(--fs-xs);
  font-weight: 700;
}

/* The camera. Everything below this point is geometry seen through it.
   A perspective this long (relative to a ~800px board) is a mild telephoto: it
   gives depth without the wide-angle distortion that would make the far corners
   of the board disagree with the near ones about how big a cell is. */
.grid-wrap {
  position: relative;
  perspective: 1700px;
  perspective-origin: 50% 34%;
  /* Die Einheit, in der jede Objekthöhe im Brett geschrieben ist — Wand,
     Flügel, Herdhaube, Notenmarke. Der Kommentar, der hier stand, nannte sie
     „Hundertstel einer Zellkante". Das war sie nie: sie hängt an `vw`, und die
     Zelle seit der Boden-Arbeit an der HÖHE (`--board-fit`, `72vh`). Gemessen
     (Wandhöhe durch Zelle, 8x8-Brett): 375x812 fuhr 0.450 gegen 0.884 auf
     1280x800 — dort tritt der Aufbau platt, und das war das „dicht gedrängt".

     Der Boden korrigiert das unter 833px, ohne den Desktop anzufassen: dort
     trägt `0.156vw` sich längst selbst (1280px → 1.997px, ab 1538px die Decke),
     0.884 ist vor wie nach dem Eingriff bitgleich; 1.30px hebt 375x812 auf
     0.557. Auch der Multiplikator ist breitenabhängig: `--wall-h` rechnet 26
     ab 812px, aber 19 auf 375px — eine Wandhöhe ohne Breakpoint ist wertlos.

     Nach oben begrenzt ihn nicht der Kasten. Die Wand tritt oben aus `.grid-wrap`
     heraus — auf 1280px heute schon um 22.65px, ohne dass das je gestört hätte.
     Der Reservierungs-Absatz bei `--wall-ext` misst gegen die Kastenoberkante
     und misst damit die falsche Gefahr. Die echte Grenze ist der Text darüber.

     Gemessene Kopffreiheit auf 812x375 — dem engsten Fall unter 833px (das
     Hochformat 375x812 misst 46.50px, hat also mehr Luft). Gemessen am 5x5-
     Brett bei geschlossener Menükarte — kleine Bretter haben große Zellen und
     damit hohe Wände — als Abstand von der obersten gemalten Wandkante zum
     nächsten Text darüber (`.muted`). Untergrenze 8px: was dem Text näher
     kommt als eine halbe Zeile des kleinsten Grads (11px), liest als Berührung.

       --zu    Kopffreiheit
       1.2667  37.57
       1.30    36.55
       1.45    31.89
       1.70    23.99
       2.00    14.27
       2.30     4.28
       2.50    -2.55

     Die letzten beiden Punkte sind die Obergrenze für den BODEN, kein Ziel. */
  --zu: clamp(1.30px, 0.156vw, 2.4px);
  /* How wide the plan itself gets — which is NOT the window, and the difference
     is a bug the room labels shipped with.

     A label sized in vw is paid for a strip the board never receives: the side
     column takes a FIXED 350px, so as the window narrows toward the two-column
     breakpoint the board loses width far faster than vw does, and the label
     keeps its size while its cell shrinks under it. Measured on the 8x8 board,
     font over cell: 0.167 at 1240px, 0.180 at 1024 (an iPad in landscape),
     0.202 at 941 — and 0.117 one pixel later, where the layout drops to one
     column and the board suddenly gets the whole window. A 73% step across a
     breakpoint is what the overlapping labels on a tablet actually were.

     So the width is derived instead: the window, capped by the app, less the
     app's own padding, less the side column, less the gap it sits behind, less
     the scene panel's padding on both sides. Each of those is a token rather
     than a literal precisely because this sum has to keep agreeing with the
     layout that spends them.

     `100vw` and not `100%`: this box is inside the very grid column whose width
     is being reasoned about, so a percentage would be circular. It is a hint for
     type size, not a layout constraint — the board is still laid out by the
     grid, and being a pixel or two out here costs nothing. */
  /* The side column and the gap it sits behind, kept as one term so the
     one-column breakpoint can take both away by setting it to zero, instead of
     restating the whole sum there — a second copy of this formula is the very
     drift the tokens above exist to prevent. */
  --board-taken: calc(var(--side-w) + var(--space-4));
  /* The scene panel's own padding, separately named for the same reason: the
     phone gives it up entirely so a 375px board can still reach a 44px touch
     target, and that is one line there rather than a restated sum. */
  --board-pad: calc(2 * var(--space-4));
  /* Was das Brett an Höhe NICHT bekommt: alles, was im Fenster über und unter
     ihm steht — Topbar, Kopfzeile des Falls, die Reserve, die diese Box für die
     Rückwand der 3D-Bühne freihält, die Legende darunter und die Ränder der App.
     Eine Zahl und keine Summe aus Tokens, weil die Posten aus vier
     verschiedenen Kästen kommen und ein Teil davon (eine umbrechende Kopfzeile)
     ohnehin nicht statisch ist.

     Nachgemessen auf 1185x826 (8x8) — und zwar mit dem LÄNGSTEN der dreissig
     Falltitel, "Die letzte Stunde von Landhaus Wolkenstein", 42 Zeichen. Dass er
     hier steht, ist kein Beiwerk: die Kopfzeile ist eine Flexzeile, ihre Höhe ist
     eine Funktion ihres Inhalts, und ohne den gemessenen Inhalt ist keine der
     Zahlen unten reproduzierbar. Mit dem Titel, der beim Öffnen gerade oben
     steht — meist deutlich kürzer — misst man an derselben Stelle 216.4 statt
     257.9, hält den Absatz für falsch und meldet einen Fehler, den es nicht gibt.
     Genau das ist einmal passiert. Reserviert wird gegen den schlimmsten Inhalt,
     nicht gegen den gerade sichtbaren.

     Dabei fiel auf, dass "eher zu gross als zu klein" hier die falsche Richtung
     war: zu gross kostet nicht nur Brettkantenlänge, es kann das Brett unter
     seinen eigenen Boden drücken — dann gewinnt der Bodenarm oben, das Brett
     steht auf der Mindestgrösse und nebenan bleibt Fensterbreite ungenutzt.
     Genau das war der Bericht "das Spielfeld ist total klein und dicht gedrängt".

     Die Posten, einzeln gemessen, alle mit dem langen Titel: über dem Brett 310
     (Topbar + Kopfzeile), davon 54 zurückgewonnen, seit der Falltitel eine Stufe
     kleiner steht und nicht mehr umbricht — auf 38px brach er selbst um und die
     Kopfzeile mass 311.6, auf den gefahrenen 28px sind es 257.9; die Reserve für
     die Rückwand der 3D-Bühne 26 (Umhüllende 426 gegen Brett 400); unter dem
     Brett hart 42 (Bühnenfuss 26 + Innenabstand 16). Zusammen 324, aufgerundet
     auf 330. Was darunter noch kommt — Legende, Spaltenabstand, App-Rand — ist
     scrollbares Beiwerk und wird bewusst NICHT reserviert: es kostet sonst
     Brettkante für etwas, das niemand gleichzeitig mit der untersten Reihe
     ansieht.

     Zwei verschiedene Umbrüche speisen diese Zahl, und sie werden leicht
     verwechselt. Auf 1185 und 1440 bricht der Titel NICHT um; dort rutscht die
     Meta-Zeile neben ihm auf eine eigene Zeile (+41.5). Der Titel selbst bricht
     erst, wenn die Kopfzeile unter seine natürlichen 630px fällt — sie folgt der
     Fensterbreite mit rund `Breite - 463`, also ab etwa 1093px Fenster abwärts
     (+31.3 obendrauf). Das ist der engste Fall, in dem dieser Token überhaupt
     noch bindet, und er hält: auf 1080x826 bleiben unter dem Brett 16.9px übrig.
     Weiter unten gewinnt der Breitenterm der Zeile darunter, und die Höhe hört
     auf zu klemmen.

     Warum überhaupt: die Zeile darunter leitet die Brettbreite aus der
     FENSTERBREITE ab. Auf einem breiten, flachen Fenster ergibt das ein
     quadratisches Brett, das höher ist als das Fenster — auf 1440x900 waren es
     749px Brett in 900px Fenster, wovon 111 sichtbar blieben. Ein Logikrätsel,
     dessen unterste Reihe man wegscrollen muss, ist kein Layout-Schönheitsfehler,
     sondern verliert den Überblick, der das Rätsel ausmacht.

     `svh` und nicht `vh`: auf dem Telefon ist `vh` die Höhe OHNE eingeblendete
     Browserleiste, also genau die Höhe, die im Moment nicht da ist.

     N-20 hat die Fallakte über das Brett gehoben, in JEDER Orientierung. Diese
     Zahl bezahlt sie im Hochformat NICHT, und das ist gemessen, nicht vergessen:
     der Höhenterm unten kennt nur `100svh` und diesen Token — was ÜBER dem Brett
     steht, geht in ihn nicht ein. Der Streifen schiebt das Brett also nach unten,
     ohne es zu verkleinern. Auf 768x1024 blieb es mit und ohne Akte exakt 682.2px
     breit; nur seine Unterkante wanderte von 976.5 auf 1038.5.

     Die naheliegende Korrektur — den Token um die 62px des Streifens auf 392
     anheben — wurde probiert und verworfen: sie schrumpft dasselbe Brett auf
     632px (auf 404 angehoben: 620px), weil ein grösserer Abzug den Höhenterm
     überhaupt erst klemmen lässt. Sie bezahlt einen Streifen mit der Karte, auf
     einer Seite, die im Hochformat ohnehin 2777px scrollt, weil die Seitenspalte
     darunter steht statt daneben. Genau diese Verkleinerung war der Anlass von
     N-20. Im Querformat ist es umgekehrt: dort ist die Falte der Vertrag, dort
     steht der Streifen als `--q-streifen` ausdrücklich in der Reserve. */
  --board-chrome: 330px;
  /* Eine Subtraktion ohne max() hat keinen kleinsten Wert. Ist das Fenster
     niedriger als `--board-chrome` (die Zahl nicht wiederholt — sie ist eine
     Messung und hat sich schon einmal geändert, während ihre Kopien hier
     stehenblieben), wird dieser Term null und darunter negativ, das min() darunter
     wählt ihn dann zuverlässig, und ein negatives `max-width` schliesst das
     Brett auf null. Gemessen war das kein Randfall: bei 812x375 — einem quer
     gehaltenen Telefon — stand hier ein 0px breites Brett mit 5px-Zellen, bei
     1024x640 eine Zelle von 39.6px gegen einen Tastboden von 50.

     Der Boden ist NICHT derselbe wie auf der Breitenachse. Er war es einmal —
     Tastkantenlänge mal Feldzahl — und das war der Fehler: dieser Term deckelt
     die HÖHE, und die Höhe staucht in der Perspektive stärker als die Breite.
     Auf 1185x826 gewann bei 8x8 der Bodenarm (8 x 50 = 400) gegen den
     Höhenterm (386); das Brett blieb auf 400px stehen, obwohl daneben 339px
     Fensterbreite ungenutzt lagen, und die hinterste Reihe bot dem Finger 36.6px
     statt 44. Ein Boden, der nur eine der beiden Achsen kennt, hält die andere
     nicht. Deshalb `--cell-comfort` (oben, aus Hausboden und gemessener
     Stauchung) und nicht `--cell-touch`. Weil `--board` darunter ein `min()`
     ist, hebt ein höherer Boden hier nur den Deckel — breiter als die
     Fensterbreite kann das Brett dadurch nie werden.

     Ab hier gewinnt lieber ehrliches Scrollen als ein Brett, das ins
     Fenster passt und nicht mehr getroffen werden kann.

     `--size` kommt von js/ui.js und wird dort ausdrücklich AUCH auf diese Box
     gesetzt, nicht nur auf das Gitter darin — der Vorgabewert 6 hier ist nur
     das Netz für den Moment vor dem ersten Rendern.

     Die Einheit ist hier `vh` und nicht `svh`, und das ist keine Nachlässigkeit,
     sondern die einzige Form, die für eine Custom Property trägt. Der Zwilling,
     den `.side` weiter unten bezahlt — erst `vh`, direkt darunter `svh` —, ist an
     dieser Stelle ein NO-OP: der Wert einer `--x`-Deklaration wird beim Parsen
     nicht auf Gültigkeit geprüft, sondern als Tokenstrom abgelegt. Die
     `svh`-Zeile würde also IMMER angenommen und ihren `vh`-Zwilling in jeder
     Engine überschreiben; ungültig wird der Wert erst beim EINSETZEN, und dann
     fällt nicht diese eine Deklaration aus, sondern `--board` darunter und mit
     ihm jeder Konsument — `max-width`, die Raumnamen, die Zellschrift. Ein
     Zwilling hier sähe aus wie die Reparatur und wäre keine.
     Die Form, die trägt, steht deshalb unter dieser Regel als `@supports`-Zweig:
     die Basis rechnet klassisch, die moderne Fassung gilt nur dort, wo die
     Engine sie versteht. */
  /* `--q-rundenleiste-aktiv` ist im Regelfall 0 und wird nur im Querformat
     aufgeschlagen, wenn die Rundenleiste wirklich sichtbar ist (siehe den
     `:has()`-Zweig weiter unten). Sie kam mit dem Mehrspieler dazu und stand in
     keinem Posten dieser Rechnung — das Brett ging dadurch auf einem iPad quer
     um 122px ueber den Schirm hinaus. */
  --q-rundenleiste-aktiv: 0px;
  --board-fit: max(
    calc(var(--cell-comfort) * var(--size, 6)),
    calc(100vh - var(--board-chrome) - var(--q-rundenleiste-aktiv))
  );
  /* Zwei Grenzen, dieselbe Zahl. Der Wert bleibt EIN Token, weil er zwei Dinge
     gleichzeitig steuern muss: wie breit das Brett gelegt wird (`max-width`
     unten) und wie gross die Raumnamen darauf gesetzt werden. Driften die
     auseinander, sind die Schilder für ein Brett bemessen, das gar nicht da ist
     — und das ist genau der Überlappungsfehler, gegen den js/hyphenate.js und
     der Kommentar oben geschrieben sind. Deshalb `min()` hier und nicht ein
     zweites `max-height` irgendwo weiter unten. */
  --board: min(
    calc(min(100vw, var(--app-max)) - 2 * var(--app-pad) - var(--board-pad) - var(--board-taken)),
    var(--board-fit)
  );
  /* Auf dem Telefon ist der Breitenterm ohnehin der kleinere, die Höhengrenze
     also wirkungslos — sie muss dort nichts abschalten. Sie greift genau da, wo
     das Fenster breiter als hoch ist. */
  max-width: var(--board);
  margin-inline: auto;
  /* The building's shell, far side. It has to out-top the tallest thing inside
     it or the furniture projects out past the board's own edge and stands in the
     black beside the house. That is not a stacking bug to be nudged with
     z-index: an upright face of height h lands h·sin(tilt) up the screen, so the
     only thing that can contain a tall face is a taller face.
     78 is derived, not chosen: the tallest part in js/furnishings.js is the
     kitchen range's flue, whose hood sits at z 52 and rises 22, so the board's
     ceiling is 74. Anyone adding a taller piece there has to raise this with it,
     which is why the derivation is written down rather than the number alone —
     and why test/geometry.test.mjs reads both sides and compares them.

     Both live on .grid-wrap rather than on .grid because BOTH elements need
     them: the board builds its walls from them, and this box has to reserve room
     for the result. Custom properties inherit downward, so .grid reads exactly
     the values it always did — but only as long as it does not redeclare them,
     which would let the wall and the space reserved for it drift apart in
     silence. */
  --wall-ext: calc(78 * var(--zu));
  /* An upright face of height h lands on screen at h·sin(tilt), so the tilt and
     the unit --zu have to be chosen together: at 19deg and 0.085vw a kitchen
     counter projected to under six pixels — the volume was geometrically real
     and visually absent. 26deg is the point where objects read as solid while
     the grid is still square enough to reason about, which on a logic puzzle
     outranks the drama of a lower camera.

     Die Neigung steht auf DIESEM Kasten und nicht auf .grid, weil der Absatz
     darunter sie braucht: wie weit die Wand über den Kasten hinausragt, hängt
     an ihr. Läge sie beim Brett, müsste der Kasten sie raten — und ein
     geratener Winkel reserviert den falschen Platz. */
  --tilt: 26deg;
  /* Neigungen im Bogen — die Buchführung zu `--tilt`.

     26deg ist der Grundwert hier oben. 21deg gilt ab `max-width: 560px`, wo
     das Telefon im Hochformat steht. 20deg gilt unter `orientation: landscape`
     UND `max-height: 500px`, wo die hinterste Reihe sonst unter den Tastboden
     fällt; die Messreihe, die das belegt, steht dort im Block und nicht hier —
     sie ist gemessen, nicht gesetzt, und gehört neben den Eingriff, den sie
     begründet.

     Die zweite Achse ist keine Nebenbedingung. Dieser Absatz nannte bis
     2026-08-07 nur `orientation: landscape` und las sich damit wie „quer gilt
     20deg" — und der Test las den Geltungsbereich mit demselben Schnitt, also
     bestätigten sich zwei Quellen gegenseitig eine Aussage, die die Datei nie
     gemacht hat. Quer und HOCH — der Desktop, 1440x800 — fährt weiter den
     Grundwert 26deg, und die Messreihe unten sagt über ihn nichts, weil sie auf
     einem Fenster genommen ist, auf dem der 20deg-Zweig bereits greift.

     Warum diese Liste überhaupt existiert: `--tilt` ist der einzige Wert im
     Bogen, der einen bestandenen Tastboden wieder unterschreiten kann, ohne
     eine einzige Boden-Zeile anzufassen. Der Boden deckt die Kachel, die das
     Brett BEKOMMT; die hinterste Reihe steht am Ende einer Perspektive und
     wird kleiner projiziert. Zwischen beiden liegt die Neigung, und sie kommt
     in keinem der max() vor, die den Boden setzen. Eine vierte Neigung,
     irgendwo eingestreut, wäre deshalb eine Zeile, die niemandem auffällt.

     Genau das hält test/tilt.test.mjs auf: der Test liest diesen Absatz und
     die Deklarationen aus derselben Datei und vergleicht sie. Wer hier etwas
     nennt, das unten niemand deklariert — oder unten etwas deklariert, das
     hier fehlt — bekommt einen roten Test statt eines stillen Bruchs. */
  /* What this box has to reserve above the board. The paragraph that stood here
     said "0.4px, so the 16px below covers it many times over". That number was
     measured, and it was still wrong — wrong the way a measurement is wrong when
     it is taken on a case that cannot discriminate.

     Two terms pull against each other. A face of height h raised out of the
     floor plane lands h·sin(tilt) up the screen; rotateX at the same time
     compresses the plan about its centre, which opens (B/2)·(1 − cos tilt) of
     slack above the untransformed box. What escapes upward is the difference:

         h·sin(tilt) − (B/2)·(1 − cos tilt)

     h is --wall-ext, B is --board. And h does NOT grow with the board: --zu caps
     at 2.4px, so from ~1538px wide the wall is pinned at 187.2px no matter what
     happens below it. Only B is still free. That inverts the worst case — a
     SMALLER board overflows MORE, because it buys less compression against the
     same wall.

     The old paragraph picked the 8x8 expert board "so the worst case". On a
     desktop that board is height-bound, not size-bound: --board-fit is a max()
     against 100svh, so --size does not move it at all. The probe was run on the
     one axis that could not answer the question, and it read 0.4px because the
     board it happened to measure was wide.

     And the two terms are an ORTHOGRAPHIC reading of a projection the renderer
     does in PERSPECTIVE. This box carries perspective: 1700px with a
     perspective-origin 34% down its own height, so the far wall is scaled about
     a pivot ABOVE the plan's centre. Measured in Chrome at 1600x800 against the
     shipped tokens: at --board 787 the term lands 0.45px OVER the real escape,
     at 587 it lands 5.88px UNDER it. It is therefore not the overflow — it is
     the part of the overflow that moves with the tokens, and it is the only
     part a stylesheet can compute without knowing the viewport.

     The rest is carried by the box's own top offset: padding-top 16 plus the
     grid's margin-top 10, so 26px the reserve never has to name, and by that
     34% pivot. What the two together leave over is the table at the end of this
     comment. Its worst case is the SMALLEST board, not the largest — h is
     pinned, so a small board buys less compression against the same wall — and
     even there 15.88px stay clear. From there the clearance only grows.

     What this replaced: 9.78px of wall painted across the Fallakte's strip on a
     1840px desktop. The player saw that before any test did — which is the
     actual failure here.

     The reserve is spent as margin-top, not as more padding-top, and both
     reasons are structural:

     1. .compass is positioned absolutely against THIS box. Padding moves the
        board down and leaves the compass behind on the old roofline — the exact
        regression recorded when an earlier attempt reserved 0.49·--wall-ext
        here. A margin moves the box itself, so the compass travels with it.
     2. The parent .scene is display:block, so this margin COLLAPSES with the
        Fallakte's own margin-bottom instead of adding to it. The gap becomes
        max(what the Fallakte asks for, what the roof needs) — no double spacing
        on the viewports where the roof needs nothing.

     max(0px, …) because on a phone the compression term wins outright: the wall
     stays inside the box, the reserve is genuinely zero, and the Fallakte keeps
     its own spacing untouched.

     padding-top below is now honestly only spacing — the clearance between the
     Fallakte's bottom edge and the topmost painted pixel — and 16px is the
     layout's own gap unit, the same one .side is held off the top by.

     test/geometry.test.mjs makes the table below load-bearing: it reads the rows
     out of this file, checks that the clearance rises as the board grows, that
     the smallest tabled board reaches down to the smallest the tokens can
     actually produce (--cell-comfort × the smallest --size in js/engine.js),
     that the worst row keeps the floor named beside the table, and that
     --roof-over still mentions all three of --wall-ext, --tilt and --board.
     Delete a term and the test goes red instead of the roof.

     Gemessene Dachkante — Brettbreite, Austritt über den Kasten, Restluft unter
     der Fallakte. Gefahren wird nur, was Restluft von mindestens 8px behält.
       265px 78.77 15.88
       400px 71.11 16.71
       587px 58.24 20.11
       787px 41.78 26.45
       794px 41.28 26.60 */
  --roof-over: max(0px, calc(
    var(--wall-ext) * sin(var(--tilt))
    - var(--board) / 2 * (1 - cos(var(--tilt)))
  ));
  margin-top: var(--roof-over);
  padding-top: var(--space-4);
}

/* Der Rückfall für `--board-fit`, in der Form, die eine Custom Property
   überhaupt zulässt. Warum kein Zwilling wie bei `.side`, steht oben an der
   Deklaration selbst; hier steht, was der Zweig leistet.

   `@supports` fragt nach der Einheit, bevor sie eingesetzt wird — die
   Bedingung wird beim Parsen ausgewertet, nicht beim Rechnen. Eine Engine ohne
   `svh` betritt den Block also gar nicht erst und behält den klassischen Wert
   darüber; eine Engine mit `svh` überschreibt ihn. Beide bekommen ein
   definiertes `--board-fit`, und damit ein definiertes `--board`.

   Der Unterschied, um den es geht, ist auf dem Telefon keine Feinheit: `vh`
   rechnet gegen das GRÖSSTE Sichtfenster (Adressleiste eingefahren), `svh`
   gegen das kleinste. Wo `svh` verstanden wird, soll deshalb `svh` gelten —
   sonst steht das Brett unter der eingeblendeten Leiste. Wo es nicht
   verstanden wird, ist ein etwas zu hoch gerechnetes Brett immer noch
   unendlich viel besser als gar keins: `--board` fiele sonst als Ganzes aus,
   `max-width` bekäme einen ungültigen Wert, und die Zellschrift und die
   Raumnamen, die alle drei aus derselben Zahl rechnen, gleich mit.

   Die zweite Fundstelle des Paares (`.side`, `max-height`) ist bewusst NICHT
   hierher gewandert: dort ist der klassische Zwilling wirksam, weil eine echte
   Eigenschaft beim Parsen geprüft wird. Zwei Fundstellen, zwei Formen, ein
   Grund — und `test/paar.test.mjs` verlangt beide getrennt. */
@supports (height: 100svh) {
  .grid-wrap {
    --board-fit: max(
      calc(var(--cell-comfort) * var(--size, 6)),
      calc(100svh - var(--board-chrome) - var(--q-rundenleiste-aktiv))
    );
  }
}

.compass {
  position: absolute;
  top: -4px;
  right: 2px;
  font-size: var(--fs-xs);
  letter-spacing: 0.12em;
  color: var(--muted);
}

.grid {
  display: grid;
  grid-template-columns: repeat(var(--size, 6), minmax(0, 1fr));
  /* No gap: room walls must read as continuous lines, not dashes */
  gap: 0;
  margin-top: var(--space-2-5);
  padding: 0;
  /* The board is the 3D stage. Everything about this block is load-bearing:
     `overflow: hidden` would force the browser to flatten every descendant into
     this one plane, which silently kills the walls and the furniture — the
     spec lists it as a grouping value, so there is no warning, just a flat
     board. The radius shrinks for the same reason: without clipping, the corner
     cells would otherwise poke past a 14px curve. */
  transform-style: preserve-3d;
  /* `--tilt` steht bewusst NICHT hier, sondern auf .grid-wrap — aus demselben
     Grund wie --zu und --wall-ext: der Kasten muss den Platz reservieren, den
     die Neigung über ihm frei räumt, und kann das nur, wenn er die Zahl kennt.
     Solange sie hier nicht redeklariert wird, rechnet das Brett mit genau dem
     Winkel, für den oben Platz gemacht wurde. */
  transform: rotateX(var(--tilt));
  /* --zu and the far wall --wall-ext are inherited from .grid-wrap, which has to
     reserve screen space for the wall and therefore has to own its height. The
     two walls named here are the ones only the board cares about.
     Interior partitions are deliberately low: they divide the plan without
     hiding what stands behind them, which is the whole reason a floor plan is
     drawn from above rather than from a doorway. */
  --wall-h: calc(26 * var(--zu));
  /* The far wall and the side walls are two different jobs that happen to share
     a number, and they get two names because of it. Only the far wall contains
     anything: a face rises up the screen, never sideways, so a tall piece can
     only escape over the top edge. The side walls are pure presentation — how
     enclosed the plan feels — and they are the ones a small screen may shorten.
     Splitting them keeps the derived 74 from being lowered by a decision about
     mood.
     The camera-side wall is the exception and stays low; at full height it would
     be a hoarding across the front of the plan and the player would be looking
     at the outside of a box instead of into rooms. */
  --wall-ext-side: calc(78 * var(--zu));
  --wall-ext-near: calc(18 * var(--zu));
  border-radius: 5px;
  background: hsl(0 0% 5%);
  /* Two rings: a bright hairline for the board edge and a dark one outside it,
     so the floor plan sits on the panel instead of floating over it. */
  border: 1px solid hsl(40 22% 78% / 0.22);
  box-shadow:
    var(--inset-hair),
    0 0 0 4px hsl(0 0% 0% / 0.35),
    var(--lift-3);
}

.grid-row { display: contents; }

/* ------------------------------------------------------------- room materials
   Ten rooms, ten surfaces. Each class is applied to every cell of that room and
   to its legend swatch, so the key and the board are literally the same
   material. `--room-floor` is a pure-CSS pattern — no image files, because this
   app ships without a build step and without vendored binaries.

   The patterns restart per cell rather than running across the room. That is
   deliberate: a cell is one square metre of floor and already carries a hairline
   border, so a per-cell repeat reads as laid tiles, and it survives the grid
   being re-rendered cell by cell after every move.

   Three constraints hold all ten together, and all three were learned the hard
   way:

   1. ALPHA IS NOT LOUDNESS — COVERAGE × ALPHA IS. A chequerboard paints half the
      surface; a hairline grid paints a sixth of it. At equal alpha the chequer
      shouts and the grid whispers, which is exactly how the first pass looked.
      Area fills therefore run at roughly a quarter of the alpha of line work.
   2. NO MATERIAL MAY OUTSHINE A WALL. The wall line is the board's structural
      cue and sits at 0.40 alpha, so every pattern stays under ~0.16. A floor the
      player reads before the room boundary is a floor that has taken over.
   3. NO TWO ROOMS MAY SHARE A GEOMETRY. Period is not a distinction: three rooms
      once carried the same orthogonal grid at 15, 6 and 5px and two more carried
      the same vertical stripe at 7 and 12px, and at a 44px cell nobody counts
      pixels — they see "grid" five times. Ten rooms therefore hold ten shapes:
      cross-hatch, dot lattice, square grid, chequer, single diagonal, rings,
      horizontal lines, veining, rectangular courses, vertical lines.

   The direction of the ink follows the floor, not the fashion. The four darkest
   floors composite to L* 10–16, where black line work is invisible, so they are
   drawn in light; the four brightest reach L* 20–26 and take dark line work.

   Periods live between 5px and 20px: below that they alias into noise on a 130px
   desktop cell, above it a 44px mobile cell shows less than one full repeat. */

/* The ten lightnesses below are not taste. They are one solved ladder: the ten
   floors, composited at --cell-tint and simulated as a deuteranope sees them,
   are spread as evenly as ten dark materials can be. Hue does the work in normal
   vision (worst pair ΔE 5.93); lightness does it when hue is gone (ΔE 4.93).
   Re-picking any single value by eye collapses a pair somewhere else in the set
   — the whole table moves together or not at all. */
.room-bibliothek {                     /* walnut herringbone parquet */
  --room-tint: hsl(24 34% 19.5%);
  /* Cross-hatch: both diagonals. The billiard room takes ONE diagonal, and that
     is the entire difference between them at 44px — hence light on both passes
     here, so the crossing actually reads. */
  --room-floor:
    repeating-linear-gradient(45deg, hsl(32 40% 72% / 0.13) 0 2px, transparent 2px 9px),
    repeating-linear-gradient(-45deg, hsl(32 40% 72% / 0.09) 0 2px, transparent 2px 9px);
}
.room-salon {                          /* plum damask carpet */
  --room-tint: hsl(270 28% 49%);
  /* Two dot grids of the same period, offset half a tile — one centred, one on
     the corner. That offset is the whole room: a single grid of dots is a dot
     grid, two interleaved ones read as a woven damask, and it costs no new
     period. Both dots stay under 2.4px, so at a 44px cell the eye gets texture
     rather than polka dots.

     The dark dot carries 0.16, which is the loudest ink anywhere on the board
     and exactly the ceiling constraint 2 sets — not by accident and not with
     room to spare. It sits there because this floor composites to L* 20.6, the
     first rung of the bright band, where dark ink is the correct direction but
     has barely started to work; the light companion dot at 0.11 is what keeps
     the weave visible at all. Anything above 0.16 here would out-shout a wall
     and the material would stop being a floor. If this room ever needs more
     presence, it has to come from the tint, not from this number. */
  --room-floor:
    radial-gradient(circle at 50% 50%, hsl(0 0% 100% / 0.11) 0 1.6px, transparent 2.4px),
    radial-gradient(circle at 0 0, hsl(0 0% 0% / 0.16) 0 1.4px, transparent 2.2px);
  --room-floor-size: 16px 16px, 16px 16px;
}
.room-wintergarten {                   /* glazing bars over the planting beds */
  --room-tint: hsl(168 30% 32.5%);
  /* The only square grid left on the board. The armoury keeps rectangular
     courses at 2:1 — an aspect ratio is legible where a period is not. */
  --room-floor:
    repeating-linear-gradient(90deg, hsl(190 44% 88% / 0.16) 0 1px, transparent 1px 15px),
    repeating-linear-gradient(0deg, hsl(190 44% 88% / 0.16) 0 1px, transparent 1px 15px);
}
.room-kueche {                         /* chequered kitchen tiles */
  --room-tint: hsl(196 26% 27.5%);
  /* The loudest geometry in the set, and the only floor whose two tones ARE the
     shape: a chequer needs both quarters to read, or it is a lattice with a
     grand name. The dark tone shipped at 0.04 once — ΔE 0.76 over this ground,
     a third of the JND the rest of the set is held to, so what actually landed
     on the board was a one-tone lattice while this comment claimed a chequer.
     0.13 puts it at ΔE 2.48, over the line and still under the 0.16 ceiling;
     the two tones then sit ΔE 9.46 apart. 0.10 was tried first and measured
     1.91, 0.12 measures 2.29 — both still under, which is the whole reason the
     dark quarter needs nearly twice the alpha of the light one: this floor
     composites to L* 15.5, so black has far less room to move down than white
     has to move up. Equal alphas here would draw an unequal chequer. */
  --room-floor: repeating-conic-gradient(hsl(0 0% 100% / 0.07) 0% 25%, hsl(0 0% 0% / 0.13) 0% 50%);
  --room-floor-size: 18px 18px;
}
.room-billard {                        /* baize, brushed one way */
  --room-tint: hsl(128 32% 44.5%);
  /* A single diagonal, not the cross-weave it was: the library already owns the
     cross-hatch, and baize really is brushed in one direction. Dark ink — this
     is one of the four brightest floors at L* 24.7. */
  --room-floor:
    repeating-linear-gradient(45deg, hsl(0 0% 0% / 0.15) 0 2px, transparent 2px 8px),
    repeating-linear-gradient(45deg, hsl(0 0% 100% / 0.05) 2px 3px, transparent 3px 8px);
}
/* Rose, not the green it started as: a moss rug next to the billiard room's
   baize gave the board two green rooms with the same weave, and no plan symbol
   can rescue two surfaces the eye files as one. Saturation 26 keeps it a
   material — the accent it now shares a quadrant with never drops below 72. */
.room-musik {                          /* rose carpet, laid in a medallion */
  --room-tint: hsl(338 26% 59%);
  /* Rings, not stripes. This room and the veranda were both vertical stripe sets
     separated only by period, which is no separation at all. --room-floor-size
     stays auto so the medallion centres itself in whatever the cell measures. */
  --room-floor:
    repeating-radial-gradient(circle at 50% 50%, hsl(0 0% 0% / 0.15) 0 2px, transparent 2px 7px),
    repeating-radial-gradient(circle at 50% 50%, hsl(0 0% 100% / 0.05) 2px 3px, transparent 3px 7px);
}
.room-arbeit {                         /* tooled leather over dark boards */
  --room-tint: hsl(232 30% 30.5%);
  /* Horizontal only — the veranda takes vertical only. One axis each is the
     cleanest pair of shapes in the set, and neither is the glazing grid. */
  --room-floor:
    repeating-linear-gradient(0deg, hsl(0 0% 100% / 0.12) 0 1px, transparent 1px 6px),
    repeating-linear-gradient(0deg, hsl(0 0% 100% / 0.05) 3px 4px, transparent 4px 6px);
}
.room-ballsaal {                       /* veined marble slabs */
  --room-tint: hsl(306 18% 48.5%);
  /* The one room that breaks the ink-direction rule above, and deliberately.
     This floor composites to L* 22.4 — the bright band, where the rule says dark
     line work — yet its DOMINANT layer is light: 0.11 at 115°. Marble veining is
     lighter than the stone it runs through; drawn dark it stops being marble and
     becomes cracks. So the light vein leads and the dark 0.10 at 66° is the
     counter-vein crossing it, which is also what keeps the room legal under
     constraint 2 — neither layer alone carries the surface. Whoever next reads
     the ladder and sees a bright room drawn in light: this is not the oversight
     it looks like. Change it and the room reads as damaged, not polished.

     The conic quarter at 0.04 is the slab joint, and its 20px period is the
     longest on the board — which makes this the sparsest legend chip in the set
     at 2.60 repeats (contrast.test.mjs recomputes that number; the floor is 2).
     20px is therefore not free to grow. */
  --room-floor:
    repeating-linear-gradient(115deg, hsl(0 0% 100% / 0.11) 0 1.5px, transparent 1.5px 14px),
    repeating-linear-gradient(66deg, hsl(0 0% 0% / 0.10) 0 1.5px, transparent 1.5px 20px),
    repeating-conic-gradient(hsl(0 0% 100% / 0.04) 0% 25%, transparent 0% 50%);
  --room-floor-size: auto, auto, 20px 20px;
}
.room-waffen {                         /* ashlar stone courses */
  --room-tint: hsl(210 6% 16.5%);
  /* 9px tall, 18px wide: a 2:1 course, which is what tells it apart from the
     conservatory's square grid without either of them changing period band.
     The darkest floor on the board (L* 10.1), so the joints are mortar-light
     rather than the shadow they used to be — black on black is no pattern. */
  --room-floor:
    repeating-linear-gradient(0deg, hsl(210 12% 78% / 0.16) 0 1.5px, transparent 1.5px 9px),
    repeating-linear-gradient(90deg, hsl(210 12% 78% / 0.11) 0 1.5px, transparent 1.5px 18px);
}
.room-veranda {                        /* weathered deck planks */
  --room-tint: hsl(64 24% 27.5%);
  /* Vertical only, against the study's horizontal only. */
  --room-floor:
    repeating-linear-gradient(90deg, hsl(0 0% 100% / 0.13) 0 2px, transparent 2px 12px),
    repeating-linear-gradient(90deg, hsl(0 0% 100% / 0.05) 2px 3px, transparent 3px 12px);
}

.cell {
  position: relative;
  /* The cell's own background IS the floor plane, at z = 0. Everything the cell
     carries rises above it on translateZ, which is why there is no isolation and
     no negative z-index here any more: `isolation: isolate` is a grouping value
     and would flatten this cell's contents back into the floor. Depth order is
     now geometry, not stacking, and geometry cannot disagree with itself. */
  transform-style: preserve-3d;
  aspect-ratio: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-0-5);
  padding: var(--space-0-5);
  border-radius: 0;
  border: 1px solid hsl(0 0% 100% / 0.05);
  border-top-width: 0;
  border-left-width: 0;
  /* background-COLOR, never the shorthand: the shorthand resets background-image
     and would erase the room's floor on every state class below. That applies to
     every .cell* rule in this file. */
  background-color: color-mix(in srgb, var(--room-tint, #666) var(--cell-tint), var(--cell-base));
  background-image: var(--room-floor, none);
  background-size: var(--room-floor-size, auto);
  color: var(--text);
  font-family: inherit;
  cursor: pointer;
  transition: box-shadow 0.12s ease, background-color 0.12s ease;
}

/* The room's plan symbol, stamped once per room on its first cell. Faint enough
   to be a floor marking rather than a piece of evidence, and never a click
   target — the cell underneath it stays fully interactive. */
.room-mark {
  position: absolute;
  inset: 50% auto auto 50%;
  width: 72%;
  height: 72%;
  /* Lifted a hair off the floor rather than pushed behind it: at 0.4 units the
     browser sorts it above the cell's own background but below every object,
     and unlike a negative z-index it cannot escape into the panel underneath. */
  transform: translate(-50%, -50%) translateZ(0.4px);
  color: hsl(0 0% 100% / 0.13);
  stroke-width: 1.1;
  pointer-events: none;
}

/* The room's name, printed on the board the way the paper puzzles print it:
   large, upper-case, and allowed to run past the cell it is anchored to, because
   a room name belongs to the room and not to one square metre of its floor.
   Counter-rotated out of the board tilt so it stays flat to the reader — a label
   that foreshortened with the floor would be the one unreadable thing on it. */
.room-plate {
  position: absolute;
  left: 50%;
  /* The TOP of the anchor square, and this is a collision fix rather than a
     taste one. `.cell-name` — the body's name, the searchable piece's name —
     sits at `bottom: 4%`, and the plate used to sit at `bottom: 8%` with a
     higher z-index. Two labels in one band with the plate winning the stack is
     not a near miss, it is a guarantee: on the phone the media query below put
     both at exactly 4% and the room name was painted straight across the
     corpse's. Anchor avoidance cannot fix that on its own, because suspects move
     during play and any square can become an occupied one between two renders.
     Separating the bands can, and it holds without knowing where anyone stands:
     at the smallest cell the sheet allows (44px) the plate occupies 2.6–13.6px
     from the top and the name 30.6–41.6px, so even a plate that wraps to two
     lines stops well clear. */
  top: 6%;
  z-index: 6;
  /* 64 units, not 30. The tallest thing in a room reaches 74 (the kitchen
     range's flue), and at 30 the label was passing THROUGH the furniture it was
     naming — depth order is real geometry here, so a plate below an object is
     genuinely behind it. 64 clears every floor-standing piece and reads as a sign
     hung at head height rather than a decal on the boards; the flue is the one
     thing it does not clear, and a chimney is a fair thing for a sign to pass
     behind. */
  transform: translateX(-50%) translateZ(calc(64 * var(--zu))) rotateX(calc(-1 * var(--tilt)));
  /* A long room name on a small board used to run out over its neighbours and,
     at the left and right edges, straight off the grid. Clamped to a little
     wider than its own cell it stays a room label — allowed to overhang, not
     allowed to leave. `nowrap` is dropped with it: two short lines inside the
     room beat one line that escapes it. */
  max-width: 190%;
  text-align: center;
  text-wrap: balance;
  pointer-events: none;
  /* A fraction of a CELL, not of the window. Everything this label has to fit
     inside is measured in cells — its own square, the 190% it may overhang, the
     three columns the allocator reserves for it — so a size that does not track
     the cell is the one measurement in the rule that can drift away from all the
     others. See --board on .grid-wrap for what the window got wrong.
     0.17 is not a new taste: it is the ratio the desktop already ships and that
     reads correctly there, now held at every width instead of only at one. It
     also settles a second discrepancy nobody had noticed — --size is the board's
     column count, so a 5x5 board, whose cells are a third larger, used to get the
     same label as the 8x8 and wore it a size too small.
     The clamp stays, and both ends still do work: 9px is the floor below which
     uppercase letter-spaced text stops being readable at all, and the ceiling
     is where a room name starts competing with something it should not. It used
     to be set at 17px against the case title. The nearer rival turned out to be
     on the board itself: at 17px the desktop plate rendered 16.9px beside a
     person's name at 12px, so the room — which never changes and is read once —
     outranked the move the player had just made.
     15px was the first answer and it was half of one. It sat a hair ABOVE
     `.cell-name`'s 14px ceiling and was defended here as the margin a signpost
     needs — while the rule that owns `.cell-name` was two thousand lines further
     down claiming the same pair of numbers had put the NAME back on top. Both
     sentences could be demonstrated, because the two labels were measured against
     different things and their order was therefore not fixed (test/typescale.test.mjs,
     „die Rangfolge der zwei Beschriftungen"). One of them had to lose, and the
     argument above says which: the label the player just placed outranks the one
     that was always there. 13px is the plate giving up its ceiling for real, which
     is what the other rule had already announced on its behalf.
     Only the ceiling moves: the 0.17 ratio still governs every smaller board,
     where the plate was never the louder of the two. */
  font-size: clamp(9px, calc(var(--board) / var(--size, 6) * 0.17), 13px);
  /* 700, and the thickening that 800 was really asking for is done explicitly
     below. The label is uppercase, letter-spaced and as small as 9px, and it has
     to hold against ten different floor materials with nothing but a halo behind
     it — no plate, because a plate would hide the floor the label is naming. So
     it does need more weight than the ramp's top step. It just cannot get it from
     `font-weight`.
     Measured, not assumed: rendering "Waffenkammer" at 40px uppercase and
     letter-spaced gives 344.63px at 400, 368.27px at 700 — and 384.67px at both
     800 and 900. The identical widths are the evidence. Segoe UI carries
     300/350/400/600/700/900 and no 800, and CSS font matching searches upward
     first for a target above 500, so 800 silently resolved to Black. The sheet
     was declaring a step it did not have, and the real rendered jump from 700
     was a whole weight class wide.
     Not universally, and that is the stronger reason to state the weight rather
     than ask for one: SF Pro on macOS does carry a Heavy, so `800` there is a
     genuine step and lands somewhere else entirely. A declared 800 was therefore
     not one design decision but two different ones depending on the visitor's
     operating system. 700 plus an explicit stroke is the same on both.
     (`document.fonts.check('800 40px "Segoe UI"')` returns true, which is why
     this had to be measured — that call answers whether the family can be drawn,
     not whether the cut exists.)
     600 was the obvious answer to the ranking problem above and is the wrong
     one, because weight here is not doing the ranking. It is doing legibility:
     the clamp still bottoms out at 9px on a small board, uppercase and
     letter-spaced over ten floor materials with nothing but a halo behind it,
     and that is the case this weight was measured for. A weight is one value for
     the whole clamp range, so paying for the ranking at the ceiling would have
     been charged at the floor, where the plate is not the louder of the two
     anyway. The ranking is settled on the channel that carries it — size, and
     the name's full white — and the weight stays where the measurement put
     it. */
  font-weight: 700;
  /* The thickening, at a size the sheet chooses rather than one the font
     substitutes: four tenths of a pixel of stroke in the fill's own colour,
     painted under the fill so the letterforms keep their shape instead of being
     eroded from outside. It is a stamp on a plan, not running text, and it is the
     only thing in the interface allowed to read as one. */
  paint-order: stroke fill;
  -webkit-text-stroke: 0.4px currentColor;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: hsl(0 0% 100%);
  /* A halo, not a box: the label has to stay legible over ten different floor
     materials, and a filled plate would hide the very floor it is naming. */
  text-shadow:
    0 0 3px hsl(0 0% 0% / 0.95), 0 0 3px hsl(0 0% 0% / 0.95),
    0 2px 5px hsl(0 0% 0% / 0.8), 0 0 12px hsl(0 0% 0% / 0.6);
}

/* The pinned case, which covers two shapes that turn out to be the same shape.
   A room one square wide against the board's edge has no interior column to hang
   its name on: centred, 190% of a cell puts nearly half the name in the black
   beside the house, and an overhang past the wall reads as a rendering fault
   where an overhang onto a neighbouring room reads as a label belonging to a
   room. A room that simply ENDS at this square mid-board has the same problem
   with a different neighbour — half the name lands on somebody else's floor
   while its own floor lies the other way, so the label points away from the
   thing it names. Both keep their anchor and give up being centred on it: the
   label starts at the cell's midline and grows toward the plan, or toward the
   rest of its own room, which at the rim are the same direction.

   250%, not 260%. The number is not taste, it is the other half of a contract:
   js/plates.js reserves the columns a plate will cover, and from the midline
   250% is exactly half of this square plus two whole ones — three columns, the
   same footprint a centred plate gets. At 260% the label could paint 1.1 cells
   past everything the allocator had claimed for it, so a neighbouring room's
   name was free to move in underneath and the two collided anyway. That failure
   was invisible to the overlap test, because both sides agreed on a footprint
   that was too small. Moving this cap means moving plateSpan() with it. */
.room-plate.pl-l,
.room-plate.pl-r {
  max-width: 250%;
  transform: translateZ(calc(64 * var(--zu))) rotateX(calc(-1 * var(--tilt)));
}
/* Anchored at the cell's midline, growing inward — not at a small inset from its
   outer edge. The distinction is the whole fix, and it took two wrong numbers to
   find it.
   The plate is lifted 64 units toward the camera, and perspective magnifies a
   lifted point away from the vanishing point: in the outermost column that means
   outward, past the floor cell beneath it. So an inset is not competing with a
   constant. It is competing with a magnification whose factor depends on how far
   the row sits from the camera — the near row of an 8x8 board is thrown roughly
   twice as far out as the far one. A single percentage is therefore wrong for
   every row but one, which is exactly how 6% and then 10% both measured clean on
   one board and still put "Ballsaal" 10.7px into the black on another.
   The midline is not a tuned number, it is a statement: the label starts where
   its own cell starts to be that cell, and everything it needs afterwards it
   takes from the plan side. That leaves half a cell of slack against the wall —
   more than the worst magnification measured on the nearest row — so it holds
   without knowing the perspective value, the tilt or the board size. */
.room-plate.pl-l { left: 50%; right: auto; text-align: left; }
.room-plate.pl-r { left: auto; right: 50%; text-align: right; }

/* The crowded case. Every plate is allowed to overhang its square, so two rooms
   whose names land within two columns of each other in the same board row
   overlap — and two overlapping room names do not read as two names, they read
   as one impossible word. The renderer reserves the columns each plate will
   cover and hands this class to the room that finds no free run: it gives up the
   overhang and wraps inside its own square instead. Narrower and often two
   lines, which is a real cost — but a name that has to be read twice is still a
   name, and "BEHANDLUNWACHSAAL" never was. */
.room-plate.pl-tight {
  max-width: 98%;
  /* The same cell-relative measure as above, one step smaller — this label has
     given up the overhang and has only its own square to live in. */
  font-size: clamp(8px, calc(var(--board) / var(--size, 6) * 0.13), 13px);
  letter-spacing: 0.02em;
  line-height: 1.05;
  /* Drei Instanzen in dieser Reihenfolge: die gesetzte Fuge, das Wörterbuch, der
     Notbruch. Die erste steht NICHT hier, und das ist die Lehre aus der Messung.

     Diese Zeilen hießen einmal „der Fix". Sie waren es nicht: `hyphens: auto` und
     `hyphens: none` rendern in einem frischen Chrome-Profil byteweise gleich
     (16.5px, eine Zeile), während dasselbe Wort mit einem eingesetzten Weichtrenn-
     zeichen korrekt umbricht (33px, zwei Zeilen). Chrome lädt die deutschen
     Trennmuster als Komponente nach; wer sie nie geladen hat, hat keine Trennung.
     `lang="de"` am <html> ist die Voraussetzung, aber keine Zusage.

     Die tragende Trennstelle wird deshalb in `js/hyphenate.js` gesetzt und in
     `test/hyphenate.test.mjs` gegen `js/scenarios.js` geprüft — sie steht in den
     Daten, nicht im Stylesheet, und ein neuer Raum ohne bekanntes Grundwort wird
     dort rot. Ohne sie brach das enge Schild mitten in der Silbe: gemessen wurden
     „NOTENSA / AL" und „REGIERAU / M" — Silben, die es nicht gibt, an einer Kante
     ohne Bindestrich. Das ist derselbe Fehler wie zwei überlappende Namen, nur
     innerhalb eines Namens.

     `hyphens: auto` bleibt trotzdem stehen, in seiner richtigen Rolle: es ehrt
     die gesetzten Weichtrennzeichen UND nimmt zusätzlich die Wörterbuch-
     Trennstellen mit, falls die Komponente doch vorhanden ist. Es ist die zweite
     Chance, nicht die erste. `hyphenate-limit-chars` gibt diesem Pfad dieselben
     drei Zahlen, die `MIN_WORD`/`MIN_PART` im Modul erzwingen — sonst dürfte das
     Wörterbuch dort trennen, wo das Modul es sich verbietet.

     `overflow-wrap: anywhere` ist die letzte Instanz für den Fall, dass beide
     nichts finden (Eigennamen, „Refektorium"). `max-width` caps the BOX; without
     this the LINE is free to be wider, and a German room name is one compound word
     with no break opportunity — "Orchestergraben" has nowhere to wrap, so it ran
     1.43 cells wide out of the 0.98-cell box the `max-width` above allows and,
     being centred, painted onto both neighbours. The renderer reserves ONE column
     for this class, so the overhang it gave up has to be given up here too. */
  -webkit-hyphens: auto;
  hyphens: auto;
  hyphenate-limit-chars: 6 3 3;
  overflow-wrap: anywhere;
}

/* Scoped out of every state class below: the plain hover tint has higher
   specificity than they do, so an unscoped rule would repaint exactly the cell
   the player is pointing at back to the passive room colour. Furniture is no
   longer among them — it has no background of its own any more, so it takes the
   ordinary floor hover, and an unsearched piece now brightens its own floor as
   well as lifting. Searched and found cells keep their own colour.
   `.cell-blocked` is the sixth, and it is the one that matters most: without
   the exclusion the darkened square would brighten like any free floor at
   exactly the moment the player points at it and asks the question. Das ist
   schlimmer als gar keine Marke — die Zelle behauptete dann aktiv das
   Gegenteil dessen, was sie tut. */
.cell:hover:not(.cell-person):not(.cell-victim):not(.cell-target):not(.cell-searched):not(.cell-trace):not(.cell-blocked) {
  background-color: color-mix(in srgb, hsl(0 0% 100%) var(--cell-lift-hover),
    color-mix(in srgb, var(--room-tint, #666) var(--cell-tint), var(--cell-base)));
}
/* Dashed white, inset deeper than every state ring: a solid accent outline at
   offset -2px is pixel-identical to the victim/conflict ring. Stays an outline
   rather than a box-shadow, which would outrank the state classes.
   The outline is deliberately fenced OUT of forced-colors mode. In normal mode
   the state rings are `border`s and the focus ring is the only `outline` on a
   cell, so there is no contest. In forced-colors mode the border channel is
   contested (one cell, one border) and four state classes were moved onto
   `outline` to survive it — and `.cell:focus-visible` carries specificity
   (0,2,0) against their (0,1,0), so it would silently take that channel back
   from ALL of them on the focused cell, regardless of order. That is not a
   ranking, it is an erasure: the focused cell would stop saying whether it is
   furniture, searched, a find, or the corpse. The ring keeps its job there on
   `::before` instead (see the forced-colors block), which is a separate box and
   competes with nothing. */
.cell:focus-visible { z-index: 2; }
@media not all and (forced-colors: active) {
  .cell:focus-visible { outline: 2px dashed hsl(0 0% 100% / 0.95); outline-offset: -4px; }
}

/* ------------------------------------------------- the room under the cursor
   Drawn on the cell's own ::after, which means at z = 0 — flat on the floor
   plane, inside `preserve-3d`, so it takes the board's tilt with everything
   else. That is the whole point of putting it here rather than on the grid
   wrapper: an overlay drawn on top of the board would be an upright rectangle
   pasted over a tilted plan, whereas this foreshortens into the perspective and
   reads as a line traced ON the floor. It also passes correctly behind walls and
   under furniture, because at z = 0 it genuinely is behind and under them.

   Four separate borders rather than one `outline`, because the shape being
   outlined is the ROOM, not the cell: each cell contributes only the sides that
   face out of its room, and the union of those sides is the room's silhouette —
   which is L-shaped or worse for most rooms here, and no single outline can
   describe that.

   `pointer-events: none` is not optional. The pseudo covers the whole cell, and
   without it the element that receives `mouseover` would be this overlay on the
   cell the player is already inside — which is harmless — but also the thing
   that swallows the click that places a suspect, which is not. */
.cell::after {
  content: '';
  position: absolute;
  inset: 0;
  /* Above the floor markings and the room tint, below the labels (z-index 5/6)
     and the focus ring (2 is shared deliberately: a focused cell inside a
     hovered room shows both, and neither needs to win). */
  z-index: 2;
  pointer-events: none;
  border: 0 solid var(--room-edge);
  opacity: 0;
  transition: opacity 0.16s ease;
}
/* Set by the renderer from the floor-owner side on all four sides. A wall is
   raised by its bottom/right neighbour only, so a room's top and left edges are
   built by the cells of the room NEXT DOOR — marking these from the wall side
   would light two of four sides and leave every outline hanging open. */
.cell.rb-t::after { border-top-width: 3px; }
.cell.rb-r::after { border-right-width: 3px; }
.cell.rb-b::after { border-bottom-width: 3px; }
.cell.rb-l::after { border-left-width: 3px; }
.cell.rb-on::after { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .cell::after { transition: none; }
}
/* The outline is decoration that duplicates information the walls already carry,
   and in forced colours it would spend one of the very few available system
   colours on it. The plan reads by shape there, not by tint. */
@media (forced-colors: active) {
  .cell::after { display: none; }
}

/* ------------------------------------------------------------------- walls
   A wall is a plane hinged on the cell edge and folded upright, so it takes the
   board's perspective, casts its colour across the floor behind it and hides
   what stands on the far side. That is the single biggest reason the board reads
   as rooms rather than as a coloured grid.

   Each is folded a quarter turn about its own hinge. Which face ends up toward
   the camera differs per side — the north wall shows the browser its back —
   so every face is a flat gradient and never carries text or an image, which
   would arrive mirrored on two of the four sides.

   Walls are structure, not subject. An earlier version painted them from a
   single flat token at 0.62 alpha; they became the brightest pixels in the app
   and the board read as a spreadsheet. What replaced it are the three stops
   below — and because they are stops of a gradient, a wall has no one
   lightness: the crest of the partition facing the camera sits at L* 33.9, its
   foot at 10.7. Only the crest is the ceiling the floors must stay under, and
   test/contrast.test.mjs reads these declarations to enforce exactly that.
   Darken --wall-lit here and the suite goes red rather than the board quietly
   flattening. (Das alte Token blieb nach dem Umbau als --wall deklariert und
   von nichts gelesen stehen — und hat prompt eine Messung gegen eine Farbe
   verleitet, die das Brett nie zeigt. Es ist entfernt.) */
.wl {
  position: absolute;
  pointer-events: none;
  background-image: linear-gradient(var(--wall-lit), var(--wall-dark));
  /* The cap. Two pixels of near-white along the top edge is what separates a
     wall from a coloured rectangle once the tilt foreshortens it. */
  border-top: 2px solid var(--wall-cap);
  --wall-lit: hsl(36 14% 30%);
  --wall-dark: hsl(30 16% 11%);
  --wall-cap: hsl(40 26% 72%);
}
/* Overhanging a pixel at each end so two walls meeting at a corner close it
   instead of leaving a lit seam straight through to the floor. */
.wl-t { top: 0; left: -1px; right: -1px; height: var(--wall-h); transform-origin: top center; transform: rotateX(90deg); }
.wl-b { bottom: 0; left: -1px; right: -1px; height: var(--wall-h); transform-origin: bottom center; transform: rotateX(-90deg); }
.wl-l { left: 0; top: -1px; bottom: -1px; width: var(--wall-h); transform-origin: left center; transform: rotateY(-90deg); }
.wl-r { right: 0; top: -1px; bottom: -1px; width: var(--wall-h); transform-origin: right center; transform: rotateY(90deg); }
/* Side walls run parallel to the light, so they stay darker than the ones facing
   it. Without this every wall is the same value and the board loses its corners. */
.wl-l, .wl-r { --wall-lit: hsl(34 14% 22%); --wall-dark: hsl(30 16% 9%); }

/* The shell. Everything above is a partition standing inside the house; these
   four are the building's outside walls and they have a different job — they have
   to out-top the tallest thing they contain. The kitchen range's flue tops out at
   74 units, and an upright face of height h lands h·sin(tilt) up the screen, so a
   26-unit partition cannot hold it: the flue projects out past the board and
   stands in the black beside the house. That is geometry, not stacking order, and
   no z-index can argue with it — the only thing that contains a tall face is a
   taller face.
   The camera-side wall is the exception and stays low. At full height it is a
   hoarding across the front of the plan and the player is looking at the outside
   of a box; kept short it reads as a cut-away, which is the whole convention a
   floor plan is drawn in. */
.wl-ext { --wall-h: var(--wall-ext); }
.wl-b.wl-ext { --wall-h: var(--wall-ext-near); }
.wl-l.wl-ext, .wl-r.wl-ext { --wall-h: var(--wall-ext-side); }
/* Load-bearing stone rather than plasterboard: the shell is a shade heavier and
   its cap a shade cooler than the partitions, so the eye can tell at a glance
   which lines are the building and which are just the rooms. */
.wl-ext { --wall-lit: hsl(32 12% 26%); --wall-dark: hsl(28 14% 8%); --wall-cap: hsl(38 18% 62%); }
.wl-l.wl-ext, .wl-r.wl-ext { --wall-lit: hsl(30 12% 19%); --wall-dark: hsl(28 14% 7%); }

/* A doorway is the wall with a hole in it, not a shorter wall: the two jambs
   have to stay put or the rooms either side stop being enclosed. A mask cuts
   the opening out of the single plane, which keeps it one element and one
   gradient — two stub walls would need their own lighting and would show a seam
   where they met the floor. `mask` forces transform-style: flat, and here that
   is free: a wall is a leaf with no 3D descendants left to flatten.
   The masked band is the middle 38%, which at every board size is close to the
   ~90cm a door actually is against the ~3.8m a cell represents. */
.wl-door {
  --door-cut: linear-gradient(var(--door-axis), #000 0 31%, transparent 31% 69%, #000 69% 100%);
  -webkit-mask-image: var(--door-cut);
  mask-image: var(--door-cut);
}
/* The opening runs along the shared edge, and which of the element's own axes
   that is depends on which way the plane was folded: top and bottom walls span
   the cell in their width, left and right in their height. */
.wl-t.wl-door, .wl-b.wl-door { --door-axis: 90deg; }
.wl-l.wl-door, .wl-r.wl-door { --door-axis: 180deg; }

/* The threshold. Brass is the reward hue on this board, so the sill is
   deliberately stone instead — a door is architecture, not evidence, and
   nothing that merely tells you where you may walk should look findable. */
.sill {
  position: absolute;
  pointer-events: none;
  transform: translateZ(0.3px);
  background-image: linear-gradient(hsl(34 12% 46%), hsl(32 12% 26%));
  box-shadow: 0 0 6px hsl(0 0% 0% / 0.55);
  border-radius: 1px;
}
.sill-t, .sill-b { left: 31%; right: 31%; height: 5%; }
.sill-l, .sill-r { top: 31%; bottom: 31%; width: 5%; }
.sill-t { top: 0; }
.sill-b { bottom: 0; }
.sill-l { left: 0; }
.sill-r { right: 0; }

/* The cell glyph IS the <svg>, so it is sized as a box — font-size is inert. */
.glyph {
  display: block;
  flex: 0 0 auto;
  width: clamp(18px, 4.2vw, 27px);
  height: clamp(18px, 4.2vw, 27px);
  color: hsl(0 0% 100% / 0.92);
  overflow: visible;
}

/* Names ride above the object they stand on and are counter-rotated out of the
   board tilt, so they stay square to the reader while the floor they label
   recedes. Absolute, because the object underneath them is absolute too and the
   cell's flex flow no longer has anything to lay out.
   64 is a height, not a stacking trick: furniture reaches 57 at its tallest —
   the bookcase and the longcase clock — and at the old 52 the name was drawn
   THROUGH the spines of the piece it stood on, which in a scene with real depth
   is the label genuinely standing behind the object. Furniture no longer carries
   a plate (js/ui.js), but a person still stands on a piece, so the clearance is
   still the one that matters.

   The box is as wide as the name, not as wide as the cell, and that is what
   makes truncation unnecessary rather than merely unfashionable. An 8x8 board
   gives each cell about 43px; the longest surname in the thirty cases,
   Aschenbrenner, paints 66.5px at the 10px floor. Nothing that honours the 44px
   touch target can hold it, so the choice is to cut the name or to let it
   overflow — and overflow is only safe if the space beside the plate is
   genuinely free.

   It was long argued from the wrong place. The claim here read: Sudoku rules
   put one person per row (`One person per row and column`, `js/engine.js`), so
   a plate's neighbours are always furniture, and furniture is now bare. That
   line is the generator's SOLUTION condition — it describes the finished case,
   not what the board permits while it is being played. The player may set a
   figure on any free square, and a row full of figures is not refused but
   flagged with `.cell-conflict`. Seven plates in one row are reachable, and at
   375px (cell 49.7px) two 13-letter names overlap by some 17px.
   The freedom is therefore made rather than assumed: `js/ui.js` reads the
   occupancy at render time and writes `--name-nudge` on the CELL — `0%` when
   only the left neighbour is taken, `-100%` when only the right is. Both values
   are measured from `left: 50%`, so the plate's near edge lands on the cell's
   MIDPOINT, not on the boundary it shares with the neighbour — each plate is
   confined to the half of its square that faces AWAY from the occupied side.
   Two adjacent plates are therefore a full cell apart, not touching at zero
   overlap, whatever the names are called. The looser claim was written first and
   measured second; the render is the stronger of the two and it was the render
   that corrected it.
   Diagonal neighbours share the horizontal band but sit a whole cell lower,
   since every plate hangs at the same 4%.
   The one place the argument runs out is the board edge, where there is no
   neighbouring cell to spill into — only the page. The two rules below pin the
   outer columns inward instead, keyed on `.grid-row` (styles.css:1824
   `.grid-row { display: contents; }`), which keeps `:first-child` and
   `:last-child` meaning first and last COLUMN while the cells still lay
   themselves out on the board's own grid. They declare `--name-nudge` on the
   plate itself and so outrank the value inherited from the cell — which is why
   the JS needs no edge case of its own.

   Size and weight are a ranking, and this label used to lose one it should
   win. Since the furniture plates went, the person's name is the ONLY label a
   square carries — it is the player's own move — yet it was set 12px/400 beside
   a room name at 16.9px/700 uppercase: the static signpost outranking the
   thing the player just did, by 1.4x and two weight steps.
   The first correction raised this to 14px/600 and white and declared the
   ranking settled. It was not: the ramp still hung on `1.35vw`, the WINDOW,
   while `.room-plate`'s hangs on the CELL — and the cell is not monotone in the
   window, because the board runs edge to edge on a tablet and inside a column on
   a desktop. So the two labels crossed: the name led at 375px and again from
   1280px up, the plate led at 600px and at 1920px, at 768px it led by exactly
   the 1.42x this paragraph calls the bug, and at 900px they were 0.04px apart.
   A ceiling comparison cannot fix that, because two measures that cross have no
   fixed order to compare.
   The ramp is therefore moved onto the same measure as the plate's, and the
   ratio set one step above it — 0.19 against 0.17, under a 14px ceiling against
   13px, over a 10px floor against 9px. `clamp()` is monotone in all three of its
   arguments, so a name whose floor, ramp and ceiling each exceed the plate's is
   larger at EVERY width, with no width needing to be measured. That is what
   test/typescale.test.mjs („die Rangfolge der zwei Beschriftungen") reads out of
   this sheet: not a sample of widths, but the ordering of the three terms. */
.cell-name {
  position: absolute;
  left: 50%;
  bottom: 4%;
  width: max-content;
  max-width: 300%;
  z-index: 5;
  transform: translateX(var(--name-nudge, -50%))
             translateZ(calc(64 * var(--zu))) rotateX(calc(-1 * var(--tilt)));
  font-size: clamp(10px, calc(var(--board) / var(--size, 6) * 0.19), 14px);
  font-weight: 600;
  line-height: 1.1;
  text-align: center;
  color: hsl(0 0% 100%);
  text-shadow: 0 1px 2px hsl(0 0% 0% / 0.95), 0 0 6px hsl(0 0% 0% / 0.8);
  white-space: nowrap;
  pointer-events: none;
}

/* The outermost columns, and a measurement that was true and still wrong.
   What stood here was a reading off an 8x8 board at 375px — the first column's
   name starting at 5.4px against a board edge at 3.2px, the last column's
   ending at 418.2px against an edge at 420.9px, "neither is cut" — and every
   one of those numbers was correct. They were taken off the wrong box.
   `getBoundingClientRect` on this label reports where the browser LAYS it out;
   the rule above then lifts it 64 units toward the camera, and perspective
   magnifies a lifted point away from the vanishing point. In the first column
   that direction is outward. The label was painted 23.1px further left than it
   was laid out, and 23.1px left of the first column is the room's left wall,
   which paints over it: "Marquardt" read as "larquardt", "Karrenbauer" as
   "arrenbauer". Nothing clipped. A wall drew on top of a letter, and the guard
   that should have caught it was measuring the untransformed rectangle.
   The fix is not an inset, and that is worth stating rather than assuming,
   because an inset is the obvious answer and this sheet has already paid for it
   twice: see .room-plate.pl-l above, where 6% and then 10% each measured clean
   on one board and put a name in the black on the next. An inset competes with
   a magnification whose factor depends on the row's distance from the camera,
   so one percentage is wrong for every row but one.
   These labels therefore take the plate's answer, on the plate's reasoning:
   they anchor at their own cell's MIDLINE and grow inward. That buys half a
   cell of slack against the throw, and half a cell is enough for a reason that
   can be written down instead of sampled. Project a point (x, z) to x·P/(P−z);
   ask that a label anchored at the midline still project inside its own cell's
   outer edge; the board width cancels and what is left is size·L <= P − z.
   With the 64-unit lift, the 2.4px cap on --zu, the 26deg tilt and the widest
   board this layout can produce, that is 1228.8 <= 1428.2 — an 8-column board
   clears it, and test/kante.test.mjs recomputes both sides out of this sheet
   rather than trusting the two numbers in this sentence. The same suite is what
   would fail if this lift were raised to the pawn's 75.
   No text-align to match the plate's: that label wraps to two lines when it is
   crowded, this one is `width: max-content` and `nowrap`, so the box is exactly
   as wide as the word and the alignment inside it has nothing to align. */
.grid-row > .cell:first-child .cell-name { left: 50%; --name-nudge: 0%; }
.grid-row > .cell:last-child .cell-name { left: auto; right: 50%; --name-nudge: 0%; }

/* No background of its own — and that omission is the rule's whole content.
   This used to repaint the cell at 6% room hue over near-black, which is the one
   thing a top-down floor plan must never do: it cut a dark hole in the floor
   under every object, so a kitchen read as a counter standing in a void instead
   of a counter standing in a kitchen. The floor is continuous because the room
   owns it; furniture stands ON the room, it does not replace it.
   The affordance the override was carrying moved onto the object itself, where
   it belongs — .cell:hover .furn lifts the piece off the floor, which says
   "this is a thing you can pick up and look under" far better than a tint. */
.cell-furniture {
  /* The only thing on the board worth searching, so it invites the click. */
  cursor: pointer;
}

/* A piece a person can share the square with — an armchair, a hearth, a mirror.
   The furnishing slides back and shrinks so the pawn has the front half of the
   floor to stand on: without this the figure lands inside the prism and the two
   read as one damaged object rather than as someone sitting down. Scoped to the
   occupied case, because an empty wing chair should still fill its cell. */
.cell-shareable.cell-person .furn {
  transform: translate3d(0, -14%, 0) scale(0.78);
}
/* …unless the piece IS the floor. A rug is the one shareable object that does
   not step aside for the figure, because there is nothing to step aside from:
   the whole claim its geometry makes — two prisms one and two hundredths of a
   cell high, see `carpet` in js/furnishings.js — is "you stand ON me". Pushed
   back and shrunk by the rule above, it read as a small mat the suspect was
   standing NEXT TO, which is precisely the sentence the piece exists to deny,
   and it would have made the `onObject` clue a lie the board tells in pictures.
   A reset rather than a `:not()` on the rule above, and the reason is the rule
   above's own weight. `.cell-shareable.cell-person .furn` is three classes, the
   same three as `.cell:hover .furn` six hundred lines down, so today the hover
   lift wins on source order alone. Writing `:not(.furn-ground)` would make it
   four and hand the shift-back a permanent win: every shareable piece with
   somebody on it would stop lifting under the cursor, and nothing here would
   say why. Equal weight plus source order costs nothing. */
.cell-shareable.cell-person .furn-ground { transform: none; }

/* Searched and empty: struck off the list, quieter than an untouched piece so
   the eye is drawn to what is left rather than to what is done. */
.cell-searched {
  cursor: default;
}
/* Dimmed on the faces rather than on the cell. `opacity` below 1 is a grouping
   value, so `opacity: 0.6` here — which is what this rule used to say — flattened
   the cell's whole 3D subtree the instant a piece was searched: the object
   collapsed into the floor as a reward for interacting with it. The faces are
   leaves, so dimming them costs nothing and the prism keeps standing. */
.cell-searched .bx-top,
.cell-searched .bx-front { filter: brightness(0.62) saturate(0.7); }
.cell-searched .furn-shade { opacity: 0.4; }
/* A find. Brass is the reward hue everywhere in this app, and the ring reads
   through the furniture tint without recolouring the room underneath. */
.cell-trace {
  /* Brass laid over the room's own floor, not over near-black. Mixing into a
     dark constant was the same hole .cell-furniture used to cut: the one cell
     the player just solved something in would drop out of its room. */
  background-color: color-mix(
    in srgb, var(--brass) 14%,
    color-mix(in srgb, var(--room-tint, #666) var(--cell-tint), var(--cell-base))
  );
}
/* The ring sits on its own box, and that is the whole point of this rule.
   A find is not exclusive: the same cell can be a placement target, can be
   blocked, can hold a person. All of those say their mark in `box-shadow` on
   the cell itself, all at specificity (0,1,0) — so the only thing separating
   them is source order, and `box-shadow` does not accumulate, it REPLACES.
   .cell-trace is the earliest of them, which meant every later partner wiped
   the find ring off the board silently. The tint above shares that fate and is
   allowed to: losing the brass wash to a target tint costs the player nothing
   as long as the ring still says "you found something here".
   `::before` is free in normal mode — the room edge lives on `::after`, and the
   forced-colors focus ring below claims `::before` only inside its own fence,
   which is why this rule is fenced out of that mode rather than competing with
   it. The ring stays `inset`, so high contrast drops it exactly as it dropped
   the old one, and the contrast block keeps its own outline word for the find. */
@media not all and (forced-colors: active) {
  .cell-trace::before {
    content: '';
    position: absolute;
    inset: 0;
    z-index: 1;
    pointer-events: none;
    box-shadow: inset 0 0 0 2px hsl(45 72% 62% / 0.75);
  }
}

.cell-person {
  background-color: color-mix(in srgb, var(--room-tint, #666) var(--cell-tint), var(--cell-base));
  box-shadow: inset 0 0 0 1px hsl(0 0% 100% / 0.30);
}
/* Clicking a placed person picks them up again, so the cell owes hover feedback —
   but a conflicted cell must keep its red ring while it is being pointed at, since
   it is the only cell-level marker of which person sits wrong. */
.cell-person:hover:not(.cell-conflict) { box-shadow: inset 0 0 0 1px hsl(0 0% 100% / 0.55); }
/* Listed after the ring so the accent paints over it — a 1px white seam. */
.cell-person.cell-conflict:hover { box-shadow: var(--conflict-ring), inset 0 0 0 3px hsl(0 0% 100% / 0.22); }
/* The body is the anchor of the whole case, so it gets the strongest single
   mark on the board: accent ring, accent fill and an accent-lit glyph. */
.cell-victim {
  box-shadow: inset 0 0 0 2px var(--accent), inset 0 0 18px hsl(0 72% 52% / 0.35);
  background-color: color-mix(in srgb, var(--accent) 20%, hsl(0 0% 7%));
  cursor: not-allowed;
}
/* Named so the hover variant can extend the ring instead of restating it. */
/* ---------------------------------------------------------- die Rundenleiste

   Sie steht ueber dem Brett und beantwortet drei Fragen, die man waehrend einer
   Runde hat und sonst nirgends beantwortet bekommt: wer sitzt am Tisch, was ist
   gerade los, und lebt die Leitung noch.

   Bewusst FLACH statt als Karte: sie liegt zwischen Fallakte und Brett, und ein
   dritter Kasten in dieser Reihe haette aus drei Ebenen vier gemacht. Sie
   traegt deshalb nur eine Haarlinie nach unten — genug, um zum Brett zu
   gehoeren, zu wenig, um mit ihm zu streiten. */
.mp-leiste {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2) var(--space-3);
  margin-bottom: var(--space-2);
  padding-bottom: var(--space-2);
  border-bottom: 1px solid var(--border);
}

.mp-leiste-koepfe { display: flex; flex-wrap: wrap; gap: var(--space-2); }

.mp-kopf {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  padding: 0.2rem 0.55rem 0.2rem 0.2rem;
  border-radius: 999px;
  background: hsl(0 0% 100% / 0.05);
  border: 1px solid var(--border);
  font-size: var(--fs-xs);
  color: var(--text);
}
/* Dieselbe Zuordnung wie auf dem Brett: der eigene Kopf traegt den Akzent, der
   fremde bleibt neutral. Wer die Marke auf einem Feld sieht, findet hier
   dieselbe Farbe und denselben Buchstaben wieder — sonst waeren es zwei
   Zeichensysteme fuer dieselbe Aussage. */
.mp-kopf-marke {
  display: grid;
  place-items: center;
  width: 1.25rem;
  height: 1.25rem;
  border-radius: 999px;
  background: hsl(0 0% 24%);
  color: hsl(0 0% 100%);
  font-size: 0.62rem;
  font-weight: 700;
}
.mp-kopf-ich .mp-kopf-marke { background: color-mix(in srgb, var(--accent) 78%, hsl(0 0% 8%)); }
.mp-kopf-name { font-weight: 600; }
.mp-kopf-zahl { color: var(--muted); }
/* „Fertig" ist im VS die letzte Auskunft, die ueberhaupt noch etwas aendert —
   und sie kommt erst, wenn die Runde ohnehin vorbei ist. */
.mp-kopf-fertig { border-color: color-mix(in srgb, var(--ok) 50%, var(--border)); }
/* Wer den Tisch verlassen hat (0015: die Koop-Runde laeuft ohne ihn weiter).
   Gedaempft, nicht rot — es ist kein Fehler, sondern ein Weggang. Der Name wird
   durchgestrichen und NICHT bloss blasser: eine reine Helligkeitsstufe waere
   von „inaktiv" nicht zu unterscheiden, und ein zweiter Kanal neben der Farbe
   ist ohnehin die Regel im Haus. Die Marke bleibt voll deckend, damit die
   Zuordnung zu den Figuren auf dem Brett lesbar bleibt — dort stehen seine
   Zuege ja weiter. */
/* KEIN `opacity` auf dem ganzen Chip. `opacity < 1` gruppiert den Teilbaum und
   komponiert ihn als Ganzes — eine Kind-Regel kann die Gruppendeckkraft nicht
   zurueckholen, `.mp-kopf-weg .mp-kopf-marke { opacity: 1 }` war also ein
   No-Op und die Marke wurde entgegen der Zusage mitgedimmt. Gedaempft wird
   deshalb, was gedaempft sein soll: die Farbe des Namens. Die Marke bleibt
   voll deckend, damit die Zuordnung zu den Figuren auf dem Brett lesbar bleibt
   — dort stehen die Zuege des Gegangenen ja weiter. */
.mp-kopf-weg { border-style: dashed; }
.mp-kopf-weg .mp-kopf-name {
  color: var(--muted);
  text-decoration: line-through;
  text-decoration-thickness: 1px;
}
.mp-kopf-leer { color: var(--muted); font-style: italic; background: none; border-style: dashed; }

/* Die Rundenzeile steht ueber der Lage-Zeile: sie aendert sich waehrend der
   ganzen Runde nicht, waehrend darunter Meldungen kommen und gehen.
   Unterschieden wird ueber die POSITION und darueber, dass nur die untere Zeile
   je Farbe annimmt (`warn`/`bad`) — nicht ueber ein `opacity` obendrauf. Ein
   solches Abdunkeln waere hier unbemerkt durchgegangen: der Flaechen-Sweep in
   test/contrast.test.mjs misst die gesetzte FARBE, und `opacity` liest er nur
   an den zwei Stellen, an denen er ausdruecklich dazu verdrahtet wurde. Die
   Regel waere also gruen gewesen und die Zeile trotzdem unter AA. */
.mp-leiste-runde {
  margin: 0;
  flex: 1 1 100%;
  font-size: var(--fs-xs);
  color: var(--muted);
}
.mp-leiste-lage { margin: 0; flex: 1 1 12rem; font-size: var(--fs-xs); color: var(--muted); }
.mp-leiste-lage.warn { color: var(--warn); }
/* `--accent-soft` und nicht `--accent`: der satte Akzent liest sich als Text auf
   der Panel-Flaeche mit 3.55:1 und faellt damit durch — dieselbe Falle, die
   dieses Blatt an vier anderen Stellen schon benannt hat. Gemessen wird das von
   test/contrast.test.mjs, und zwar so, dass es einen NICHT durchlaesst: der
   Fehlversuch mit `--accent` stand hier eine Fassung lang. */
.mp-leiste-lage.bad { color: var(--accent-soft); }

.mp-leiste-tat { display: flex; align-items: center; gap: var(--space-2); margin-inline-start: auto; }

/* Der Puls sagt, ob zugestellt oder nachgefragt wird. Er ist klein und still,
   weil er im Normalfall nichts zu melden hat — und genau deshalb faellt der
   Ausfall auf: eine erloschene Lampe sieht anders aus als eine, die nie da war.
   Er traegt einen `title`, keinen Text: drei Woerter neben dem Brett fuer eine
   Auskunft, die man einmal am Abend braucht, waeren Dauerlaerm. */
.mp-puls {
  width: 0.5rem;
  height: 0.5rem;
  border-radius: 999px;
  background: var(--muted);
  flex: none;
}
.mp-puls[data-zustand="verbunden"] { background: var(--ok); }
.mp-puls[data-zustand="getrennt"] { background: var(--warn); }
/* Beendet ist kein Zwischenfall, sondern ein Ende — also weder gruen noch warn,
   sondern still. `--muted` und keine Animation: der Punkt ist dann ein
   Merkzeichen und kein Melder. */
.mp-puls[data-zustand="vorbei"] { background: var(--muted); }
@media (prefers-reduced-motion: no-preference) {
  .mp-puls[data-zustand="verbunden"] { animation: mp-puls 2.4s ease-in-out infinite; }
}
@keyframes mp-puls {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.45; }
}

@media (forced-colors: active) {
  /* Im Kontrastmodus faellt jede Farbe weg, also traegt die FORM: verbunden ist
     gefuellt, getrennt ist hohl. */
  .mp-puls { border: 1px solid CanvasText; background: Canvas; }
  .mp-puls[data-zustand="verbunden"] { background: CanvasText; }
}

/* ------------------------------------------------- wer hat das gesetzt (Koop)

   Ein Buchstabe in der oberen linken Ecke, und die Farbe verstaerkt ihn nur.
   Sie traegt ihn NICHT: die Figuren auf diesem Brett haben schon Mantelfarben,
   eine dritte Farbebene waere fuer jeden Laerm und fuer Rot-Gruen-Blinde gar
   keine Unterscheidung. Der Buchstabe ist der Kanal, der ohne Farbsehen
   funktioniert — und weil er die Initiale des Anzeigenamens ist, sagt er
   ausserdem WER und nicht bloss „der andere".

   Oben LINKS und nicht rechts: rechts unten sitzt bereits die Fundmarke der
   durchsuchten Moebel, und zwei Marken in derselben Ecke waeren auf 375px zwei
   Punkte, die man fuer einen haelt.

   `translateZ`: das Brett steht unter `rotateX`, und alles, was auf der
   Bodenebene bleibt, wird von der Figur ueberlaufen. Der Wert liegt unter dem
   der Figur (die haengt hoeher) und ueber dem Boden — die Marke klebt also am
   Feld, nicht an der Figur, und wandert beim Kippen mit dem Feld. */
.urheber-marke {
  position: absolute;
  top: 2px;
  left: 2px;
  transform: translateZ(calc(40 * var(--zu, 1px)));
  display: grid;
  place-items: center;
  /* `min()` gegen einen Anteil der ZELLE, nicht bloss rem. Ein fester rem-Wert
     waechst relativ zum Feld, sobald das Feld schrumpft: gemessen 11,7 % der
     Zellbreite auf 1440x900/5x5, aber 31,4 % auf 812x375/8x8 — auf dem
     kleinsten Brett ein Drittel des Feldes fuer eine Nebenauskunft. Die
     Prozente loesen gegen `.cell` auf (die Marke liegt absolut darin), also
     schrumpft sie ab jetzt MIT dem Feld statt gegen es. */
  min-width: min(0.95rem, 22%);
  height: min(0.95rem, 22%);
  padding-inline: 0.2em;
  border-radius: 999px;
  font-size: 0.58rem;
  font-weight: 700;
  line-height: 1;
  letter-spacing: 0.02em;
  /* Weisse Schrift auf getoenter Flaeche, nie Akzent-auf-Akzent-Tint: bei
     dieser Groesse faellt Akzenttext auf eigener Tint durch WCAG AA (~3.3:1). */
  color: hsl(0 0% 100%);
  pointer-events: none;
}

/* Der eigene Zug traegt den Akzent, der fremde bleibt neutral. Wer aufs Brett
   schaut, sucht zuerst „was hat der ANDERE getan" — und findet es als das, was
   NICHT leuchtet, also ohne die Farbe lesen zu muessen. */
.cell-von-mir .urheber-marke {
  background: color-mix(in srgb, var(--accent) 78%, hsl(0 0% 8%));
  box-shadow: 0 0 0 1px hsl(0 0% 100% / 0.28);
}
.cell-von-dir .urheber-marke {
  background: hsl(0 0% 24%);
  box-shadow: 0 0 0 1px hsl(0 0% 100% / 0.34);
}

/* Auf dem kleinsten Brett bleibt die Marke lesbar, statt zu schrumpfen: ein
   Zeichen unter 0.5rem ist bei dieser Kippung keines mehr. Sie darf lieber
   einen Hauch mehr vom Feld nehmen. */
@media (max-width: 420px) {
  .urheber-marke { min-width: min(0.9rem, 24%); height: min(0.9rem, 24%); font-size: 0.55rem; }
}

@media (prefers-reduced-motion: no-preference) {
  /* Ein Zug des anderen soll man ANKOMMEN sehen. Einmal, kurz, und nur die
     Marke — das Feld selbst blinken zu lassen waere auf einem Brett mit
     laufender Uhr Unruhe ohne Aussage.

     Die Klasse ist FLUECHTIG (`urheber-neu`, von `js/ui.js` genau an dem einen
     angekommenen Zug gesetzt) und nicht der Dauerzustand `.cell-von-dir`. An
     dem hing sie zuerst — und weil `renderGrid` bei jedem Zeichnen alle Zellen
     neu baut, pulsierten nach jeder eigenen Platzierung saemtliche fremden
     Marken. Der Nachklang sagte damit nicht „hier ist etwas angekommen",
     sondern „es wurde gezeichnet". */
  .urheber-marke.urheber-neu { animation: urheber-an 460ms ease-out 1; }
}
@keyframes urheber-an {
  from { transform: translateZ(calc(40 * var(--zu, 1px))) scale(0.4); opacity: 0; }
  to   { transform: translateZ(calc(40 * var(--zu, 1px))) scale(1);   opacity: 1; }
}

.cell-conflict {
  --conflict-ring: inset 0 0 0 2px var(--accent), inset 0 0 12px hsl(0 72% 52% / 0.45);
  box-shadow: var(--conflict-ring);
}
/* "Armed" — the same two tokens as the picked-up suspect and the engaged mode,
   so board, tray and toolbar say it in one voice. Never the accent: red is the
   verdict colour, and a third meaning would make it unreadable.
   Der Raumton steckt IN der Aufhellung, nicht neben ihr. Vorher mischte die
   Fläche gegen `--cell-base` allein, also gegen den nackten Neutralton, und
   damit wurde das Brett in genau dem Moment farblos, in dem der Spieler
   entscheidet, in WELCHEN RAUM er jemanden setzt — auf dem 8×8-Feld tragen
   dann 57 von 64 Zellen diese Klasse gleichzeitig. Die Hinweise sprechen aber
   in Raumnamen, und der teuerste Kanal dieses Bretts (zehn nach CIE-L*
   gestufte, auf Dichromasie geprüfte Böden) darf nicht ausgerechnet beim
   Handeln abschalten. Die Raumsilhouette rettet das nicht: sie hängt am Hover
   und hilft damit weder dem Finger noch dem ersten Blick.
   Die Verschachtelung ist wörtlich dieselbe wie beim gewöhnlichen Hover eine
   Regel weiter oben, nur mit 7% statt `--cell-lift-hover`. Fläche sagt Raum,
   Kontur sagt Zustand — ein Kanal, eine Aussage.
   Im Ruhezustand trägt diese Regel KEINEN Ring, und das ist gemessen statt
   gefunden. Hier stand `inset 0 0 0 2px var(--pick-line)`, und die Begründung
   dafür war „die Marke markiert ein Feld". Am gebauten Brett tut sie das nicht:
   bei gepinntem `Math.random` und über alle vier Grade tragen 23/35/47/62 leere
   Felder diese Klasse — 92.0% bis 97.2% aller leeren —, und von ihnen hat
   AUSNAHMSLOS jedes einen weiteren Ringträger orthogonal daneben (100.0%,
   freistehend: 0). Ein inset-Ring auf zwei benachbarten Feldern legt zwei Linien
   direkt aneinander; was hier stand, war deshalb keine Marke auf einem Feld,
   sondern ein zweites Gitter über dem, das dieses Brett ohnehin zeichnet. Die
   Sperrmarke ist im selben Lauf auf jedem Grad zu 100% freistehend (1 bzw. 2
   Felder, keines mit sperrendem Nachbarn) — sie ist von den beiden die einzige,
   die wirklich ein Feld auszeichnet.
   Die FLÄCHE bleibt, und sie ist nicht dasselbe Argument: eine Aufhellung legt
   keine Linie neben eine Linie, sie sagt den MODUS an, und dass sie fast überall
   steht, ist ihr Zweck. Der Hover-Ring bleibt ebenfalls — er steht per
   Konstruktion auf genau einem Feld und ist damit die Minderheit, um die es
   geht. Gehalten von test/ring-render.test.mjs, das am gebauten Brett zählt. */
.cell-target {
  background-color: color-mix(in srgb, hsl(0 0% 100%) 7%,
    color-mix(in srgb, var(--room-tint, #666) var(--cell-tint), var(--cell-base)));
}
.cell-target:hover { box-shadow: inset 0 0 0 2px hsl(0 0% 100% / 0.92); }
/* Die Gegenaussage — und sie erscheint zur selben Sekunde wie die Zielmarke,
   weil sie nur zusammen mit ihr etwas bedeutet. Ohne Figur in der Hand ist eine
   Truhe schlicht eine Truhe; erst wenn jemand platziert werden soll, wird aus
   ihr ein Nein, und genau dann darf das Brett es sagen.
   Drei bewusste Entscheidungen stecken darin. Nicht der Akzent: Rot ist auf
   diesem Brett das Urteil (Opfer, Konflikt), und eine dritte Bedeutung machte
   alle drei unlesbar. Ein zurückgenommener Ring — die Marke soll das Feld
   ZURÜCKNEHMEN, nicht darum werben; wie weit sie das darf, ohne unter die
   Sichtbarkeitsschwelle zu fallen, steht bei der Regel selbst.
   Und kein `translateZ`: die Tönung bleibt auf dem Boden, unter dem
   Möbel, das sie erklärt. Ein Schleier darüber verdeckte den Gegenstand, der
   der ganze Grund für die Sperre ist.
   Der Zeiger bleibt `pointer` und wird ausdrücklich NICHT auf `not-allowed`
   gesetzt. Das Feld ist gesperrt für eine Person und für nichts sonst — es
   trägt das Möbelstück, und das Möbelstück ist durchsuchbar. `not-allowed`
   behauptete, hier sei überhaupt nichts zu holen, und nähme dem Spieler
   ausgerechnet auf den beweistragenden Feldern die Einladung zum Klick. Die
   Sperre steht im Ring, in der Tönung und im Namen der Zelle; der Zeiger
   spricht über den Klick, und der Klick geht durch. */
/* 3px auf 0.50, und beide Zahlen sind gerechnet statt gewählt.
   Zwei Fassungen sind hier schon gescheitert, und zwar an derselben Stelle. Die
   erste stand auf 1px/0.14 und erreichte gegen die eigene Füllung 1.49:1 — unter
   jeder Schwelle, ab der ein grafisches Objekt einen Zustand tragen darf (1.4.11
   will 3:1). Die zweite hob auf 0.38 und erreichte 3.42:1. Damit war der Ring
   sichtbar, sobald man ihn ansah — aber der Auftrag lautet nicht "auffindbar",
   er lautet "direkt grafisch klar".
   Was beide übersahen: diese Marke steht nie allein. `js/ui.js` gibt jedem
   leeren Feld eine der beiden Marken, die Sperrmarke den gesperrten, die
   Zielmarke allen übrigen. Sie erscheinen gleichzeitig und teilen das Brett
   unter sich auf — und zwar sehr ungleich: rund drei gesperrte Felder gegen
   mindestens dreiundzwanzig freie. Wie laut eine Marke auf dem BRETT ist, hängt
   deshalb nicht nur an ihrer Zeichnung, sondern auch an ihrer Häufigkeit. Bei
   1px/0.38 gegen den damaligen 2px/0.72-Ring der Zielmarke stand die seltene,
   auskunftgebende Marke gegen das Neunzigfache an Farbe der häufigen. Die alte
   Begründung "sie wirbt nicht" hat die Seltenheit damit nicht ausgeglichen,
   sondern verdoppelt.
   Die Korrektur nahm den zweiten Weg statt des ersten: nicht heller, sondern
   breiter. Pro Pixel blieb die Sperrmarke mit 0.50 unter den 0.72 der Zielmarke,
   sie war nur auf mehr Pixeln zu sehen — 0.50 × 3px = 1.50 gegen 1.44.
   Die dritte Fassung hat den Gegner dieses Vergleichs entfernt statt ihn zu
   unterbieten: die Zielmarke trägt im Ruhezustand gar keinen Ring mehr, weil sie
   gemessen keine Marke war, sondern ein zweites Gitter (100.0% ihrer Träger
   hatten einen Ringträger direkt daneben, auf allen vier Graden). Damit ist
   diese hier der einzige ruhende Feldring des Bretts, und die Rechnung verliert
   ihren Boden — nicht ihre Decke. Was bleibt, sind zwei Schranken, die beide aus
   diesem Bogen gelesen und nicht erfunden sind: nach unten die absolute 3:1 aus
   WCAG 1.4.11 gegen die eigene Füllung, an der schon Fassung eins und zwei
   scheiterten; nach oben der lauteste Feldring, den dieses Haus kennt —
   `.cell-target:hover` mit 0.92 × 2px = 1.84 —, denn ein ruhender Zustand darf
   nicht lauter sein als die Antwort auf den Zeiger. `test/affordance.test.mjs`
   liest beide aus diesem Bogen und fordert zusätzlich, dass im Ruhezustand
   überhaupt nur die seltenere der beiden Marken einen Ring trägt — wer den Ring
   der Zielmarke zurückstellt, bekommt Rot, keine stille Drift.
   Die Breite ist zudem der ehrlichere Kanal: sie ist Form, nicht Helligkeit,
   und trägt ihren Satz auch dem zu, der Helligkeiten nicht vergleichen kann.
   Die Schraffur bleibt bei 0.38 — sie unterscheidet gesperrt von frei und muss
   dafür nur ihre eigene Schwelle halten, nicht an diesem Wettbewerb teilnehmen. */
.cell-blocked {
  /* Ein Ring, keine Vignette. Die 22px-Verdunkelung nach innen, die hier stand,
     war die vierte Betonungsschicht auf demselben Feld — nach der 34%igen
     Abdunklung des Grundes, dem Ring und der Schraffur. Sie half keiner davon:
     die Abdunklung besorgt den Helligkeitskanal ohnehin, und der Schraffur nahm
     sie am Feldrand genau den Untergrund weg, gegen den sie sich abheben soll.
     Was sie kostete, war Betonung — ein gesperrtes Feld ist eine Auskunft, kein
     Ereignis, und es stand damit lauter da als die Zielmarke, die eine Handlung
     anbietet. */
  box-shadow: inset 0 0 0 3px hsl(0 0% 100% / 0.50);
  /* Der zweite Kanal, und er ist kein Helligkeitskanal: Textur. Die Tönung
     allein trennt gesperrt von frei nur um 1.09:1 auf dem dunkelsten Boden
     (hsl(210 6% 16.5%) auf --cell-base), und dagegen hilft kein tieferes
     Verdunkeln — bei 48% statt 34% sind es 1.13:1. Wo schon fast nichts ist,
     lässt sich nichts mehr wegnehmen. Also kommt etwas dazu, das nicht heller
     oder dunkler ist, sondern GERASTERT: 2px Schraffur auf 8px Periode, quer.
     Die 23° sind gerechnet, nicht gewählt. Ein Streifensatz gegen einen anderen
     Streifensatz ist nur dann eine Formunterscheidung, wenn er eine andere
     RICHTUNG hält; bei gleicher Richtung bleibt bloss ein Dichteunterschied,
     und Dichte ist wieder eine Helligkeitsfrage — genau der Kanal, dem diese
     Ebene ausweichen soll. Die zehn Böden belegen sechs Richtungen; unter
     `rotateX(--tilt)`, das jede Richtung ausser 0° und 90° verschiebt, liegen
     sie bei 0/41.9/63.7/90/117.4/138.1°. 23° projiziert auf 20.9° und hält
     damit zu jeder von ihnen mindestens 20.9° Abstand — die breiteste Lücke
     des Satzes gibt nicht mehr her als 21.0°. Bei 45° stand die Marke exakt
     auf der Diagonalen von Bibliothek und Billardzimmer; im Billardzimmer war
     sie sogar phasengleich, dort trug dieser Kanal null Information.
     Dünn und mit 6px Lücke, damit der Raumboden zwischen den Strichen stehen
     bleibt: die Schraffur liegt ÜBER `--room-floor`, sie ersetzt es nicht —
     deshalb die zweite Ebene in `background-image` und das führende `auto` in
     `background-size`, das die Grössenliste des Raumes unangetastet lässt.
     Gehalten von test/affordance.test.mjs → "Formabstand". */
  background-image:
    repeating-linear-gradient(23deg,
      hsl(0 0% 100% / 0.38) 0 2px,
      hsl(0 0% 100% / 0) 2px 8px),
    var(--room-floor, none);
  background-size: auto, var(--room-floor-size, auto);
  /* Verdunkelt wird der Boden des RAUMES, nicht ein dunkler Ersatzgrund. Ein
     `color-mix` gegen `--cell-base` allein verwirft `--room-tint` und schneidet
     unter jedes sperrende Möbel dasselbe Loch, das `.cell-furniture` weiter oben
     schon einmal gerissen hat und das `.cell-trace` deshalb verschachtelt. Auf
     zehn verschieden getönten Böden ist das der Unterschied zwischen "die Truhe
     steht in der Waffenkammer" und "die Truhe steht in einem Loch". */
  background-color: color-mix(
    in srgb, hsl(0 0% 0%) 34%,
    color-mix(in srgb, var(--room-tint, #666) var(--cell-tint), var(--cell-base))
  );
  cursor: pointer;
}
/* A player's own note is bookkeeping, not evidence — it stays quieter than
   every generated mark on the board and shrinks to keep that hierarchy.
   Quieter is a statement about weight, though, not about whether it arrives. Flat
   on the floor at z=0 the cross was not soft, it was BEHIND: depth order here is
   real geometry, so any prism on the same square simply covered it, and a note
   the board can swallow is not a note.
   75, and the number is derived rather than picked. Searchable pieces top out at
   57 and rise by the hover lift of 7 while the cursor is on them; room dressing
   reaches 74 and is exempt from that lift — so 74 is the tallest thing that can
   ever stand between this mark and the reader. test/geometry.test.mjs reads both
   halves back, the pieces out of js/ and the lift out of this sheet, and fails if
   a new piece grows past it. That guard is the only thing that keeps this true,
   because the furniture is generated and nobody will think to come back here.
   Upwards the far wall (78) is the cap: perspective magnifies a lifted point away
   from the vanishing point, so altitude is bought with up-screen drift, and a
   cross floating a cell above its own square marks nothing at all. The counter-
   rotation is the other half — lifted but still tilted it would just be a higher
   rhombus; turned out of the board's tilt it faces the reader, the same trick
   .room-plate and .cell-name already use. */
/* Der Hub allein macht die Marke sichtbar, nicht lesbar. Über allem zu stehen
   heißt jetzt, auf allem zu stehen: der Grund unter dem Kreuz ist nicht mehr der
   Raumboden, sondern der Deckel des Möbels, das im selben Feld liegt. Weiß bei
   0.42 über dem Elfenbein-Deckel eines Gemäldes sind 1.07:1 — vorhanden und
   trotzdem nicht da. Beide Hälften der Antwort stehen unten und
   test/contrast.test.mjs rechnet sie gegen alle 41 erreichbaren Gründe nach.
   Die erste ist Alpha: 0.72 statt 0.42, gedeckelt durch die 0.92 von
   .cell-name — eine Notiz markiert ein LEERES Feld und darf nie lauter werden
   als ein gesetzter Name.
   Die zweite ist die Platte, und sie ist der eigentliche Träger. Ein dunkler
   Schatten in der FORM des Kreuzes färbt nur das Kreuz um und liefert keine
   Kante (weiß@0.72 über schwarz@0.95 über dem Feuerdeckel: 1.50:1). Was trägt,
   ist Fläche: eine Scheibe, die über die Striche hinausragt, gibt dem Kreuz
   einen eigenen Nahgrund, und dann trägt das Paar den Kontrast — gleichgültig,
   was darunter liegt. Deshalb `background` und nicht `drop-shadow`: unter einer
   Weichzeichnung steht nicht das deklarierte Alpha, und jede Annahme darüber
   wäre eine erfundene Zahl. Das Polster ist der Überstand; box-sizing ist
   border-box, also wird es zur Kastengröße addiert statt vom Zeichen
   abgezogen — sonst antwortete man „zu schwach" mit einem kleineren X. */
.cell-note .note-mark {
  --mark-size: clamp(13px, 2.9vw, 18px);
  --mark-pad: 2px;
  color: hsl(0 0% 100% / 0.72);
  background: hsl(0 0% 0% / 0.62);
  border-radius: 50%;
  padding: var(--mark-pad);
  width: calc(var(--mark-size) + 2 * var(--mark-pad));
  height: calc(var(--mark-size) + 2 * var(--mark-pad));
  stroke-width: 2;
  transform: translateZ(calc(75 * var(--zu))) rotateX(calc(-1 * var(--tilt)));
}

/* Das Ausweichen selbst — und es ist kein Geschmacksmaß, sondern die Antwort auf
   eine Kollision, die drei für sich richtige Regeln miteinander erzeugen: die
   Marke hängt auf `75 * --zu`, das Raumschild auf `64 * --zu`, und unter
   `rotateX(--tilt)` werden aus elf Höheneinheiten Bildversatz nach OBEN. Die
   Marke steigt dem Schild entgegen, das ohnehin am oberen Rand derselben Zelle
   hängt (`top: 6%`, im Telefonzweig 4%).

   Warum `translateY` VORNE in der Kette steht: dort wirkt es noch im
   Elternraum, also in der gekippten Brettebene — die Marke rutscht über das
   Brett, nicht durch die Luft davor. Das ist wörtlich das „in der Fläche
   ausweichen", das N-15b verlangt; ein `top`/`margin` täte es nicht, weil der
   Fluss der Zelle nur ~9.5px hergibt, wo ~15px nötig sind (44px-Zelle, 17px
   Marke, `--space-0-5` Polster).

   Warum Prozent und keine px-Zahl: `%` löst gegen die eigene Randbox der Marke
   auf, und die skaliert bereits mit `clamp(13px, 2.9vw, 18px)`. Ein fester Wert
   wäre am kleinen Brett zu groß und am großen zu klein — gemessen ist der nötige
   Weg 16.25px bei 375x667 (Marke 17px) und 24.7px bei 1440x520 (Marke 22px), also
   in beiden Fällen ungefähr eine eigene Markenhöhe. Der Zuschlag darüber ist der
   Kosinus der Kippung: in der Ebene zurückgelegter Weg erscheint auf dem Bild um
   `cos(--tilt)` verkürzt (26deg → 0.899).

   Der Wert steht auf `:root` statt an der Regel, damit die Gegenprobe ihn von
   außen auf 0 drehen kann; welche Felder überhaupt ausweichen, entscheidet
   trotzdem allein die Klasse — und die vergibt `planRoomPlates` über `plateInk`,
   nicht diese Datei. Dass die Zahl reicht, behauptet dieser Absatz nicht: das
   misst test/marke-render.test.mjs als Rechteck-Schnitt gegen jedes Schild, in
   sieben Fenstern über alle drei Kippungszweige. Der zweite `:root`-Block steht
   absichtlich hier und nicht oben bei den Grundtoken: er ist keine Farbe und kein
   Abstand, sondern gehört zu genau dieser Kollision — oben stünde eine Zahl ohne
   ihren Grund, und ein `136%` ohne Grund ist beim nächsten Aufräumen weg.

   Warum ausgerechnet 136%: die Zahl ist nach OBEN und nach UNTEN begrenzt, und
   beide Grenzen sind gemessen, keine geschätzt. Nach unten schneidet die Marke
   wieder ins Schild — bei 1440x520 (die engste Kippung, 26deg auf breitem Brett)
   bleibt bei 130% nur 1.72px Luft, bei 115% sind es schon drei Schnitte. Nach oben
   verlässt die Marke ihre eigene Zelle: bei 812x375 und 820x500 steht ihre
   Unterkante bei 145% nur noch 0.88px über der Zellgrenze und bei 160% mit 2.76px
   darunter — das wäre die Marke auf der Gitterlinie, also der Defekt, den NEU-4
   verbietet, eingetauscht gegen den, den N-15b verbietet. Ein Durchlauf über
   128/132/136/140/144% in allen sieben Fenstern gibt genau einen Wert, an dem die
   beiden engsten Ränder gleich groß sind: 136% → 3.02px zum Schild (1440x520) und
   3.06px zur eigenen Zellkante (812x375). Wer die Zahl ändert, muss beide Zahlen
   neu messen, nicht nur die, die ihn gerade stört. */
:root {
  --mark-dodge: 136%;
}

.cell-note-dodge .note-mark {
  transform: translateY(var(--mark-dodge)) translateZ(calc(75 * var(--zu)))
    rotateX(calc(-1 * var(--tilt)));
}

/* ------------------------------------------------------------------ objects
   Furniture is built from rectangular prisms standing on the floor. Each prism
   paints exactly two faces — see js/furnishings.js for why the other four never
   produce a pixel — and the browser depth-sorts them against the walls and
   against each other, so a chair genuinely stands behind the wall in front of
   it rather than being ordered to look as if it does. */
.furn,
.pawn {
  position: absolute;
  inset: 0;
  /* The height at which a figure meets the floor. Two children read it — the
     contact disc lies AT that height, the upright body plane is raised TO it —
     and they are the same contact, so they may not be two numbers. They were:
     both spelled `calc(3 * var(--zu))`, and either one could have been retuned
     alone, which would have left the figure hovering above its own shadow. The
     declaration sits in this shared block rather than a `.pawn`-only rule
     because `.pawn` has none — furniture inherits a variable it never reads,
     which costs nothing, while a second `.pawn` block would cost every reader
     the question of which of the two governs. */
  --pawn-floor: calc(3 * var(--zu));
  transform-style: preserve-3d;
  pointer-events: none;
  /* Eine Geste, eine Dauer. Ein Zeiger auf einem Feld löst DREI Antworten aus:
     die Zelle hellt auf (0.12s, weiter oben), das Möbel hebt ab, sein Schatten
     verblasst. Fertig ist die Geste erst, wenn die langsamste fertig ist — mit
     den 0.18s, die hier standen, war das Feld längst hell, während das Objekt
     darauf noch stieg. Genau das liest sich als „reagiert spät", auch wo die
     Fläche selbst schon schnell war: der Spieler schaut auf das Ding, nicht auf
     den Ton. Die Kurve bleibt, sie trägt das Gewicht des Aufstiegs; nur die
     Dauer geht auf die 0.12s der Zelle. */
  transition: transform 0.12s cubic-bezier(0.2, 0.8, 0.3, 1);
}

/* Contact shadow. It lies in the floor plane, so it foreshortens with the board
   exactly as a real shadow would — the cheapest single cue that an object is
   standing on the floor rather than floating over it. */
.furn-shade {
  position: absolute;
  inset: 14%;
  transform: translateZ(0.2px);
  border-radius: 50%;
  background: radial-gradient(ellipse at 54% 58%, hsl(0 0% 0% / 0.55), transparent 68%);
  /* Der Schatten gehört zum Aufstieg, also dauert er genauso lang. Ohne diese
     Zeile sprang er sofort auf seinen Hover-Wert, während das Möbel darüber
     noch stieg — der Schatten war schon oben, bevor das Objekt losging. */
  transition: opacity 0.12s cubic-bezier(0.2, 0.8, 0.3, 1);
}

/* Die Fransen — die eine Sache, die den Teppich vom liegenden Bilderrahmen
   trennt. js/icons.js führt diesen Gedanken für das Glyph schon aus ("ohne sie
   lesen sich dieselben zwei Rechtecke als `painting`"), und das Prisma auf dem
   Brett hatte ihn nicht: dort standen genau die zwei Rechtecke, die der Kommentar
   als mehrdeutig benennt. Was das Glyph mit zwei Strichen je Ende andeutet, ist
   auf 60px eine Kette, denn das ist, was eine Franse physisch IST — die
   Kettfäden, die über das Gewebe hinausstehen. Deshalb ein Kamm und keine zwei
   Striche: eine Vergrösserung derselben Aussage, keine zweite.

   Die Ausrichtung ist nicht Geschmack. Fäden laufen LÄNGS, die Franse sitzt
   folglich an den beiden Enden der Längsachse des Abdrucks, und der Kamm
   wiederholt sich QUER dazu. Welche zwei Seiten das sind, rechnet
   `segmentEdges` als fünfte Antwort (`laengs`) aus; die beiden Verläufe hier
   müssen nur zu der Seite passen, an der sie hängen.

   Der Ton ist ungefärbte Wolle und liegt damit bewusst ausserhalb der beiden
   Materialtöne des Teppichs (`t-wood` aussen, `t-cloth` innen): eine Franse in
   der Farbe des Belags wäre eine ausgefranste Kante, keine Kette. Er ist deckend
   und nicht durchscheinend, weil ein Alpha-Wert seinen Kontrast vom Raumboden
   darunter borgen würde — dann stünde für zehn Räume nicht EINE gemessene Zahl,
   sondern zehn, und die schlechteste davon hätte niemand ausgerechnet.
   Was er gegen alle erreichbaren Böden leistet, prüft test/contrast.test.mjs. */
.furn-fringe {
  position: absolute;
  /* Ein Hauch über dem Boden, aus demselben Grund wie beim Kontaktschatten
     darüber: gleiche Ebene heisst z-fighting. Nicht in `--zu`, weil das eine
     Höhe wäre — die Franse liegt, sie steht nicht. */
  transform: translateZ(0.3px);
  --fringe-c: hsl(40 24% 70%);
  --fringe-t: 1.6px;
  --fringe-d: 5px;
  pointer-events: none;
}
.franse-left,
.franse-right {
  background-image: repeating-linear-gradient(to bottom,
    var(--fringe-c) 0 var(--fringe-t), transparent var(--fringe-t) var(--fringe-d));
}
.franse-top,
.franse-bottom {
  background-image: repeating-linear-gradient(to right,
    var(--fringe-c) 0 var(--fringe-t), transparent var(--fringe-t) var(--fringe-d));
}

.bx {
  position: absolute;
  transform-style: preserve-3d;
  transform: translateZ(calc(var(--z, 0) * var(--zu)));
  /* The reference game outlines every object in near-black, and it is right to:
     an outline is what keeps a small object readable against a patterned floor
     at 44px. Below it is an inset ring, so it costs no layout and no node.
     Colour and width are named apart from it because a piece spanning several
     cells needs that outline only along its OUTER sides — run all the way
     round, two rings meet at every seam and lay a dark band across a carpet
     exactly where no edge is. That per-side form is computed in
     js/furnishings.js and arrives as `--ring`. The two marks stand HERE, above
     the faces that spend them, because `--ring` is composed here and a custom
     property is substituted on the element that DECLARES it: named only below,
     the composed value is invalid, and every face falls back to the full ring. */
  --ring-c: hsl(24 24% 4% / 0.92);
  --ring-w: 1.6px;
  /* Gemessener Ring — was diese beiden Token wirklich leisten.
     Sie waren bis eben ein Literal mitten in der box-shadow-Zeile darunter;
     benannt sind sie das einzige Paar dieser Datei ohne Messreihe gewesen, und
     ein Ring, den JEDES Objekt trägt, ist der teuerste Ort für eine Zahl, die
     niemand nachgerechnet hat. Nachgerechnet wird sie in test/contrast.test.mjs.

     Zuerst die unbequeme Hälfte: nach AUSSEN trägt dieser Ring nichts. Gegen die
     zehn Raumböden, in Ruhe wie unter dem Zeiger, spannt er 1.15 … 2.22
     (schlechtester: Waffenkammer in Ruhe). Er verfehlt die 3:1 einer
     Nicht-Text-Markierung auf JEDEM Boden, und das ist kein Versäumnis, sondern
     die Bauart: ein Beinahe-Schwarz kann sich von einem dunklen Boden nicht
     durch Helligkeit lösen. Breiter macht das nicht besser — Breite verstärkt
     eine Stufe, sie erzeugt keine. Wer hier 3:1 will, muss den Ring aufhellen,
     und ein heller Ring ist keine Naht mehr, sondern ein zweiter Gegenstand.
     Die 3:1, die das Brett schuldet, tragen deshalb die Bodenmarken
     (`--room-edge`, 3.61 … 6.78) und die Zellzustände — nicht die Möbelkanten.

     Was der Ring trägt, ist die Stufe nach INNEN: er trennt das Objekt von sich
     selbst, zeichnet ihm eine geschlossene Silhouette, und DIESE Stufe steht
     unter Kontrolle dieser Datei, weil beide Seiten hier stehen. Gemessen als
     Ring auf der jeweiligen Deckfläche:

       wood        2.51
       lacquer     2.29
       ivory      11.88
       cloth       2.36
       stone       3.85
       metal       3.47
       brass       7.83
       gilt        6.02
       glass       7.19
       leaf        3.37
       terracotta  2.69

     Daraus folgt die Regel, die der Test hält: KEIN Material darf so dunkel
     werden, dass der geteilte Ring es nicht mehr erreicht. Genau das war der
     Defekt, den diese Messung gefunden hat — Lack stand auf 17% Helligkeit und
     kam auf 1.35, während das nächstdunkle Material bei 2.36 lag. Ein Objekt,
     dessen Umriss auf ihm selbst verschwindet, während seine Fläche mit 1.01
     auf dem Boden liegt, ist schlicht nicht da. Lack steht jetzt auf 32% und
     bei 2.29, also im Band der anderen. Die Untergrenze ist 2.25 — nicht
     gerundet aus Bequemlichkeit, sondern knapp unter die schlechteste gemessene
     Zeile gelegt, damit sie rot wird, bevor ein neues Material unter das Band
     fällt, und nicht erst, wenn es unsichtbar ist.

     Und jetzt der Satz, der beim ersten Anlauf fehlte: diese Innen-Schranke
     bewacht die AUSSENSEITE mit, obwohl sie nach innen misst. Der Grund ist
     gemessen, nicht behauptet. Der Ring ist selbst beinahe so dunkel wie der
     dunkelste Boden — wer ihn auf einer Deckfläche mit 2.25 erreicht, hat damit
     gesagt, dass diese Fläche um mindestens diesen Betrag über einem
     Beinahe-Schwarz liegt, und über dem dunkelsten Boden liegt sie dann fast
     ebenso weit. In der Waffenkammer in Ruhe, wo der Ring mit 1.15 sein
     Schlechtestes gibt, steht die schlechteste Fläche bei 2.06: dort trägt die
     FLÄCHE die Silhouette, nicht der Ring.

     Umgekehrt genauso. Im Musiksalon unter dem Zeiger, dem hellsten Grund,
     fällt die schlechteste Fläche auf 1.04 — praktisch weg —, und genau dort
     steht der Ring mit 2.22 an seinem Besten. Die beiden sind nicht redundant,
     sie sind gegenläufig: das Paar aus Fläche und Ring, über elf Materialien ×
     zehn Räume × zwei Zustände, gibt nie weniger als 1.56 her (schlechtestes
     Paar: Lack im Arbeitszimmer unter dem Zeiger). Wer die 2.25 also mit einem
     reinen Innen-Argument senkt, senkt zugleich die 2.06 auf dem dunkelsten
     Boden — und dort gibt es keine zweite Sicherung, die das auffängt.

     `.t-fire` fehlt in der Tabelle, weil es diesen Ring gar nicht trägt: es
     ersetzt den box-shadow durch seinen eigenen Glut-Saum. Was es nicht anzieht,
     kann diese Messreihe ihm auch nicht vorschreiben. */
  /* Die Grundrundung der Kästen. Sie stand als nackte 2 im Vorgabewert unten und
     galt damit für alle vier Ecken — auch für die, die bei einem Möbel über
     mehreren Feldern auf der NAHT sitzt, wo gar keine Kante ist. Um sie dort
     abzuräumen, muss js/furnishings.js eine Ecken-Liste einsetzen, und dafür
     braucht es einen Namen für den Wert. Der Wert selbst bleibt hier: sonst
     stünde er an zwei Orten, und der zweite wäre der, den niemand pflegt.
     Deklariert oben aus demselben Grund wie `--ring-c` — die Ersetzung findet
     auf dem Element statt, das `--r` trägt, und das ist `.bx`. */
  --bx-r: 2px;
}
.bx-top,
.bx-front {
  position: absolute;
  border-radius: var(--r, var(--bx-r));
  box-shadow: var(--ring, inset 0 0 0 var(--ring-w) var(--ring-c));
  /* The lit line along the edge where lid meets face, named so a material can
     switch it off without restating its own gradients. Unequal by surface: the
     lid faces the light and the face does not, and an arris of equal brightness
     on both would say the light comes from nowhere. */
  --arris-lid: hsl(40 40% 92% / 0.16);
  --arris-face: hsl(40 40% 92% / 0.09);
}
/* Der Belag trägt den Ring auf dem DECKEL weiter und auf der Vorderseite nicht
   mehr, und der Schnitt liegt genau dort, weil er dort gemessen ist. Ein Ring
   ist ein Umriss AUF einer Fläche; er setzt voraus, dass unter ihm noch Fläche
   übrig bleibt. Auf dem laufenden Brett nachgezählt, über 128 Kästen: bei den
   Bodenstücken ist die Vorderseite 0.82 bis 1.75px hoch und der Ring bedeckt
   davon 91 bis 100 Prozent — er ist dort keine Kante mehr, er IST die Fläche.
   Bei jedem nicht liegenden Stück ist dieselbe Vorderseite mindestens 1.79px
   hoch und die Deckung höchstens 89 Prozent. Das ist der zweite Befund aus
   N-27: der Teppich bekam eine fast schwarze Kante, wie sie einem Rahmen
   gehört, nicht einem Belag — und zwar nicht, weil der Ring falsch gewählt
   wäre, sondern weil eine Höhe von einer Einheit für einen Umriss zu wenig ist.

   Was hier bewusst NICHT passiert: `--ring-c` für Bodenstücke aufweichen. Der
   Block eine Regel weiter oben misst nach, dass Fläche und Ring GEGENLÄUFIG
   tragen — auf dem dunkelsten Boden trägt die Fläche die Silhouette, auf dem
   hellsten (Musiksalon unter dem Zeiger, Fläche 1.04) trägt sie der Ring. Ein
   geschwächter Ring am Teppich hätte ihn also ausgerechnet dort verschwinden
   lassen, wo er allein steht. Der Deckel behält ihn deshalb unverändert; nur
   die Fläche, auf der kein Platz für ihn ist, gibt ihn ab.
   test/contrast.test.mjs führt diese Ausnahme als benannte Belegung — eine
   zweite, die sich hier hereinschreibt, wird dort rot. */
.furn-ground .bx-front { box-shadow: none; }
/* The lid: lifted to the object's full height, lit from above. */
.bx-top {
  inset: 0;
  transform: translateZ(calc(var(--h) * var(--zu)));
  /* The arris — the edge where the lid meets the face. Both surfaces already
     carry their own gradient, so the object is not flat; what it lacked was the
     thin lit line real edges have, and without it the two faces read as two
     painted rectangles that happen to touch rather than as one solid catching the
     light along its rim.
     It is painted as a band in the background rather than as a fourth inset
     shadow, and that is the fix rather than a preference. In box-shadow the first
     entry paints on top, so a 1px arris listed ahead of the 1.6px contour did not
     sit inside the outline — it sat on it, bleaching its lowest pixel and leaving
     the lit line itself with nothing but near-black behind it. It read as a
     nick in the contour, not as an edge catching light. A background band starts
     where the contour ends: transparent for the outline's own 1.6px, then the
     arris in the next 1.6px, against the object's own colour where a highlight
     can actually be seen. That is also why it is brighter than before — it is no
     longer competing with black. */
  background-image:
    linear-gradient(to top,
      transparent 0 1.6px, var(--arris-lid) 1.6px 3.2px, transparent 3.2px),
    linear-gradient(150deg, var(--tone-lit), var(--tone-top));
}
/* The face toward the camera, hinged on the footprint's near edge. Square at the
   bottom where it meets the floor — a rounded foot would show floor through the
   corners of anything with a radius. */
.bx-front {
  left: 0;
  right: 0;
  bottom: 0;
  height: calc(var(--h) * var(--zu));
  transform-origin: bottom center;
  transform: rotateX(-90deg);
  border-bottom-left-radius: 0;
  border-bottom-right-radius: 0;
  /* The face's half of the arris, at the top edge where it meets the lid and at
     roughly half the strength, because this surface is turned away from the
     light. Same construction as the lid's for the same reason. */
  background-image:
    linear-gradient(to bottom,
      transparent 0 1.6px, var(--arris-face) 1.6px 3.2px, transparent 3.2px),
    linear-gradient(var(--tone-side), var(--tone-foot));
}

/* Materials. Four stops each — lit lid, lid, face, foot — because a single
   colour per object reads as a coloured block, and the whole point of the
   geometry is that light lands on it. */
/* Wood and terracotta are the two the board is full of, so they are also the two
   that decide whether it reads as a manor or as a toy. Both came in near 45%
   saturation, which on a near-black floor is the orange of a children's game;
   held to the low thirties they read as walnut and clay instead. */
.t-wood       { --tone-lit: hsl(28 30% 42%); --tone-top: hsl(26 29% 32%); --tone-side: hsl(24 28% 22%); --tone-foot: hsl(22 26% 13%); }
.t-lacquer    { --tone-lit: hsl(220 10% 53%); --tone-top: hsl(220 12% 32%); --tone-side: hsl(220 12% 22%); --tone-foot: hsl(220 12% 13%); }
.t-ivory      { --tone-lit: hsl(42 34% 92%); --tone-top: hsl(40 26% 82%); --tone-side: hsl(38 20% 64%); --tone-foot: hsl(36 18% 46%); }
.t-cloth      { --tone-lit: hsl(348 22% 44%); --tone-top: hsl(348 24% 35%); --tone-side: hsl(348 26% 25%); --tone-foot: hsl(348 26% 16%); }
.t-stone      { --tone-lit: hsl(30 8% 56%); --tone-top: hsl(30 8% 44%); --tone-side: hsl(30 8% 31%); --tone-foot: hsl(30 8% 20%); }
.t-metal      { --tone-lit: hsl(210 8% 58%); --tone-top: hsl(210 8% 42%); --tone-side: hsl(210 8% 28%); --tone-foot: hsl(210 8% 17%); }
.t-brass      { --tone-lit: hsl(45 68% 68%); --tone-top: hsl(43 62% 52%); --tone-side: hsl(40 56% 36%); --tone-foot: hsl(38 50% 22%); }
.t-gilt       { --tone-lit: hsl(46 58% 62%); --tone-top: hsl(44 50% 46%); --tone-side: hsl(42 44% 30%); --tone-foot: hsl(40 40% 18%); }
.t-glass      { --tone-lit: hsl(190 34% 78%); --tone-top: hsl(192 30% 60%); --tone-side: hsl(196 28% 40%); --tone-foot: hsl(200 26% 25%); }
.t-leaf       { --tone-lit: hsl(126 34% 48%); --tone-top: hsl(130 34% 34%); --tone-side: hsl(134 32% 23%); --tone-foot: hsl(138 30% 14%); }
.t-terracotta { --tone-lit: hsl(18 34% 47%); --tone-top: hsl(16 33% 36%); --tone-side: hsl(14 32% 25%); --tone-foot: hsl(12 30% 16%); }

/* Gemessener Glanz — wie stark jeder Stoff die Deckfläche spiegelt, und in
   welcher Reihenfolge.

   Gemessen ist `--tone-lit` gegen `--tone-top`, also genau der Sprung, den der
   150deg-Verlauf auf `.bx-top` über das Lid zieht. Ein hoher Wert liest sich
   als harte Spiegelung, ein niedriger als matte Fläche. Es ist ausdrücklich
   kein Kontrast gegen den Boden — wie hell ein Stoff insgesamt ist, steht in
   der Ring-Tabelle weiter oben.

     lacquer      2.19
     leaf         1.81
     metal        1.76
     gilt         1.62
     glass        1.59
     fire         1.54
     terracotta   1.54
     wood         1.53
     stone        1.52
     brass        1.41
     cloth        1.39
     ivory        1.25

   Die Reihenfolge ist die eigentliche Aussage und der Grund, warum die Tabelle
   überhaupt existiert. Lack führt sie an, und das war einmal nicht so: er stand
   auf `--tone-lit: hsl(220 10% 44%)` und kam damit auf 1.59, hinter gebürstetes
   Metall. Eine lackierte Fläche, die matter spiegelt als Metall, widerspricht
   dem, was das Brett mit dem Wort „Lack" behauptet — also wurde der Stopp auf
   53% gehoben, und der Glanz stieg auf 2.19. `--tone-top` blieb dabei
   unangetastet, damit die Ring-Zeile des Lacks exakt auf 2.29 stehen bleibt;
   die beiden Tabellen fassen sich nicht gegenseitig an.

   Die Korrektur war richtig, hing aber an nichts: keine Zahl im Baum hielt die
   Ordnung fest, und die nächste Materialstimmung hätte sie lautlos umgedreht.
   Eine Rangliste ist nichts, was beim Draufsehen auffällt — man sieht ein Brett
   voller Möbel. `test/contrast.test.mjs` rechnet deshalb jede Zeile nach,
   verlangt, dass die Tabelle fallend steht, und verlangt namentlich den Lack an
   ihrer Spitze. Gleichstand ist erlaubt: Feuer und Terrakotta liegen beide bei
   1.54 und dürfen das auch, sie dürfen nur nicht in der falschen Reihenfolge
   stehen.

   Feuer steht mit in der Reihe, obwohl es das einzige Material ist, das
   leuchtet statt zu spiegeln. Sein Verhältnis ist trotzdem definiert und wird
   gemessen wie jedes andere — die Liste kommt aus der CSS selbst, nicht aus
   einer zweiten, gepflegten Aufzählung, die veralten könnte. Ein Material, das
   hier fehlen soll, muss aufhören, zwei Deckflächen-Stopps zu führen. */
/* The only material that emits rather than reflects, so it ignores the lighting
   model entirely and carries its own bloom. */
.t-fire {
  --tone-lit: hsl(44 100% 78%); --tone-top: hsl(32 100% 62%);
  --tone-side: hsl(20 92% 50%); --tone-foot: hsl(8 84% 34%);
}
/* Switching the arris off is the point of naming it. Fire ignores the lighting
   model — an arris is a lit edge, and a surface that is itself the light source
   has no lit edge to show. It had none before either, but only because it
   replaced the whole box-shadow the arris used to live in; now that the arris is
   background, that accident has to become a statement or the flames grow a
   highlight nobody asked for. */
.t-fire .bx-top { --arris-lid: transparent; box-shadow: inset 0 0 0 1px hsl(40 90% 70% / 0.6), 0 0 14px 3px hsl(28 100% 56% / 0.55); }
.t-fire .bx-front { --arris-face: transparent; box-shadow: inset 0 0 0 1px hsl(30 90% 55% / 0.5); }

/* --------------------------------------------------------------- the people
   A body on the board is a turned base with a figure standing in it — a head
   over a coat, upright in board space and therefore leaning with the tilt like
   everything else; a figure that stayed perfectly square to the screen would be
   the one object on the board disobeying its own camera.

   It was a flat portrait card until now, and the card was the problem the design
   review named: from the board's angle seven cards differing only by a 20px
   chest icon read as one row of identical counters. Two things are legible at
   that distance and an icon is not — a silhouette, and a colour. So the figure
   got a shape, and the coat got a hue per person.

   The hue is the half that makes this identity rather than finish, and it only
   does that because the tray paints the same `--coat` — why that crossing is the
   whole point is stated once, at .avatar.avatar-coat below.

   The victim, the one figure the player did not place, wears the accent — because
   the victim is a verdict. The suspects' own palette is set out below. */
/* :where() so the defaults carry zero specificity. They are only defaults, and a
   default that ties with the palette below and wins on source order is a rule
   that breaks when someone sorts the file. */
:where(.pawn) {
  --coat: hsl(0 0% 30%);
  --coat-lit: hsl(0 0% 42%);
  --coat-glow: hsl(0 0% 0% / 0.6);
  --pawn-lit: hsl(0 0% 62%);
  --pawn-base-col: hsl(0 0% 34%);
  --pawn-skin: hsl(34 20% 66%);
  --pawn-skin-shade: hsl(30 16% 44%);
  /* The single declaration that makes a stack of boxes read as a turned piece.
     A solid of revolution has no flat face to catch the light: every horizontal
     slice through it is a circle, so its shading runs ACROSS the silhouette and
     not down it — dark at both rims where the surface turns away, bright on the
     band nearest the viewer. The furniture does not need this because a chest of
     drawers really is flat-faced; a lathed piece is the one shape on this board
     where a plain vertical gradient is a lie you can see.
     It is layered over each part's own colour rather than replacing it, so the
     seven coats and the victim's accent still come through underneath — the hue
     is identity here, and shading must never be allowed to eat it.
     Off-centre because the light in this house comes from the upper left, which
     is the same direction --arris-lid is lit from. */
  --turn: linear-gradient(90deg,
    hsl(24 24% 4% / 0.5) 0%,
    hsl(24 24% 4% / 0.14) 13%,
    hsl(40 40% 92% / 0.2) 32%,
    hsl(40 40% 92% / 0) 52%,
    hsl(24 24% 4% / 0.26) 80%,
    hsl(24 24% 4% / 0.52) 100%);
}
/* The palette, and the one place in this stylesheet where the obvious idea was
   the wrong one.

   The first version held lightness and saturation constant and moved only the
   hue, on the argument that an even ramp reads as one system rather than seven
   decisions. It does — to a trichromat. Simulating the seven coats for the three
   dichromacies said the rest: with L and S pinned, the coats differed ONLY in the
   channel colour blindness removes. Four of them landed within 1.7 dE76 of each
   other under deuteranopia (JND is ~2.3, i.e. indistinguishable), and hue 300 vs
   334 came out at dE 0.00 under tritanopia — not similar, identical. Roughly one
   man in twelve would have been handed seven suspects wearing one colour.

   So the ramp moved from hue to LIGHTNESS: the seven coats climb an even
   perceptual ladder in CIE L*, and hue became the second channel rather than the
   only one. The tempting way to finish that sentence is "and lightness is the one
   channel no dichromacy takes away", and it is false: simulate the dark stop
   under deuteranopia and the closest pair's L* gap falls to well under a tenth of
   a JND, while the residual hue collapses on some pair too. Neither axis carries
   the palette by itself. What holds is that no SINGLE pair loses both at once,
   and that is a property of the whole set, not of either channel — so it cannot
   be stated as a rule of thumb, only measured pair by pair.

   Which is why no dE figure is named here. Any number quoted in this comment
   would be quoted from whichever stop the solver happened to score, and the
   solver scored the lit one while the suspect chips paint the dark one, which is
   more than twice as bad. A number no test recomputes is an opinion with decimal
   places. test/contrast.test.mjs walks every pair on BOTH stops under normal
   vision and all three dichromacies and fails below the discrimination
   threshold; the L values were solved against that same luminance function, and
   the hue-to-rung assignment was picked by scoring the permutations, but only
   the checked part is load-bearing. Read the bar there, not here.

   Two hue bands stay deliberately absent. Accent red means verdict in this house
   and brass means evidence; a coat borrowing either would read as a claim ABOUT
   the person rather than a name FOR them. */
.coat-0 { --coat: hsl(202 24% 15%); --coat-lit: hsl(202 26% 23%); }
.coat-1 { --coat: hsl(282 24% 22%); --coat-lit: hsl(282 26% 31%); }
.coat-2 { --coat: hsl(82 24% 20%);  --coat-lit: hsl(82 26% 27%); }
.coat-3 { --coat: hsl(162 24% 24%); --coat-lit: hsl(162 26% 31%); }
.coat-4 { --coat: hsl(242 24% 39%); --coat-lit: hsl(242 26% 51%); }
.coat-5 { --coat: hsl(322 24% 40%); --coat-lit: hsl(322 26% 50%); }
.coat-6 { --coat: hsl(122 24% 36%); --coat-lit: hsl(122 26% 43%); }
/* Off the ramp, and that is the statement: more saturated than any suspect, lit
   from its own colour rather than by the room, and carrying the glow none of the
   others has. It also sets the base, so the whole figure agrees — and since the
   head became a token it agrees there too: the same warm skin the seven wear,
   drained of most of its saturation and dropped a good way in lightness. That
   was the point of tokenising it. The head sat hardcoded while every other part
   of this figure could be re-coloured, so the one person in the house who is
   dead had a living complexion, and no amount of red below it could say
   otherwise. */
.coat-victim {
  --coat: hsl(0 46% 26%);
  --coat-lit: hsl(0 52% 37%);
  --coat-glow: hsl(0 72% 45% / 0.55);
  --pawn-lit: hsl(0 40% 46%);
  --pawn-base-col: hsl(0 44% 24%);
  --pawn-skin: hsl(28 7% 47%);
  --pawn-skin-shade: hsl(24 6% 30%);
}

/* The disc the piece stands on — the felt under a chess base. Kept narrower than
   the foot above it so it reads as contact with the floor rather than as part of
   the figure; it was once the widest thing on the square, and a figure whose
   widest element lies flat on the ground is a counter, not a piece. */
.pawn-base {
  position: absolute;
  left: 31%;
  right: 31%;
  bottom: 17%;
  height: 15%;
  border-radius: 50%;
  transform: translateZ(var(--pawn-floor));
  background-image: linear-gradient(150deg, var(--pawn-lit), var(--pawn-base-col));
  box-shadow: inset 0 0 0 1px hsl(0 0% 0% / 0.7);
}
/* The upright plane. Every turned part is laid out inside it as an ordinary 2D
   box, so exactly one element in the figure carries the rotation and the parts
   cannot drift out of plane relative to each other.

   It hinges at the disc's plane and not at the floor, which is the whole of the
   translateZ. The disc above sits three units up; a plane hinged at zero puts
   the figure's feet three units BELOW its own base — the piece stands through
   the thing it is supposed to stand on. On bare board that is invisible, which
   is why it survived: nothing is drawn between the two planes to give it away.
   The rug gave it away the moment the rug became ground. Its cloth face lies at
   two units, so at 375px a good two fifths of the foot sank under the surface
   the README invites you to stand „mitten drauf“ on — the one square where the
   sheet and the promise could be seen disagreeing.
   Three clears every top face a GROUND piece has today (cloth 2, wood frame
   1) — ground, not merely begehbar; nobody stands ON a chair. A piece that
   tops three moves the disc too: figure and disc are one number, not two. */
.pawn-body {
  position: absolute;
  left: 50%;
  bottom: 21%;
  width: 72%;
  height: 46%;
  translate: -50% 0;
  transform-origin: bottom center;
  /* Read right to left: turn the plane about its bottom edge first, then lift it
     along the BOARD's normal. Swapped, the lift would be measured in the
     already-turned frame and would push the figure across its square instead of
     up off it.

     Die Drehung ist ein GEGENZUG zur Kameraneigung, kein rechter Winkel — und
     das ist der Fix fuer „die Figuren sehen plattgedrueckt aus", zweimal
     gemeldet. Vorher stand hier `rotateX(-90deg)`: eine Wand auf einem Brett,
     das selbst um `--tilt` (26deg) gekippt ist. Netto also -64deg, und auf dem
     Schirm bleibt von der Hoehe |cos(-64deg)| ~ 44%. Gemessen war es schlimmer
     als die Rechnung: `.pawn-head` ist per `aspect-ratio: 1` ein KREIS und kam
     in Zeile 0 als 25.4x10.1 an, in Zeile 4 als 24.1x6.5 — Verhaeltnis 0.40 bis
     0.27, also bis zu 3.7x breiter als hoch. Der Perspektiv-Ursprung sitzt bei
     34%, deshalb faellt es nach hinten weiter ab; ein Hoehen-Aufschlag haette
     das nie geheilt, nur die Ellipse gestreckt.
     `calc(-1 * var(--tilt))` hebt die Brettneigung exakt auf: netto 0deg, die
     Ebene steht parallel zum Schirm und behaelt ihre volle Hoehe. Gemessen
     danach: `.pawn-head` 20.3x20.3 (rund), Koerper 28.5px -> 46.3px in einer
     91.9px-Zelle. Das ist das Billboard-Idiom, das diese Datei fuer Plaketten
     und Beschriftungen ohnehin schon viermal fuehrt (2029, 2141, 2489, 2824) —
     hier zum ersten Mal fuer einen Koerper.
     `--tilt` wird per `var()` gelesen und nicht als -26deg eingetragen: der
     Wert ist mehrfach deklariert (`test/affordance.test.mjs` liest alle
     Vorkommen), eine eingetragene Zahl waere ab der ersten Aenderung falsch.
     Die 46% ersetzen 70%: unter -90deg trug die Zahl den Kompressionsfaktor
     mit, jetzt ist sie die Hoehe, die man sieht. */
  transform: translateZ(var(--pawn-floor)) rotateX(calc(-1 * var(--tilt)));
}

/* Every part of the figure is turned on the same lathe, so they share
   everything except their width, their height and where they sit. Widths are
   percentages of the plane, which is itself a percentage of the cell — so the
   whole piece keeps its proportions from a 44px phone cell to a 130px desktop
   one, exactly like the furniture. */
.pawn-foot,
.pawn-stem,
.pawn-coat,
.pawn-collar,
.pawn-head {
  position: absolute;
  left: 50%;
  translate: -50% 0;
  /* The contour. Same reasoning as .bx-top/.bx-front: at 44px an outline is what
     keeps a small object readable against a patterned floor. Here it does second
     duty — it is the line between two turned parts, and it is why five boxes of
     one colour read as one piece with five sections rather than as one blob.
     The second inset is the join: whatever stands on this section shades its top
     edge. It is the only cue on the piece that is about the JOIN rather than
     about a surface, and no per-part gradient can supply it — a gradient can say
     "this is round", only an occlusion can say "and something is resting on it".
     Kept small on purpose: the tallest section renders 19px at a desktop cell,
     so a band that reads as shadow rather than as a stripe is barely 2px.

     The third inset is the rim, and it is a different KIND of claim from the
     radial washes below. A wash says the middle of the surface faces the lamp;
     a rim says the silhouette turns toward it. Photographed at real size the
     washes lost: the collar is 37x4px and has no middle to speak of, and on the
     19px coat the 1.4px contour above spends 15% of the height on outline, which
     flattens faster than an interior gradient rounds. The edge cue is the one
     that survives at four pixels.
     Offset sideways rather than down, and that is not a preference: the join
     inset above darkens the TOP edge, so a top-left rim would be lighting the
     very pixels the occlusion is there to darken and the two would cancel. The
     lamp is upper-left everywhere on this piece, so the LEFT flank is lit and
     the join keeps the top to itself.

     Der leere Steckplatz in der zweiten Zeile bleibt fuer vier der fuenf
     Abschnitte leer. Er existiert, weil die Kontur darueber die dunkle HAELFTE
     einer zweitonigen ist und die helle nur an einer Stelle gebraucht wird:
     styles.css:3709 `--pawn-rim-lit`. Dass er hier steht und nicht als eigene
     `box-shadow`-Regel am Kopf, ist keine Bequemlichkeit — `box-shadow`
     kaskadiert nicht schichtweise. Eine zweite Deklaration ERSETZT diese hier
     mitsamt Fuge und Rimlicht, und die Kopfregel muesste beide abschreiben.
     Was das kostet, steht sichtbar in `.pawn-coat` weiter unten: die Regel
     schreibt genau darum die ganze Liste ab — und traegt den Steckplatz nicht. */
  box-shadow: inset 0 0 0 1.4px hsl(24 24% 4% / 0.92),
    var(--pawn-rim-lit, 0 0 transparent),
    inset 0 1.5px 2.5px -0.5px hsl(24 24% 4% / 0.62),
    inset 2px 0 2.5px -1px hsl(40 40% 92% / 0.34);
}

/* Why every section below carries a radial term of its own, and why the obvious
   alternative was tried first and thrown away.

   The obvious one was the furniture's: a prism paints .bx-top over .bx-front, two
   planes disagreeing about the light, and that is what makes a chest of drawers
   read as an object. Given horizontal crests the figure looked WORSE — photographed
   at 6x it was a stack of saucers.

   Der eine Grund, der frueher hier stand, gilt nicht mehr, und er wird ersetzt
   statt geloescht: „eine aufrechte Flaeche wird auf ~43% ihrer Hoehe gestaucht
   (.pawn-head rendert 30x13), eine liegende behaelt ihre Breite — die Kappe
   kommt also immer breiter an als der Abschnitt, den sie kappt." Das war eine
   Messung am `rotateX(-90deg)`-Koerper. Seit die Ebene per Gegenzug zur
   Kameraneigung steht (styles.css `.pawn-body`), gibt es diese Stauchung nicht
   mehr: derselbe Kopf misst 20.3x20.3. Wer die Kappen heute erneut versuchen
   will, kann das also tun — nur nicht mit diesem Argument.

   Der zweite Grund traegt weiter und ist der eigentliche: die Anleihe war auch
   im Prinzip falsch. Ein Kasten hat eine flache Oberseite, ein auf der Drehbank
   gedrehtes Teil hat keine. Jeder Abschnitt hier ist eine Kuppel, und die
   Oberseite einer Kuppel ist ein Horizont, keine Flaeche — unabhaengig davon,
   wie die Kamera steht.

   So the volume is carried by light instead. --turn alone cannot do it: it varies
   on one axis, which is the correct shading for a cylinder and exactly the wrong
   shading for a dome — it says nothing about whether the surface is also turning
   away above and below. Each part gets a second, radial term with a highlight that
   closes on itself and a rim that darkens in every direction; the geometry differs
   per part because five different solids catch the light in five different places.
   All of them are lit from the upper left, the same lamp as --turn and the head. */

/* Widest at the ground, and the only part with a flat bottom, because it is the
   one part actually resting on something. Its dome is squat and seen almost
   edge-on, so the highlight is wide and sits high on the little that is visible. */
.pawn-foot {
  bottom: 0;
  width: 80%;
  height: 13%;
  border-radius: 34% 34% 26% 26% / 66% 66% 26% 26%;
  background-image: radial-gradient(64% 130% at 34% 18%,
      hsl(40 40% 92% / 0.28) 0%, hsl(40 40% 92% / 0) 56%, hsl(24 24% 4% / 0.34) 100%),
    var(--turn), linear-gradient(var(--coat-lit), var(--coat));
}
/* The pinch. It carries no detail and it is not supposed to — its entire job is
   to be narrow, so that the foot below and the bell above both read as wide.
   Its vertical gradient runs the other way from every other part: lit at the
   bottom, dark under the shoulder, which is what a groove does to light. */
.pawn-stem {
  bottom: 10%;
  width: 34%;
  height: 22%;
  border-radius: 30% 30% 22% 22% / 26% 26% 14% 14%;
  /* The one part whose highlight sits LOW, for the same reason its linear ramp is
     inverted: a waist catches the light where it is closest to the lamp, and on a
     concave section that band is under the shoulder, not on it. */
  background-image: radial-gradient(78% 60% at 32% 74%,
      hsl(40 40% 92% / 0.24) 0%, hsl(40 40% 92% / 0) 58%, hsl(24 24% 4% / 0.3) 100%),
    var(--turn), linear-gradient(var(--coat), var(--coat-lit));
}
/* The bell — the mass of the figure and the face that carries the crest. */
.pawn-coat {
  bottom: 28%;
  width: 76%;
  height: 44%;
  display: grid;
  place-items: center;
  /* A bell flares: narrow and round at the shoulder, near-square at the skirt.
     A trapezoid would want clip-path, and clip-path would cut the outline away
     along exactly the diagonal that makes the silhouette readable; an asymmetric
     radius keeps the ring, because box-shadow follows the radius. */
  border-radius: 46% 46% 18% 18% / 62% 62% 16% 16%;
  /* The tallest section, 19px at a desktop cell, so it is the one where the dome
     term does the most work — and the only one where the highlight can be placed
     high enough to say "shoulder" without a crest being drawn there. */
  background-image: radial-gradient(66% 78% at 34% 26%,
      hsl(40 40% 92% / 0.3) 0%, hsl(40 40% 92% / 0) 58%, hsl(24 24% 4% / 0.34) 100%),
    var(--turn), linear-gradient(var(--coat-lit), var(--coat));
  /* This rule overrides the shared box-shadow rather than adding to it, so the
     join occlusion AND the rim have to be repeated here — dropping either would
     leave the widest part of the figure as the one place the collar above it
     casts nothing and the one place the form does not turn into the light. That
     is also the most visible place to lose them: 74x19px against a patterned
     floor is the surface the "sieht flach aus" report was actually about. */
  box-shadow: inset 0 0 0 1.4px hsl(24 24% 4% / 0.92),
    inset 0 1.5px 2.5px -0.5px hsl(24 24% 4% / 0.62),
    inset 2px 0 2.5px -1px hsl(40 40% 92% / 0.34),
    0 0 12px var(--coat-glow);
}
/* The second pinch, and the reason the head reads as a head rather than as a
   knob on a post. */
.pawn-collar {
  bottom: 68%;
  width: 40%;
  height: 8%;
  border-radius: 50%;
  /* 4px tall on screen. The radial is stretched far past its own box (150%) so the
     part reads as a slice cut out of a larger ball rather than as a bead with its
     own complete highlight — which is what it is. */
  background-image: radial-gradient(70% 150% at 33% 30%,
      hsl(40 40% 92% / 0.26) 0%, hsl(40 40% 92% / 0) 60%, hsl(24 24% 4% / 0.3) 100%),
    var(--turn), linear-gradient(var(--coat), var(--coat-lit));
}
/* Circular by aspect-ratio rather than by matched percentages: the plane is not
   square, so a 28%/28% box would be an egg. */
.pawn-head {
  bottom: 72%;
  width: 28%;
  height: auto;
  aspect-ratio: 1;
  border-radius: 50%;
  /* Die helle Haelfte der zweitonigen Kontur. Sie steht NUR hier, weil nur der
     Kopf frei im Raum steht — Fuss, Schaft, Mantel und Kragen decken einander,
     und auf dem 19px hohen Mantel gaebe ein zweiter Ring die Flaeche auf, die
     er beschreiben soll (die Rechnung dazu steht im geteilten Block oben).

     Warum ueberhaupt zwei Toene. Ein Kopfrand liegt nicht auf jedem Boden;
     welche es sind, rechnet die Suite aus: ein besetztes Feld nimmt weder
     Sperre noch Zielmarke, der Zeiger laesst besetzte Felder aus, und ein
     Moebelfeld traegt nie eine Figur. Bleiben zehn besetzte Raumboeden, die
     eigene Flaeche der Leiche — und der eigene Koerper, denn der Kopf sitzt
     bei 72% ueber einem Kragen, der bis 76% reicht. Gegen diese fuenfzehn
     traegt die Kontur allein 1.00:1, schlechtester Grund die Waffenkammer.
     Das ist keine Nachlaessigkeit, sondern dieselbe Bauart, die
     styles.css:3265 `Nicht-Text-Markierung` fuer die Moebelkanten festhaelt:
     ein Beinahe-Schwarz kann sich von einem dunklen Boden nicht durch
     Helligkeit loesen. Aufhellen allein dreht es nur um — hell allein faellt
     auf 1.72:1 gegen die lebende Haut. Die Gruende liegen an BEIDEN Enden der
     Helligkeit, und ein einzelner Ton steht immer einem Ende zu nah.

     Zwei Toene loesen es, weil sie sich mit 13.10:1 voneinander loesen: wer so
     weit von seinem Partner entfernt liegt, kann nicht gleichzeitig beiden
     Enden nahe sein, also traegt auf jedem Grund mindestens einer. Der
     schlechteste Fall dieses jeweils Besseren misst 3.63:1 gegen den Hautrand
     der Lebenden — ueber den 3:1, die 1.4.11 fuer grafische Objekte verlangt.

     Eine FLANKE, kein Ring, und beides ist gemessen statt gewaehlt. Erstens
     die Lampe: der geteilte Block oben laesst NUR die linke Flanke leuchten,
     weil die Fuge die Oberkante fuer sich behaelt, und die Rampe unten dunkelt
     den Rand in JEDE Richtung ab, damit der Kopf eine Kugel bleibt. Ein rundum
     gleicher Ring hebt genau diese Rampe auf — dann ist der Kopf wieder die
     Muenze, die der Block oben schon einmal verworfen hat. Zweitens die
     Kamera: sie staucht den Kopf auf 43% seiner Hoehe, ein rundum gleicher
     Rand traegt oben also nur 0.43 dessen, was er seitlich traegt, und wird
     dort subpixel — zwei helle Pickel statt eines Randes. Die Flanke unten
     misst 2.4px und liegt auf der ungestauchten Breitenachse; sichtbar ist
     davon 1.0px, weil die Kontur ueber ihr die aeusseren 1.4px zudeckt.

     Die Deckkraft ist die ihres eigenen Partners: die dunkle Haelfte traegt
     dieselbe 0.92, und zwei Haelften einer Kontur teilen sie. Untergrenze
     0.5 (3.14:1), es bleibt also Abstand fuer die naechste Aenderung. */
  --pawn-rim-lit: inset 2.4px 0 0 0 hsl(40 40% 92% / 0.92);
  /* A sphere lit by a straight-edged ramp is a coin. Light cannot arrive as a
     straight line on a curved surface — it has to fall away in every direction
     from wherever the surface faces the lamp — so the base ramp closes on itself
     and is offset to the upper left, the same lamp --turn and --arris-lid use.
     --turn stays layered over it and is doing a second job here: its dark bands
     at 0% and 100% are the darkened limb of a ball, not a lathe mark. */
  background-image: var(--turn),
    radial-gradient(circle at 34% 26%, var(--pawn-skin) 6%, var(--pawn-skin-shade) 76%);
}

.pawn-coat > svg { width: 44%; height: auto; aspect-ratio: 1; color: hsl(40 30% 92%); }

/* Pointing at a piece lifts it clear of the floor — the confirmation that this
   is an object with somewhere to be lifted to, and the invitation to search it. */
.cell:hover .furn { transform: translateZ(calc(7 * var(--zu))); }
/* Der Schatten gehört zum Aufstieg, also gehört er auch nur DEM, was aufsteigt.
   Die Klasse `.furn-shade` sagt das nicht: js/furnishings.js hängt denselben
   Kontaktschatten auch unter die Figur (`pawnNode`), und für die Figur gibt es
   keine Hebe-Regel — es gab nie eine `.cell:hover .pawn`. Ihr Schatten hat also
   bei jedem Zeigen den Aufstieg von etwas mitgespielt, das stehen blieb.
   N-27 hat diesen Widerspruch beim Bodenstück gesucht und dort auch benannt;
   gemessen sass er eine Klasse daneben. Am Bodenstück ist er inzwischen an der
   Wurzel weg — ein Belag hat keine Fuge unter sich, also hängt `furnishingNode`
   ihm gar keinen Kontaktschatten mehr an —, und was übrig blieb, war die Figur.
   Der Zusatz `.furn` schneidet genau sie heraus, denn ihr Schatten liegt unter
   `.pawn`. Kein `:not()`: das brächte die Kollision, die sechzig Zeilen weiter
   unten ausbuchstabiert ist. Drei Klassen statt zwei schlagen weiterhin die
   zwei von `.cell-searched .furn-shade`, und das muss so bleiben — ein
   durchsuchtes Feld darf unter dem Zeiger nicht in seinen Ruhewert
   zurückfallen. */
.cell:hover .furn .furn-shade { opacity: 0.55; }
/* Scenery is the exception: it must stay put under the cursor. On a board where
   some objects hide clues, decoration that lifted like a searchable piece would
   promise a find it cannot deliver, and the player would learn to distrust the
   cue everywhere. It is also dimmed a little, so the eye separates the room's
   furniture from the room's evidence without being told. */
.furn-dress { transition: none; }
.cell:hover .furn-dress { transform: none; }
/* Ground is the other exception, and it is the opposite one. Scenery stays put
   because it has nothing to find; a rug stays put although it has, because the
   lift above is a specific sentence — "there is a gap under me" — and a floor
   answers that sentence with the floor. Lifted seven units the rug rose past
   the figure standing on it (.pawn-base sits at three) and sliced through its
   feet: the one piece whose whole geometry argues "you stand on me" would have
   spent every hover proving it was something else.
   Nothing is lost by staying down. The invitation is still made twice — the
   cell keeps `cursor: pointer` from .cell-furniture, and the cell's own 8%
   hover brightening (--cell-lift-hover) always keeps floor to paint. How much
   varies: 84% by 76% alone, but `segmentPart` pulls a shared rug flush to its
   inner edges, so a 2x2 corner leaves an L of 8% and 12%, a 1x3 middle two 8%
   strips. Thin, never nothing — while AREA_SHAPES makes no inner cell.
   Here too a reset rather than a `:not()`, and here the collision is the one
   next door. Folding this into the lift as `.cell:hover .furn:not(.furn-ground)`
   would make that selector four classes against the three of
   `.cell:hover .furn-dress` one rule up — so the lift would start winning over
   the line that holds the scenery down, and every decorative prism on the board
   would begin promising a find it has not got. Two flat resets, both at three,
   both after the rule they answer. */
.cell:hover .furn-ground { transform: none; }
/* The dimming sits on the faces, never on the wrapper. `filter` is one of the
   grouping properties: it forces transform-style to flat, so a filter on the
   wrapper silently collapsed every prism inside it and the upright faces —
   which are rotated a full 90deg — turned edge-on and rendered zero pixels
   wide. The scenery was there, correct, and completely invisible. A face is a
   leaf, so dimming it costs nothing: it has no 3D descendants left to flatten. */
.furn-dress .bx-top,
.furn-dress .bx-front { filter: brightness(0.82) saturate(0.88); }

/* Ambient motion. Each of these belongs to a thing that would actually be moving
   — a flame, a draught in the fronds, a record on a turntable — so the board
   reads as a place rather than a diagram. Nothing here loops faster than a
   second, because a puzzle is read for minutes at a time. */
@keyframes ob-fire {
  0%, 100% { transform: translateZ(calc(var(--z, 0) * var(--zu))) scale(1); }
  35%      { transform: translateZ(calc(var(--z, 0) * var(--zu))) scale(1.13, 0.94); }
  65%      { transform: translateZ(calc(var(--z, 0) * var(--zu))) scale(0.93, 1.08); }
}
/* The brightness half of the flicker rides on the two faces, not on the prism
   that holds them. `filter` is a grouping property, so on the prism it flattened
   its own children: the flame kept its lid, lost its upright face entirely, and
   burned as a patch lying flat on the floor. Split in two like this, the shape
   pulses in 3D while the light pulses on the surfaces. */
@keyframes ob-glow {
  0%, 100% { filter: brightness(1); }
  35%      { filter: brightness(1.28); }
  65%      { filter: brightness(0.88); }
}
@keyframes ob-sway {
  0%, 100% { transform: translateZ(calc(var(--z, 0) * var(--zu))) rotate(-3.5deg); }
  50%      { transform: translateZ(calc(var(--z, 0) * var(--zu))) rotate(3.5deg); }
}
.fx-fire  { animation: ob-fire 1.7s ease-in-out infinite; }
.fx-flame { animation: ob-fire 1.1s ease-in-out infinite 0.4s; }
.fx-fire  > * { animation: ob-glow 1.7s ease-in-out infinite; }
.fx-flame > * { animation: ob-glow 1.1s ease-in-out infinite 0.4s; }
.fx-sway  { animation: ob-sway 5.5s ease-in-out infinite; }

/* Die Figuren atmen. Bis hierher war die einzige Bewegung, die eine Spielfigur
   je hatte, `pawn-land` — ein halbe-Sekunde-Einschlag beim Setzen, danach steht
   sie fuer den Rest der Partie exakt still. Sieben stehende Kegel, die sich nie
   ruehren, sind Marker; die Beschwerde „anstatt vernuenftig animiert" ist also
   nicht dieselbe wie „plattgedrueckt", sie ist die zweite Haelfte.

   Die Bewegung sitzt auf `.pawn-body` und NICHT auf `.pawn`: `.pawn` traegt
   `pawn-land`, und zwei Animationen auf einem Element mit derselben Eigenschaft
   loeschen einander (die spaeter deklarierte gewinnt, das Einschlagen bliebe
   also aus). Zwei Elemente, zwei Kanaele.

   Die Keyframes schreiben die BASIS-Transformation mit ab. Das ist keine
   Redundanz — `transform` ist eine Eigenschaft, kein Stapel; eine Keyframe, die
   nur `rotate()` nennt, wirft `translateZ` und den Kameragegenzug weg, und die
   Figur legt sich fuer die Dauer der Animation wieder flach hin. Genau darum
   fuehrt `ob-sway` darueber sein `translateZ` in jeder Zeile mit.

   `rotate()` ist hier rotateZ IN der bereits gegengedrehten Ebene, also ein
   Wiegen in der Bildebene um die Fusskante (`transform-origin: bottom center`).
   Kein `filter`, kein `opacity`: beides sind Gruppierungs-Eigenschaften und
   wuerden den 3D-Teilbaum der Figur fuer die Dauer der Animation flachlegen —
   dieselbe Falle, die diese Datei bei `ob-glow` und beim Zellen-Eintritt schon
   zweimal dokumentiert.

   Der negative Verzoegerungswert startet jede Figur mitten im Zyklus statt sie
   erst zu verzoegern, sonst stuenden sieben Verdaechtige eine halbe Minute lang
   im Gleichschritt. `--enter-i` ist `r + c` und wird in `js/ui.js`
   (`setProperty('--enter-i'`) an der Zelle gesetzt, erbt also bis hierher.
   Eine Zeilennummer stuende hier NICHT: `test/verweise.test.mjs` verlangt fuer
   jeden dateiuebergreifenden Verweis ein Symbol, weil eine Zahl nach der
   naechsten Einfuegung woandershin zeigt und dabei genauso verlaesslich
   aussieht wie vorher. */
@keyframes pawn-idle {
  0%, 100% { transform: translateZ(var(--pawn-floor)) rotateX(calc(-1 * var(--tilt))) rotate(-1.4deg); }
  50%      { transform: translateZ(var(--pawn-floor)) rotateX(calc(-1 * var(--tilt))) rotate(1.4deg); }
}
.pawn-body {
  animation: pawn-idle 4.6s ease-in-out infinite;
  animation-delay: calc(var(--enter-i, 0) * -290ms);
}
/* There is no ob-spin any more. The gramophone record carried it, and a disc is
   rotationally symmetric: the animation ran forever and changed nothing a player
   could see. It was replaced by a tonearm, which is a shape rather than a motion
   — see js/furnishings.js. Ambient motion has to earn its repaint. */

/* ------------------------------------------------------- one-shot motion */
/* Everything above this line loops, because it belongs to something that is
   always moving. Everything below fires exactly once, because it marks a
   moment instead of a state: a pawn landing, a drawer coming up empty, a clue
   being struck out.

   Every board-side rule here is written in `transform` alone, and that is a
   constraint rather than a preference. A cell holds the room's prisms, and both
   `opacity` below 1 and `filter` are grouping properties — they flatten a 3D
   subtree for exactly as long as they apply. A cell that faded in would play its
   whole entrance with the furniture lying flat on the floor and snap it upright
   on the last frame. Transforms group nothing, so the room stays furnished the
   whole way through. Where a fade genuinely is wanted it goes on a leaf: a
   pseudo-element, or one of the two faces. Never on a cell. */

@keyframes cell-enter {
  from { transform: translate3d(0, 30px, 0) scale(0.86); }
  to   { transform: none; }
}
/* The delay counts along both axes at once, so the plan draws itself out from
   its near corner rather than sweeping row by row. `backwards` is what makes the
   stagger a stagger: without it a cell would sit at its final position for the
   length of its own delay and then jump back to the start to animate. */
.cell.is-entering {
  animation: cell-enter 0.44s cubic-bezier(0.22, 1, 0.36, 1) backwards;
  animation-delay: calc(var(--enter-i, 0) * 24ms);
}

/* A suspect is set down, not teleported. The overshoot past the resting frame
   and the small rebound are the whole trick — a linear drop reads as a sprite
   changing coordinates, this reads as weight. */
@keyframes pawn-land {
  0%   { transform: translate3d(0, -38px, 0) scale(1.16); }
  58%  { transform: translate3d(0, 0, 0) scale(0.94, 1.06); }
  76%  { transform: translate3d(0, -6px, 0) scale(1.02, 0.98); }
  100% { transform: none; }
}
.cell-landed .pawn { animation: pawn-land 0.46s cubic-bezier(0.34, 1.2, 0.64, 1); }

/* Searching is the one action whose outcome the player cannot predict, so the
   two outcomes have to feel different before the text is read. A find blooms
   outward in brass; an empty drawer gets a short, flat shake and nothing else. */
@keyframes cell-find {
  0%   { box-shadow: inset 0 0 0 14px hsl(45 78% 62% / 0), inset 0 0 0 1px hsl(45 78% 62% / 0.9); }
  45%  { box-shadow: inset 0 0 0 5px hsl(45 78% 62% / 0.5), inset 0 0 0 1px hsl(45 78% 62% / 1); }
  100% { box-shadow: inset 0 0 0 0 hsl(45 78% 62% / 0), inset 0 0 0 1px hsl(45 78% 62% / 0); }
}
.cell-found { animation: cell-find 0.72s ease-out; }

@keyframes cell-dust {
  0%, 100% { transform: none; }
  18%      { transform: translate3d(-3px, 0, 0); }
  42%      { transform: translate3d(3px, 0, 0); }
  68%      { transform: translate3d(-2px, 0, 0); }
}
.cell-dusted { animation: cell-dust 0.34s ease-in-out; }

/* The resting state of a ticked clue is the plain line-through below; this is
   the stroke being drawn. It sweeps left to right at pen speed and then lifts,
   leaving the quiet state behind — the same way the printed puzzles are worked
   through with a pencil rather than reformatted. */
.clue-text { position: relative; }
.clue-struck .clue-text::after {
  content: '';
  position: absolute;
  left: -2px;
  right: -2px;
  top: 52%;
  height: 2px;
  border-radius: 2px;
  background: var(--accent-soft);
  /* The resting state has to be invisible, not merely unanimated. The class is
     dropped on animationend, but if that event never lands — a background tab,
     a compositor that never started the animation — the declared value is what
     stays on screen, and a bar in the accent hue is the same red the app uses
     for "widerlegt". So the rule rests at zero and the keyframe raises it. */
  opacity: 0;
  transform-origin: left center;
  animation: clue-strike 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes clue-strike {
  0%   { transform: scaleX(0); opacity: 1; }
  62%  { transform: scaleX(1); opacity: 1; }
  100% { transform: scaleX(1); opacity: 0; }
}

/* Panels arrive; they do not appear. The verdict panel in particular is the
   payoff of a case that took minutes to reason through, and a hard cut there
   reads as a browser alert rather than a result. */
@keyframes panel-rise {
  from { transform: translate3d(0, 14px, 0) scale(0.97); opacity: 0; }
  to   { transform: none; opacity: 1; }
}
.modal[open] .modal-inner { animation: panel-rise 0.28s cubic-bezier(0.22, 1, 0.36, 1); }

/* The scorecard reads top to bottom, so it should also arrive top to bottom —
   the delay is what turns a block of numbers into a tally being counted out. */
@keyframes row-in {
  from { transform: translate3d(-8px, 0, 0); opacity: 0; }
  to   { transform: none; opacity: 1; }
}
.modal[open] .score-row {
  animation: row-in 0.3s ease-out backwards;
  animation-delay: calc(180ms + var(--row-i, 0) * 55ms);
}

/* The verdict card is the answer to the case, so it does not slide in with the
   panel — it lands on it. Overshoot and settle rather than a plain fade,
   because the whole point of the beat is that something is being SET DOWN; the
   sound that plays with it is a stamp for the same reason.
   Scoped past `.verdict` to the stamp class so the accomplice card, which
   shares every other declaration of `.verdict`, does not land a second time a
   frame later — two cards stamping in sequence reads as a stutter, not as two
   culprits. */
@keyframes verdict-stamp {
  0%   { transform: scale(1.06) translate3d(0, -6px, 0); opacity: 0; }
  55%  { transform: scale(0.985) translate3d(0, 0, 0); opacity: 1; }
  100% { transform: none; opacity: 1; }
}
.modal[open] .verdict-stamp {
  animation: verdict-stamp 0.42s cubic-bezier(0.34, 1.4, 0.5, 1) backwards;
}

/* The number the count-up runs on. Delay is the SAME expression the row uses,
   read from the same custom property — `--row-i` is set on the row and custom
   properties inherit, so the swell cannot drift from the row it belongs to the
   day a scoring term is added or removed. It is also the moment `playSolved`
   fires and `countUp` starts, which is why this is one beat and not three. */
@keyframes total-swell {
  0%   { transform: scale(1); text-shadow: none; }
  40%  { transform: scale(1.14); text-shadow: 0 0 18px hsl(45 72% 62% / 0.55); }
  100% { transform: scale(1); text-shadow: none; }
}
.modal[open] .score-total .score-value {
  display: inline-block;               /* transform needs a box; a span has none */
  animation: total-swell 0.7s ease-out backwards;
  animation-delay: calc(180ms + var(--row-i, 0) * 55ms);
}

/* A promotion is the rarest event in the game, and the badge that changes sits
   in the header — the far side of the screen from the modal the player is
   reading. Without this, the one thing that persists after the case is closed
   changes silently while attention is elsewhere.

   Finite on purpose: three passes and done. An `infinite` pulse would have to be
   named in the reduced-motion block below, because the wildcard there shortens
   durations without stopping repeats — a 0.01ms infinite animation is a repaint
   storm. Finite means the wildcard alone is enough, which is one fewer place to
   forget. */
@keyframes rank-promote {
  0%, 100% { box-shadow: var(--inset-hair); border-color: var(--border); }
  50%      { box-shadow: var(--inset-hair), 0 0 0 3px hsl(45 72% 62% / 0.22); border-color: hsl(45 72% 62% / 0.55); }
}
.rank.is-promoted { animation: rank-promote 0.62s ease-in-out 3; }

.scene-foot { margin-top: var(--space-3); display: flex; flex-wrap: wrap; gap: var(--space-2-5) var(--space-4); align-items: center; }

.legend { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-3-5); }
.legend-item { display: inline-flex; align-items: center; gap: var(--space-1-5); font-size: var(--fs-sm); color: var(--muted); }
/* The swatch is a chip of the room's real floor with the room's real plan
   symbol on it — the same two cues the board uses, at map-key size. A pure hue
   chip made the player translate colour back into a room; this one does not. */
.legend-dot {
  /* Never let the flex row shrink the swatch — it is the whole payload. */
  flex: 0 0 auto;
  /* The pattern layer is a pseudo-element, so this has to be its containing
     block. See the ::before below for why it is not painted here directly. */
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 26px; height: 26px;
  border-radius: 4px;
  background-color: color-mix(in srgb, var(--room-tint, #666) var(--cell-tint-key), var(--cell-base));
  box-shadow: inset 0 0 0 1px hsl(0 0% 100% / 0.22);
}
/* The floor used to be painted straight onto the chip, and at board scale that
   was honest — but a key is not a board. The chip is a twelfth the area of a
   cell, and six of the ten floors have periods of 12px and up: salon 16, kueche
   18, waffen 18, ballsaal 20. At 20px square those showed ONE band and a bit of
   the next, which is not a pattern, it is a stripe. The comment above promised
   two cues and delivered one and a half.

   So the layer is drawn on a box twice the chip in each direction and then
   scaled by half. That halves every period — including the ones baked into the
   gradient stop lists, which `--room-floor-size` cannot reach at all, since only
   salon, kueche and ballsaal set it. Scaling the PAINTED RESULT is the only
   lever that reaches all ten from one token, and the token stays the single
   source of truth: no second set of key-sized gradients to drift out of sync
   with the board. With the chip at 26px the worst room now shows 2.6 repeats
   (ballsaal), the best 8.7 (arbeit); test/contrast.test.mjs holds that floor.

   200% scaled from the top-left corner lands exactly on the chip, so there is
   nothing to clip — the radius is inherited only so the corners stay rounded. */
.legend-dot::before {
  content: "";
  position: absolute;
  top: 0; left: 0;
  width: 200%; height: 200%;
  transform: scale(0.5);
  transform-origin: 0 0;
  border-radius: inherit;
  background-image: var(--room-floor, none);
  background-size: var(--room-floor-size, auto);
}
.legend-glyph {
  width: 13px; height: 13px;
  /* Reads over ten different materials, so it cannot borrow the room hue. */
  color: hsl(0 0% 100% / 0.88);
  stroke-width: 1.9;
}

.conflict { margin: 0; font-size: var(--fs-sm); color: var(--accent-soft); }
/* The element stays in the DOM permanently so it can act as a live region;
   emptiness is what makes it absent, not the `hidden` attribute. */
.conflict:empty { display: none; }

/* ------------------------------------------------------------------ people */

.suspect {
  display: flex;
  align-items: center;
  gap: var(--space-2-5);
  width: 100%;
  text-align: left;
  padding: var(--space-2) var(--space-2-5);
  border-radius: var(--radius-card);
  border: 1px solid var(--border);
  background: var(--panel-strong);
  color: var(--text);
  font-family: inherit;
  font-size: var(--fs-md);
}

/* Ein Modifikator auf `.suspect`, keine eigene Kartenform mehr: die Leiche wird
   gesetzt wie jede andere Figur und muss deshalb dieselben Zustände zeigen
   können. Nur die Farbe bleibt ihre — sie ist die einzige Unterscheidung,
   solange noch keine Figur auf dem Brett steht.
   Die Zustandsregeln darunter greifen absichtlich darüber: `.suspect.is-selected`
   und `.suspect.is-placed:not(.is-selected)` sind zwei Klassen breit und schlagen
   diese eine — der Zustand gewinnt also über die Rolle.

   Was die rote Kante überlebt und was nicht, stand hier falsch („überlebt beide"),
   und zwar nachmessbar falsch: `.is-placed` setzt nur `border-style: dashed`, also
   bleibt die Farbe — `.is-selected` setzt `border-color` UND `background` und
   nimmt beide roten Signale weg. Auf der ausgewählten Leiche ist rot also weg,
   und das ist Absicht: die Auswahl ist der Zustand, den der Spieler in genau
   diesem Moment braucht. Die Rolle geht dabei nicht verloren, weil sie nicht an
   der Farbe hängt — der Totenkopf im Avatar wird aus `isVictim` abgeleitet
   (js/ui.js, `coatAvatar`) und von keiner Zustandsklasse angefasst. Ein dritter,
   zustandsfester Marker wäre deshalb eine Wiederholung, kein Gewinn.
   Gegenprobe: `grep -n "is-selected\|is-placed" styles.css` — findet eine dieser
   Regeln künftig `box-shadow` oder `--skull`, stimmt dieser Absatz nicht mehr. */
.suspect-victim { border-color: hsl(0 72% 52% / 0.5); background: hsl(0 60% 20% / 0.28); }

.suspect { cursor: pointer; transition: border-color 0.12s ease, background 0.12s ease; }
.suspect:hover { border-color: var(--border-strong); }
.suspect:focus-visible { outline: 2px solid var(--accent-soft); outline-offset: 2px; }
.suspect.is-selected { border-color: var(--pick-line); background: var(--pick-fill); }
/* Placed suspects stay fully legible — only the avatar signals the state.
   A placed suspect can also be the selected one, so the tint is withheld there
   instead of overwriting the selection fill it ties with on specificity. */
.suspect.is-placed { border-style: dashed; }
.suspect.is-placed:not(.is-selected) { background: var(--panel-faint); }
/* 0.7, nicht 0.5, und die Zahl ist gemessen statt gewählt. Gruppendeckkraft
   blendet Icon und Mantel gemeinsam gegen den Untergrund; weil Leuchtdichte
   nicht linear in der Deckkraft ist, sinkt der Kontrast dabei stärker als die
   Zahl vermuten lässt — bei 0.5 fiel das Berufssymbol auf dem dunkelsten
   Mantel auf 2.73:1 und damit unter die 3:1. Die Karte bleibt anklickbar und
   wählt die Person weiterhin aus, also greift die WCAG-Ausnahme für inaktive
   Bedienelemente hier nicht. Der Zustand ist ohnehin dreifach angezeigt —
   gestrichelter Rand, Tönung, Abdunklung —, die Abdunklung darf also die
   schwächste der drei sein. contrast.test.mjs rechnet die Grenze nach. */
.suspect.is-placed .avatar { opacity: 0.7; }

/* The affirmative half of the placed state — see renderPeople() for why the
   three subtractive cues above were not enough on their own. `margin-left: auto`
   rather than a spacer element: .person-meta already carries `min-width: 0`, so
   it absorbs the free space and would otherwise push this token past the card's
   padding on a long name.
   Green rather than the crime-red accent, and that is a real distinction on this
   board: red is the case's own colour — the victim card, the culprit, the
   conflict line — so a red tick in the tray would read as an alarm about the
   suspect rather than as a completed step. This is the only positive-state
   colour in the app, which is exactly why it is safe to spend it here. */
.suspect-mark {
  display: grid;
  place-items: center;
  flex: 0 0 auto;
  margin-left: auto;
  width: 26px;
  height: 26px;
  border-radius: 8px;
  /* The RING is what carries the shape, not the fill, and the two alphas were
     picked by measuring rather than by eye. A tinted fill this small can never
     do the job alone: at any alpha that still reads as a tint it lands around
     1.4:1 against the card it sits on, which is invisible to anyone who cannot
     separate the hue. The ring can, and does — 3.82:1 against the placed card's
     own background (--panel-faint over --panel-strong over --bg), past
     the 3:1 the WCAG non-text rule asks of a control boundary. The glyph then
     lands at 7.43:1 on the fill. Both figures are computed against the DARKEST
     card variant, so the selected card — which is lighter — only improves them. */
  background: hsl(152 46% 44% / 0.2);
  box-shadow: inset 0 0 0 1px hsl(152 46% 52% / 0.62);
  color: hsl(152 58% 72%);
}
.suspect-mark .glyph { width: 15px; height: 15px; }

/* Wraps an <svg>, so the padding sets the optical icon size — the old
   font-size contract died with the emoji. */
.avatar {
  display: grid;
  place-items: center;
  flex: 0 0 auto;
  width: 34px;
  height: 34px;
  padding: var(--space-2);
  border-radius: var(--radius-inner);
  background: hsl(0 0% 100% / 0.08);
  box-shadow: inset 0 0 0 1px hsl(0 0% 100% / 0.07);
  color: hsl(0 0% 100% / 0.9);
}

/* The board hue reaches the tray, and that crossing is the whole point of it: a
   colour only the board knows is decoration, a colour both surfaces share is a
   name. The chip paints the flat coat rather than its lit stop — the darker of
   the two grounds the same icon lands on, so whatever passes here passes there.
   A marker class rather than a list of the seven tones, because such a list would
   be a THIRD copy of "how many coats there are", after COAT_COUNT in
   furnishings.js and the blocks in this file. An eighth suspect would get a coat
   on the board and a plain grey chip in the tray, and nothing would say so. It
   cannot restyle `.avatar` itself either — the plain chip is a
   measured surface in test/contrast.test.mjs and the test reads that declaration
   literally — so the tone-carrying chip announces itself instead of being
   enumerated. The fallbacks are not decoration: an unset custom property makes
   the whole declaration invalid at computed-value time, which resolves to
   `transparent` rather than falling back to the `.avatar` rule above. Without
   them, a chip that got the marker class but no tone would be a hole. */
.avatar.avatar-coat {
  background: var(--coat, hsl(0 0% 100% / 0.08));
  box-shadow: inset 0 0 0 1px var(--coat-lit, hsl(0 0% 100% / 0.07));
}

/* Größe, sonst nichts. Opfer- und Täter-Icon sitzen in einer Karte, die schon rot
   umrandet und rot gefüllt ist — malte das Icon sein eigenes Rot, sagte der
   Rahmen „Urteil“ und das Icon es noch einmal, während die einzige Frage, die es
   beantworten könnte — WER —, offen bliebe. Den Mantel der Person trägt es über
   .avatar-coat. Dass hier keine Farbe steht, hält außerdem eine Spezifitätsfalle
   zu: mit 0,1,0 verlöre diese Regel gegen .avatar.avatar-coat mit 0,2,0, sobald
   beide Klassen auf einem Knoten landen. Kein erhöhter Selektor, sondern kein
   Konflikt. */
.avatar-culprit {
  width: 44px;
  height: 44px;
  padding: var(--space-2);
  border-radius: 13px;
}

/* Untereinander, und im Querformat auch dann noch, wenn dort die Höhe knapp
   ist. Das ist gemessen und nicht angenommen: der naheliegende Griff — Name
   links, Ort rechtsbündig auf DERSELBEN Zeile, Merkmal darunter über die volle
   Breite — wurde am 2026-08-04 auf 1024x768 geprüft und ist dort ein
   Rückschritt, kein Gewinn.

   Der Grund ist eine Breite, die man von aussen falsch schätzt. Die Spalte ist
   350px breit, aber davon gehen Avatar, Lücke und Polsterung ab; für
   `.person-meta` bleiben 206,4. Gemessen wurde mit „Schiffsärztin Dr. Ingrid
   Waldau": 31 Zeichen, die genau auf 206,4 kommen und die Zeile damit exakt
   ausfüllen. Ein `minmax(0, 1fr) auto` gibt dem Ort seine 110,3 und lässt dem
   Namen 116,7: gemessen brachen danach ALLE VIER Namen zweizeilig um (20 →
   40px), die Karte wuchs 72 → 75, und der Rollinhalt der Spalte stieg 1723 →
   1735. Die Massnahme kostet also 12px, statt die veranschlagten 108 zu sparen,
   und bezahlt das mit vier gebrochenen Namen.

   Hier stand bis 2026-08-05, diese 31 Zeichen seien „der längste Name des
   Falls". Das ist gleich zweifach keiner. Der Fall HAT keinen festen längsten
   Namen: `attemptCase` (`js/engine.js`) zieht die Verdächtigen mit
   `sample(scenario.cast, …)` aus acht, die Besetzung wechselt also von Partie
   zu Partie. Und schon in Waldaus eigenem Fall steht mit „Reedereianwältin
   Solveig Marstrand" (34) ein längerer. Der längste Name aller Fälle
   („Handschriftenkundler Dr. Ivo Rasmussen") misst 38 Zeichen — dieselbe 38,
   die dieser Bogen bei `--fuss-rueckfall` schon nennt — und damit rund 253px.
   Er bricht hier also SCHON HEUTE um; im ungünstigen Zug ist die Ablage
   vierzeilig, nicht dreizeilig.

   Das dreht den Schluss nicht um, es schärft ihn: je länger der Name, desto
   teurer die verworfene Zweispalten-Variante, denn sie nimmt ihm 89,7 seiner
   206,4px. Auch die kleinere Variante trägt nicht: Ort (110,3) und Merkmal
   (bis 125,2) nebeneinander sind 235,5 und damit ebenfalls breiter als 206,4.
   Die Zeilenzahl ist hier keine Nachlässigkeit, sondern das, was die Breite
   hergibt. Wer die Höhe der Ablage senken will, muss an die ANZAHL der Zeilen
   heran, nicht an ihre Anordnung — und das hiesse, Information zu streichen.

   Dass der genannte Name existiert und wirklich der längste ist, prüft
   `node test/prosa.test.mjs`. Ein Superlativ ist auch eine Behauptung über die
   Quelle, nur ohne Ziffer daneben — und diese hier stand vier Monate falsch
   im Bogen, weil keine Zahl danebenstand, die jemandem aufgefallen wäre. */
.person-meta { display: flex; flex-direction: column; min-width: 0; }
.person-meta strong { font-weight: 600; font-size: var(--fs-md); }
.person-meta .muted { font-size: var(--fs-sm); }

/* The habit line, and it is deliberately the quietest thing on the card. It is
   reference material — read once when a trait clue turns up, ignored the rest
   of the time — so it must not compete with the name or with the placement it
   sits under. Italic rather than a second colour: --muted is already spent on
   the line above, and a third tone in a card this small starts to look like a
   list of equals. `--muted` on the panel plate is the pair contrast.test.mjs
   already measures, so this adds a surface to no token. */
.person-meta .suspect-trait {
  font-size: var(--fs-sm);
  font-style: italic;
  color: var(--muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Der Fundort-Satz auf der Opferkarte — und er ist NICHT dieselbe Stimme wie die
   Gewohnheit darüber, auch wenn er an derselben Stelle steht. Genau das stand
   hier vorher („dieselbe leise Stimme … aus demselben Grund") und war die
   Begründung, die sich der Satz nicht verdient: die Gewohnheit ist Dekoration,
   einmal gelesen und danach Beiwerk; der Fundort ist der EINZIGE Weg, auf dem
   der Spieler die Opferzelle herleiten kann. Er ist ein Hinweis, und `.clue-btn`
   schreibt den Kontrakt für Hinweise bereits auf: „Clue text is the surface the
   player reads most, so it stays at body size."

   Ganz erfüllen kann er ihn hier nicht — auf `--fs-md` wüchse die Karte weiter
   und konkurrierte mit dem Namen, unter dem sie steht. Von den drei
   Abschwächungen, die vorher übereinander lagen (klein, kursiv, gedämpft),
   fallen darum die zwei, die nichts an Höhe kosten: Farbe und Schnitt. Übrig
   bleibt die kleinere Stufe, und die trägt die Unterordnung unter den Namen
   allein. Der eigentliche Gewinn ist der Nebeneffekt: Gewohnheit und Fundort
   sahen bis eben identisch aus, obwohl die eine wegfallen darf und der andere
   das Spiel entscheidet. Zwei Ränge sehen jetzt wie zwei Ränge aus.

   Er BRICHT UM, wo die Gewohnheit abschneidet, und das bleibt der andere
   Unterschied: die Gewohnheit ist ein Etikett („raucht Pfeife"), von dem ein
   abgeschnittenes Ende noch lesbar bleibt; dieser Satz nennt Raum und Landmarke,
   und wer ihn nur halb sieht, kann die Zelle nicht mehr herleiten. Eine Ellipse
   wäre hier kein Platzsparen, sondern das Einbehalten des Hinweises. */
.person-meta .victim-fundort {
  font-size: var(--fs-sm);
  color: var(--text);
  /* Die Karte ist flex; ohne diese Zeile wächst der Text über die Panelbreite
     hinaus, statt in ihr umzubrechen. */
  min-width: 0;
  /* Der Gürtel zum Hosenträger `softHyphenate` an der Renderstelle: die Fugen
     decken zusammengesetzte Raumnamen ab, diese Zeile deckt den Fall ab, für den
     es keine Fuge gibt — ein langes Möbelwort ohne Trennstelle in einer Spalte,
     die auf 375px nur rund 206px misst. Heute läuft nichts über (längstes Wort
     des Korpus „Aussichtsplattform" ≈130px); der Umbruch ist aber die
     Eigenschaft, an der die Herleitbarkeit des Hinweises hängt, und die gehört
     nicht an die Wortlängen der aktuellen Fallakte gebunden. */
  overflow-wrap: anywhere;
}

/* Only on liar cases, and only there because the row exists only there: on a
   normal case the tray holds bare .suspect buttons and this rule never matches.
   Column, not row: the withdraw control is a full-width second line under the
   suspect rather than a button squeezed beside the name, because the tray is the
   narrowest panel on the page and the label is four words long. */
.suspect-row {
  display: flex;
  flex-direction: column;
}
/* The suspect keeps the row's corners at the top, the control at the bottom, so
   the pair reads as one card split by a seam rather than two stacked buttons —
   which only holds if the seam is a LINE. A gap between them left two separate
   borders with panel showing through, and the softened inner corners then read
   as a rounding mistake rather than as a joint. No gap, and the lower box is
   pulled up by exactly the border width so the two 1px edges land on each other
   and paint one shared rule. */
.suspect-row .suspect { border-radius: var(--radius-card) var(--radius-card) 4px 4px; }
.suspect-row .suspect-withdraw { margin-top: -1px; }

/* The price of that shared seam, paid here. Both halves ring at outline-offset
   2px, so a focused box draws 2px INTO its neighbour — and an outline paints with
   its own element, which means the later sibling's background covers the upper
   box's ring while the upper box's ring covers nothing of the lower one. Focus
   would be visible on one of the two and clipped on the other, which is the worse
   failure: it looks like the ring is simply missing. Lifting whichever box holds
   focus settles it for both directions at once. Same remedy .cell:focus-visible
   already uses for the board, where every cell shares all four edges. */
.suspect-row .suspect:focus-visible,
.suspect-row .suspect-withdraw:focus-visible { position: relative; z-index: 1; }

/* Stated, not inherited: this control is not a .btn, so none of the button rules
   reach it, and its label is one short line — on padding alone it measured 31px,
   the smallest pressable thing in the app and below the 40px floor every other
   button here keeps. Flex rather than line-height so the text stays left-aligned
   while the box grows around it — aligned to the card's content edge, which is
   the AVATAR's left edge and not the name's: the two sit 44px apart, and this
   comment claimed the name for long enough that it is worth naming which one it
   actually is. Under the avatar is the right of the two anyway; the button
   belongs to the whole card, not to the line of text it happens to sit below. */
.suspect-withdraw {
  display: flex;
  align-items: center;
  min-height: 40px;
  padding: var(--space-1-5) var(--space-2-5);
  border-radius: 4px 4px var(--radius-card) var(--radius-card);
  border: 1px solid var(--border);
  background: var(--panel-faint);
  color: var(--muted);
  font-family: inherit;
  font-size: var(--fs-sm);
  text-align: left;
  cursor: pointer;
  transition: border-color 0.12s ease, background 0.12s ease, color 0.12s ease;
}
.suspect-withdraw:hover { border-color: var(--border-strong); color: var(--text); }
.suspect-withdraw:focus-visible { outline: 2px solid var(--accent-soft); outline-offset: 2px; }
/* Warn rather than accent, matching the chip that announced the mode: this is
   the same fact stated twice on two surfaces, and it should not change colour
   between them. Which is also why it is not red — the withdrawal is a working
   note, not a verdict, and red in this app means the accusation went wrong. */
.suspect-withdraw.is-withdrawn {
  border-color: hsl(var(--warn-plate) / 0.45);
  background: hsl(var(--warn-plate) / 0.08);
  color: var(--warn);
}
/* A row with nothing on the list to strike. Dimmed rather than hidden, because
   the control comes back the moment that suspect's statement is extracted from
   the dossier, and a button that appears out of nowhere is harder to understand
   than one that was visibly waiting. */
.suspect-withdraw:disabled {
  cursor: default;
  opacity: 0.5;
}
.suspect-withdraw:disabled:hover { border-color: var(--border); color: var(--muted); }

/* The premise of the case, sitting above the roster it governs. Warn-tinted like
   the Fallart chip in the case header and the withdrawn button below it —
   one fact, one hue, across all three surfaces. The border is not decoration:
   forced-colors drops the tint, and without an edge the block would collapse
   into the panel and read as loose text under the heading.

   Named for the SLOT and not for the liar since the accomplice case took the
   same slot: nothing in these five rules was ever about lying, only the three
   sentences the script puts inside them were. */
.case-brief {
  border: 1px solid hsl(var(--warn-plate) / 0.35);
  border-radius: var(--radius-card);
  background: hsl(var(--warn-plate) / 0.06);
  padding: var(--space-2) var(--space-2-5);
  display: grid;
  gap: var(--space-1);
}
.case-brief-head {
  font-size: var(--fs-xs);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--warn);
}
.case-brief-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: var(--space-1);
  font-size: var(--fs-sm);
  color: var(--muted);
}
/* Marker drawn rather than inherited: list-style markers sit outside the padding
   box and would hang into the block's border at this width. */
.case-brief-list li { padding-left: var(--space-2); position: relative; }
.case-brief-list li::before {
  content: '';
  position: absolute;
  left: 0;
  top: 0.62em;
  width: 4px;
  height: 4px;
  border-radius: 50%;
  background: hsl(var(--warn-plate) / 0.7);
}

.tray { display: grid; gap: var(--space-1-5); }
/* --fs-xs is reserved for uppercase labels; this line explains the core
   interaction and must not be the smallest text on the page. */
.hint-line { margin: var(--space-2-5) 0 var(--space-2); font-size: var(--fs-sm); color: var(--muted); }

/* ------------------------------------------------------------------- clues */

.clues { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-1-5); }

.clue {
  border-radius: var(--radius-inner);
  border: 1px solid var(--border);
  background: var(--panel-faint);
  transition: border-color 0.12s ease, background 0.12s ease;
}

.clue-btn {
  display: flex;
  gap: var(--space-2);
  width: 100%;
  text-align: left;
  padding: var(--space-2) var(--space-2-5);
  border: none;
  border-radius: inherit;
  background: none;
  color: inherit;
  font-family: inherit;
  /* Clue text is the surface the player reads most, so it stays at body size. */
  font-size: var(--fs-md);
  line-height: 1.45;
  align-items: center;
  min-height: 40px;
  cursor: pointer;
}

/* Hover outranks the .clue-ok/.clue-bad verdict colours on specificity, so it
   only tints the surface and leaves the border to whichever verdict is set. */
.clue:hover { background: hsl(0 0% 100% / 0.06); }
.clue:not(.clue-ok):not(.clue-bad):hover { border-color: var(--border-strong); }
.clue-bad:hover { background: hsl(0 72% 52% / 0.16); }
/* Neutral white: red already means "refuted" in this list. */
.clue:has(.clue-btn:focus-visible) { border-color: hsl(0 0% 100% / 0.55); }
.clue-btn:focus-visible { outline: 2px solid var(--accent-soft); outline-offset: -2px; }

.clue-mark { flex: 0 0 auto; width: 14px; color: var(--muted); font-weight: 700; }
.clue-ok { border-color: hsl(151 55% 50% / 0.4); }
.clue-ok .clue-mark { color: var(--ok); }
.clue-bad { border-color: hsl(0 72% 52% / 0.55); background: hsl(0 72% 52% / 0.10); }
.clue-bad .clue-mark { color: var(--accent-soft); }
/* Struck through in both states, and deliberately with the SAME stroke. They
   are not alternatives — a withdrawn clue can also be ticked off — and while the
   two rules differed only in the stroke's dash pattern the later one silently
   won on equal specificity, so marking a withdrawn clue as done erased the done
   state instead of adding to it. Dashes are also the weakest typographic signal
   in the file; the distinction is already carried where the eye actually looks,
   by the mark in front of the sentence: ✓ ✕ · for an evaluated clue, — for one
   that is not evaluated at all. Clues stay live toggle buttons in both states,
   so they still owe AA — the stroke carries the state, the opacity only softens
   it. */
.clue-done .clue-text,
.clue-withdrawn .clue-text { text-decoration: line-through; opacity: 0.55; }

/* The background is flattened instead of tinted so the row recedes without
   claiming a fourth verdict colour — but only while the pointer is elsewhere.
   Unscoped, this outranked .clue:hover on specificity and made withdrawn rows
   the one part of the list that gave no feedback under the cursor, which reads
   as a dead control rather than a quiet one. Deliberately no border rule, which
   would fight .clue-from-file's brass rail for the left edge and hide where the
   sentence came from. */
.clue-withdrawn:not(:hover) { background: none; }
/* Half the list's hover tint, because this row starts from nothing. Every other
   clue lifts from its own faint plate to 0.06; a withdrawn one lifted from
   transparent, so the quietest row in the list answered the cursor with the
   loudest change on the panel. Must stay after .clue:hover — equal specificity,
   so source order is the whole argument. */
.clue-withdrawn:hover { background: var(--panel-faint); }

/* Clues promoted out of the case file keep the brass rail they were read
   under, so the list shows at a glance how much came from the prose. */
.clue-from-file { border-left: 2px solid hsl(45 72% 62% / 0.6); }
.clue-from-file .clue-mark { color: var(--brass); }

/* Picking up a suspect lifts that suspect's own sentences out of the list.
   --pick-line and not brass: brass is already spoken for twice over — the rail
   above means "aus der Fallakte" and --room-edge means "durchsucht" — whereas
   --pick-line is this app's single word for "in der Hand", the same token
   .suspect.is-selected wears. The tray card and the clue row then say the same
   thing in the same voice instead of inventing a second vocabulary for one act.

   The ring carries the row, not a tint: a fill light enough to stay quiet on the
   plate measures 1.66–1.91:1, which is not a boundary at any size. The ring
   lands at 9.31:1 against the clue plate, 8.51:1 against the .clue-bad red tint
   and 8.07:1 against .clue-bad:hover, so it survives on every verdict a row can
   be wearing. box-shadow rather than border because .clue has no shadow of its
   own and a border would shift every sibling by a pixel on selection. */
.clue-focus { box-shadow: 0 0 0 1px var(--pick-line); }

/* Both `background` and `color` are load-bearing overrides, not styling: the UA
   default for <mark> is black on yellow, which on this panel would be the single
   brightest object in the app. White on the 0.16 fill measures 10.32:1 on the
   plain clue plate, 8.45:1 on the worst one the row can wear (.clue:hover at the
   brightest sampled 375px pixel) — the number this comment used to carry alone
   was the best case, quoted as if it were the general one.

   The sentence that stood here — „the mark stays legible even where the
   surrounding sentence is dimmed to 0.55 by .clue-done, because opacity
   composites the whole subtree and dims fill and text together" — was wrong, and
   wrong in a way that only a measurement finds: dimming both together does NOT
   preserve the ratio. The fill LIGHTENS the ground it sits on, so dimming it
   helps the text, while dimming the opaque white hurts it — and the second
   effect dominates. Measured: 8.45 undimmed falls to 4.08 at 0.55, i.e. under
   AA. The fill was costing the name more contrast than it was buying it
   visibility (1.27:1 against its own plate once dimmed — not a highlight at all).
   So the dimmed states drop the fill and let the underline mark the name; see
   the rule below. Both numbers are guarded in test/contrast.test.mjs now. */
.clue-name {
  background: hsl(0 0% 100% / 0.16);
  color: #fff;
  font-weight: 600;
  border-radius: 4px;
  padding: 0 3px;
  /* Cancels the padding so the marked name keeps its place in the line's rhythm
     — a name that widened on selection would reflow the sentence under the eye. */
  margin: 0 -1px;
  box-shadow: inset 0 -1px 0 var(--pick-line);
}

/* A dimmed sentence keeps its marked name, but not its fill. Under `opacity` the
   whole subtree is composited as a group, so the 0.16 wash lands at 0.088 and
   lightens the ground under white text that has itself dropped to 0.55 — the
   trade is a loss (4.08:1, under AA). Without the fill the name reads 5.36:1
   against the plate, and the inset underline carries the mark on its own at
   3.50:1, which is the 1.4.11 bar for a non-text boundary. Neither number is
   transcribed: both are asserted in test/contrast.test.mjs across every plate a
   done clue can wear, and the rule below is itself read back there — dropping it
   turns the measurement red rather than silently changing what is measured. */
.clue-done .clue-name,
.clue-withdrawn .clue-name { background: none; }

.clue-empty {
  padding: var(--space-3);
  border-radius: var(--radius-inner);
  border: 1px dashed var(--border-strong);
  font-size: var(--fs-sm);
  color: var(--muted);
  line-height: 1.45;
}

/* ------------------------------------------------------------------ traces */

/* The second evidence layer. It borrows the brass of the case file rather than
   the neutral grey of the clue list: a trace is a reward for looking, not an
   item of bookkeeping, and the colour is what tells the two lists apart. */

.traces { display: grid; gap: var(--space-1-5); }

.trace-entry {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  width: 100%;
  min-height: 40px;
  padding: var(--space-2) var(--space-2-5);
  text-align: left;
  border-radius: var(--radius-inner);
  border: 1px solid hsl(45 72% 62% / 0.28);
  background: hsl(45 72% 62% / 0.06);
  color: inherit;
  font-family: inherit;
  font-size: var(--fs-md);
  cursor: pointer;
  transition: background 0.12s ease, border-color 0.12s ease;
}
.trace-entry:hover { background: hsl(45 72% 62% / 0.12); border-color: hsl(45 72% 62% / 0.5); }
.trace-entry:focus-visible { outline: 2px solid var(--brass); outline-offset: -2px; }

.trace-icon { flex: 0 0 auto; width: 20px; height: 20px; color: var(--brass); }

.trace-meta { display: grid; gap: 0; min-width: 0; }
.trace-meta strong { line-height: calc(1em + 6px); font-weight: 600; letter-spacing: -0.01em; }
.trace-meta .muted { font-size: var(--fs-sm); line-height: calc(1em + 5px); }

/* One tile per trace still missing. Without them the panel had no shape until
   the player found something: an empty box under a „0/2“ counter, which reads as
   a section that failed to load rather than one waiting to be filled. Dashed
   rather than tinted, because a slot is the absence of a trace and must not be
   mistaken for a dim one. Decorative — the count is already announced by
   #trace-count, and two identical „not found yet“ nodes in the accessibility
   tree would be noise, not information. */
.trace-slot {
  min-height: 40px;
  border-radius: var(--radius-inner);
  border: 1px dashed hsl(45 72% 62% / 0.28);
}

.trace-btn { width: 100%; margin-top: var(--space-2); }

/* ---------------------------------------------------------------- evidence */

/* Bewusst NICHT das Messing der Spuren. Die beiden Panels stehen untereinander,
   und wenn sie dieselbe Farbe trügen, läse sich das als eine Liste mit einer
   willkürlichen Trennlinie. Die Belege sind Papier — Ordner, Briefe, Protokolle
   — also bleiben sie im neutralen Grau der Ablage, und das Messing markiert
   weiterhin genau eine Sache: den Fund, der einen Buchstaben trägt.

   Kein Hover, kein Fokus-Ring, kein Cursor: eine Spur ist ein Knopf (sie lässt
   sich noch einmal ansehen), ein Beleg ist ein abgelegtes Blatt. Etwas, das wie
   ein Bedienelement aussieht und keines ist, ist die teurere Lüge. */

.evidence { display: grid; gap: var(--space-1-5); }

.evidence-entry {
  display: grid;
  gap: var(--space-1);
  padding: var(--space-2-5);
  border-radius: var(--radius-inner);
  border: 1px solid var(--border);
  background: var(--panel-faint);
}

/* Die Gattung als Etikett, nicht als Überschrift: sie ordnet den Eintrag ein,
   ohne ihn anzukündigen. Versalien plus Sperrung, damit ein Wort von fünf
   Buchstaben auch ohne Fläche als Marke liest. */
.evidence-kind {
  justify-self: start;
  font-size: var(--fs-xs);
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted);
}

/* Körpergrösse, und zwar nach dem Vorbild der Hinweisliste zwei Panels weiter
   oben (styles.css:4436 `.clue-btn`): das ist die Fläche, auf der ein Spieler
   liest und abwägt, und ein Beleg tut genau dasselbe. Was die Belege von den
   Spuren unterscheidet, ist nicht der Grad, sondern die Länge — eine Spur ist
   drei Wörter, ein Beleg ein ganzer Satz. Deshalb die ausgeschriebene
   Zeilenhöhe: geerbt wäre sie die des Fliesstexts, und ein dreizeiliger Absatz
   in einer 370px-Spalte fällt ohne sie zusammen. */
.evidence-text { margin: 0; line-height: 1.45; }
.evidence-entry .muted { margin: 0; font-size: var(--fs-sm); line-height: 1.5; }

/* .hint-line bringt eigene Ränder mit (10px oben, 8px unten). In normalem Fluss
   fielen die zusammen — in einem Raster nicht, sie addieren sich also auf das
   `gap` obendrauf. Ergebnis vor dieser Zeile: 24px zwischen den beiden leisesten
   Zeilen des Panels gegen 6px zwischen den Karten darüber, also der grösste
   Abstand an der unwichtigsten Stelle. Ränder raus, `gap` entscheidet; nur der
   Übergang von der letzten Karte zur ersten Fussnote wird verdoppelt, weil dort
   tatsächlich ein Rang wechselt. */
#evidence-list .hint-line { margin: 0; }
#evidence-list .evidence-entry + .hint-line { margin-top: var(--space-1-5); }

/* The cipher itself. Set apart from the prose around it as a physical object:
   its own plate, monospaced so a morse row and a digit row line up, and spaced
   wide enough that the player reads glyph by glyph instead of as a word. */
.cipher {
  display: grid;
  gap: var(--space-2);
  justify-items: center;
  margin: var(--space-3-5) 0;
  padding: var(--space-4) var(--space-3-5);
  border-radius: var(--radius-card);
  border: 1px solid hsl(45 72% 62% / 0.3);
  background: linear-gradient(hsl(45 72% 62% / 0.07), hsl(45 72% 62% / 0.02));
  box-shadow: var(--inset-hair);
}

.cipher-line {
  font-family: var(--font-cipher);
  font-size: var(--fs-lg);
  font-weight: 600;
  letter-spacing: 0.22em;
  /* The trailing letter-space would otherwise push the row off-centre. */
  text-indent: 0.22em;
  color: var(--text);
  text-align: center;
  overflow-wrap: anywhere;
}

/* The margin note — the correction that makes the cipher readable. Smaller and
   in the file's serif, because it is handwriting on the evidence, not print. */
.cipher-note {
  font-family: var(--font-file);
  font-size: var(--fs-md);
  font-style: italic;
  color: var(--brass);
}

.cipher-key { width: 100%; margin-top: var(--space-0-5); }
.cipher-key summary {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  padding: var(--space-1) var(--space-0-5);
  font-size: var(--fs-sm);
  line-height: calc(1.5em + 1px);
  color: var(--muted);
  cursor: pointer;
  list-style: none;
}
.cipher-key summary::-webkit-details-marker { display: none; }
.cipher-key summary::before { content: '▸'; font-size: 0.75em; transition: transform 0.12s ease; }
.cipher-key[open] summary::before { transform: rotate(90deg); }
.cipher-key summary:hover { color: var(--text); }
.cipher-key summary:focus-visible { outline: 2px solid var(--brass); outline-offset: 2px; border-radius: 6px; }
.cipher-key p { margin: 0; font-size: var(--fs-sm); color: var(--muted); }
/* The leading sits on `details.cipher-key p` and not on the line above, because
   the line above does not win. `.modal-inner p` (0,1,1) stands further down this
   file at the same weight and takes both `font-size` and `line-height` from it;
   the paragraph renders at 15px/1.6, never at the 13px/1.5 the rule reads like.
   `details.cipher-key p` is (0,1,2) and outranks it. The `+1px` is this site's
   half of the 2px that used to be `margin-top` — the other half is on `summary`.
   Verify-cmd: `node -e "const c=require('fs').readFileSync('styles.css','utf8');
   console.log(/\.modal-inner p\s*{[^}]*line-height/.test(c))"` -> `true`; drops
   to `false` and this comment is stale, not the cascade. */
details.cipher-key p { line-height: calc(1.6em + 1px); }

/* The morse alphabet. Laid out for LOOKING UP, not for reading: the player holds
   four dots in their head and wants the row that matches, so the codes are the
   thing that must line up in a scannable column and the letters are only labels.
   Hence auto-fit columns rather than a fixed count — the panel is 260px on a
   phone and much wider on a desktop, and a 26-row single column would push the
   cipher itself off the screen on the very device where the table is needed most.
   The code is the one part that keeps the board's mono font: the glyphs are
   U+00B7 and U+2212, and in a proportional face the dot drifts off the baseline
   and the minus shortens until it stops matching what the trace printed. */
.morse-key {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(4.5rem, 1fr));
  gap: 0.1rem var(--space-1);
  margin: var(--space-1) 0 var(--space-0-5);
  font-size: var(--fs-sm);
}
.morse-pair { display: flex; align-items: baseline; gap: 0.4em; }
.morse-key dt { font-weight: 600; color: var(--muted); }
.morse-key dd {
  margin: 0;
  font-family: var(--font-cipher);
  letter-spacing: 0.12em;
  color: var(--text);
}

/* ----------------------------------------------------------------- actions */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-1-5);
  min-height: 40px;
  padding: var(--space-2) var(--space-3-5);
  border-radius: var(--radius-card);
  border: 1px solid var(--border);
  background: hsl(0 0% 100% / 0.08);
  color: var(--text);
  font-family: inherit;
  font-size: var(--fs-md);
  /* 600, like every other piece of emphasised UI text. It was 500 — a step that
     appeared exactly once in the sheet and did no work: 100 away from the body's
     400 and 100 from the 600 everything else uses, so it read as neither. Its
     only visible effect was that a secondary button sat lighter than the primary
     beside it, which the accent fill already says far more loudly than a weight
     ever could. A ramp is only readable while each step has a job. */
  font-weight: 600;
  cursor: pointer;
  transition: background 0.12s ease, border-color 0.12s ease, transform 0.12s ease;
}

.btn:hover { background: hsl(0 0% 100% / 0.14); }
.btn:active { transform: translateY(1px); }
.btn:focus-visible { outline: 2px solid var(--accent-soft); outline-offset: 2px; }
/* Exactly one button in this house is ever disabled — „Namen entschlüsseln“,
   while traces are still missing — and `opacity: 0.45` put its label at 4.23:1
   on the panel: the darkest text on the page, in a house whose own test enforces
   7.4. WCAG's exemption for disabled controls did not cover it, because that
   label was also the panel's only status copy — dimming the control dimmed the
   sentence explaining why it is off. The sentence has moved to a .hint-line
   beside it, so what is left here is a control that reads as unavailable through
   its ground and its edge instead of through unreadable text.

   Dashed rather than merely recoloured: `--border` is what `.btn` already draws,
   so a colour swap here would have changed nothing at all. The dash is borrowed
   from `.trace-slot` a few rules up, which uses it for the traces the player has
   not found yet — and the one button that ever reaches this rule sits directly
   under those slots. Same panel, same sentence: dashed means not yet. */
.btn:disabled {
  cursor: not-allowed;
  color: var(--muted);
  background: transparent;
  border-style: dashed;
  border-color: var(--border);
}
.btn:disabled:hover { background: transparent; transform: none; }

/* No `font-weight` here any more: it inherits the 600 from `.btn`. Restating it
   would claim the primary is emphasised over the secondary when it no longer is. */
.btn-primary { background: var(--accent); border-color: transparent; color: #fff; }
/* Darker on hover — white on the lighter accent-soft would fall below AA */
.btn-primary:hover { background: var(--accent-deep); }

.btn-ghost { background: hsl(0 0% 100% / 0.05); }
.btn-ghost.is-active { border-color: var(--pick-line); background: var(--pick-fill); }

.btn-sm { padding: var(--space-2) var(--space-3); font-size: var(--fs-sm); }
.btn-block { width: 100%; }

.actions { display: grid; gap: var(--space-2-5); }
/* Jede Reihe hängt AN einem Inhalt — die Notiz-Knöpfe an der Ablage darüber,
   „Hinweis aufdecken“ an der Hinweisliste darüber. Der Rand oben ist dieser
   Zusammenhang. Es gab hier bis 2026-08-04 zwei Ausnahmen (`.side >
   .row-actions` ohne Rand, und derselbe Selektor mit `outline-offset: -2px`),
   solange eine nackte Werkzeug-Reihe direktes Kind der Spalte war. Beide sind
   mit ihr weggefallen, und das ist die Probe darauf, dass die Platzierung
   stimmt: eine Reihe in einer Karte hat Polsterung, also trägt der Ring wieder
   aussen wie überall sonst, und der Abstand hat wieder ein Subjekt. Eine
   Platzierung, die zwei Sonderregeln braucht, war die Platzierung, nicht das
   Haus. */
.row-actions { display: flex; gap: var(--space-2); flex-wrap: wrap; margin-top: var(--space-2-5); }
/* Dieselbe Reihe, nur vor ihrem Gegenstand statt dahinter: der Abstand wandert
   mit. Ein `margin-top` über einer Liste, die selbst unter einer Überschrift
   steht, verdoppelt die Fuge zur Überschrift und lässt den Knopf frei stehen,
   statt ihn an sie zu binden. Gleiche Zahl, andere Seite — nicht abgeschrieben,
   sondern dieselbe Variable. */
.row-actions--lead { margin-top: 0; margin-bottom: var(--space-2-5); }
/* Der einzige Griff der Spalte, der die vollständige Deduktion einer Sitzung
   nimmt — und er steht neben „Notizen löschen“, das ein Panel aufräumt. Gleiche
   Grösse, gleiche Schrift, gleiche Fläche: als drittes `btn-ghost` wäre der
   Unterschied ausschliesslich im Wort. Zwei Trennungen, beide billig — beide
   standen hier am 2026-08-04 in einer Fassung, die gegen die laufende Seite
   gemessen fiel, und beide Messungen stehen deshalb dabei:

   ERSTENS eine eigene Zeile, aber als DOM-Bruch (`.row-actions--tool`), nicht
   als `flex-basis: 100%`. Die Flex-Basis erzwang nicht nur die eigene Zeile,
   sondern auch die volle Breite: gemessen 301,3px — auf den Zehntelpixel
   dieselbe Breite, Höhe und Eckenrundung wie `#accuse` 93px darunter. Der
   Absatz darunter hatte `btn-danger` abgelehnt, damit der zerstörende Griff
   nicht das lauteste Element der Spalte wird, und ihm dann die andere Hälfte
   der Primär-Signatur gegeben. Ein eigener Container kostet eine Zeile Markup
   und kann von keinem kürzeren Label rückgängig gemacht werden — die Zusage,
   die die Flex-Basis geben sollte, hält erst er.

   ZWEITENS eine eigene Ruhe-Kante statt eines `btn-danger`. Der Bogen kennt
   bewusst keine rote Knopf-Variante: Rot ist hier die Farbe des Opfers und der
   widerlegten Hinweise. Die Kante war derselbe Hue wie die Anklage bei 34%
   Deckkraft — und damit unsichtbar: gemessen 1,379:1 gegen den Knopfgrund,
   während die harmlose Nachbarkante `--border` bei 1,355:1 steht. Ein
   Unterschied von 0,024 ist keine Unterscheidung, und die Hausschranke für eine
   bedeutungstragende Kante ist 3:1 (siehe `--pick-line`). Die 34% stammten aus
   der Weiss-Rechnung am Kopf dieser Datei, wo sie wirklich 3,09:1 ergeben;
   `hsl(0 72% 52%)` erreicht selbst bei voller Deckkraft nur 3,458:1, also war
   die Zahl bei dieser Farbe nie erreichbar. Der Träger ist deshalb nicht die
   Deckkraft, sondern die HELLIGKEIT: `--accent-soft` (`hsl(0 72% 80%)`).

   Die Deckkraft dazu stand hier zweimal falsch, und beide Male aus demselben
   Grund. Erst 34% mit „3,09:1" — eine Zahl aus einer FREMDEN Farbe. Dann 50%
   mit „3,132:1 innen und 3,244:1 aussen" — drei Nachkommastellen, gerechnet
   gegen EINEN Grund, während dieser Knopf in drei Verschachtelungen vorkommt
   und die Panel-Wäsche den Grund aufhellt. Über dieselbe Matrix gefahren, die
   `test/contrast.test.mjs` (`WARY_STATES`) für jede andere bedeutungstragende
   Kante benutzt, ergaben die 50% **2,46:1** — nicht 3,13, und damit unter der
   Hausschranke, für die der Absatz oben argumentiert. Die engste Paarung liegt
   innen, im Panel-Kasten, am hellsten Punkt des Wash; das ist nicht die Stelle,
   die man beim Rechnen im Kopf wählt. Der Fehler ist beide Male derselbe: eine
   Zahl im Kommentar ist eine Behauptung, und eine Behauptung mit drei
   Nachkommastellen liest sich wie eine Messung, ohne eine zu sein. Deshalb
   steht sie jetzt nicht mehr nur hier. 65% ergeben **3,27:1**, bleiben leiser
   als die Füllung von `#accuse` (3,904:1) und tragen den Unterschied auch in
   Graustufen, weil er in L* liegt und nicht nur im Farbton.

   Und dann ein drittes Mal, im Wächter selbst: der prüfte nur den RUHENDEN
   Zustand, mit der Begründung, der Hover hebe die Kante ja nur an. Das ist
   wieder eine Behauptung über etwas Ungemessenes. `.btn:hover` hebt zugleich
   die FLÄCHE von 5% auf 14%; der hellere Innengrund frisst den Kantengewinn
   auf, und der Hover war mit **2,92:1** der schlechtere der beiden Zustände —
   unter der Hausschranke, während der Wächter grün blieb, weil er ihn nicht
   ansah. Der Hover steht deshalb jetzt als eigene Achse in der Matrix, mit
   seiner eigenen Fläche gepaart, und trägt 85% für **3,46:1**. Dass die
   Deckkraft im Hover HÖHER liegt als im Ruhezustand, ist kein Schreibfehler,
   sondern genau die Kompensation für den aufgehellten Grund.

   Die drei Zahlen dieser Farbe sind eine Abschrift von `--accent-soft`;
   ein Fragment-Token wie `--warn-plate` wäre die andere Lösung, scheitert hier
   aber an `token()` in der Suite, das für Grundfarben absichtlich die opake
   Form verlangt. Also bewacht statt umgebaut: die Suite vergleicht die Kante
   gegen das Token, damit die Abschrift nicht driften kann.
   Verify-cmd: `node test/contrast.test.mjs | grep -i tatort`.
   Abgefedert wird die Folge ohnehin vom Rückfrage-Dialog in `js/ui.js`
   (`function resetBoard`), diese Kante arbeitet eine Stufe davor, gegen die
   Verwechslung selbst.

   DRITTENS der Abstand — die billigste der drei und die einzige, die hier
   zunächst RÜCKWÄRTS lief. `.row-actions` setzt 10px zwischen Ablage und
   Notiz-Reihe; dieselbe Klasse mit `--space-2` liess den zerstörenden Griff
   mit 8px ENGER an der Notiz-Reihe hängen als die Notiz-Reihe an ihrer
   eigenen Ablage. Nähe ist eine Aussage über Zusammengehörigkeit, und diese
   Aussage widersprach dem DOM-Bruch und der Kante, für die die beiden Absätze
   oben argumentieren. 12px ist eine Stufe ÜBER dem Peer-Abstand, nicht
   darunter; die Reihenfolge der drei Zahlen (10 innerhalb, 12 zum
   abgesetzten Griff) ist der eigentliche Träger, nicht der Wert selbst. */
.row-actions--tool { margin-top: var(--space-3); }
/* Kein abgeschriebener Farbwert mehr. Hier stand zweimal `hsl(0 72% 80% / …)`,
   also die Ziffernfolge von `--accent-soft` von Hand kopiert, mit der
   Begründung, die Deckkraft brauche die ausgeschriebene Form. Das war eine
   bewachte Kopie, und eine bewachte Kopie ist strikt schwächer als keine:
   `color-mix` holt die Transparenz aus dem Token selbst, `--accent-soft`
   bleibt danach die einzige Stelle im Bogen, an der diese drei Zahlen stehen.
   Die Funktion ist hier ohnehin schon dreizehnmal im Einsatz — die Ausnahme
   war also nicht einmal eine Fähigkeitsgrenze, sondern nur eine
   ungeprüfte. */
.btn-wary { border-color: color-mix(in srgb, var(--accent-soft) 65%, transparent); }
.btn-wary:hover { border-color: color-mix(in srgb, var(--accent-soft) 85%, transparent); }

/* Der Gewinn, der hier stand, war keiner — und weil er sich zweifach als
   Ersparnis auszahlte, steht die Widerlegung an seiner Stelle und nicht in
   einem Bericht.

   Behauptet war: `#status` steht im Markup ÜBER `#accuse`, das Blatt klebt mit
   `bottom: 0`, eine Meldung wächst also nach oben und lässt den Knopf stehen —
   folglich brauche das Feld keine reservierte Doppelzeile mehr, sondern nur
   eine Untergrenze für sich selbst (1,2rem), und im Ruhezustand könne es per
   `:empty` ganz verschwinden. Der erste Halbsatz stimmt und bleibt: die
   Reihenfolge trägt die Stabilitätszusage, und sie kostet nichts.

   Der Rest wurde am 2026-08-04 gegen die laufende Seite gemessen und fiel:

   1. Die Untergrenze bindet — und der Vorschlag fällt genau deshalb, nicht
      etwa weil sie untätig wäre. Bei 1024x768 ist die Textbox 301,33px breit,
      eine Zeile misst 19,5px (`--fs-sm` x `line-height: 1.5`), der Boden steht
      mit `* 3` bei 39px und trägt damit zwei. 20 der 32 statischen Meldungen
      in `js/ui.js` bleiben unter dem Maßstab, den sich dieser Bogen bei
      `--fuss-rueckfall` selbst gibt, und stehen auf EINER Zeile — darunter die
      erste Meldung jeder Sitzung. Für sie hält der Boden die Oberkante des
      gepinnten Blattes still; auf 19,2 gesenkt liesse er sie bei jedem Wechsel
      zwischen ein- und zweizeiliger Meldung um eine Zeilenhöhe springen. Die
      „Ersparnis" von 19,2px existierte darüber hinaus nur im Modell der Reserve
      oben (`scroll-padding-bottom`) — und dort war sie ein Schaden, weil die
      Reserve dadurch zu kurz wurde.

      Hier stand bis 2026-08-05 das Gegenteil („die Untergrenze band nie … jede
      geprüfte Meldung bricht um"), und der Satz widersprach dem Absatz weiter
      unten, der einem Boden ausdrücklich zugesteht, „den häufigen Fall zu
      treffen". Er stammt aus derselben zu freundlichen Messung, die der
      nächste Absatz schon als Prüfstands-Artefakt entlarvt: gemessen wurden
      Meldungen MIT Platzhaltern — die brechen um, die statischen nicht. Aus
      „jede GEPRÜFTE Meldung" wurde beim Umzug in diesen Bogen „jede Meldung",
      und damit aus einer Eigenschaft der Stichprobe eine des Programms. Die
      Aufteilung bewacht jetzt `test/quer.test.mjs` gegen die gezählten
      `setStatus('…')`-Literale; wer eine Meldung umformuliert oder hinzufügt,
      bekommt hier Rot statt einer stillen Unwahrheit.

      Hier stand als Beleg „die Meldungen dieser App sind 60 bis 89 Zeichen
      lang — geprüft mit 66, 70 und 79". Die Spanne war eine Eigenschaft der
      MESSUNG, nicht der App: die Sonde hatte die Platzhalter mit Möbelnamen
      aus `js/data.js` gefüllt (längster damals (Stand 2026-08-04)
      `Kupferkessel`, 12 Zeichen), während
      `setStatus` in Wahrheit Namen aus `js/scenarios.js` einsetzt — 570 Stück,
      längste Person 38 Zeichen, längster Gegenstand 40. Dieselbe Vorlage misst
      damit 143 statt 96 Zeichen und füllt DREI Zeilen (58,5px). Eine Zahl, die
      aus einem zu freundlichen Einsetzwert stammt, liest sich wie eine
      Eigenschaft des Programms und ist eine des Prüfstands.

      Das „damals (Stand …)" in beiden Absätzen ist kein Schmuck, sondern das,
      was die zwei Sätze überhaupt lesbar hält. Sie sind ein PROTOKOLL einer
      Messung, kein aktueller Superlativ: die 96 oben sind aus den 12 gerechnet,
      und wer den Namen auf den heute längsten fortschreibt, lässt eine
      Rechnung ohne ihren Summanden stehen. Genau das ist am 2026-08-07
      passiert — `test/prosa.test.mjs` verlangte an dieser Fundstelle den
      LEBENDEN Superlativ, der Möbelvorrat wuchs von 15 auf 35, und die Sonde
      machte aus einer Widerlegung eine Aufforderung, den Beleg zu überschreiben
      (`Hellebardenständer`, 18). Eine Sonde, die eine Vergangenheitsform in der
      Gegenwart prüft, zerstört den Befund, den der Satz trägt. Die Sonde fragt
      seit dem nach dem Stand-Vermerk und rechnet nur noch nach, dass die
      genannte Zeichenzahl die des genannten Namens ist.
   2. `:empty` sparte 49px, aber nur bis zum ersten Klick. `setStatus`
      (`js/ui.js`) hat 57 Aufrufstellen und KEINE davon schreibt den leeren
      String; das Feld wird einmal beschrieben und danach nie wieder leer. Der
      Ruhezustand ist der Zustand vor der ersten Handlung, nicht ein Zustand,
      in den die Ansicht zurückkehrt. Dazu kommt der Preis: `role="status"
      aria-live="polite"` in einem `display: none`-Knoten ist nicht im
      Barrierefreiheits-Baum, und die erste Meldung einer Sitzung füllt UND
      zeigt ihn im selben Schritt — die Ansage, die diese Region ausmacht,
      fällt genau dann aus.

   Also: eine Höhe von zwei Zeilen, hergeleitet statt getroffen, und kein
   `:empty`. `calc(var(--fs-sm) * 3)` ist 2 x 1,5 x `--fs-sm`.

   Was dieser Boden kostet und was er kauft, gehört hierher, denn beides ist
   klein und keins ist null. Er ist zwei Zeilen hoch, und `#status` kennt drei
   Zeilenzahlen: eine bei den statischen Meldungen, zwei bei den geprüften
   Vorlagen, drei bei der längsten mit eingesetzten Namen (58,5px). Zwischen
   drei Zeilenzahlen liegen ZWEI Übergänge, und der Boden stillt genau einen
   davon — den von eins auf zwei. Der Sprung von zwei auf drei bleibt, volle
   19,5px, und er tritt genau dann ein, wenn ein langer Name eingesetzt wird.
   Ein Boden ist damit keine Ruhe, sondern die häufigere Hälfte davon; das ist
   der ehrliche Anspruch, und der Absatz weiter unten („darf den häufigen Fall
   treffen") ist genau dieser und kein grösserer.

   Was dabei ruhig gehalten wird, ist ausserdem nicht der Knopf. `#status`
   steht im Blatt ÜBER `#accuse`, und das Blatt klebt mit `bottom: 0` — eine
   wachsende Meldung schiebt die OBERE Kante weg, der Knopf steht ohnehin
   still, mit Boden wie ohne. Ruhig gehalten wird die obere Kante des Blattes,
   also die Fuge zur Belegliste darüber. Wer den Boden als „damit der
   Anklageknopf nicht springt" liest, begründet ihn mit einer Wirkung, die die
   Reihenfolge im DOM schon erledigt hat — und begründet ihn damit an einer
   Stelle, an der er ersatzlos gestrichen werden könnte, ohne dass die genannte
   Wirkung ausfiele.

   Der Preis sind 19,5px reservierte Leere, sobald die Meldung einzeilig ist.
   Das ist gegen das zu halten, was N-35 an derselben Spalte zurückgeholt hat:
   rund 49,6px Fussmiete (172 → 122,4). Der Boden gibt also knapp zwei Fünftel
   davon wieder aus — teuer genug, dass die Frage berechtigt ist, und billig
   genug, dass sie mit Ja beantwortet bleibt. Die Gegenprobe zeigt, warum er
   nicht höher darf: drei Zeilen Boden (58,5px) stillten auch den zweiten
   Übergang, kosteten im Ruhezustand aber 39px — fast die ganze N-35-Ersparnis,
   und das für den selteneren der beiden Sprünge.

   Diese Zahl ist bewusst NICHT mehr dieselbe wie die Reserve oben
   (`scroll-padding-bottom`, `* 6`). Hier stand, die beiden könnten „nicht mehr
   auseinanderlaufen, weil sie denselben Ausdruck lesen" — das war als
   Sicherheit gemeint und war das Gegenteil: die beiden Zeilen beantworten
   verschiedene Fragen, und sie gleichzuschalten hiess, die falsche Antwort auf
   beide zu geben. Ein `min-height` ist ein BODEN: er hält die Zeile ruhig,
   solange die Meldung kurz ist, und darf den häufigen Fall treffen. Eine
   Scroll-Reserve ist eine OBERGRENZE: sie muss den seltensten, längsten Fall
   überdecken, sonst verschwindet ein Fokusring. Ein Boden, der auf die
   Obergrenze gehoben wird, reisst im Ruhezustand ein leeres Feld auf; eine
   Obergrenze, die auf den Boden gesenkt wird, verletzt SC 2.4.11. Genau
   letzteres ist am 2026-08-04 passiert, und `test/quer.test.mjs` konnte es
   nicht sehen, weil sein Wächter die GLEICHHEIT der beiden Ausdrücke prüfte —
   also die Behauptung gegen sich selbst statt gegen eine gerenderte Höhe.

   Was N-35 wirklich zurückgeholt hat, steht eine Etage höher: die zwei Knöpfe,
   die den Fuss verlassen haben. */
.status { margin: 0; min-height: calc(var(--fs-sm) * 3); font-size: var(--fs-sm); color: var(--muted); }
.status.ok { color: var(--ok); }
.status.warn { color: var(--warn); }
.status.bad { color: var(--accent-soft); }

.footer {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: center;
  gap: var(--space-1) var(--space-3-5);
  margin-top: var(--space-4-5);
  padding-top: var(--space-3-5);
  border-top: 1px solid var(--border);
  font-size: var(--fs-sm);
  text-align: center;
}
#score-line { color: var(--brass); font-variant-numeric: tabular-nums; }

/* Der Boden, der keinen bestimmten Zeiger voraussetzt.

   Weiter unten stehen genau diese beiden Links auf 44 — dem Hausboden aus
   WCAG 2.5.5. Dort ist die Zahl richtig, aber sie haengt an einer Bedingung:
   der Block darum fragt nach einem groben Zeiger. Ein Zeigersatz ohne groben
   Zeiger — eine Maus, ein Trackpad — erreicht die Regel also nie, und dort
   galt bis hierher gar kein Boden. 2.5.8 kennt diese Bedingung nicht; seine
   24 Pixel gelten fuer jede Eingabeart. Gemessen waren es 63x20 und 71x20:
   an der Breite vorbei, an der Hoehe durch.

   inline-flex und nicht bloss die Hoehe: als Kinder eines Flex-Containers
   sind die beiden ohnehin blockartig, die Untergrenze griffe also auch nackt
   — der Text saesse dann aber oben in einer 24 Pixel hohen Kiste. Der eigene
   Flex-Kontext ist das, was ihn mittig haelt, ohne die Links aus der
   zentrierten, umbrechenden Zeile zu nehmen, die der Fuss auslegt.

   Und `align-items` am Fuss selbst ist die andere Haelfte desselben Eingriffs:
   der Fuss legt Punktestaende und Rechtstexte in dieselbe Zeile. Sobald die
   Links vier Pixel hoeher sind als die Spannen daneben, streckt die Vorgabe
   `stretch` die Spannen mit und laesst deren Text oben stehen — zwei Pixel
   Versatz zwischen zwei Zeilen, die als eine gelesen werden. Heute ist die
   Zeile ein Nullzug, weil alle vier gleich hoch sind; sie ist der Preis
   dafuer, dass sie es bleiben.

   Dass diese Zahl die Zielgroesse ihres Kriteriums ist und nicht irgendeine,
   prueft test/geometry.test.mjs. */
.footer a {
  display: inline-flex;
  align-items: center;
  min-height: 24px;
}

/* ------------------------------------------------------------------ modals */

.modal {
  border: none;
  padding: 0;
  background: transparent;
  color: var(--text);
  /* Both clearances are literals, and deliberately so: they hold the dialog off
     the VIEWPORT, not off a neighbouring box, so they are not on the spacing
     scale and must not drift with it. They are also a matched pair — spelling
     one as a token and leaving the other at 24px would read as an oversight the
     next time someone retunes the ramp. */
  max-width: min(520px, calc(100vw - 24px));
  /* A centred <dialog> clips its own top when it outgrows the viewport, and
     the clipped part is unreachable — so the panel owns the scroll instead.

     Zwei Zeilen, wie bei `.side` weiter oben: eine Engine ohne `dvh` verwirft
     die zweite beim Parsen, und ohne die erste haette der Dialog dann GAR
     keinen Deckel — also genau den abgeschnittenen, unerreichbaren Kopf, den
     der Satz darueber ausschliesst. Der Rueckfall ist `vh` und damit auf dem
     Telefon der grosszuegigere Wert; das ist hier richtig herum, denn er greift
     nur in Engines, die eine ein- und ausfahrende Browserleiste ohnehin nicht
     in eine Einheit fassen. `test/paar.test.mjs` haelt das Paar. */
  max-height: calc(100vh - 32px);
  max-height: calc(100dvh - 32px);
}

.modal::backdrop { background: hsl(0 0% 0% / 0.72); backdrop-filter: blur(4px); }

.modal-inner {
  background: var(--bg-soft);
  border: 1px solid var(--border);
  border-radius: var(--radius-surface);
  padding: var(--space-4-5);
  /* Handed to the action rail below, exactly as `.menu-card` hands its own to
     `.menu-actions`. A sticky bar inside a padded scroller otherwise leaves
     that padding UNDER the pinned bar, where it shows as a strip of panel the
     scroll can never close. */
  padding-bottom: 0;
  max-height: inherit;
  overflow-y: auto;
  overscroll-behavior: contain;
}

.modal-inner h2 {
  margin: 0 0 var(--space-3);
  font-family: var(--font-file);
  font-size: var(--fs-2xl);
  font-weight: 700;
  letter-spacing: -0.012em;
  line-height: 1.15;
  text-wrap: balance;
}
.modal-inner p { font-size: var(--fs-md); line-height: 1.6; }

/* The age gate used to render all three of its paragraphs identically, so the
   line naming the actual content sat at the same weight as the line reassuring
   the player they can go back — and the „ab 18" chip on the case panel was
   louder than the gate that guards it. Three ranks instead of one flat block:
   what the register contains, what that means in practice, and the way out.
   --warn rather than --accent: this is a caution, and the accent is already the
   victim's colour everywhere else on the screen.

   Qualified by `.modal-inner` for the same reason `.modal-helper` below it is:
   `.modal-inner p` already sets a size, and a bare class loses to it on
   specificity no matter how far down the file it sits. Unqualified, the lede
   kept its colour and silently lost its size — which left the rank it exists to
   create visible only in hue. */
.modal-inner p.modal-lede {
  font-size: var(--fs-lg);
  line-height: 1.5;
  color: var(--warn);
  margin: 0 0 var(--space-3);
}
.modal-inner p.modal-helper { font-size: var(--fs-sm); color: var(--muted); }

/* Dieselbe Qualifizierung wie bei `.modal-lede` darueber und aus demselben
   Grund: `.modal-inner p` setzt bereits eine Groesse, gegen die eine nackte
   Klasse auf Spezifitaet verliert.

   Die Farbe bleibt `--text` und wird ausdruecklich NICHT `--warn`. Das Warn-
   gelb gehoert der Altersabfrage, die etwas zurueckhaelt; der Regelkasten
   haelt nichts zurueck, er ordnet. Ein dritter Signalton neben `--accent`
   (dem Opfer) und `--warn` (der Altersabfrage) haette dem Kasten eine
   Dringlichkeit gegeben, die er nicht hat — und `--accent-soft` ist an die
   acht Abschnittsmarken vergeben.

   Den Rang — Lede ueber Regelzeile — tragen darum Groesse UND Gewicht: 18px
   gegen 15px sind nur das 1,2-fache, bei gleicher Farbe und gleichem Schnitt
   zu wenig fuer einen sichtbaren Vorrang. 600 schliesst die Leiter, die es
   ohnehin schon gibt (h2 700, `.rule-head` 600, Fliesstext 400), statt eine
   vierte Stufe zu erfinden. */
.modal-inner p.rules-lede {
  font-size: var(--fs-lg);
  font-weight: 600;
  line-height: 1.5;
  margin: 0 0 var(--space-3);
}

/* `--space-6` und nicht `--space-4`: dieser obere Rand wird gegen ZWEI ver-
   schiedene Nachbarn kollabiert. Vor der ersten Ueberschrift steht der Lede-
   Absatz mit 12px (`--space-3`, an `.rules-lede` darueber gesetzt) — vor jeder
   folgenden steht `.rule-list` mit 32px.
   Beim Kollabieren gewinnt der groessere Wert, 16px ergaeben hier also eine
   erste Blockgrenze von 16px und sieben weitere von 32px. Mit 32px sind alle
   Grenzen gleich, und die Rampe im Kommentar unten gilt auch fuer die erste. */
.rule-head {
  margin: var(--space-6) 0 var(--space-1);
  font-size: var(--fs-xs);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: var(--accent-soft);
}

/* Der untere Rand von `.rule-head` ist an die Regelliste geeicht: 15px auf 1,6
   setzen ihre erste Zeile ohnehin ein Stueck vom Kopf ab. Der Schlussabsatz
   bekommt hier 8px dazu — aber nicht, weil ihm dieser Vorlauf fehlte: 15px auf
   1,6 und 18px auf 1,5 ergeben denselben Durchschuss von 9px, also dieselben
   4,5px ueber der ersten Zeile; er saesse ohne diese Regel exakt so eng am Kopf
   wie jede Liste. Genau das ist der Grund: ein Absatz in 18px und 600 ist keine
   Liste und soll ihren Rhythmus nicht mitsprechen — die 8px sind das Zeichen,
   dass das Muster hier endet und ein Schlusswort folgt. Der Griff liegt am
   Absatz und nicht am Kopf, weil der Kopf beide Nachbarn bedient und ein
   groesserer Rand unten die Rampe an jeder Liste mit aufzoege.

   Qualifiziert wie die zwei Absatzregeln oben, aber gegen einen anderen
   Gegner: hier gewinnt nicht `.modal-inner p`, sondern die eigene Regel
   `.modal-inner p.rules-lede` — deren `margin`-Kurzform setzt den oberen Rand
   auf 0 mit, und drei Klassen schlagen zwei; das `p` steht in beiden und
   entscheidet nichts. Der Nachbarschaftsselektor `+` wiegt dabei ebenfalls
   nichts. Unqualifiziert stand hier
   eine Regel, die nichts tat, unter einem Kommentar, der eine Wirkung
   versprach — gefordert waren 8px, gemessen wurden 0. */
.modal-inner .rule-head + p.rules-lede {
  margin-top: var(--space-2);
}

/* Eine Regelzeile ist ein Satz, kein Listenpunkt aus drei Woertern: mehrere
   laufen ueber zwei Zeilen. `gap` kommt zum Durchschuss HINZU, den 15px auf
   1,6 ohnehin stellen: 9px im Umbruch, mit den 10px Abstand 19px
   zwischen zwei Regeln, 32px zwischen zwei Bloecken — jede Ebene etwa das
   Doppelte der darunter. Dazu `text-wrap: pretty` gegen die Schusterjungen. */
.rule-list {
  margin: var(--space-1) 0 var(--space-6);
  padding-left: var(--space-4);
  font-size: var(--fs-md);
  line-height: 1.6;
  display: grid;
  gap: var(--space-2-5);
  text-wrap: pretty;
}

/* Der Punkt ist Zeichensetzung, nicht Inhalt. In `--text` liest er sich wie das
   erste Wort der Regel und macht aus jedem Satz einen Aufzaehlungspunkt mehr;
   in `--muted` faellt er auf den Rang zurueck, den ein Komma hat. Die Regeln
   selbst bleiben unangetastet — nur ihre Marken treten zurueck. */
.rule-list li::marker { color: var(--muted); }

/* Die Moebellegende (NEU-7) gehoert optisch zu den beiden Saetzen ueber ihr,
   nicht zum naechsten Kopf: sie zeigt, WELCHE Stuecke die beiden Saetze meinen.
   Zwischen Geschwistern kollabiert der GROESSERE der beiden Raender, ein
   kleinerer oberer Rand an der Legende waere also wirkungslos — die Verkuerzung
   muss an der Liste passieren, und dafuer braucht es `:has()`. Nach unten
   traegt sie dieselben 32px wie `.rule-list`, damit die Blockzaesur zum
   naechsten `.rule-head` genau die bleibt, an die N-6 den Kasten geeicht hat.
   Zwischen den beiden Gruppen steht aus demselben Grund derselbe Griff und ein
   engerer Wert: sperrend und begehbar sind zwei Faecher EINER Aussage, sie
   duerfen nicht so weit auseinanderstehen wie zwei Bloecke des Kastens. */
.rule-list:has(+ .rule-legend) { margin-bottom: var(--space-2); }
.rule-legend:has(+ .rule-legend) { margin-bottom: var(--space-4); }

.rule-legend {
  margin: 0 0 var(--space-6);
  display: grid;
  gap: var(--space-2);
}

/* Der Kopf traegt die Kategorie als TEXT, weil die Farbe sie nicht tragen kann:
   `.legend-dot` faellt ohne `--room-tint` auf ein neutrales Grau zurueck, beide
   Gruppen sehen also identisch aus. Keine Versalien und deshalb `--fs-sm` statt
   `--fs-xs`: ein Halbsatz mit Gedankenstrich in Grossbuchstaben liest sich als
   Etikett, und dieser Kopf ist eine Aussage. */
.rule-legend-head {
  margin: 0;
  font-size: var(--fs-sm);
  font-weight: 600;
  color: var(--text);
}

/* ------------------------------------------------------------ win screen */

/* The named culprit is the payoff of the whole case, so it gets its own card
   above the numbers rather than a line of text among them. */
.verdict {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  margin: var(--space-3-5) 0;
  padding: var(--space-3) var(--space-3-5);
  border-radius: var(--radius-card);
  border: 1px solid hsl(0 72% 52% / 0.45);
  background: linear-gradient(150deg, hsl(0 60% 22% / 0.5), hsl(0 0% 100% / 0.02));
}
.verdict strong { font-family: var(--font-file); font-size: var(--fs-xl); letter-spacing: -0.01em; }

/* Same card, other hue — and the hue is the one the accomplice chip already wears
   in the header, so the colour the player learned at the top of the case is the
   colour that names him at the end of it. Red stays reserved for the murderer:
   two identical cards would rank the helper with the killer, which is neither
   what the case says nor what the second question asked.

   An override rather than a variant class of its own, because everything except
   the two colour declarations — the flex row, the avatar gap, the file-typeface
   name — is the thing both cards ARE. Duplicating that block to recolour it is
   how the two drift the day the win screen gains a line. */
.verdict-accomplice {
  border-color: hsl(var(--warn-plate) / 0.4);
  background: linear-gradient(150deg, hsl(28 60% 20% / 0.5), hsl(0 0% 100% / 0.02));
}

.fact-grid {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  gap: var(--space-2) var(--space-4);
  margin: 0 0 var(--space-4);
  padding: var(--space-3) var(--space-3-5);
  border-radius: var(--radius-card);
  background: var(--panel-faint);
  border: 1px solid var(--border);
  font-size: var(--fs-md);
}
.fact-grid dt {
  color: var(--muted);
  font-size: var(--fs-xs);
  text-transform: uppercase;
  letter-spacing: 0.1em;
  align-self: center;
}
.fact-grid dd { margin: 0; font-weight: 600; }

/* The bonus round sits in the fact grid's slot and is replaced by it, so it
   carries the same plate: the panel is not a second surface stacked on the
   verdict, it is the one surface before it has been filled in. */
.quiz {
  display: grid;
  gap: var(--space-3-5);
  margin: 0 0 var(--space-4);
  padding: var(--space-3) var(--space-3-5);
  border-radius: var(--radius-card);
  background: var(--panel-faint);
  border: 1px solid var(--border);
}
.quiz-lede { margin: 0; font-size: var(--fs-sm); color: var(--muted); line-height: 1.5; }
.quiz-row { display: grid; gap: var(--space-2); }
.quiz-prompt { margin: 0; font-weight: 600; font-size: var(--fs-md); }

/* auto-fit rather than three fixed columns: one of the two questions offers
   motives, and a motive is a sentence fragment, not a word. Three of those side
   by side on a phone wrap to two characters a line. */
.quiz-options {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: var(--space-2);
}
.quiz-option {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  /* 44 from the start, not behind a (pointer: coarse) gate. Every control in
     this house that had to be raised later was one that shipped at the desktop
     floor first — this one is born in a modal that phones open too. */
  min-height: 44px;
  padding: var(--space-2) var(--space-2-5);
  border-radius: var(--radius-inner);
  border: 1px solid var(--border);
  background: hsl(0 0% 100% / 0.05);
  color: var(--text);
  font-family: inherit;
  font-size: var(--fs-sm);
  font-weight: 600;
  line-height: 1.35;
  text-align: left;
  cursor: pointer;
}
.quiz-option:hover { border-color: var(--border-strong); background: hsl(0 0% 100% / 0.09); }
/* The same pair every armed control in this house uses. A picked option is the
   same kind of state as an armed accusation — chosen, still undoable — and
   inventing a colour for it here would make it read as a verdict instead. */
.quiz-option.is-picked { border-color: var(--pick-line); background: var(--pick-fill); }
.quiz-icon { flex: 0 0 auto; width: 22px; height: 22px; color: var(--brass); }
.quiz-label { min-width: 0; }

.quiz-actions { display: flex; gap: var(--space-2); flex-wrap: wrap; }
/* Disabled until both questions carry an answer, so it has to LOOK unavailable
   and it must not brighten under the cursor: a button that reacts to hover and
   then ignores the click is the dead-button bug wearing a working button's
   clothes. */
.quiz-submit:disabled { opacity: 0.45; cursor: not-allowed; }
.quiz-submit:disabled:hover { background: var(--accent); }

/* Brass, like the total — it is the only other line on the sheet that is not a
   term of the case score, and green would file it under „Zeitbonus". */
.score-row.is-bonus .score-value { color: var(--brass); }

.score-sheet {
  display: grid;
  gap: 0;
  padding: var(--space-3) var(--space-3-5);
  border-radius: var(--radius-card);
  border: 1px solid var(--border);
  background: var(--panel-faint);
  font-size: var(--fs-md);
}

/* The leading is declared HERE and not on `.score-sheet`, and that is the whole
   trick: `calc(1.5em + 1px)` computes to a LENGTH on the element that declares
   it, then inherits as that fixed length. On the sheet it would freeze at the
   15px body size and the 18px total row would carry a leading meant for smaller
   text. On the row it resolves per row — 23.5px here, 28px on `.score-total`. */
.score-row { display: flex; justify-content: space-between; gap: var(--space-4); padding: var(--space-1) 0; line-height: calc(1.5em + 1px); }
/* Tabular figures so the column of numbers lines up like a real receipt. */
.score-value { font-variant-numeric: tabular-nums; font-weight: 600; color: var(--ok); }
.score-row.is-cost .score-value { color: var(--accent-soft); }
/* A zero bonus is information, not a win — it must not read as green. */
.score-row.is-zero { color: var(--muted); }
.score-row.is-zero .score-value { color: var(--muted); }

.score-total {
  margin-top: var(--space-2);
  padding-top: var(--space-2-5);
  border-top: 1px solid var(--border-strong);
  font-size: var(--fs-lg);
  font-weight: 700;
}
.score-total .score-value { color: var(--brass); font-size: var(--fs-lg); }

/* Closing remarks after the sheet — one accent rail each, colour-coded.
   Scoped to .modal-inner so it outranks that block's generic <p> size, which is
   why the variants have to travel through custom properties: a plain
   `border-left`/`background` on .note-ok (0,1,0) loses to this rule's shorthands
   (0,2,0) and every colour code would be silently repainted grey. */
/* DIE VORGABEN STEHEN EINE STUFE TIEFER, und das ist der ganze Fix.
   Sie standen in der Regel darunter, also mit (0,2,0) — und die vier Varianten
   setzen dieselben zwei Eigenschaften mit (0,1,0). Damit verlor JEDE Variante,
   unabhaengig von der Quellreihenfolge: im Dialog trugen `note`, `note-ok`,
   `note-warn`, `note-rank` und `note-bad` alle dieselbe neutrale Schiene
   `rgba(255,255,255,0.22)` und denselben neutralen Grund. Nur die Textfarbe
   unterschied sich, weil die Regel darunter keine setzt — der zweite Kanal
   neben der Farbe war also nie gemalt, und drei von neun Deklarationen in den
   vier Variantenzeilen waren tot.

   Der Kommentar, der hier stand, hatte den Mechanismus genau verkehrt herum:
   „die Varianten muessen durch Custom Properties reisen, weil ein direktes
   `border-left` gegen die Kurzform verlieren wuerde". Richtig ist, dass eine
   Custom Property GENAUSO nach Spezifitaet aufgeloest wird. Der Umweg hilft
   nur, wenn die VORGABE schwaecher steht als die Variante — sonst verschiebt er
   das Problem bloss von der Kurzform auf die Variable.

   `:where()` gaebe (0,0,0) und waere noch sauberer, kostet hier aber die
   Lesbarkeit fuer nichts: `.note` allein ist bereits schwaecher als jede
   Variante, und die Varianten stehen weiter unten im Blatt. */
.note {
  --note-rail: var(--border-strong);
  --note-tint: hsl(0 0% 100% / 0.04);
}

.modal-inner .note {
  margin: var(--space-3) 0 0;
  padding: var(--space-2-5) var(--space-3);
  border-radius: var(--radius-inner);
  border-left: 3px solid var(--note-rail);
  background: var(--note-tint);
  font-size: var(--fs-sm);
  line-height: 1.5;
}
.note-ok { --note-rail: var(--ok); --note-tint: hsl(151 60% 55% / 0.08); color: var(--ok); }
.note-warn { --note-rail: var(--warn); --note-tint: hsl(var(--warn-plate) / 0.08); color: var(--warn); }
.note-rank { --note-rail: var(--brass); --note-tint: hsl(45 72% 62% / 0.10); color: var(--brass); }
/* `.note-bad` gab es hier NICHT, obwohl es die Kennzeichnung jeder dringenden
   Lobby-Meldung ist: gescheiterter Beitritt, „die Runde liess sich nicht
   aufmachen", der Funkloch-Satz, „deine Anmeldung ist abgelaufen". Alle vier
   kamen im Fliesstext-Grau der Nachbarzeilen — die dringendste Meldung des
   Features sah aus wie die harmloseste. `--accent-soft` und nicht `--accent`:
   der satte Akzent faellt als Text auf der Panel-Flaeche durch AA, dieselbe
   Falle, die dieses Blatt an fuenf anderen Stellen benennt. */
.note-bad { --note-rail: var(--accent-soft); --note-tint: hsl(0 72% 52% / 0.10); color: var(--accent-soft); }

/* The dialog itself already caps its height and scrolls, so a second scroll
   container here would nest two scrollbars on short viewports. */
.accuse-list { display: grid; gap: var(--space-1-5); margin: var(--space-3) 0; }

/* Sticky for the same reason `.menu-actions` is, and by the same model — the
   long note there carries the rest of the argument. Measured, the rules sheet
   is 2417px of content in a 778px scroller at 375 (2104 in 866 at 1440), so
   „Verstanden" sat about one and a half screens below the fold: the dialog's
   only in-page way out was the one part of it a player could not see. Escape
   covers a keyboard, the backdrop is a ~12px strip beside a 520px card, and a
   phone has neither of those. Every caller of `openModal` passes at least one
   action, so this bar is never an empty rule across the panel.

   The negative inline margin is what lets the background and the border-top
   run the full width of the panel rather than stopping at the text column; the
   padding it pays back is the `padding-bottom` `.modal-inner` gives up above.

   No `env(safe-area-inset-bottom)` — and for a different reason than the menu
   rail, which pays it via its overlay: the <dialog> is centred inside
   `max-height: calc(100dvh - 32px)`, so this card never reaches the home
   indicator to begin with. */
.modal-actions {
  position: sticky;
  bottom: 0;
  z-index: 1;
  display: flex;
  gap: var(--space-2);
  justify-content: flex-end;
  flex-wrap: wrap;
  margin-top: var(--space-3-5);
  margin-inline: calc(var(--space-4-5) * -1);
  padding: var(--space-3) var(--space-4-5) var(--space-4-5);
  background: var(--bg-soft);
  border-top: 1px solid var(--border);
  box-shadow: 0 -12px 24px -18px hsl(0 0% 0% / 0.9);
}

/* -------------------------------------------------------------------- menü */

/* FIRST, and not optional. The UA sheet's `[hidden] { display: none }` is a
   plain type-less rule, so the moment `.menu` below declares any `display` at
   all it outranks it and the overlay is painted over the game permanently —
   with `hidden` still in the markup and still reported to assistive tech, which
   is the worst version of the bug: the DOM says closed, the screen says open.
   The renderer only ever toggles the attribute, so this rule is the entire
   open/closed mechanism. */
.menu[hidden] { display: none; }

/* Das Gegenstück zu `inert`, und aus demselben Grund von Hand geschrieben: ein
   echtes <dialog> bekäme die Sperre umsonst, ein <div> nicht. `inert` nimmt der
   Seite dahinter die Tastatur und die Vorlesehilfe — den FINGER nimmt es ihr
   nicht. Hinter dem offenen Menü liegt bei geladenem Fall ein Weg von 2666px
   (375x812), 2372px (812x375) und 2744px (375x667), den der Daumen sonst
   zurücklegen kann, während er das Menü zu bedienen glaubt.

   Was diese Regel dagegen ausrichtet, ist mit einem Skript NICHT vorführbar,
   und das gehört hierhin, damit niemand es später vergeblich versucht:
   `overflow: hidden` sperrt die BENUTZER-Eingabe, nicht `scrollTo()` — der
   gemessene Weg ist mit und ohne Sperre exakt derselbe (2372px in beiden
   Zuständen bei 812x375). Ein selbst erzeugtes `wheel`-Ereignis wiederum
   scrollt gar nichts, auch bei OFFENER Seite nicht; beide Sonden können also
   nicht unterscheiden. Der Beleg ist deshalb der berechnete Wert am
   ausbreitenden Element: `visible` -> `hidden` beim Öffnen, zurück beim
   Schließen.

   Unerreichbar wird nichts: das Menü ist `position: fixed`, #menu-start bleibt
   auf allen drei Größen im Bild, und die Karte scrollt innen weiter (581px
   Inhalt in 342px Rahmen). Dass die Seite nach dem Schließen wieder oben steht,
   kommt NICHT von hier — das tut sie mit und ohne diese Klasse gleichermaßen,
   weil der Fokus auf #open-menu in der Topbar zurückgeht.

   Beide Elemente, weil je nach Dokument mal das eine und mal das andere der
   scrollende Kasten ist; `overscroll-behavior: contain` an .menu-card hält
   davor die Kette am Kartenrand an. Die Klasse hängt an <html> statt an <body>,
   damit sie unabhängig davon greift, welches von beiden der Browser gerade als
   Ansichtsfenster führt. */
html.menu-open,
html.menu-open body { overflow: hidden; }

/* The scrim repeats `.modal::backdrop` by hand because this is a plain <div>
   and not a <dialog> — see the note in index.html for why it must not be one.
   Same black, same blur, so the two overlays cannot drift into looking like two
   different kinds of interruption.
   Clearance is written as literals rather than off the --space-* scale: that
   scale measures gaps between neighbouring boxes, and this one measures against
   the viewport edge, which the scale's own comment exempts (precedent: .modal).
   The safe-area insets matter here in a way they do not for a centred dialog —
   a tall card on a notched phone would otherwise start under the notch.

   The Grundriss above the scrim is for the COLD start, and it is here rather
   than in the scrim because the scrim was measured and found correct. Two
   separate knobs were confused in the entry that asked for this: a Gaussian of
   sigma pulls pixels together only within about two sigma of an edge, so blur(4)
   moves a band of 16px — under a third of the smallest cell the app lays out
   (--cell-touch 50px) — and therefore cannot touch the room-to-room lightness
   ladder at all, whatever its radius. That ladder is governed purely by the
   alpha, which would have to reach ~0.90 to crush it under a JND, and 0.90 also
   kills the warm start the entry calls correct. The radius meanwhile already
   does its own job: the loudest floor pattern left under blur(4px) measures
   0.515 L*, half a JND. So there was nothing to re-tune, and the thing that is
   actually wrong on a cold start is that there is no ground: #grid has no
   children yet, the card is 17.65% of a 1440x900 viewport, and the remaining
   82% — including ~246px of flat band under the card — is unlit --bg.

   Drawn as walls, not as texture, because the failure this replaces is a surface
   that reads as almost-decipherable CONTENT. A plan at architectural scale can
   never be mistaken for a board: the widest gap in each layer is at least three
   times the coarsest floor period the rooms use, and test/veil.test.mjs parses
   both sides of that comparison so re-picking a floor moves the bound with it.
   The pool is anchored at the bottom for the same measured reason — both of the
   shell's ambient pools sit at negative and zero Y (`at 12% -10%`, `at 100% 0%`),
   so the band under the card is the one place the page has no light at all.
   Every layer's excursion is held inside [1 JND, the span a warm board still
   shows through the same scrim] — visible, and never louder than the case it
   stands in for.

   The pool's SIZE is load-bearing and was got wrong once, in a way no unit test
   could see: a radial-gradient fades out at its `transparent` stop, so an ellipse
   whose VERTICAL RADIUS is R reaches only 0.64*R from its centre. At
   `420px at 50% 116%` on a 900px viewport that is 269px above y=1044, i.e. a run
   that starts at y=775 and is still at zero alpha there — the whole band it was
   meant to carry lay in the layer's faintest millimetres. The suite passed
   anyway, because it bounds each layer's AMPLITUDE and gap width and had no
   notion of where a layer lands or how strongly. Only the rendered probe caught
   it. Hence `at 50% 100%` with a 780px vertical radius: at the middle of the
   ~246px band under the card at 1440x900 the pool still carries 75% of its peak.
   The second number is a RADIUS, not a diameter — the earlier note here halved
   it, and the test that was meant to guard this repeated the same halving, which
   is why both now compute the ellipse distance instead of an axis reach. */
.menu {
  position: fixed;
  inset: 0;
  z-index: 30;
  display: grid;
  place-items: center;
  padding: calc(16px + env(safe-area-inset-top)) 16px calc(16px + env(safe-area-inset-bottom));
  /* Named once so the cold-start rule below can reuse it. Restating the colour
     there would be a second literal free to drift from this one, and the drift
     would be invisible: both rules would still look like a scrim. */
  --menu-scrim: hsl(0 0% 0% / 0.72);
  background: var(--menu-scrim);
  backdrop-filter: blur(4px);
  -webkit-backdrop-filter: blur(4px);
}

/* And ONLY on a cold start. With a case running there is already a board under
   the veil — that is the case the entry calls correct — and painting a second,
   coarser plan over it would put two plans on the screen at once. #menu-resume
   carries `hidden` exactly when there is no running case — the one line
   `$('menu-resume').hidden = !hasCase` (`js/ui.js`) — so the
   condition is read off the markup that already decides it rather than kept in
   a second class somebody has to remember to toggle.
   Where `:has()` is missing the rule simply does not match, and the menu falls
   back to the bare scrim — the warm case, which is the safe one to degrade to.
   Scrim LAST: the background list paints topmost-first, so the plan has to be
   listed before it or the scrim would dim its own stage by 0.28. */
.menu:has(#menu-resume[hidden]) {
  background:
    linear-gradient(90deg, transparent 0 88px, hsl(38 55% 62% / 0.04) 88px 90px, transparent 90px),
    linear-gradient(270deg, transparent 0 88px, hsl(38 55% 62% / 0.04) 88px 90px, transparent 90px),
    linear-gradient(0deg, transparent 0 120px, hsl(38 55% 62% / 0.035) 120px 122px, transparent 122px),
    linear-gradient(180deg, transparent 0 72px, hsl(38 55% 62% / 0.03) 72px 74px, transparent 74px),
    radial-gradient(1000px 780px at 50% 100%, hsl(38 45% 55% / 0.05), transparent 64%),
    var(--menu-scrim);
}

/* Opaque, like .modal-inner and for the same reason: this card is the surface
   the age gate, the rules and the win card have to appear ABOVE, and a
   translucent ground would show the blurred board through two stacked overlays.
   It also retires .panel's blur — behind an opaque colour that filter is pure
   cost on every frame the menu is up.
   `max-height: 100%` rather than a calc against 100dvh: the grid area above is
   already inset by the padding AND the safe areas, so a percentage inherits
   both and cannot fall out of step with them. The card scrolls internally, so
   the overlay itself never needs a second scroll container.
   Being opaque and dark makes it the easiest ground in the house — 16.64 for
   --text, 10.27 for --muted, against a floor of 7.4. Those two numbers used to
   read 17.95 and 11.08, before the paper gradient below was layered on; the
   drop is the whole reason the test parses this declaration instead of naming
   --bg-soft. Recorded because the headroom is what allows the rows below to be
   washed over it at all; measured because "it's the modal colour, it must be
   fine" is an argument, and this surface is one lightening away from that
   argument being wrong. */
.menu-card {
  width: min(560px, 100%);
  max-height: 100%;
  overflow-y: auto;
  overscroll-behavior: contain;
  /* Handed to the action rail, which pays it back as its own padding. .panel's
     bottom padding would otherwise sit BELOW the sticky rail and be the one
     thing the rail cannot cover: a sticky child is clamped by its containing
     block, and for a flex item that is the container's CONTENT box, 16px short
     of the scrollport it is trying to stick to. The rows would then scroll
     through a 16px strip of lit card under a bar that looks like it ends. */
  padding-bottom: 0;
  /* The .dossier paper, OVER --bg-soft and never instead of it — the opacity
     above is a load-bearing invariant, not a colour choice, and a gradient that
     replaced the solid would let the blurred board through both overlays. Two
     layers, bottom last, so the warm stop composites onto the card's own ground
     rather than onto whatever the overlay is dimming.
     Borrowed rather than invented because the room this card names is a room in
     the same building: the Fallakte is warm paper on dark, and an Ermittlerbüro
     that is flat neutral grey reads as the settings sheet of a different app. */
  background:
    linear-gradient(180deg, hsl(38 22% 12% / 0.55), hsl(0 0% 100% / 0.02)),
    var(--bg-soft);
  /* The manila tab, same 3px amber edge as .dossier. It is the one mark that
     says „file" before a word is read, and it costs no contrast at all. */
  border-left: 3px solid hsl(45 72% 62% / 0.55);
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  box-shadow: var(--lift-3);
  /* Flex and not grid, for one reason that has nothing to do with layout: the
     action rail below is sticky, and a sticky GRID item cannot move at all —
     its containing block is its own grid area, so the constraint rectangle and
     the box are the same rectangle and there is no slack to slide in. A flex
     item is laid against the container's content box instead, which is the full
     scroll length. A single column of stretched children with one gap is the
     same picture either way; only the sticky child can tell the difference. */
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
}

.menu-head { display: flex; align-items: center; gap: var(--space-3); }
/* The case-file voice, and the reason is consistency rather than decoration:
   --font-file is what .scene-head h2, .dossier-par, .modal-inner h2 and
   .verdict strong already speak. The modal is the OTHER opaque card in the
   house — two cards of the same kind disagreeing about their heading face was
   the actual inconsistency here, not a missing flourish. */
/* --fs-xl and not --fs-lg, because two things outranked this heading while it
   was 18px. The badge beside it is a fixed 44px, and at 1440 the whole title
   block — 27px heading over a one-line sub — measured 48.5. Four and a half
   pixels of clearance is not a rank; the mark and the name it labels were the
   same object, and the header read as an icon with a caption rather than a
   card with a title. At --fs-xl the heading alone is 42 and the block is 63.5,
   so the badge is now plainly the smaller of the two. At 375 the sub wraps and
   the block was already 68, which is exactly why the badge comparison had to be
   made at desktop: the mobile row hid the problem behind a line break.
   The second reason is worse than the first: --fs-2xl is what .modal-inner h2
   takes, and the modal is a LATER screen — a rules sheet outranking the gateway
   a player meets first is a hierarchy that runs backwards.
   Not --fs-2xl itself, though, and the difference is what each heading stands
   over. The modal's h2 has a paragraph under it and nothing else; this one has
   three sections and a sticky action rail below it, so it takes the rung below
   rather than spending the fold on its own title. Measured, the two now sit one
   step apart at both ends: 22 against 26 at 375, 28 against 38 at 1440. */
.menu-head h2 { margin: 0; font-family: var(--font-file); font-size: var(--fs-xl); line-height: calc(1.5em + 1px); }
.menu-head p { margin: 0; font-size: var(--fs-sm); line-height: calc(1.5em + 1px); }

/* The same icon holder as .brand-mark, deliberately: this is the same app
   wearing the same badge, and a second holder idiom would say otherwise. Only
   the ground underneath differs — the header's mark sits on a washed panel,
   this one on the card's own paper-over---bg-soft, which is darker and
   therefore the easier of the two for the glyph: 6.54 here against the header's
   5.83, both against the 3.0 that WCAG 1.4.11 asks of a graphic. It read 7.07
   while the card was flat --bg-soft; the gradient took half a point off it and
   the suite followed, which is what a parsed declaration buys over a named
   token. test/contrast.test.mjs
   measures both rather than either, because "the darker ground must be fine"
   holds only while the gradient above stays the same on both. */
.menu-mark {
  display: grid;
  place-items: center;
  flex: 0 0 auto;
  width: 44px;
  height: 44px;
  padding: var(--space-2-5);
  border-radius: 14px;
  color: var(--accent-soft);
  background: linear-gradient(158deg, hsl(0 72% 52% / 0.32), hsl(0 0% 100% / 0.05));
  border: 1px solid var(--border);
  box-shadow: var(--inset-hair), var(--lift-1);
}

.menu-section { display: grid; gap: var(--space-2-5); }

/* This used to borrow from .field-label — "a small caption over a group of
   controls" — and that was true while the menu was one short column. It stopped
   being true when the card grew a scroll container: „Ermittlerakten" now sits
   above a list that can push „Neuer Fall" off the fold, and two captions of
   equal weight separated by nothing but a gap do not say which controls belong
   to which. What they label is a SECTION, which is .panel-title's job, so the
   hairline comes with it — the same 1px fade, for the same reason given there:
   it is what turns a small heading into the parent of what follows instead of
   the smallest line in the stack.
   The type stays as it was. .panel-title's own weight and tracking are tuned
   against a panel's opaque ground; here the run is shorter and the ground is
   the warmed paper, and lifting to 700/0.14em made the two captions louder than
   the „Ermittlerbüro" they sit under. Rank is what is being borrowed, not size. */
.menu-heading {
  display: flex;
  align-items: center;
  gap: var(--space-2-5);
  margin: 0;
  font-size: var(--fs-xs);
  font-weight: 600;
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.08em;
}
.menu-heading::after {
  content: '';
  /* Same floor as .panel-title::after and for the identical reason: this is the
     only flexible item in the row, so without one it pays for every character
     the caption grows by, and a two-pixel remnant reads as a clipped border
     rather than a line. */
  min-width: var(--space-6);
  flex: 1 1 auto;
  height: 1px;
  background: linear-gradient(90deg, var(--border-strong), transparent);
}

.profile-list { display: grid; gap: var(--space-2); }

/* Translucent over the card, so the row reads as nested rather than as a second
   card. The active row goes to --panel-strong; both grounds are measured, and
   the LIGHTER of the two is the one the text has to survive — 14.71 for the
   name, 9.08 for the meta line under it. Two rules and therefore two
   measurements: the test reads each selector on its own, so lifting only the
   active row's alpha cannot slip through on the base row's number. */
.profile-row {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-2-5);
  border: 1px solid var(--border);
  border-radius: var(--radius-card);
  background: var(--panel);
}
/* The two grounds above are 0.045 and 0.08 white over the same card, which is a
   difference of about 4 L* — and this file spends a whole block at :1130 arguing
   that a step that small is under the threshold at which a difference is a
   difference. A ladder of ten floors got patterns for exactly that reason; a
   list of four profiles cannot then be asked to work on lightness alone.

   So the active row carries a mark rather than a shade. Brass and a 3px rail
   because that is already this file's word for „this one is yours" — see
   `.note-rank` at :3162 and the `--note-rail` clues that share the device — and
   because a saturated edge survives all three dichromacies, where a 4 L* wash
   survives none of them.

   `box-shadow: inset` and NOT `border-left: 3px`: the row's border is part of
   its box, so widening it on one state alone would shift every name 2px to the
   right the moment a player switches profile. The inset shadow paints inside
   the existing padding instead — 10px of it, of which the rail takes 3 and the
   text keeps 7 — so nothing on the row moves.

   Visual only, and deliberately so: `pick.setAttribute('aria-current', 'true')`
   (`js/ui.js`) already puts that state on the row's button, which is the thing
   a screen reader reads. Adding a CSS glyph here would announce a second time in some engines
   and not at all in others. */
.profile-row.is-active {
  background: var(--panel-strong);
  border-color: var(--border-strong);
  box-shadow: inset 3px 0 0 0 var(--brass);
}

/* A button and not a row-wide click handler: switching profiles is the primary
   action of the row, so it has to be reachable by keyboard without the two
   buttons beside it becoming unreachable in the process. */
.profile-pick {
  flex: 1 1 auto;
  min-width: 0;
  display: grid;
  gap: 0;
  padding: 0;
  border: none;
  background: none;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
}
/* Two states this button shipped without, and it is the only control in the
   house that was missing either. `cursor: pointer` was the whole of its
   interactive vocabulary: no hover, and — worse — no focus ring, in a sheet
   where fourteen other selectors declare one. A keyboard player tabbing through
   the profile list could not see where they were standing.

   The name and not the row takes the underline, because the row is not what is
   pressed: the two buttons to its right sit inside the same box and must not
   look as though they belong to the thing being hovered. */
.profile-pick:hover .profile-name { text-decoration: underline; text-underline-offset: 3px; }
.profile-pick:focus-visible { outline: 2px solid var(--accent-soft); outline-offset: 2px; border-radius: 8px; }

.profile-name {
  font-weight: 600;
  line-height: calc(1.5em + 1px);
  color: var(--text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Truncated on the same terms as the name above it, and for a reason the name
   does not have: this line is three facts joined by dots, so wrapping it breaks
   it BETWEEN facts and hands the next row a different height. A column of rows
   whose heights depend on how long a rank title happens to be reads as a list
   that cannot decide how tall it is. What the ellipsis costs — the points, which
   sit last — is bought back by the 560px rule below, where the buttons step off
   the row and give this line the whole card. */
.profile-meta {
  font-size: var(--fs-xs);
  line-height: calc(1.5em + 1px);
  color: var(--muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The row's hierarchy was upside down, and it measured that way: `.profile-pick`
   — the primary action, the thing the whole row is for — paints
   `background: rgba(0,0,0,0); border: 0px`, while the two buttons beside it each
   carried a filled `.btn` ground, a 1px border and `font-weight: 600`. At 375px
   the 560px branch below wraps them onto their own line, so the row grows to
   about 100px and its centre of gravity lands on „Umbenennen" and „Löschen"
   rather than on the name being chosen.

   Rather than promote the name — which would put a filled button inside a row
   that is itself a list item, i.e. two competing grounds on one line — the pair
   steps down to text. They keep the whole `.btn` box (the 40px floor, the
   padding, the radius, the coarse-pointer rules) and give up only the two things
   that made them read as primary: the fill and the border. Both come back on
   hover and focus, so the affordance is not removed, only held until it is
   asked for.

   `border-color: transparent` and not `border: none`: dropping the border would
   collapse the box by 2px in each axis and move the buttons the moment a pointer
   arrives. */
.profile-actions { display: flex; gap: var(--space-1); flex: 0 0 auto; }
/* 500 and not the house 600, which is the third of the three signals and the last
   one left: with the fill and the border gone, the buttons still matched
   `.profile-name` (:3459) weight for weight, so the row's two ranks were
   separated by colour alone — --muted 199 against --text.

   And 500 is a weight this sheet already threw out once, so it has to answer
   that: :2857 removed it from `.btn` on the grounds that it sits 100 from the
   body's 400 and 100 from 600, reading as neither, and that its only visible
   effect — a secondary button sitting lighter than the primary beside it — was
   already being said far more loudly by the accent fill. Both halves of that
   argument turn on a fill being present. Here there is none, because the fill is
   exactly what the block above took away; nothing else in this row is left to
   say which of the two ranks is which. The step that did no work next to an
   accent button is the only thing doing it here. */
.profile-actions .btn {
  background: none;
  border-color: transparent;
  color: var(--muted);
  font-weight: 500;
}
.profile-actions .btn:hover,
.profile-actions .btn:focus-visible {
  background: hsl(0 0% 100% / 0.08);
  border-color: var(--border);
  color: var(--text);
}

/* And the one of the two that cannot be undone. „Löschen" was byte-identical to
   „Umbenennen" — same class string, same weight, same colour — for an action
   that ends a career the row above it is still counting.

   --warn and not --accent, on the same grounds as :2980: the accent is the
   victim's colour everywhere else on this screen, and a destructive control that
   borrows it reads as part of the case rather than as a caution.

   A rail and not the label, and that is a measurement rather than a taste. The
   label was written as `color: var(--warn)` first; the contrast suite came back
   7.15 against the lighter of the two row grounds, a quarter-point under the
   house floor of 7.4. Rather than argue the floor down for one word, the caution
   moved to a mark — which is what the extract pill did when --brass could not
   carry its label either, and which answers to the 3:1 bar for non-text instead.
   The label stays --muted, level with its sibling, so nothing here re-inflates
   the two secondary buttons the block above just stepped down.

   2px and inset, not 3px: the row itself now carries a 3px brass rail for „this
   one is yours", and a second rail of the same weight 300px to its right would
   read as a pair rather than as two different statements. Inset for the reason
   given up there too — a real border would move the label the moment the class
   is applied.

   On approach the rail is redundant and the border does the work, so it steps
   aside: hover and focus swap it for a --warn edge around the whole box, which
   is the same shape the sibling gets in white.

   The two left corners come down to 4px, and that is arithmetic rather than
   taste. An inset shadow follows the box's radius, so at the button's 40px
   height and the house 12px corner the mark's straight run is 40 − (2 × 12) =
   16px — 40% of the edge, the other 60% curve. At that ratio it stops reading
   as a rail and starts reading as an opening bracket wrapping „Löschen". The
   row's own brass rail, measured live at 375px, runs 108.5 − 24 = 84.5 of
   108.5, or 78% straight, which is why that one reads as a rail. 4px brings
   this one to 40 − 8 = 32 of 40, or 80% — within two points of the row it is
   answering, and the comment above finally describes the thing on screen.
   Only the two left corners move; the right pair stays at 12 so the button
   still belongs to the row of buttons.

   And the radius steps aside with the rail, not after it: once hover hands the
   job to a full --warn border there is nothing left to keep straight, and 4px
   on the left against 12px on the right would be an asymmetry with no mark to
   justify it. */
.profile-drop,
.account-drop {
  box-shadow: inset 2px 0 0 0 var(--warn);
  border-top-left-radius: 4px;
  border-bottom-left-radius: 4px;
}
.profile-actions .btn.profile-drop:hover,
.profile-actions .btn.profile-drop:focus-visible,
.account-actions .btn.account-drop:hover,
.account-actions .btn.account-drop:focus-visible {
  border-color: var(--warn);
  box-shadow: none;
  border-radius: var(--radius-card);
}

/* Replaces the row's own content while a rename is open, so the name being
   edited never appears twice on screen at two different values. */
.profile-rename { display: flex; gap: var(--space-2); align-items: center; flex: 1 1 auto; min-width: 0; }
.profile-rename .input { flex: 1 1 auto; min-width: 0; }

.profile-new { display: flex; gap: var(--space-2); align-items: flex-end; }
.profile-new .field { flex: 1 1 auto; min-width: 0; }

/* Reserved, not collapsed. This line is a live region that is empty at rest and
   fills on a refusal; without a floor the buttons below it would jump the
   moment the player is told why nothing happened, which reads as a second
   glitch on top of the first. */
.menu-note { margin: 0; min-height: 1rem; font-size: var(--fs-xs); }

/* ------------------------------------------------------------------ Konto */

/* The account rides on <details>, so the row below is a summary wearing the
   same caption as its neighbours. Two things have to be taken back from the UA:
   the disclosure triangle, which `display:flex` already loses in Chromium and
   would otherwise survive only in Firefox — one browser with a marker and one
   without is worse than neither — and the text cursor, because this line is the
   control.
   The caret is drawn the way `.select` draws its own: a background image rather
   than a pseudo-element, since `::after` here is already spoken for by the
   hairline. Same asset, same grey, so the two carets in this card cannot drift
   apart. */
.account > .account-summary {
  cursor: pointer;
  list-style: none;
  min-height: 44px;
  padding-right: var(--space-6);
  background-image:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath fill='%23a3a3a3' d='M1 1.5 6 6.5l5-5'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right center;
  background-size: 11px 7px;
}
.account > .account-summary::-webkit-details-marker { display: none; }
/* The same path mirrored, not a rotation: a transform on the element would take
   the caption with it, and there is nothing here to rotate on its own. */
.account[open] > .account-summary {
  background-image:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath fill='%23a3a3a3' d='M1 6.5 6 1.5l5 5'/%3E%3C/svg%3E");
}
.account > .account-summary:focus-visible { outline: 2px solid var(--accent-soft); outline-offset: 2px; }

/* `order` rather than a different DOM position, because the state belongs to
   the summary's accessible name and has to be read out with it. The caption's
   uppercase run is undone: „NICHT ANGEMELDET" shouts a fact nobody asked for,
   and it is the only lower-case sentence in a row of labels for that reason. */
/* Und das Gewicht zurück auf 400: die Zeile erbte 600 von der Überschrift und
   war damit in Größe, Farbe und Gewicht deckungsgleich mit der Beschriftung
   direkt darüber (beide 11px, 600, rgb(199,199,199)) — zwei Zeilen, die gleich
   aussehen, aber nicht dasselbe sind. Eine Überschrift benennt den Bereich, ein
   Zustand berichtet über ihn; das Gewicht ist hier der einzige Unterschied, der
   ohne Farbe auskommt. */
.account-state {
  order: 1;
  text-transform: none;
  letter-spacing: normal;
  font-weight: 400;
}

.account-form, .account-signed { display: grid; gap: var(--space-2-5); }
/* Wraps rather than shrinks: two buttons at 44px on a 320px phone fit side by
   side, but only until a translation grows one of them. */
.account-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.account-actions .btn { flex: 1 1 auto; }

/* Der einzige Rang, den das Formular selbst vergibt. „Anmelden" muss vor
   „Registrieren" stehen — es ist der Weg, den fast jeder hier geht — aber die
   Menükarte hat ihren gefüllten Akzent schon in der Fußzeile ausgegeben, und
   zwei gefüllte Flächen auf einer Karte sind so gut wie keine: das Auge muss
   wählen und hört auf, der Farbe zu glauben.

   Also die Kante statt der Fläche. Das ist eine dritte Aussage neben den beiden,
   die das Haus schon kennt, und sie ist von beiden unterscheidbar: die gefüllte
   Fläche ist der eine Weg nach vorn, die Schiene links am Rand (`.account-drop`,
   in --warn) ist die eine Handlung, die etwas wegnimmt, und diese Kante ist
   „innerhalb dieses Feldes zuerst".

   Hier stand --accent-deep, mit einem Absatz darüber, warum die dunklere Stufe
   als Linie liest und die hellere als Leuchten. Das war gut geschrieben und
   falsch: gemessen kommt diese Kante gegen ihren eigenen Ghost-Grund auf 2.62
   und verfehlt damit die 3:1, die 1.4.11 für ein grafisches Objekt verlangt.
   Eine Kante, die einen Rang AUSSAGT, ist genau das — sie darf nicht die
   Nachsicht bekommen, die für Dekoration gilt. --accent misst 3.24 — knapp, aber
   über der Schwelle, und der Abstand ist klein genug, dass jede künftige Änderung
   an --accent oder am Ghost-Grund die Suite rot macht statt still zu unterlaufen.

   Beide Zahlen stammen aus test/contrast.test.mjs, nicht von Hand: die 3.24 stand
   hier zwischenzeitlich als 3.59, weil ich die Leuchtdichte des Ghost-Grunds
   geschätzt statt komponiert hatte. Eine Zahl in einem Kommentar, die die Suite
   nicht ausgibt, ist genau der Fehler, den dieser Absatz beschreibt.

   Kein eigenes Hover: `.btn:hover` hebt den Grund bereits an, und die Kante
   bleibt dabei stehen, was genau richtig ist — die Rangfolge ändert sich beim
   Zeigen nicht. */
.account-submit { border-color: var(--accent); }
.account-mail-line { margin: 0; font-size: var(--fs-xs); }

/* Art. 17 hinter einer eigenen Klappe, und die Klappe ist die eigentliche
   Sicherung. „Abmelden" und „Konto löschen" nebeneinander sind auf einem Handy
   zwei Ziele mit einem Daumen Abstand, von denen eines rückgängig zu machen ist
   und das andere nicht — der Abstand entscheidet dort mehr als jeder Dialog.
   Ein <details> kostet einen Tipp mehr und ist der einzige Teil dieser Absicht,
   der ohne Skript funktioniert.
   Die Trennlinie oben zieht die Grenze sichtbar: darüber alles, was man täglich
   tut, darunter das eine Mal. */
/* `gap: 0` ist hier kein Verzicht auf Abstand, sondern die Korrektur einer
   Annahme: Chromium schiebt zwischen ein <details> und seine Kinder eine eigene
   interne Box (`::details-content`). Der Grid-Abstand dieses Elements greift
   deshalb nur ZWISCHEN summary und dieser Box — innerhalb der Box liegen Hinweis,
   Feld und Knopf wieder als gewöhnliche Blöcke. Gemessen: summary→Hinweis 10px
   (der Abstand, der ankommt), Hinweis→Feld 0px, Feld→Knopf 0px. Genau dort, wo
   eine unumkehrbare Handlung Luft braucht, klebte alles aneinander.
   Das direkte Geschwister `#account-signed` ist ein schlichtes <div> und misst
   dieselbe Regel korrekt aus — der Unterschied ist allein das <details>. Deshalb
   tragen die Kinder ihren Abstand hier selbst; Margins sind in einem <details>
   nicht die altmodische, sondern die belastbare Wahl. */
.account-danger {
  margin-top: var(--space-1);
  padding-top: var(--space-2-5);
  border-top: 1px solid var(--border);
  display: grid;
  gap: 0;
}
/* Ohne `list-item` bliebe der native Pfeil stehen und säße neben einer Zeile,
   die schon selbst eine Aufforderung ist. Der Zeiger sagt, dass hier etwas
   aufgeht — die Beschriftung sagt, was. */
.account-danger-summary {
  cursor: pointer;
  font-size: var(--fs-sm);
  font-weight: 600;
  list-style: none;
}
.account-danger-summary::-webkit-details-marker { display: none; }
.account-danger-summary::before { content: '▸ '; color: var(--muted); }
.account-danger[open] > .account-danger-summary::before { content: '▾ '; }
.account-danger-summary:focus-visible { outline: 2px solid var(--accent-soft); outline-offset: 2px; }
/* Die drei Abstände, die der Grid-Abstand oben nicht mehr setzen kann. Sie
   stehen einzeln, damit man sieht, dass es drei sind — und nicht ein Wert, der
   zufällig dreimal wirkt. */
.account-danger-note { margin: var(--space-2-5) 0 0; font-size: var(--fs-xs); }
.account-danger .field { margin-top: var(--space-2-5); }

/* Der einzige Knopf der App, dessen Wirkung niemand zurückholen kann, war der
   BREITESTE: `.account-actions .btn { flex: 1 1 auto }` verteilt den Platz auf
   die vorhandenen Knöpfe, und hier ist nur einer da — gemessen 524px gegen
   142.5px bei „Abmelden" eine Reihe darüber. Größe ist auf dieser Fläche aber
   die Betonung, und sie lag auf der falschen Handlung. Dazu lief die
   Warn-Schiene an der linken Kante rund 500px von ihrer eigenen Beschriftung
   weg und markierte am Ende leere Fläche statt eines Wortes.
   `flex: 0 0 auto` gibt dem Knopf seine Inhaltsbreite zurück, `flex-end` stellt
   ihn dorthin, wo eine Bestätigung erwartet wird — hinter das Feld, das sie
   verlangt. Beides ausdrücklich auf diese Klappe begrenzt: die Knopfreihen
   darüber teilen sich zu zweit eine Zeile und sollen das weiter tun. */
.account-danger .account-actions {
  margin-top: var(--space-2-5);
  justify-content: flex-end;
}
.account-danger .account-actions .btn { flex: 0 0 auto; }

/* auto-fit rather than a viewport breakpoint: this grid lives inside a card of
   fixed max width, so what decides whether three selects fit beside each other
   is the CARD, not the window. A media query here would collapse the row on a
   narrow phone and keep it collapsed on a wide one. */
.menu-controls {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: var(--space-3);
}

/* ------------------------------------------------------------- die Reiter

   Vier Bereiche statt eines Scrollers. Was sie ersetzen, ist gemessen: mit vier
   Akten und angemeldetem Spieler standen 1345px Inhalt in einem 778px hohen
   Rahmen (375x812) — 58 % sichtbar, 567px Überlauf; im Querformat 812x375
   blieben 35 %. Die häufigste Aufgabe lag dabei HINTER der seltensten.

   Klebend am Kopf der Karte, nicht mitscrollend: die Reiterzeile ist die
   einzige Stelle, an der man erfährt, dass es die anderen drei Bereiche
   überhaupt gibt. Scrollte sie weg, wäre die Karte für den, der nach unten
   gefahren ist, wieder ein Scroller ohne Ausgang. */
.menu-reiter {
  position: sticky;
  top: 0;
  z-index: 1;
  display: flex;
  gap: var(--space-1);
  margin-inline: calc(var(--space-4) * -1);
  padding: var(--space-1) var(--space-4) 0;
  background: var(--bg-soft);
  border-bottom: 1px solid var(--border);
}

.menu-reiter-knopf {
  flex: 1 1 0;
  min-height: 2.5rem;
  padding: var(--space-2) var(--space-1);
  border: 0;
  border-bottom: 2px solid transparent;
  /* Keine Rundung. Sie waere hier reine Zierde — der Reiter liest sich ueber
     seine Kante — und ein zusammengesetzter Wert ausserhalb der Radienskala
     verlangt einen Eintrag in deren Buchfuehrung. Eine Ausnahme fuer eine
     Ecke, die niemand braucht, ist eine Ausnahme zu viel. */
  background: none;
  color: var(--muted);
  font: inherit;
  font-size: var(--fs-sm);
  font-weight: 600;
  cursor: pointer;
  /* Kein `btn-primary`, keine gefüllte Fläche: der einzige gefüllte Akzent
     dieser Karte gehört ihrer Aktionszeile, und test/menu.test.mjs setzt das
     durch. Der aktive Reiter trägt deshalb eine KANTE, keine Füllung — dieselbe
     Sprache, die das Brett für „ausgewählt" benutzt. */
}
.menu-reiter-knopf:hover { color: var(--text); }
.menu-reiter-knopf[aria-selected="true"] {
  color: var(--text);
  border-bottom-color: var(--accent);
}
.menu-reiter-knopf:focus-visible {
  outline: 2px solid var(--accent-soft);
  outline-offset: -3px;
}

/* Die Tafeln erben den Abstand, den die Abschnitte vorher untereinander hatten.
   Ohne diese Zeile klebt der erste Inhalt an der Reiterzeile. */
.menu-tafel { display: grid; gap: var(--space-2-5); padding-top: var(--space-3); }

@media (forced-colors: active) {
  /* Farbe trägt hier nichts: `aria-selected` ist die Aussage, und die Kante
     macht sie sichtbar. Im Kontrastmodus wird sie dicker statt bunt. */
  /* 4px und nicht 3px: `3px solid Highlight` ist im Kontrastmodus das Tripel
     des FOKUSRINGS, und zwei Zustände mit identischer Kontur sind dort keine
     zwei Zustände mehr — der ausgewählte Reiter sähe aus wie der fokussierte
     und umgekehrt. test/affordance.test.mjs setzt die Eindeutigkeit durch; sie
     ist hier bezahlt, nicht behauptet. */
  .menu-reiter-knopf[aria-selected="true"] { border-bottom: 4px solid Highlight; }
}

/* Sticky, because on the smallest supported viewport this rail is simply not on
   screen: four profiles put the card at 1037px of content in a 633px window, and
   „Fall aufnehmen" then opens 343px below the fold with nothing at the edge
   saying there is anything down there. Neither button is optional — one resumes
   the case the player is standing in, the other starts a new one — so the card
   scrolling them away is the menu hiding its own purpose.
   Opaque, and that is not a style choice: the card's opacity is load-bearing
   (test/contrast.test.mjs parses every background layer of .menu-card and throws
   if no opaque ground survives), and a translucent rail would let the rows it
   passes over show through it while they scroll. --bg-soft is the card's own
   bottom layer; at the foot of the card the paper gradient above has faded to
   hsl(0 0% 100% / 0.02) over exactly that, so the seam is invisible without the
   rail having to restate the gradient and drift from it later.
   The negative inline margin is the card's padding: the rail spans the full
   width so its top border reads as an edge of the card rather than a line drawn
   inside it.
   No env(safe-area-inset-bottom) here, although a bar pinned to `bottom: 0`
   invites it: .menu already pays that inset as its own padding, and the card's
   `max-height: 100%` is a percentage OF that inset area — so the rail's lowest
   possible position is already clear of the home indicator. Paying it twice
   would take ~34px out of the list on exactly the phones whose blocker was
   vertical space, and it would be invisible to any test on a viewport where
   env() resolves to 0. */
/* ------------------------------------------------------------ Zu zweit */

/* Eine Reihe aus Feld und Knopf, die auf schmalen Schirmen umbricht.
   `flex-wrap` und nicht das Raster von `.menu-controls` daneben: dort sind alle
   Kinder gleichrangige Felder, hier ist eines ein Eingabefeld und eines ein
   Knopf, und die beiden sollen NICHT gleich breit werden. Ein
   `auto-fit`-Raster machte aus „Beitreten" einen halbseitigen Balken. */
.mp-reihe {
  display: flex;
  flex-wrap: wrap;
  align-items: end;
  gap: var(--space-2);
}

/* Die Vorschau ist eine Beschriftung des Knopfes darunter, kein eigener Absatz —
   deshalb dicht an ihm und leiser als der Fliesstext daneben. Sie steht auch
   dann da, wenn sie sich nie aendert; ein Feld, das nur bei Abweichung
   erscheint, waere ein Warnhinweis, und das ist es nicht. */
.mp-vorschau {
  margin: var(--space-1) 0;
  font-size: var(--fs-xs);
  color: var(--muted);
  line-height: 1.5;
}

/* Das Feld nimmt den Rest, der Knopf seine eigene Breite. `min-width` statt
   `width`, damit der Umbruch bei 320px nicht in einer Spalte endet, in die
   „ABC123" nicht mehr passt. */
.mp-code-feld { flex: 1 1 9rem; min-width: 9rem; }

/* Der Code wird VORGELESEN und ABGETIPPT — beides braucht Zeichen, die man
   nicht verwechselt. Die Sperrung trennt die sechs Stellen voneinander,
   `tabular-nums` haelt Ziffern und Buchstaben auf gleicher Breite, damit der
   Code beim Wechsel einer Stelle nicht wandert. Das Alphabet ohne 0/O und 1/I
   kommt aus `api/_kern.js`; die Typografie hier ist die zweite Haelfte
   derselben Ueberlegung und nicht Zierde. */
.mp-code { margin: 0; color: var(--muted); font-size: var(--fs-sm); }
/* Die Feldhilfe unter dem Code-Eingabefeld. Sie traegt die Beschreibung, die
   `aria-describedby` frueher aus der allgemeinen Meldezeile `#mp-note` holte —
   dort stand je nach Lage „Runde zurueckgezogen." als Erklaerung eines
   Eingabefeldes.
   `display: block` und ein eigener Abstand, weil das `<label>` seine Kinder
   sonst in eine Zeile legt und die Hilfe neben dem Feld statt darunter stuende.
   `--fs-sm` und nicht `--fs-xs`: die Zeile ist ein SATZ, keine Auszeichnung —
   `--fs-xs` traegt in dieser Datei Versalien und Sperrung. */
.mp-feldhilfe {
  display: block;
  margin-top: var(--space-1);
  font-size: var(--fs-sm);
  line-height: 1.4;
}
.mp-code strong {
  display: inline-block;
  margin-inline-start: var(--space-1);
  color: var(--brass);
  font-size: var(--fs-lg);
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.22em;
  /* Die Sperrung haengt rechts ein Zwischenraum an, den man beim Markieren
     mitnimmt — die Einrueckung gleicht ihn aus, damit der Code mittig unter
     seinem Label steht statt einen halben Buchstaben nach links versetzt. */
  padding-inline-start: 0.11em;
}

.menu-actions {
  position: sticky;
  bottom: 0;
  z-index: 1;
  display: grid;
  gap: var(--space-2);
  margin-inline: calc(var(--space-4) * -1);
  padding: var(--space-3) var(--space-4) var(--space-4);
  background: var(--bg-soft);
  border-top: 1px solid var(--border);
  box-shadow: 0 -12px 24px -18px hsl(0 0% 0% / 0.9);
}

/* ----------------------------------------------------------------- loading */

/* Anchored to the top: the bottom of the viewport belongs to .status and
   .footer, which the pill covered on narrow screens. */
.loading {
  position: fixed;
  inset: calc(var(--space-3) + env(safe-area-inset-top)) 0 auto 0;
  z-index: 20;
  margin: 0 auto;
  width: max-content;
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-3-5);
  border-radius: 999px;
  border: 1px solid var(--border-strong);
  background: hsl(0 0% 8% / 0.94);
  backdrop-filter: blur(8px);
  box-shadow: 0 6px 20px hsl(0 0% 0% / 0.45);
  font-size: var(--fs-sm);
}

.spinner {
  width: 13px;
  height: 13px;
  border-radius: 50%;
  border: 2px solid hsl(0 0% 100% / 0.25);
  border-top-color: var(--accent);
  animation: spin 0.8s linear infinite;
}

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

/* ----------------------------------------------------------- Rechtsseiten */

/* Impressum und Datenschutzerklärung sind die zweite Langtext-Fläche im Haus —
   die erste ist .dossier-text, und dieser Block borgt sich deren einziges
   Argument: jenseits von rund 100 Zeichen pro Zeile verliert das Auge den
   Rücksprung. 72ch statt der 66ch des Dossiers — das Dossier ist Prosa in der
   Aktenschrift, hier stehen Paragraphenzeichen und Aufzählungen in der
   UI-Schrift. In ch und nicht in px, damit das Maß der Schrift folgt und nicht
   einer Zahl von heute.

   Was hier NICHT steht, ist Absicht: keine einzige Farb-Deklaration. Die
   Farbenliste in test/contrast.test.mjs (USAGE) wird aus genau den Regeln
   abgeleitet, die ein Token auf Text legen; der Suchbefehl dafür steht im Kopf
   jener Datei und bewusst nicht hier — dort durchsucht er styles.css von außen,
   eine Zeile, die ihn hier zitiert, stünde in seinem eigenen Ergebnis und
   zählte sich selbst mit. Jede neue Fläche, die ein Token trägt, müsste dort
   eine Zeile bekommen. Diese Seiten kommen mit
   dem aus, was sie ohnehin erben — .panel trägt --text, .muted ist gemessen,
   und die Warnplatte ist .note-warn, also exakt die Fläche, die die Suite
   unter `noteWarn` bereits prüft. Eine Rechtsseite ist der schlechteste Ort
   für eine ungemessene Farbe. */
/* `margin-inline: auto` gehört zum `max-width` und nicht in eine spätere Zeile:
   ohne die Zentrierung endet die Textspalte 571px vor der Kante ihrer EIGENEN
   Kopf- und Fußzeile, die die volle Panelbreite behalten. Das liest sich nicht
   als gesetztes Maß, sondern als abgeschnittene Seite. */
.legal { max-width: 72ch; margin-inline: auto; }
.legal h2 {
  margin: 0 0 var(--space-4);
  font-size: var(--fs-xl);
  font-weight: 700;
  letter-spacing: -0.01em;
}
/* Der Abstand ist oben deutlich größer als unten: die Überschrift gehört zum
   Absatz UNTER ihr, nicht zu dem darüber. Ohne das Gefälle liest sich eine
   Seite aus zwölf Abschnitten wie eine gleichmäßige Liste, in der man die
   Gliederung erst beim zweiten Durchgang sieht. */
.legal h3 {
  margin: var(--space-5-5) 0 var(--space-2);
  font-size: var(--fs-lg);
  font-weight: 650;
}
.legal p { margin: 0 0 var(--space-3); line-height: 1.65; text-wrap: pretty; }
.legal ul { margin: 0 0 var(--space-3); padding-left: var(--space-4-5); }
.legal li { margin-bottom: var(--space-2); line-height: 1.65; }
.legal li:last-child { margin-bottom: 0; }
/* Nur die Schriftfamilie, kein Kasten: ein `localStorage` im Fließtext ist ein
   Eigenname, keine Codezeile. Ein getönter Hintergrund wäre eine neue Fläche,
   auf der --text zu messen wäre — siehe oben. */
.legal code { font-family: var(--font-cipher); font-size: 0.9em; }

/* Dieselbe Schachtel wie .modal-inner .note, mit einem Unterschied, der
   gewollt ist: --note-rail und --note-tint werden hier NICHT gesetzt, sondern
   nur als var()-Fallback verwendet. Die Varianten (.note-warn und Geschwister)
   deklarieren sie mit (0,1,0); eine Grundregel mit (0,2,0), die dieselben
   Eigenschaften deklariert, gewänne die Kaskade und würde jede Farbcodierung
   still zurück auf Grau ziehen. Der Fallback erreicht dasselbe Ziel, ohne den
   Wettbewerb überhaupt zu eröffnen. */
.legal .note {
  margin: 0 0 var(--space-5);
  padding: var(--space-3) var(--space-3-5);
  border-radius: var(--radius-inner);
  border-left: 3px solid var(--note-rail, var(--border-strong));
  background: var(--note-tint, hsl(0 0% 100% / 0.04));
  font-size: var(--fs-sm);
  line-height: 1.55;
}
.legal .note :last-child { margin-bottom: 0; }
.legal-stand { margin: var(--space-6) 0 0; font-size: var(--fs-sm); }

/* -------------------------------------------------------------- responsive */

@media (max-width: 940px) {
  .layout { grid-template-columns: minmax(0, 1fr); }
  .side { grid-template-columns: minmax(0, 1fr); }
  /* The side column moved below the board, so the board gets the window back —
     and the room labels have to be told, or they stay sized for a strip that is
     no longer taken from them. This is the same one line the layout above says
     in its own terms; saying it here rather than in a second copy of the sum is
     what keeps the two from disagreeing. */
  .grid-wrap { --board-taken: 0px; }
  /* The rank badge is the widest topbar item; on one row it would push the
     difficulty select onto a third line, so it spans instead. */
  .rank { order: 3; width: 100%; }
  .rank-meta { min-width: 0; flex: 1 1 auto; }
}

@media (max-width: 560px) {
  /* The phone buys width back at the edges, and it is now the TOKEN that says so
     rather than .app alone — the room labels subtract this same padding to work
     out how wide the board got, so overriding only the box would have left them
     sized for a narrower plan than the one they are standing on. */
  :root { --app-pad: var(--space-2-5); }
  /* The rank badge already spans the full row at this width (940px block above),
     so it is a full-width strip of chrome sitting between the player and the
     board on the smallest screen the game has. Nothing is dropped — the title
     is the rank now held, the bar is how far into it the player is, and the note
     is what is still owed for the next one; none of the three can be read off
     another — but the badge gives up the padding and the icon size at 375px,
     which is ~14px of the first screen handed back to the crime scene. */
  .rank { gap: var(--space-2); padding: var(--space-1) var(--space-3) var(--space-1) var(--space-2); }
  .rank-icon { width: 26px; height: 26px; padding: var(--space-1); }
  .topbar-actions { width: 100%; }
  .topbar-actions .field { flex: 1 1 140px; }
  /* The three selects grow, so while there were only two of them the buttons
     inherited a tidy remainder. Once the row wraps, „Neuer Fall" and „Regeln"
     are left at their content width on a row of their own — two narrow tabs
     against a full-width row. They share the row instead. */
  .topbar-actions .btn { flex: 1 1 auto; }
  /* Nothing here hides a name any more, and the deletion is worth a paragraph
     because the rule that stood here was right about its problem and wrong about
     its remedy. The floor is orthographic, so anything standing on it projects
     at sin(tilt) — at 21deg a suspect's coat lands 5.1px tall on a 375px screen
     and the icon inside it 2.9px (measured, 7x7 board). At that size neither the
     coat colour nor the profession glyph is an identity channel: the whole
     figure is a coloured sliver, and the name is the only thing left that tells
     two suspects apart. So the old rule dropped the FURNITURE label to buy the
     person label room — `.cell-furniture:not(.cell-person):not(.cell-victim)
     .cell-name { display: none; }` — and then still let the surviving name
     truncate to about six characters plus an ellipsis, on the argument that the
     cast is written with the role in front.
     Both halves are gone. Furniture carries no plate at all now (js/ui.js), on
     any screen, so the selector has nothing left to match; and the name it was
     protecting no longer truncates, because it is sized to its own text and
     allowed to overflow into the neighbouring cells (styles.css:2520
     `.cell-name`). Diese Zeile berief sich bis zu diesem Zyklus darauf, die
     Nachbarfelder seien „durch die Sudoku-Regel garantiert" leer. Sie sind es
     nicht: die Ein-pro-Reihe-Bedingung beschreibt den GELÖSTEN Fall, nicht das,
     was das Brett während des Spiels zulässt — zwei Figuren dürfen
     nebeneinander stehen, sie werden dann als `cell-conflict` markiert und
     nicht verweigert. Das Schild verlässt sich deshalb nicht mehr auf eine
     Garantie, die es nie gab, sondern weicht aktiv zur freien Seite aus
     (`--name-nudge`, js/ui.js). A phone gets the same whole name a desktop
     does, which is what the six-character compromise was only ever approximating. */
  /* A 71px cell cannot afford the desktop camera: the same wall that frames a
     desktop cell would eat a third of a phone one, and the grid has to stay
     legible as a grid first. But the tilt cannot simply be flattened either —
     an upright face lands on screen at h·sin(tilt), so shallowing the camera
     shortens every object on the board at the same time. 21deg is where a phone
     cell keeps its square shape and a counter is still visibly a counter. The
     unit itself is held up by the floor of the clamp on --zu above. */
  /* The side walls scale down with the partitions: a 78-unit shell against a
     19-unit partition turns the board into a walled courtyard on a phone. The far
     wall does not scale down with them, and that separation is the point.
     It used to. One number covered all three shell walls, so lowering the side
     walls for the look silently lowered the only wall that was doing structural
     work, and the range's 74-unit flue started overhanging the top edge. The
     comment here even named it as a deliberate trade — it was not, it was a
     grouping accident, and a probe over six phone boards found it twice in 2360
     prisms, both times in the far row, 3.1px and 5.4px into the black.
     Both times in the far row is the whole diagnosis: a face rises h·sin(tilt) up
     the screen and never sideways, so the side walls contain nothing and are free
     to be short, while the far wall has to stay at the derived ceiling. */
  /* Die Neigung geht an .grid-wrap, die drei Wand-Token bleiben beim Brett —
     dieselbe Trennung wie oben in der Datei: der Kasten reserviert Platz und
     braucht dafür den Winkel, das Brett baut Wände und braucht dafür die Höhen. */
  .grid-wrap { --tilt: 21deg; }
  .grid { --wall-h: calc(19 * var(--zu)); --wall-ext-side: calc(50 * var(--zu)); --wall-ext-near: calc(13 * var(--zu)); }
  /* 4%, and from the TOP. This line is where the overlap was worst: the phone
     override used to pull the plate down to `bottom: 4%` — the exact offset
     `.cell-name` sits at — so on the one viewport where the cells are smallest
     the two labels were not merely close, they were in the same place. */
  /* The two fixed sizes that used to stand here are gone, not forgotten: they
     were a hand-made copy of "a fraction of a cell", made when the base rule
     could not express it, and a copy is a thing that stops agreeing. The cell
     is now the measure everywhere, and on this viewport it lands on the clamp's
     own floor — which is what those literals were approximating. */
  .room-plate { top: 4%; }
  /* WCAG 2.5.5 needs 44px on BOTH axes. An 8x8 board inside the scene padding
     only reaches ~40px wide at 375px, so the board goes full-bleed instead. */
  .scene { padding-left: 0; padding-right: 0; }
  /* …and the labels have to be told, or they go on sizing themselves for a
     board 32px narrower than the one they are printed on. */
  .grid-wrap { --board-pad: 0px; }
  /* `.mp-leiste` gehoert in dieselbe Aufzaehlung wie `.scene-head` und
     `.scene-foot`: `.scene` gibt sein seitliches Polster hier vollstaendig ab,
     damit das BRETT randlos laufen kann, und jedes Kind, das kein Brett ist,
     bekommt es einzeln zurueck. Die Rundenleiste kam nach diesem Block dazu und
     stand als einzige Textzeile bis an die Kante — Namen, Marken und die
     Lage-Zeile klebten am Rand, waehrend Kopf- und Fusszeile darueber und
     darunter eingerueckt waren. */
  .scene-head, .scene-foot, .mp-leiste { padding-inline: var(--space-3); }
  .grid { border-radius: 0; border-left: 0; border-right: 0; }
  /* No `aspect-ratio: auto` here. There used to be, paired with a min-height, and
     the comment justifying it was circular: it feared the row collapsing to
     whatever a glyph needs, but that collapse is only reachable AFTER the square
     has been released. The pair caused the very problem it was insuring against.
     Measured: 5x5 went 68x50 to 72x50 (ratio 1.36 to 1.44), 8x8 tipped the other
     way at 42x50 (0.84), and one pixel of viewport at 561 snapped it back to
     square. A Sudoku grid is read along rows and columns; a rectangle makes the
     two look different lengths and takes away the property the board is read for.
     The touch promise lives under `any-pointer: coarse` below, where it belongs,
     and the width floor above keeps that promise from forcing a rectangle: on a
     coarse pointer a 42px-wide cell would be pulled back to 50px tall by that
     min-height, so the floor is a PRECONDITION for the square here, not a
     separate fix. Headless Chrome is not a coarse pointer and cannot show that
     interaction — test/cellbox.test.mjs holds it in the source instead. */
  /* The full-bleed board would otherwise push the compass against the edge. */
  .compass { right: var(--space-3); }
  .brand-mark { width: 40px; height: 40px; padding: var(--space-2); }
  /* The board is full-bleed here, so its neighbours own the side padding. */
  .dossier { margin-inline: var(--space-3); }
  .dossier-body { padding: var(--space-1) var(--space-3) var(--space-3-5); }
  .dossier-summary { flex-wrap: wrap; }
  /* The note takes its own line here, so it is ordered behind the caret —
     otherwise the caret is pushed onto a third line and floats alone. */
  .dossier-note { order: 3; flex-basis: 100%; text-align: left; margin-left: 0; }
  .dossier-summary::after { order: 2; margin-left: auto; }
  /* Full-width so the statement's action is easy to hit one-handed. */
  .dossier-extract { width: 100%; justify-content: center; }
  /* The leading rides inside the query on purpose: two columns wide, `dt` and
     `dd` sit SIDE BY SIDE and their leading is not a distance between lines at
     all. Only here do they stack, so only here does the row gap become one. */
  .fact-grid { grid-template-columns: minmax(0, 1fr); gap: 0; }
  .fact-grid dt { line-height: calc(1.5em + 1px); }
  .fact-grid dd { margin-bottom: var(--space-2); line-height: calc(1.5em + 1px); }
  .fact-grid dd:last-child { margin-bottom: 0; }
  /* „Umbenennen" and „Löschen" side by side are ~200px of a 355px card, which
     leaves the career line about a third of a row to say a rank, a case count
     and a score in. They step down instead: the identity gets the full width and
     its ellipsis stops being the normal state, and the two actions stay a pair
     rather than being squeezed to their own truncation. Right-aligned because
     they belong to the row above them, not to the row below. */
  .profile-row { flex-wrap: wrap; }
  .profile-actions { width: 100%; justify-content: flex-end; }
}

/* Touch targets belong to the input device, not the viewport width. Gated on
   width alone, every tablet and every phone in landscape (561px and up) shipped
   the compact 40px desktop floor while being operated by a finger.

   `any-pointer`, not `pointer`: the plain one describes only the PRIMARY device,
   so a touchscreen laptop and a tablet with a keyboard case both answer „fine"
   and drop back to the desktop floor — while still being tapped all day. The
   plain query asks which device the machine prefers; this one asks whether a
   finger is among the devices at all, which is the only question a 44px floor
   cares about. It can only ever widen a target: under a mouse the raised floor
   costs four pixels of height, under a fingertip the missing four are a miss. */
@media (any-pointer: coarse) {
  .btn { min-height: 44px; }
  /* Und weil .btn hier wächst, wächst das angeheftete Blatt mit — hier stand
     deshalb eine zweite `scroll-padding-bottom`-Summe, Glied für Glied wie die
     oben, nur mit 44 statt 40. Sie ist ersatzlos weg, und beide Gründe dafür
     sind derselbe: die Reserve wird jetzt an der GERENDERTEN Höhe des Blattes
     gemessen, und eine gerenderte Höhe enthält die Knopfhöhe bereits. Der
     Rückfall oben rechnet ohnehin mit 44 — grosszügig zu sein kostet dort
     nichts. Ein Zwilling, der von Hand nachgezogen werden müsste, kostet
     dagegen genau das, was er hier zweimal gekostet hat. */
  /* .btn-sm needs no floor of its own — it inherits this one, because it is never worn without .btn. Asserted against index.html AND js/ui.js in test/geometry.test.mjs; the count belongs there, where it can go red, not here. */
  /* The controls, and the only floor in this block that is a RESTATEMENT rather
     than a raise: both are already 44 at the base, and a rule 3000 lines up
     narrows them to 40 behind `pointer: fine`. That gate is the one this block
     condemns, so on a hybrid the narrowing applied and the raise never did. The
     font size comes back with it — 16px is not a size here but the number below
     which iOS Safari zooms the page on focus, and a control that is tall enough
     to tap and still zooms has only half kept the promise. */
  .select,
  .input { min-height: 44px; font-size: 16px; }
  /* A single-line clue would otherwise stay at the 40px desktop floor. */
  .clue-btn { min-height: 44px; }
  .dossier-extract { min-height: 44px; }
  .trace-btn { min-height: 44px; }
  /* .trace-entry is a real button — it opens the cipher — and it was the one
     pressable thing in this house still on the 40px desktop floor under a
     finger. In today's markup the floor never binds: the entry carries a
     headline and the object it was found in, so it measures 56px. It is stated
     anyway, because that 56 is a property of the copy, not of the rule, and a
     trace headline that ever fits on one line would drop straight back to 40.

     The slot is not pressable and is aria-hidden; it rises with the entry only
     so the placeholder does not read as a smaller class of thing beside it. It
     does not close the gap — a found trace is taller than an empty tile at any
     pointer, and should be: something arrived. */
  .trace-entry { min-height: 44px; }
  .trace-slot { min-height: 44px; }
  /* Same case as .trace-entry, one step further removed. The chip clears 44
     today only because it wraps a 34px avatar and pads it — the guarantee lives
     in `.avatar`'s height, a rule about an ICON, while the promise is about a
     PRESSABLE THING. Shrink that avatar for a narrow screen, a reasonable thing
     to want, and the touch target goes with it silently. The floor belongs on
     the button. */
  .suspect { min-height: 44px; }
  /* The suspect's second line. It sits directly under the chip above and shares
     its width, so if the two disagreed about the floor the pair would read as a
     button with a smaller button glued to it. */
  .suspect-withdraw { min-height: 44px; }
  /* The one pressable thing in the menu the pointer gate never reached. It is
     the same case as .suspect one more time: the row it sits in clears 44
     comfortably, so the miss is invisible until you measure the BUTTON — 42.5px,
     which is 24px of name plus a 2px gap plus a 16.5px meta line. Every number
     in that sum belongs to the copy, not to the rule: a profile whose meta line
     ever fits differently, or a name at a different weight, moves it again. Its
     two siblings in the row are .btn-sm and sit on 44 exactly, so the row was
     already one button out of step with itself.
     align-content, because unlike every other floor in this block this element
     is a grid: without it the two lines would settle at the top of the taller
     box and the name would no longer line up with the buttons beside it. */
  .profile-pick { min-height: 44px; align-content: center; }
  /* And the cell — which is the reason this whole block exists, and was the one
     pressable thing still missing from it. Every button above was moved off the
     viewport gate years too late; the board's own cells stayed behind on
     `max-width: 560px`, so a tablet or a phone in landscape — 561px and up,
     operated by a finger — got the desktop cell. That is the most-pressed
     element in the game sitting on the exact gate this comment was written to
     condemn. It is `var(--cell-touch)` and not 44 for the reason given at the
     token: on a tilted board 44 laid out is 40 presented. Stated here as well as
     in the width block above, because the two rules mean different things — up
     there the number holds the row open, here it holds the promise. */
  .cell { min-height: var(--cell-touch); }
  /* And the two that are not buttons at all. Impressum and Datenschutz are two
     words of running text in the smallest size the page owns, so nothing about
     them reads as a control — which is why every sweep over this block, mine
     included, walked past them. They measured 20px: the shortest reachable
     thing in the game, and the only two below the floor.

     They are links, so the floor needs a box to act on — inline boxes ignore
     min-height. inline-flex gives them one without taking them out of the
     centred, wrapping row the footer lays out. The padding is what actually
     separates the two hit areas: at 44px tall and touching, a thumb aimed at
     „Impressum" lands on „Datenschutz" as often as not, and a floor that only
     makes targets BIGGER without pulling them apart trades one miss for
     another. These two are also the legally required links in a German-market
     product — the pair a regulator taps first, and until now the pair a finger
     could not reliably hit. */
  .footer a {
    min-height: 44px;
    display: inline-flex;
    align-items: center;
    padding-inline: var(--space-2);
  }
}

/* Below 400px a full-bleed 8x8 board falls under the touch floor per cell, so the
   touch target wins over fitting the whole board on screen.

   400, not 372. The old threshold was not miscalculated, it was calculated on the
   wrong board: the comment here used to derive it from "at 360px it lands on
   42.5px laid out", and 360px on a 6x6 is the width where the question cannot be
   decided. Measured on the 8x8, the hindmost PROJECTED cell WIDTH — the one a
   finger actually meets, since this box carries a perspective — reads 375 -> 41.0,
   390 -> 42.8, 395 -> 43.4, 400 -> 44.0. So the floor was gated out of exactly
   the 27px band where it binds, and into that hole fall iPhone SE/8/X/XS/11 Pro
   (375), 12/13/14 (390) and 14 Pro (393). Same lesson as the board-height floor
   one level up: a term was verified at a width where it does not bind.

   Above 400 the natural board carries that WIDTH on its own (405 -> 44.7,
   430 -> 47.6, 435 -> 48.2), so no scroll affordance is needed there — and this
   block governs a width, so a width is what it can promise. The projected HEIGHT
   is a separate matter and is NOT settled here: the tilt compresses the hindmost
   row more vertically than horizontally, so 405..430 measures 40.9..43.5 tall.

   What carries that shortfall was stated wrongly here twice, and the second
   correction is the instructive one. It first read "561 upwards sits at 41.9 on
   every desktop width" — and 561 is, once again, a WIDTH. An independent
   re-measurement (Design Director, run a590acce792fbdc01, 2026-07-30) put the
   carrier on the HEIGHT: 48.14 x 42.11 (1.89 under 44) at 1440x800, 1366x768,
   1280x720, 1440x650, 1024x640, 812x375, 844x390, 896x414 and 740x360 — while
   1440x900 (54.82 x 47.74), 375x667, 390x844 (48.47 x 44.22) and 768x1024
   (68.87 x 59.41) all pass. The same 1440 fails at 800 and passes at 900. So the
   carrier is height <~840px, independent of width, and anyone hunting it along
   the width axis will not find it. Naming only the 30px band would
   be the same error as the 372 above — patching where a probe happened to sample
   while the dominant case keeps the identical shortfall. It is one issue in the
   perspective/--board-fit relationship, it clears WCAG 2.5.8 AA (24x24) with room
   to spare and misses only the self-imposed 44 house floor, and it is tracked as
   its own cycle (ROADMAP N-11). Ungating this block
   entirely would be worse than leaving it: overflow-x makes overflow-y compute to
   auto at EVERY width, and the wall drawn above this box would go from painted
   over to cut off. test/cellbox.test.mjs holds the threshold at or above
   --cell-touch x the largest board, so a 9x9 fails there instead of shipping a
   41px target. */
@media (max-width: 400px) {
  /* overflow-x makes overflow-y compute to auto, and that turns anything drawn
     above this box from a paint-over into a clip. The compass is the one thing up
     there that has to move, so it comes inside the box.

     The wall stays, and this box must NOT shrink its own headroom to hold it.
     There used to be a `padding-top: var(--space-2)` here, buying 8px of vertical
     room; measured against the far wall it cost more than it bought. The wall's
     height rides on --zu, but its projected overhang rides on the CELL, so the
     small board is the tall case: 5x5 has 4.3px of slack above the content edge
     at the inherited 16px of padding and -3.9px at 8px. The 8x8 (10.6px, then
     2.5px) squeaked through, which is why this shipped unnoticed — the board most
     likely to be played on a phone was the one being clipped. */
  .grid-wrap { overflow-x: auto; overscroll-behavior-x: contain; }
  .compass { top: 0; }
  .grid { min-width: calc(var(--cell-touch) * var(--size, 6)); }
}

/* A phone held sideways offers ~375px of height to a board that is as tall as
   it is wide. Unconstrained it took 696px inside a 376px viewport and began
   979px down the page: reachable only by scrolling past the whole chrome, and
   never more than a third of it on screen at once. The width gate is deliberate
   — this is about a short viewport, and a short viewport is not a narrow one, so
   none of the max-width blocks above ever fire here.

   Die Abstände allein haben das nicht erledigt, und der Satz oben ist genau der
   Grund, warum das lange nicht auffiel: 979px auf 294.6px zu drücken sieht nach
   einem gelösten Problem aus, aber 294.6 von 375 lassen 80.4px stehen — 29%,
   also weiterhin „nie mehr als ein Drittel". Die Beschreibung des Fehlers traf
   auf den Zustand NACH der Korrektur noch zu. Wer einen Deckel senkt, muss
   gegen den Fehler nachmessen, nicht gegen den Ausgangswert. */
@media (orientation: landscape) and (max-height: 500px) {
  .app { padding-top: var(--space-2); padding-bottom: calc(var(--space-4) + env(safe-area-inset-bottom)); }
  .topbar { margin-bottom: var(--space-2-5); }
  /* The board is what the viewport is short of, so everything stacked above it
     gives up its vertical share first. */
  .rank { padding-top: var(--space-1); padding-bottom: var(--space-1); }
  .scene-head { margin-bottom: var(--space-2); }
  /* Padding allein reichte nicht, und der Grund steht eine Zeile über dem, was
     dieser Block bisher anfasste: --fs-2xl ist ein clamp() auf der BREITE
     (4.2vw), und ein quer gehaltenes Telefon ist breit. Auf 812x375 löst es zu
     34.1px auf — großzügig bemessen für eine Achse, an der es hier nicht fehlt.
     `text-wrap: balance` bricht den längsten Falltitel dann auf zwei Zeilen und
     die Kante des Bretts fällt von 294.6px auf 374.3px; sichtbar bleiben 0.7px.

     Darum senken diese vier Zeilen zum ersten Mal in diesem Block eine
     SCHRIFTGRÖSSE statt eines Abstands. 18px Georgia brauchen für den längsten
     Titel — „Die letzte Stunde von Landhaus Wolkenstein", 42 Zeichen — 404px
     gegen ~764px Inhaltsbreite; die zweite Zeile entsteht damit gar nicht erst,
     statt nur schmaler zu werden.

     Hier stand „rund 340px", geschätzt und nie gemessen. Für DIESEN Block war
     der Fehler folgenlos, 340 und 404 liegen beide weit unter 764 — er wurde
     nur weitergereicht: der Querformat-Zweig für Tablets hat die 340 als
     Anforderung zitiert und seinen Titelplatz danach bemessen, wo die
     Inhaltsbreite nicht 764px ist, sondern 561. Nachgemessen wurde jetzt gegen
     den berechneten Stil des laufenden Bogens (`normal 700 18px Georgia`), über
     alle 30 Titel aus `js/scenarios.js`, und die Messung gegen die echte
     `scrollWidth` des Elements kalibriert. Eine geschätzte Zahl in einem
     Kommentar ist keine kleine Ungenauigkeit — sie ist eine Anforderung, die
     der nächste Leser übernimmt, ohne zu wissen, dass sie geraten war.

     Die beiden Augenbrauen verschwinden ganz: .case-kicker trägt den
     zugänglichen Namen NICHT, den holt sich die Sektion über ihr eigenes
     `aria-labelledby="case-title"` (`index.html`), und .tagline steht neben
     der Wortmarke `<h1>Indizoku</h1>` (`index.html`), die selbst stehen bleibt.
     `display: none` ist hier also kein Verlust an Bedienbarkeit, sondern an
     Dekoration.

     `.tagline` und nicht `.brand-sub`: der zweite Name stand in der Vorlage, im
     Markup steht er nirgends. Eine Regel auf einen Wählernamen, den es nicht
     gibt, ist still — sie fällt weder im Test noch beim Nachladen auf, sie tut
     einfach nichts. Gemessen wurde deshalb gegen den berechneten Stil, nicht
     gegen die geschriebene Zeile. */
  #case-title { font-size: var(--fs-lg); line-height: 1.15; }
  .case-kicker { display: none; }
  .topbar h1 { font-size: var(--fs-lg); }
  .tagline { display: none; }
  /* Square cells make the board's height equal to its width, so the height is
     capped by capping the width — das ist der `min(100%, 72vh)`-Arm.

     Der `max()` darum herum ist der Grund, warum dieser Deckel das Brett nicht
     unter den Tastboden drückt, und er ist bewusst hier: 72vh sind auf einem
     quer gehaltenen Telefon 270px, ein 8x8-Brett braucht aber 8 * 50 = 400.
     Ohne den max() gewinnt der Deckel gegen den Boden und die Kachel fällt auf
     34px. Gemessen, weil ich ihn genau deshalb einmal für überflüssig gehalten
     und entfernt habe: auf dem 5x5-Brett (Boden 250 < Deckel 270) ändert das
     Entfernen nichts, auf dem 8x8-Brett kostet es 16px pro Kachel. Wer einen
     Term wegnimmt, muss den Fall messen, in dem der Term BINDET.

     Was der max() hier NICHT kann, und das war der eigentliche Fehler weiter
     oben: er hebt die OBERGRENZE, nicht die Breite. Eine Kiste wird davon nicht
     breiter, sie darf nur breiter werden — wie breit sie wirklich wird,
     entscheidet ihr Elternteil. Als .grid-wrap über --board auf null
     geschlossen war, sah diese Zeile aus wie ein Netz, das längst gespannt war,
     und hat keins gehalten. Der Boden für diesen Fall sitzt darum in
     --board-fit, oben in dieser Datei, wo die Höhe ausgerechnet wird.

     Zwei Böden also, und keiner davon doppelt: --board-fit hält das Elternteil
     offen, dieser max() hält den Deckel darin über dem Tastmaß. Das Muster für
     die Breitenachse steht neun Zeilen weiter oben — min-width PLUS overflow-x
     am Elternteil — und ist wieder ein anderes, weil dort ehrliches Scrollen
     das bessere Scheitern ist. */
  /* Der Tastboden oben deckt die Kachel, die die Kachel BEKOMMT — nicht die,
     die man sieht. Die hintere Reihe steht am Ende einer Perspektive und wird
     um --rear-shrink kleiner projiziert, und diese eine Zahl geht durch keine
     der max()-Zeilen darüber hindurch. Gemessen auf 812x375, 8x8: vorne
     50.16px, hinten 41.91px. Der Boden ist eingehalten und trotzdem
     unterschritten, weil er die falsche Kachel misst.

     Der Hebel ist die Neigung, nicht die Kachelgröße: eine Fläche steigt
     h·sin(tilt), also hebt jedes Grad weniger die hintere Reihe an, ohne vorne
     etwas wegzunehmen. Gemessene Kette (dieselbe Sonde, 812x375): 26deg 41.91
     — 23deg 43.20 — 22deg 43.61 — 21deg 44.01 — 20deg 44.40.

     21deg wäre die naheliegende Zahl, weil das Telefon-Hochformat sie neun
     Zeilen weiter oben schon fährt. Sie steht hier trotzdem nicht: 44.01
     besteht den Boden um weniger als einen Rundungsschritt, und ein Boden, den
     ein halbes Pixel Zoom wieder unterschreitet, ist keiner. 20deg kostet
     2.1px Bretthöhe und hält 0.4px Abstand — auf einer Achse, auf der ehrliches
     Scrollen laut dem Absatz darunter ohnehin das bessere Scheitern ist.

     Die drei Wand-Token von oben werden NICHT mitgezogen. Sie sind auf 26deg
     eingestellt, und weniger Neigung lässt eine Wand h·sin(tilt) WENIGER weit
     ragen — die Richtung, in die der Überhang verschwindet, nicht entsteht. */
  .grid-wrap { --tilt: 20deg; }
  .grid {
    max-width: max(calc(var(--cell-touch) * var(--size, 6)), min(100%, 72vh));
    margin-inline: auto;
  }
}

/* ── Querformat auf dem Tablet und aufwärts ──────────────────────────────────
   Der Zweig existiert, weil `--board-chrome: 330px` weiter oben eine MESSUNG
   ist, aufgenommen auf 1185x826 — und beide Zuschläge, aus denen die 330
   bestehen, hängen an der FENSTERBREITE: die Meta-Zeile fällt zwischen 1185
   und 1440 auf eine eigene Zeile (+41.5px), der Titel selbst bricht unter
   ~1093px um (+31.3px). Reserviert wird damit HÖHE, begründet wird sie mit
   BREITE. Im Querformat ist das nicht bloss unsauber, sondern falsch herum:
   auf 1024x768 bindet der Höhenterm bei 438px, während 140px Fensterbreite
   ungenutzt danebenliegen. Das Brett schrumpft auf 57 % der Fensterhöhe,
   obwohl die knappe Achse gar nicht knapp ist.

   Deshalb wird hier nicht die 330 nachjustiert, sondern ersetzt: durch eine
   Summe benannter Posten, von denen jeder einzeln falsifizierbar ist — jeder
   zeigt auf genau eine Deklaration, die man aufschlagen und widerlegen kann.
   Fünf davon werden im selben Block GESETZT (wer sie prüfen will, liest die
   Regel drei Zeilen weiter unten), vier stehen ANDERSWO im Bogen und werden
   hier nur mitgezählt. Ändert sich einer, ändert sich die Summe — und
   `test/quer.test.mjs` rechnet beides gegeneinander.

   Die Posten sind nachgemessen, nicht geschätzt, und die Messung hat zwei der
   ursprünglich neun widerlegt. `--q-fussleiste` reservierte 24px für einen
   App-Rand, der auf 768px Höhe rund 123px UNTER der Kante sitzt und dem Brett
   folglich nie im Weg war; der Posten ist ersatzlos gefallen.

   `--q-topbar` hat eine längere Geschichte, und sie ist der Grund, warum diese
   Datei zwischen einer Messung und einem Deckel unterscheidet. Die Zahl stand
   zuerst auf 44 mit der Begründung, `.brand-mark` sei 44px hoch und gebe die
   Zeile vor. Das war eine Behauptung: die Zeile gab `.rank` vor, gemessene
   65.33px. Also wurde 66 eingetragen — und damit ein Zustand abgeschrieben, den
   dieser Zweig hätte ändern müssen, denn 66 waren nicht 66 Pixel Topbar, sie
   waren 22 Pixel Brett. Heute stehen wieder 44 da, aber aus dem umgekehrten
   Grund: zwei Regeln weiter unten (`.tagline` fällt weg, `.rank-meta` läuft
   einreihig) drücken die gemessene Höhe auf 44, und der Posten gibt sie als
   Deckel aus. Dieselbe Zahl, zweimal — einmal geraten, einmal getragen. Die
   Herleitung im Einzelnen steht bei der Deklaration.

   Dass die allererste Summe trotzdem stimmte, lag daran, dass sich zwei Fehler
   gegenseitig auffraßen: 22px zu wenig oben, 24px zu viel unten. Genau diese
   Sorte Zufall ist der Grund, warum hier Posten und nicht eine Zahl stehen.

   Die Auswahl läuft über Orientierung und Höhe — und zusätzlich über die eine
   Breite, an der das Layout die Spalten wechselt. Hier stand bis N-20, eine
   Breitenschranke wäre „wieder dieselbe Verwechslung, die die 330 verursacht
   hat". Der Satz verwechselte zwei verschiedene Dinge miteinander. Falsch
   bleibt, den Zweig STATT der Höhe über die Breite zu wählen: das war die 330,
   eine Reserve, die die Höhe meint und sich mit der Breite ändert. Falsch war
   der Umkehrschluss, jede Breitenschranke sei derselbe Fehler.

   Denn diese Posten sind keine Querformat-Posten, sie sind ZWEISPALTEN-Posten.
   `--q-topbar` deckelt eine Topbar, die auf EINER Zeile liegt, und einzeilig
   ist sie nur oberhalb von 940 — darunter greift der Zusammenklapp
   (styles.css:6635 `.rank { order: 3; width: 100%; }`), `.rank` nimmt eine
   eigene zweite Zeile, und keine der beiden Regeln unten kann das aufhalten:
   sie sparen Höhe, indem sie in die Breite ausweichen, und Breite ist genau
   das, was unter 941 fehlt. Nachgemessen auf 900x600 misst die Topbar dort
   135.8px. Die Reserve war um rund 70px zu klein, und zwar in der Richtung,
   die dem Brett Höhe stiehlt: gemessen 371.8px Brett bei 2302px Dokumenthöhe,
   also schlechter als das Hochformat, gegen das dieser Zweig geschrieben ist.

   Zwei der Begründungen weiter unten stützen sich ausdrücklich auf die zweite
   Spalte — `#progress { display: none }` bezahlt mit „die `.side`-Spalte steht
   im Querformat vollständig sichtbar daneben". Unterhalb von 941 stimmt das
   nicht: `.side` ist dort `position: static` und liegt UNTER dem Brett
   (gemessen auf 900x600: y 739.5 bei 600px Fensterhöhe). Der Zweig hat unter
   der Schwelle also nicht bloss eine zu kleine Zahl, er hat auch ein Argument
   verloren.

   Deshalb `min-width: 941px`. Die Zahl ist nicht gegriffen, sie ist die
   Spaltenschwelle selbst — 940 + 1, die Gegenkante zu styles.css:6624
   `@media (max-width: 940px) {`. `test/quer.test.mjs` liest sie nicht aus
   dieser Zeile ab, sondern an ihrer Wirkung: der Zusammenklapp-Block ist der,
   der `--board-taken` auf 0 zurückstellt, und jede Breitenschranke dieses
   Zweiges muss diese Schwelle treffen. Wer 941 auf 900 zieht, ohne den
   Zusammenklapp mitzuziehen, fällt dort durch.

   Was unterhalb von 941 im Querformat gilt, ist damit die Basisregel — und das
   ist kein Verzicht, sondern die richtige Zuordnung: einspaltig, Topbar
   umgebrochen, `.side` unter dem Brett. Das ist Zeile für Zeile dasselbe
   Layout wie im Hochformat, und die 330 ist die Reserve, die für genau dieses
   Layout hergeleitet wurde.

   `min-height: 501px` grenzt gegen den Querformat-TELEFON-Zweig weiter oben ab,
   der seine eigenen, viel härteren Regeln behält.

   Die 501 ist die 500 des Telefon-Zweiges plus eins, und diese Fuge ist der
   Grund, warum die Höhenachse hier lückenlos sein muss und die Breitenachse
   nicht: an der Höhe stossen zwei Zweige aneinander, an der Breite stösst
   dieser Zweig an die Basisregel, die es ohnehin gibt. Der Sweep in
   `test/quer.test.mjs` prüft die Höhen deshalb im zweispaltigen Regime — dort,
   wo die Fuge wirklich eine Fuge ist.

   Diese Fuge ist der ganze Inhalt der Zahl. Hier stand zuerst 540 — eine runde
   Zahl ohne Gegenüber, und damit ein Loch von 39 Höhen, die KEINEN der Zweige
   bekamen: volle Hochformat-Abstände in einem Fenster, das dafür zu niedrig
   ist, plus den Fallstreifen über dem Brett, den dort niemand bezahlt.
   Auffallen konnte das nicht, weil in diesem Band der Komfortboden
   (6 x 53.01 = 318px) die Brettgrösse ohnehin setzt: die Zahl blieb gesund,
   während das Layout es nicht war. Wer eine der beiden Schranken verschiebt,
   verschiebt die andere mit — `test/quer.test.mjs` überstreicht die Höhen
   320..1600 und meldet jede Höhe, die zwischen die Zweige fällt.

   Dass 1440x900 mitgefangen wird, ist Absicht und kein Streuverlust: der
   Defekt ist dort derselbe, das Brett geht 570 -> 704. */
@media (orientation: landscape) and (min-height: 501px) and (min-width: 941px) {
  :root {
    /* GESETZT — jeder Posten hat unten in diesem Block seine Regel. */
    --q-polster: 6px;      /* .app padding-top */
    --q-luft: 6px;         /* .topbar margin-bottom */
    --q-buehnenrand: 8px;  /* .panel.scene padding-top */
    --q-kopfzeile: 32px;   /* .scene-head, einzeilig erzwungen */
    --q-streifen: 44px;    /* zugeklappte Fallakte, Tastziel-Mindestmass */
    --q-topbar: 44px;      /* .topbar als Deckel; .brand-mark ist das höchste Kind */

    /* --q-topbar stand bis 2026-08-02 auf 66 und in der Liste darunter, mit
       der Begründung: die Topbar ist so hoch wie ihr höchstes Kind, und das ist
       nicht `.brand-mark` (44px), sondern `.rank` mit gemessenen 65.33px. Die
       Messung stimmte. Der Schluss daraus war der Fehler — er hat einen Zustand
       abgeschrieben, den dieser Zweig hätte ändern müssen.

       Denn 66 waren nicht 66 Pixel Topbar, sie waren 22 Pixel Brett. Im
       Querformat ist die Höhe das knappe Gut, und ein Balken, der eine
       dreizeilige Fortschrittspille und einen Untertitel trägt, ist genau die
       Sorte Aufwand, die man dort zuerst zurückfährt. Beides steht weiter unten
       als Regel: `.rank-meta` läuft einreihig, `.tagline` fällt weg. Danach
       misst `.rank` 40 und die Wortmarke 44, und das höchste Kind ist wieder
       `.brand-mark` — 44px, weil es ein Tastziel ist und dort nicht schrumpfen
       darf. 44 ist also kein Wunsch, es ist der Boden, den dieser Zweig nicht
       unterschreiten will.

       Damit wandert der Posten von unten nach oben: er ist nicht mehr Notiz
       einer Messung, sondern Deckel einer Regel. Ein Posten, der in der Summe
       steht, wird als Deckel ausgegeben; sonst ist die Summe eine Hoffnung.

       ANDERSWO GESETZT — die Regel steht woanders im Bogen, bezahlt wird sie
       hier. Jeder dieser fünf nennt seinen Fundort, damit „stimmt das noch?"
       ein Aufschlagen ist und keine Neumessung.

       --q-saum: drei Haarlinien liegen im Stapel über dem Brett und sind
       jeweils `border: 1px` — `.panel` (die Oberkante der Bühne), `.dossier`
       oben und `.dossier` unten. Nominal also 3px. Chrome rastet Rahmen auf
       ganze Gerätepixel, weshalb sie bei DPR 1.5 zusammen nur 2px messen;
       reserviert wird die nominale Drei, denn bei DPR 1 sind es drei.

       --q-fuge: `.dossier` hat unten den einzigen Abstand zwischen Fallakte
       und Brett. Er wird in diesem Block auf 8px gesetzt — siehe `.dossier`
       weiter unten — und steht trotzdem hier, weil er dieselbe Art Posten ist.

       Die Acht ist ein Skalenwert (`--space-2`) und deckt den Dachrest; sie
       ist NICHT aus ihm abgelesen. Der Unterschied ist die ganze Begründung.
       Gemessen bleibt nach dem Abzug auf 1024x768 ein Rest von 0.6px, auf
       1180x820 einer von 6.3px und auf 1366x1024 der grösste mit 6.8px. Hier
       stand deshalb lange eine 7: die kleinste Zahl, die 6.8 gerade noch
       trägt. Das war eine Messung mit einem Gleichheitszeichen davor. Eine
       Zahl, die nur eben deckt, ist keine Grösse, sondern ein Rest — sie
       wandert mit dem nächsten Wandwert, und sie wandert still, weil sie im
       Bogen wie eine Entscheidung aussieht.

       Die Skala führt auch eine 6 (`--space-1-5`), und die wäre zu klein: die
       beiden grossen Reste ragten um 0.3 bzw. 0.8px heraus. Es gibt also nicht
       „irgendeinen Skalenwert", sondern genau einen nächsten, der beides ist,
       gewählt und ausreichend, und der hat 1.2px Luft. Geprüft wird beides
       getrennt in `test/quer.test.mjs` — Herkunft (steht auf der Skala) und
       Deckung (trägt den grössten Rest) —, damit ein künftiger Wandwert, der
       den Rest über 8 hebt, als Deckungsfehler auffällt und nicht als stiller
       Anlass, die Fuge wieder auf den Rest zu setzen.

       --q-rueckwand: zwei Deklarationen, keine Wand. `.grid-wrap` trägt
       `padding-top: 16px`, `.grid` trägt `margin-top: var(--space-2-5)` = 10px;
       zusammen 26. Der frühere Kommentar schrieb die 26 einem Überhang der
       Raumwand zu — der existiert (rund 4px, und es ist der `.compass`), hat
       aber mit dieser Zahl nichts zu tun. Richtige Zahl, erfundener Grund.
       Diese 26px sind zugleich der ERSTE Zahler des Dachs. Sie liegen genau
       zwischen der Oberkante des Kastens und der Oberkante des Bretts — und
       genau dort malt die austretende Rückwand. Was `--roof-over` als
       `margin-top` obendrauf legt, kauft dieselbe Strecke ein zweites Mal;
       deshalb steht bei `.grid-wrap` weiter unten ein Abzug in Höhe dieses
       Postens. Gemessen tritt die Wand auf 1024x768 um 25.69px über die
       Kastenkante — das Polster deckt sie ganz, und der Rand ist dort null.

       --q-sockel und --q-schluessel reservieren, was UNTER dem Brett steht.
       Bis 2026-08-02 endete die Reserve an der Unterkante des Bretts, und das
       war der Fehler: die Legende ist kein Beiwerk, sie ist der Schlüssel, mit
       dem man die Symbole im Brett überhaupt liest. Auf 1024x768 lag ihre
       Unterkante bei 794.7 — 26.7px unter der Fensterkante, also genau der
       Griff zum Rad, den dieser Zweig abschaffen sollte.

       --q-sockel: `.scene-foot` trägt den einzigen Abstand zwischen Brett und
       Fuss, `margin-top: var(--space-3)` = 12px (styles.css:4169 `.scene-foot`).

       --q-schluessel: die Fusszeile ist so hoch wie ihr höchstes Kind, und das
       ist `.legend-dot` mit 26px (styles.css:4185 `width: 26px; height: 26px`).
       Die 26 sind dort tragend — sie halten den Kontrastboden der Raumfarben —
       und dürfen deshalb nicht geschrumpft werden, um hier zu sparen; sie
       werden bezahlt. Reserviert wird EINE Zeile, nicht die umbrochene: die
       Fusszeile bricht auf 55.5px, sobald `#conflict` etwas sagt. Genau dafür
       steht weiter unten `#conflict { order: -1 }`. */
    --q-saum: 3px;
    --q-fuge: 8px;
    --q-rueckwand: 26px;
    --q-sockel: 12px;
    --q-schluessel: 26px;
  }

  /* Kein `padding-bottom` mehr: der frühere Posten `--q-fussleiste: 24px` band
     den unteren App-Rand als Höhenreserve ein, obwohl der auf 768px Fensterhöhe
     rund 123px unter der Kante liegt, hinter Bühnenfuss und Legende. Er hat
     nie etwas geschützt. Mit dem Posten fällt auch die Deklaration, die es nur
     gab, um ihn zu rechtfertigen — und damit kehrt die Basisregel zurück, die
     `env(safe-area-inset-bottom)` mitrechnet und die dieser Zweig stillschweigend
     ausgeschaltet hatte. */
  .app { padding-top: var(--q-polster); }

  /* `--q-topbar` war bis 2026-08-07 der einzige der elf Posten ohne eigene
     Regel: sein Kommentar nannte `.brand-mark` als höchstes Kind und liess es
     dabei bewenden. Ein Posten ohne Regel ist eine Abschrift des Ist-Standes.
     Er hält nichts, er berichtet — und beim ersten Kind, das über 44 wächst,
     berichtet er falsch, ohne dass irgendwo etwas rot wird. Genau das ist die
     Klasse, gegen die `--q-kopfzeile` weiter unten seinen Deckel bekommen hat.

     Nachgemessen im laufenden Bogen auf 941x560, der schmalsten Breite dieses
     Zweigs. Jede Zeile ist eine Differenz gegen denselben Zustand OHNE die
     jeweilige Deklaration, nicht die Beschreibung eines Mechanismus:

       `flex-wrap: nowrap`  Rangtitel und Restweg in der Länge, die ein
                            späterer Dienstgrad wirklich liefert: Balken
                            44 → 147.33, Bühnenkante 56 → 159.33.
                            103.33 Brettpixel.
       `height`             Rang-Abzeichen auf 60px — ein grösseres Abzeichen
                            ist der wahrscheinlichste Zuwachs, und es ist der
                            Fall, den `nowrap` NICHT fängt: 44 → 69.33,
                            Bühnenkante 56 → 81.33. 25.33 Brettpixel.
       Ellipse an Rang      ohne sie hängt der Rang 12px unter der Kante
       und Restweg          (scrollHeight 56 gegen 44 Deckel).
       `flex-shrink: 0`     ohne sie werden die drei Knöpfe von 235.18 auf
       an den Knöpfen       214.47 gestaucht, ihre Beschriftung bricht um,
                            40 → 57.33, und hängt ebenfalls heraus (51).

     Kein `overflow: hidden`, anders als bei `.scene-head` weiter unten, und
     der Unterschied ist gemessen und nicht Geschmack. Dort wächst PROSA, die
     bei reinem Textzoom über die Fallakte malt. Hier ist jede Prosa schon am
     eigenen Kasten geklemmt (die Ellipse gleich unten), übrig bleibt nur das
     Wachsen ganzer KÄSTEN — und das abzuschneiden versteckt ein halb sichtbares
     Bedienelement, statt Brett zu retten, das `height` schon gerettet hat. Der
     Preis wäre benennbar: die drei Knöpfe stehen 40 hoch in einem 44er Kasten
     (Oberkante 8 gegen 6), ihr Ring aus `.btn:focus-visible` (styles.css:4961
     `outline: 2px solid var(--accent-soft); outline-offset: 2px`) reicht damit
     von 4 bis 52 — zwei Pixel oben und unten lägen im Schnitt. */
  .topbar {
    height: var(--q-topbar);
    margin-bottom: var(--q-luft);
    flex-wrap: nowrap;
  }

  /* Und wer stattdessen nachgibt. Dieselbe Abmachung wie zwischen `.scene-meta`
     und `#case-title` weiter unten, nur mit vertauschten Rollen: dort gibt der
     Titel nach und die Chips halten, hier halten die Knöpfe und die
     Auszeichnung gibt nach. Der Grund ist derselbe wie dort, in die andere
     Richtung gelesen: ein gekürzter Dienstgrad steht in der Fallakte noch
     einmal ganz da und im Menü ein drittes Mal, ein halbierter Knopf hat keine
     zweite Fassung. Ohne die Abmachung entscheidet die Reihenfolge im Markup,
     und das ist keine Entscheidung.

     `flex-wrap: nowrap` an den Knöpfen misst für sich genommen nichts — mit
     `flex-shrink: 0` kommen sie gar nicht erst in die Lage, umbrechen zu
     müssen. Es steht trotzdem da, weil der Deckel-Schiedsrichter in
     `test/quer.test.mjs` es aus dem Markup HERLEITET: was unter einem Deckel
     unbedingt umbricht, wird im Zweig zugehalten. Die Alternative wäre, ihm
     `flex-shrink: 0` als Ersatz beizubringen — aber dieser Ersatz gilt nur,
     solange niemand das Schrumpfen zurückholt, und ein Schiedsrichter mit
     Ausnahmen ist teurer als eine Zeile, die nichts kostet. */
  .topbar-actions { flex-wrap: nowrap; flex-shrink: 0; }
  #rank-title,
  #rank-note {
    min-width: 0;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  /* Diese Regel trägt die Reserve NICHT — sie repariert einen Schaden, den der
     Zweig selbst angerichtet hat. `.side` steht seit jeher auf `top: 16px` und
     `max-height: calc(100svh - 32px)`; beide Zahlen wurden für den KLEBENDEN
     Zustand hergeleitet, in dem die Spalte 16 unter der Fensterkante beginnt
     und 16 darüber endet. Im Hochformat stimmt das. Hier nicht mehr: die Spalte
     startet statisch bei 56 (`--q-polster` 6 + `--q-topbar` 44 + `--q-luft` 6),
     behält aber ihre 736 Deckelhöhe — 56 + 736 = 792 gegen eine Kante bei 768.

     Nachgemessen auf 1024x768, vor der Reparatur: `.side` 56..792, und darin
     die klebende Urteils-Leiste `.side > .actions` bis 790, `#status` von 734.9
     bis 773.3. Das ist der Punkt, an dem es kein Schönheitsfehler mehr ist:
     `#status` ist die Live-Region (`role="status"`, `aria-live="polite"`), in
     die jedes `setStatus()` schreibt — auch die begründete Verweigerung, mit
     der „Anklage erheben" antwortet, solange nicht alle Personen platziert
     sind. Eine Meldung, die der Screenreader vorliest und der sehende Spieler
     nicht sieht, ist genau der Defekt, den der Kommentar bei der klebenden
     Leiste weiter oben als gelöst beschreibt. Über diesen Zweig war er zurück.

     Also dieselben zwei Zahlen noch einmal, aber in den Token dieses Zweigs
     ausgeschrieben statt in denen des Hochformats. `top` auf den statischen
     Start: dann sind Ruhelage und Klebelage dieselbe Kante, die Spalte springt
     beim Kleben nicht mehr um 40px. Der Deckel auf `100svh` minus diesem Start
     minus `--space-4`, also 768 - 56 - 16 = 696. Damit endet die Spalte 16 über
     der Kante — derselbe Abstand, den die Grundregel meint, nur diesmal von der
     Stelle aus gerechnet, an der die Spalte hier wirklich anfängt.

     Kein Posten, keine Summe: `.side` ist eine BREITE im Brett-Term
     (`--board-taken`), ihre Höhe geht in `--board-chrome` nicht ein. Reserve
     und Anteile bleiben also exakt die, die der Herleitungsblock weiter unten
     rechnet — die Zahlen stehen dort und bewusst nur dort. Hier standen sie
     bis 2026-08-05 ein zweites Mal und waren dabei auf dem Stand vor der
     Korrektur `--q-fuge` 7 -> 8 eingefroren; eine Abschrift altert genau so.
     Der Posten-Parser sieht diese Regel nicht — er liest
     `--q-name: <zahl>px`-Deklarationen, keine `var()`-Verwendungen. */
  .side {
    top: calc(var(--q-polster) + var(--q-topbar) + var(--q-luft));
    /* Das Paar, nicht nur seine zweite Haelfte. Die Wurzelregel zahlt fuer
       denselben Deckel bewusst zwei Zeilen und begruendet das dort; dieser
       Zweig setzte bis 2026-08-07 nur die `svh`-Zeile neu. Eine Engine ohne
       `svh` verwarf sie also, die Spalte erbte den Wurzeldeckel 736 — und
       `top` blieb trotzdem bei den 56 dieser Zeile darueber, weil es in
       reinen `var()`/px-Werten steht und nie verworfen wird. 56 + 736 = 792
       gegen eine Kante bei 768: die klebende Urteils-Leiste stand unter der
       Kante, `#status` zu zwei Dritteln darunter. Das ist derselbe Schaden,
       den der Absatz oben als repariert beschreibt, nur ueber die Einheit
       wieder hereingeholt. Ein halb bezahltes Paar ist gefaehrlicher als gar
       keines: es sieht an seiner Fundstelle vollstaendig aus, und die
       fehlende Haelfte gilt woanders weiter. `test/paar.test.mjs` fragt das
       fuer den ganzen Bogen ab. */
    max-height: calc(100vh - var(--q-polster) - var(--q-topbar) - var(--q-luft)
      - var(--space-4));
    max-height: calc(100svh - var(--q-polster) - var(--q-topbar) - var(--q-luft)
      - var(--space-4));
  }

  /* Die zwei Regeln, die `--q-topbar: 44px` ERREICHBAR machen — ausgegeben wird
     der Posten seit 2026-08-07 weiter oben als Deckel auf `.topbar`. Der
     Unterschied ist der ganze Punkt von N-23: ein Deckel erzwingt die 44, diese
     beiden sorgen dafür, dass sie ohne Abschneiden zu haben sind. Beide senken
     eine HÖHE,
     indem sie in die BREITE ausweichen — die Achse, an der es hier nicht fehlt:
     auf 1024x768 sind 961px Topbar verfügbar, nachgemessen belegt sind davon
     646.3, und nach diesem Umbau 757.8. Es bleiben 203px Luft, die Zeile bricht
     also nicht um. Genau dieser Umbruch wäre der stille Rückfall: eine zweite
     Zeile kostet mehr Höhe, als hier gespart wird.

     `.tagline` ist der Untertitel „Krimi trifft Sudoku" neben der Wortmarke
     `<h1>Indizoku</h1>` (`index.html`). Er benennt nichts, was die Wortmarke
     nicht schon benennt, und trägt keinen zugänglichen Namen — dasselbe
     Argument, mit dem ihn der Querformat-TELEFON-Zweig weiter oben ausblendet.
     Ohne ihn misst der Textblock der Marke 42 statt 58.5, und das höchste Kind
     der Marke ist wieder `.brand-mark` mit seinen 44.

     `.rank-meta` stapelt Rang, Balken und Restweg; einreihig gelegt misst der
     Block 19.5 statt 48, und `.rank` fällt mit dem kleineren Polster von 66 auf
     40. Es fällt nichts weg: dieselben drei Angaben stehen nebeneinander statt
     übereinander. `min-width` muss dabei mit — die 148px sind für die gestapelte
     Form gedacht und würden die Spalten hier auseinanderziehen; `.rank-bar` ist
     ein `display: block`-Span ohne eigene Breite und fiele in einer
     Spaltenspur auf 0, bekommt also die einzige Breite, die er braucht.

     Die Maße dieser Zeile sind seit 2026-08-02 nachgezogen, ohne dass ein
     einziger Pixel Höhe dazukommt. Gestapelt ordnet die Reihenfolge von selbst:
     Rang, darunter sein Balken, darunter der Restweg. Einreihig mit gleichem
     Abstand nach beiden Seiten ordnet nichts mehr — ein 64x4-Streifen zwischen
     zwei Beschriftungen liest sich dann als TRENNER, nicht als Fortschritt.
     Deshalb rückt der Balken mit `--space-1-5` an seinen Rang heran und der
     Restweg mit derselben Marge wieder ab: aus drei gleich weit entfernten
     Dingen werden zwei Gruppen. 72x6 statt 64x4, weil ein 4px-Streifen bei
     dieser Länge auch dann noch als Linie durchgeht, wenn er halb gefüllt ist.
     Höhe unverändert: die Zeile misst weiter 19.5 (der Text ist höher als der
     Balken), und das höchste Kind von `.rank` bleibt `.rank-icon` mit 30. */
  .tagline { display: none; }
  .rank { padding-top: var(--space-1); padding-bottom: var(--space-1); }
  .rank-meta {
    grid-auto-flow: column;
    align-items: center;
    gap: var(--space-1-5);
    min-width: 0;
  }
  .rank-bar { width: 72px; height: 6px; }
  .rank-note { margin-left: var(--space-1-5); }

  /* Diese Regel trägt die Reserve NICHT — sie repariert eine Rangfolge, die
     der Zweig selbst zerbrochen hat. Weiter unten fällt `#case-title` auf
     `--fs-lg` (styles.css:7439 `font-size: var(--fs-lg)`), damit die Kopfzeile
     einzeilig bleibt. Die Wortmarke fiel nicht mit und stand danach auf
     `--fs-xl` (styles.css:547 `font-size: var(--fs-xl)`): auf allen drei
     Referenzbreiten klemmt der `clamp` dort auf 1.75rem, das sind 28px gegen
     18, also das 1.55-fache. Damit ruft der Produktname lauter als der Fall —
     und er ist zudem der einzige der beiden, der ganz zu sehen ist, denn der
     Titel daneben trägt `nowrap` und Ellipse. Der Kommentar bei `.brand h1`
     sagt ausdrücklich, dass die beiden gleichauf stehen und sie nicht die
     Größe trennt, sondern die Schrift
     (styles.css:542 `getrennt wird die beiden nicht die Größe`).

     Der Boden-Einwand von dort greift hier nicht, und das ist der Grund,
     warum diese Zeile in diesem Zweig stehen darf und in der Basis nicht:
     `--fs-xl` ist dort Boden, WEIL der Textblock mit 28px Zeile plus 11px
     `.tagline` auf 58.5 kommt und damit über der 44px-Marke bleibt. In diesem
     Zweig ist die `.tagline` neun Zeilen weiter oben abgeschaltet — der Block
     misst schon vor dieser Regel nur 42 und liegt damit ohnehin unter der
     Marke. Die Lage, vor der der Boden schützt, ist hier bereits eingetreten;
     diese Regel verursacht sie nicht. Der Querformat-TELEFON-Zweig zieht
     genau denselben Schluss und senkt beide Seiten (styles.css:6999
     `.topbar h1 { font-size: var(--fs-lg); }`).

     Kosten: null Brettpixel. Der Textblock fällt 42 → 27, höchstes Kind der
     Marke bleibt `.brand-mark` mit 44, `--q-topbar` bleibt also bei 44 — und
     der Kopfzeile wachsen nebenbei ~46px Ellipsen-Reserve. */
  .topbar h1 { font-size: var(--fs-lg); }

  .panel.scene { padding-top: var(--q-buehnenrand); }

  /* Der Posten der Rundenleiste, aufgeschlagen NUR wenn sie da ist.
   `:has()` und keine Klasse am Rumpf: der Zustand steht schon im DOM (die
   Leiste traegt `hidden`, solange keine Runde laeuft), und eine zweite
   Zustandsangabe daneben waere genau die Drift, gegen die dieses Hauptbuch
   gebaut ist. Faellt `:has()` aus (aeltere Browser), bleibt es beim alten
   Verhalten — das Brett ist dann etwas zu gross, aber nichts bricht.
   Die Leiste wird im Querformat ausserdem einzeilig gehalten, sonst ist der
   Posten eine Behauptung statt einer Messung — dieselbe Ueberlegung wie bei
   `--q-kopfzeile` darunter. */
/* KEIN `max-height` mehr. Der Deckel stand hier auf 900px und schloss damit
   ausgerechnet das groesste Tablet aus: iPad Pro 12.9 quer ist 1366x1024, die
   Hoehe faellt aus dem Zweig, die Reserve blieb 0 — und das Brett ging mit
   sichtbarer Leiste 19px ueber den Schirm (Dokument 148px), waehrend iPad 9
   (-38) und iPad Air (-31) im Zweig lagen und sauber blieben.

   Warum der Deckel ersatzlos geht und nicht nur hochgesetzt wird: eine
   Hoehenschranke zwischen 1024 und 1080 waere genau die Sorte Zahl, die beim
   naechsten Geraet wieder falsch ist. Die Bedingung ist nicht "kleiner
   Schirm", sondern "die Leiste ist da und kostet Hoehe" — und die steht schon
   im `:has()` darunter.

   Der Desktop wurde vorher gemessen, weil dieser Griff ihn mitnimmt: dort
   gewinnt in `--board: min(Breitenterm, --board-fit)` der Breitenterm (794px
   gegen 865), die zusaetzliche Reserve aendert `--board` also nicht. Ohne
   diese Messung waere der Fix eine Hypothese, die anderswo Brettpixel kostet.
   Nachstellen: node .sonden/r18-fund15-quer.mjs */
@media (orientation: landscape) {
  /* ADDITIV, nicht neu gerechnet. `--board-chrome` traegt je nach Zweig 330px
     oder 215px; eine zweite Rechnung daneben waere die eine Zahl, die beim
     naechsten Umbau des Hauptbuchs zurueckbleibt. Der Posten geht stattdessen
     als eigener Summand in `--board-fit` ein und steht ohne Runde auf 0. */
  /* 46px = die einzeilige Leiste (Marke 20 + Polster) plus ihre Fuge nach
     unten. Die Zahl steht HIER und nicht im `:root` des Hauptbuchs darueber:
     jenes fuehrt die FESTEN Posten, deren Summe `--board-chrome` ergibt, und
     ein bedingter Posten in dieser Summe macht sie fuer jeden
     Einzelspieler-Fall falsch — der Waechter in `test/quer.test.mjs` rechnet
     sie nach und hat genau das gemeldet (261 gegen 215). */
  /* NUR der Szenen-Selektor. Hier stand daneben
     `.grid-wrap:has(~ .mp-leiste:not([hidden]))`, und der konnte nie greifen:
     `~` sucht ein NACHFOLGENDES Geschwister, in `index.html` steht
     `#mp-leiste` aber VOR `.grid-wrap`. Eine tote Zeile in einer Regel, die
     eine Reserve setzt, ist besonders teuer — sie liest sich wie eine zweite
     Absicherung und ist keine, und wer den funktionierenden Selektor spaeter
     umbaut, glaubt einen Rueckfall zu haben. */
  .scene:has(.mp-leiste:not([hidden])) .grid-wrap {
    --q-rundenleiste-aktiv: 46px;
  }
  .mp-leiste {
    flex-wrap: nowrap;
    overflow-x: auto;
    gap: var(--space-2);
    margin-bottom: var(--space-1);
    padding-bottom: var(--space-1);
  }
  .mp-leiste-lage { flex: 1 1 8rem; }
}

/* Einzeilig erzwungen, sonst ist `--q-kopfzeile` eine Behauptung statt einer
     Reserve: `flex-wrap: wrap` weiter oben ist genau der Mechanismus, der am
     330er-Posten des Hochformats die 41.5px Zuschlag erzeugt hat
     (styles.css:1486 `+41.5`).

     `height` und nicht `min-height`. Bis 2026-08-02 stand hier der Boden, und
     ein Boden sagt nur, wie klein der Kasten werden darf — wie gross er wird,
     sagt er nicht. Nachgestellt auf 1024x768 mit sichtbarer Altersfreigabe,
     sichtbarem Lügner-Chip und einem Falltitel der Länge, die der Generator
     wirklich liefert: 67px gegen 32 reservierte. Die 35px Differenz zahlt das
     Brett, und sie entstehen aus genau dem Umbruch, gegen den dieser Zweig
     geschrieben ist. Ein Posten, der in der Summe steht, wird als Deckel
     ausgegeben; sonst ist die Summe eine Hoffnung.

     `overflow: hidden` macht den Deckel erst zu einem. Eine feste `height`
     schneidet nichts ab — sie legt nur fest, wie viel Platz der Kasten im
     Fluss BEANSPRUCHT; was darin nicht passt, wird trotzdem gemalt und läuft
     über die Fallakte darunter. Der Fall tritt ein, sobald der Benutzer die
     Textgröße allein hochzieht (reines Text-Zoom, `--fs-lg` wächst, `--q-*`
     nicht): dann steht Fremdtext über der Aktenzeile statt abgeschnitten am
     eigenen Rand. Ein Posten, der die Summe hält, aber daneben malt, hat den
     Streit nur verlagert. */
  .scene-head {
    height: var(--q-kopfzeile);
    margin-bottom: 0;
    flex-wrap: nowrap;
    align-items: center;
    overflow: hidden;
  }

  /* `align-items: center` zentriert die AUSSENKANTE des Flex-Kindes, und die
     schliesst den Rand ein. Der Titel trägt aus `.scene-head h2` ein
     `margin: var(--space-1) 0 0`, also 4px oben und nichts unten — im
     mitwachsenden Kasten war das die Fuge zur Kickerzeile darüber, hier ist die
     Zeile weg (`.case-kicker { display: none; }` gleich unten) und der Rand
     bleibt als reine Schieflage übrig.

     Nachgemessen im laufenden Bogen auf 941x560: Kasten 87–119, Titel 94.2–115.8,
     also 7.2px Luft oben gegen 3.2px unten. Mit `margin-top: 0` sitzt er
     92.2–113.8, 5.2px auf beiden Seiten. Die Höhe des Kastens bleibt in beiden
     Fällen 32px — das ist kein Posten-Eingriff, `--q-kopfzeile` deckelt ihn
     ohnehin. Es ist ein Versatz von 2px, den man einzeln nicht sieht und neben
     der Uhr rechts, die ihre eigene Mitte hat, sehr wohl. */
  .scene-head h2 { margin-top: 0; }

  /* Ein Deckel klemmt nur ab, was nicht umbrechen darf — und die Chip-Reihe
     durfte es. `.scene-head` allein zuzuhalten hätte die zweite Chip-Zeile aus
     dem Kasten hängen lassen statt sie zu verhindern. */
  .scene-meta { flex-wrap: nowrap; flex-shrink: 0; }

  /* Alle drei verschwinden, weil sie im selben Bild nichts sagen, was nicht
     danebensteht — aber jedes aus einem eigenen Grund, und der dritte hat
     einen Preis.

     `.case-kicker` benennt einen Abschnitt, den `aria-labelledby="case-title"`
     bereits benennt.

     `#case-sub` nennt vier Dinge, die alle zweimal dastehen: Grösse (am Brett
     ablesbar), Opfer (die Karte im Ablagefach, `.suspect-victim` — hier stand
     `#victim-card`, eine eigene Kartenform, die es seit dem Fundort nicht mehr
     gibt: die Leiche wird gesetzt wie jede andere Figur), Verdächtigenzahl
     (siehe unten) und
     Schwierigkeit. Die Schwierigkeit steht im Select `#difficulty` — das liegt
     in `.menu-section`, also hinter der Menü-Taste, nicht in der Topbar. Hier
     stand „Topbar-Select"; das war die dritte Stelle in diesem Block, an der
     ein Kommentar einen Fallback an einen Ort gelegt hat, wo er nicht ist.
     Nachgemessen im laufenden Bogen auf 1024x768: `#difficulty`, `#tone` und
     `#mode` haben alle drei eine Rect der Grösse 0. Der Verweis trägt trotzdem,
     nur eben auf einen Umweg: sichtbar ist die Schwierigkeit erst nach einem
     Tastendruck.

     `#progress` ist der eigentliche Eingriff, und er ist ein Tausch, kein
     Streichen. Nachgemessen auf 1024x768: `.scene-head` ist 561px breit, Fuge
     zur Chip-Reihe 12px, Chip-Abstand 8px. Die Chips messen tone 55.2,
     liar 89.0, accomplice 89.8, timer 59.9, progress 91.2 — und liar und
     accomplice schliessen einander aus, weil ein Fall aus genau EINER
     MODES-Zeile entsteht (`js/ui.js`, `MODES`). Der schlechteste Fall ist also
     tone + accomplice + timer + progress + 3 Abstände = 320.1px, nicht die
     Summe aller fünf. Für den Titel bleiben damit 228.9px. Von den 30 Titeln
     aus `js/scenarios.js` sind im laufenden Stil (`normal 700 18px Georgia`,
     gegen die echte `scrollWidth` kalibriert) 16 länger als das. Ohne
     `#progress` sind es 328.1px und noch 2; im häufigen Fall (plain,
     klassisch: nur timer + progress) 489.1px und keiner.

     Bezahlt wird das damit, dass die Verdächtigenzahl nicht mehr als eine Zahl
     dasteht. Sie bleibt ablesbar, aber verteilt: die `.side`-Spalte steht im
     Querformat vollständig sichtbar daneben, und jede nicht platzierte Karte
     sagt selbst „noch nicht platziert". Bei fünf Verdächtigen ist das ein
     Blick. Ehrlich dazu: eine Karte ist 72px hoch bei 78px Schrittweite und die
     erste beginnt bei 221 — sieben passen ohne Scrollen, bei acht (Experte,
     8x8) fällt die letzte unter die Kante. Wer das zurückdrehen will, dreht
     damit auch die 16 abgeschnittenen Titel zurück. */
  .case-kicker { display: none; }
  #case-sub,
  #progress { display: none; }

  /* Zwei Kästen in einer Zeile, die nicht umbrechen darf, brauchen eine
     Abmachung darüber, welcher nachgibt — sonst entscheidet die Reihenfolge im
     Markup, und das ist keine Entscheidung. Der Titel gibt nach, weil Uhr und
     Fortschritt der laufende Spielstand sind und ein halb abgeschnittener
     Timer schlicht unlesbar ist. Gekürzt mit Auslassungspunkt, nicht
     abgeschnitten — `.scene-title` trägt dafür bereits `min-width: 0`, ohne
     das ein Flex-Kind gar nicht erst unter seine Inhaltsbreite schrumpft.

     Hier stand als Begründung, der Titel stehe „zwei Zentimeter weiter unten
     noch einmal vollständig in der Fallakte". Das war falsch, und zwar
     nachprüfbar falsch: die Fallakte rendert `p.dossier`, der Titel kommt im
     ganzen Bogen genau einmal vor, in `renderHead()`. Der Satz hat den Schnitt
     nicht bloss beschönigt, er wurde weitergereicht — ein Design-Review hat
     ihn als Tatsache zitiert. Ein Kommentar, der eine Fallback-Kopie erfindet,
     ist gefährlicher als gar keiner: er nimmt dem nächsten Leser den Anlass,
     nach dem echten Fallback zu suchen.

     Den echten trägt seit N-20 das Element selbst — `renderHead()` setzt neben
     `textContent` auch `title`, damit der abgeschnittene Fall wenigstens
     abrufbar bleibt. Wer den Schnitt hier ändert, ändert dort mit. */
  #case-title {
    font-size: var(--fs-lg);
    line-height: 1.2;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  .dossier { margin-bottom: var(--q-fuge); }

  /* Eine Rangfolge, die die Höhe schon vorgibt, darf die Schrift nicht
     dagegenstellen. Der zugeklappte Fallstreifen ist mit `--q-streifen` 44px
     hoch, die Titelzeile darüber mit `--q-kopfzeile` 32px — der Streifen ist
     also das grössere Feld, obwohl er nur die Tür zu dem Kasten ist, dessen
     Überschrift der Titel trägt. In der Vertikalen lässt sich das nicht
     auflösen: 44px sind das Mindestmass einer Tippfläche, und der Titel steht
     bereits auf einer Zeile. Also gibt die Schrift nach, nicht der Kasten.

     `--fs-sm` -> `--fs-xs` ist dabei kein Ausweichen auf die nächstkleinere
     Zahl, sondern das Einlösen eines Vertrags — nur nicht des zitierten. Hier
     stand „`--fs-xs` ist als uppercase eyebrows only deklariert"; der Satz war
     schon damals falsch, denn sieben von zwanzig Regeln standen klein — eine
     davon (`.dossier-note`) zwei Absätze tiefer in diesem Block. Gemessen und
     seit `test/versalien.test.mjs` bewacht gilt: Versalien und Laufweite reisen
     zusammen, und `.dossier-label` löst genau das ein
     (styles.css:1132 `.dossier-label {`). Der Streifen bleibt 44px hoch und
     damit tippbar; nur sein Gewicht sinkt unter das des Titels.

     Gleich gross heisst dabei nicht gleich laut. `.dossier-note` steht schon
     auf `--fs-xs`, der Rang zwischen Etikett und Notiz hängt also nach diesem
     Schritt nicht mehr an der Grösse — er hängt an vier anderen Achsen, und die
     bleiben unberührt. Auf 1024x768 gemessen: 600 gegen 400 im Gewicht,
     `uppercase` gegen `none`, 1.32px gegen 0 Laufweite, #fafafa gegen #c7c7c7.
     Vier Unterschiede tragen eine Rangfolge, die vorher fünf trugen; der
     fünfte war der einzige, der Höhe kostet. */
  .dossier-label { font-size: var(--fs-xs); }

  /* Der Fallstreifen holt seine Höhe aus demselben Posten, der sie bezahlt.
     `nowrap` + `overflow: hidden` sind nicht Kosmetik: eine zweite Zeile im
     Streifen wäre 44px, die niemand reserviert hat, und damit dieselbe
     Fehlerklasse wie die Meta-Zeile am 330er-Posten. Was nicht passt, wird
     geklammert — die Notiz ist eine Wiederholung, kein Inhalt. */
  .dossier-summary {
    height: var(--q-streifen);
    min-height: 0;
    padding-block: 0;
    flex-wrap: nowrap;
    white-space: nowrap;
    overflow: hidden;
  }

  /* Die Fusszeile hat zwei Kinder und trägt Reserve für eine Zeile. Also muss
     entschieden werden, welches der beiden die Zeile bekommt, wenn beide nicht
     nebeneinander passen — und das tun sie nicht: Legende 470.7 + Fuge 16 +
     Meldung 251 = 738 gegen 561.3 verfügbarer Breite, gemessen auf 1024x768.

     Die Meldung gewinnt. `#conflict` ist eine `role="status"`-Live-Region
     (index.html `id="conflict"`) und der einzige Kanal, über den das Spiel
     einen Regelverstoss meldet; die Legende ist ein Nachschlagewerk, das
     dieselbe Auskunft nach dem Scrollen unverändert gibt. Eine Rückmeldung,
     die man erst suchen muss, ist keine.

     Dass die Zeile trägt, ist nachgemessen und nicht abgeleitet. Auf 1024x768
     mit gesetzter Meldung: Fusszeile 26 -> 55.5px, und die zweite Zeile liegt
     bei y 771.5, also unter der Kante von 768. Mit `order: -1` bekommt die
     Meldung y 742 und die Legende die 771.5; in DOM-Reihenfolge ist es
     umgekehrt, die Meldung landet bei y 778. Verschoben wird also nicht, was
     sichtbar ist, sondern welches der beiden Kinder es ist.

     Der Preis der Alternative ist bezifferbar, und bezifferbar ist hier die
     DIFFERENZ, nicht die Reserve: eine Reserve, die beide Zeilen trägt, läge
     um die zweite Zeile höher — 29.5px, gemessen an der Fusszeile 26 -> 55.5px
     oben — und das Brett fiele auf 1024x768 um dieselben knapp 30px. Der
     Ausgangswert steht im Herleitungsblock und wird hier nicht abgeschrieben;
     der Absatz argumentiert ohnehin mit dem Abstand und nicht mit dem Stand.
     Diese 30px sind der Grund, warum hier umsortiert und nicht aufgestockt
     wird.

     Die zweite Alternative ist gemessen und verworfen: die Fusszeile auf
     `nowrap` stellen und die Legende als schmalen, waagerecht scrollenden
     Streifen daneben stehen lassen. Technisch trägt sie — mit `flex-wrap:
     nowrap` auch auf `.legend`, ausgeblendeter Leiste und einer Verlaufsmaske
     bleibt die Fusszeile bei 26px, die Legende zeigt 62.4% ihrer Breite (294
     von 471), und die Meldung endet bei y 761.8, also über der Kante. Zwei
     Zwischenstufen fallen vorher durch, beide nachgemessen: ohne `nowrap` auf
     `.legend` greift `overflow-x` nie, weil die Legende innen umbricht statt
     überzulaufen — Fusszeile 60px, Meldung auf y 759.3, unter der Kante, also
     schlechter als heute. Und mit sichtbarer Scrollleiste kostet der Streifen
     15.3px Höhe, das hebt `--q-schluessel` von 26 auf 41 und drückt den Anteil
     auf 0.703, unter SHARE_MIN.

     Verworfen wird sie trotzdem, und zwar am Inhalt: die Meldung spricht von
     Reihe und Spalte, die Legende ordnet Farben den Räumen zu. Wer den
     Konflikt liest, braucht keine Raumfarbe, um ihn aufzulösen — die beiden
     stehen auf verschiedenen Achsen. Ein waagerecht scrollender Bereich mit
     versteckter Leiste braucht `tabindex="0"`, sonst kommt die Tastatur nicht
     an den überlaufenden Teil (WCAG 2.1.1). Diesen Tabstopp in jeder
     Ausrichtung dauerhaft einzuziehen, für einen Zustand, den es nur im
     Querformat und nur während eines Regelverstosses gibt, ist der schlechtere
     Tausch. Ohne Meldung — der Normalfall — steht die Legende ohnehin
     vollständig da, bottom 765 von 768.

     Nur `order`, kein `flex-direction`: die Fusszeile bleibt eine umbrechende
     Zeile, und `order` ändert allein, welche Zeile zuerst kommt. Die
     Vorlesereihenfolge bleibt die des DOM — was hier verschoben wird, ist die
     Malreihenfolge, nicht die Bedeutungsreihenfolge. */
  #conflict { order: -1; }

  /* Von der Fensterkante bis zur Unterkante der Fusszeile, in der Reihenfolge,
     in der man sie von oben nach unten durchläuft:
       --q-polster      6   .app padding-top
       --q-topbar      44   .topbar, Höhe von `.brand-mark` (Rang/Marke gedeckelt)
       --q-luft         6   .topbar margin-bottom
       --q-saum         3   3 x `border: 1px` — .panel oben, .dossier oben/unten
       --q-buehnenrand  8   .panel.scene padding-top
       --q-kopfzeile   32   .scene-head, einzeilig erzwungen
       --q-streifen    44   .dossier-summary, zugeklappt
       --q-fuge         8   .dossier margin-bottom
       --q-rueckwand   26   .grid-wrap padding-top 16 + .grid margin-top 10
       --q-sockel      12   .scene-foot margin-top
       --q-schluessel  26   .legend-dot, höchstes Kind der Fusszeile
     = 215. Die ersten neun Posten (177) reichen bis zur Unterkante des Bretts,
     die letzten zwei bezahlen Fuss und Legende darunter. Beide Zahlen bewacht
     `test/quer.test.mjs` gegen die gerechnete Summe — sie standen hier als
     einzige Abschrift und liefen trotzdem auseinander, weil `--q-fuge` von 7
     auf 8 stieg und nur die Gesamtsumme nachgezogen wurde.

     Elf Zeilen und ein Zwölfter, der nicht mitzählt, aber mitschiebt: der
     `margin-top`, mit dem `.grid-wrap` dem Dach ausweicht. Er steht nicht in
     dieser Liste, weil er kein eigener Raum ist — er kollabiert mit `--q-fuge`
     und wird nur dann sichtbar, wenn er grösser ist als sie. Die Buchhaltung
     bleibt deshalb elfzeilig, aber sie ist nicht mehr die ganze Wahrheit über
     die Fensterhöhe; die prüft `test/quer.test.mjs` Pflicht (e) nach, indem sie
     das Dach aus derselben Formel rechnet, die der Bogen fährt, den Abzug aus
     der Regel bei `.grid-wrap` liest und verlangt, dass die Legende im Fenster
     endet. Eine Reserve kann stimmen und die Seite trotzdem zu lang sein.

     Die Summe ist zweimal nachgemessen worden, und beim ersten Mal stand hier
     235. Der Unterschied sind nicht 22 gefundene Pixel, sondern ein anderer
     Umgang mit demselben Posten: `--q-topbar` war eine notierte Messung (66) und
     ist jetzt ein Deckel (44), den die zwei Regeln oben tragen. Die Herleitung
     dazu steht bei der Deklaration; hier zählt nur, dass die Zeile ihren
     Charakter gewechselt hat.

     215 trägt auf allen drei Referenzen, ist aber ausdrücklich NICHT mehr der
     kleinstmögliche Wert — hier stand 214, und das war er. Der eine Pixel
     Unterschied ist die Fuge, die von 7 auf den Skalenwert 8 gegangen ist
     (Begründung bei ihrer Deklaration). Damit hat diese Zahl ihren Charakter
     gewechselt, so wie `--q-topbar` ihn vorher gewechselt hat: sie ist die
     Summe elf einzeln begründeter Posten und nicht mehr das Minimum. Wer sie
     senken will, muss einen Posten senken und dessen Begründung mitnehmen; ein
     Abzug direkt an dieser Summe wäre eine Zahl ohne Herleitung. Der Beleg
     ist derselbe wie bei 235: die neun Posten bis zur Brettkante messen auf
     1024x768, 1180x820 und 1366x1024 dieselbe Zahl — eine Reserve, die die Höhe
     meint, darf sich mit der Breite nicht ändern, und diese tut es nicht. Die
     Aufrundungen (Saum 2 -> 3) liegen absichtlich auf der Seite, auf der zu viel
     reserviert wird statt zu wenig.

     Nicht reserviert wird das `padding-bottom` der Bühne (`.panel`, 16px):
     das ist Leerraum UNTER der letzten Zeile, und ein Posten schützt nur, was
     Inhalt trägt — dasselbe Argument, an dem oben `--q-fussleiste` gestorben
     ist.
     Literal und nicht `calc()`, damit die Zahl im Stylesheet steht und gegen
     die Summe der Posten geprüft werden kann statt gegen sich selbst.
     Auf `.grid-wrap`, weil die Basisdeklaration dort sitzt — auf `:root`
     gesetzt verlöre der Wert gegen sie. */
  .grid-wrap {
    --board-chrome: 215px;

    /* Und der Abzug, um den es oben ging. Die Basisregel schiebt den Kasten um
       das volle Dach nach unten; im Querformat ist die obere Strecke aber schon
       bezahlt — `--q-rueckwand` IST das Polster zwischen Kastenkante und
       Brettkante, und genau darauf malt die austretende Rückwand. Gezahlt wird
       hier nur noch, was über das Polster hinausragt.

       Nicht ersatzlos gestrichen: oberhalb von rund 1538px Fensterbreite läuft
       `--zu` in seinen Deckel, die ferne Wand hört auf mitzuwachsen, und das
       Dach steht bei rund 42px gegen 26px Polster. Ohne Rest malte die Wand
       dort etwa 10px in den Fallstreifen. Und nicht `- var(--q-fuge)` obendrauf:
       die Fuge wird durch den Kollaps ohnehin gewährt, sie ein zweites Mal
       abzuziehen verschöbe die Deckung um genau ihren Betrag nach unten.

       Gemessene Restluft unter der Fallakte (Dach, Abzug 26, Spalt):
         1024x768   26.6   0.6   8 (die Fuge gewinnt)
         1180x820   32.3   6.3   8 (die Fuge gewinnt)
         1366x1024  32.8   6.8   8 (die Fuge gewinnt)
       Auf allen drei Referenzen deckt das Polster das Dach vollständig; der
       Rand tritt erst darüber hinaus in Erscheinung. */
    margin-top: max(0px, calc(var(--roof-over) - var(--q-rueckwand)));
  }
}

@media (prefers-reduced-motion: reduce) {
  /* The delay is reset along with the duration. Several of the one-shots stagger
     themselves with `animation-delay` and `backwards`, so shortening the duration
     alone would leave every cell parked at its start frame for the length of its
     own delay and then snap — a worse flicker than the animation it replaces. */
  /* `*` allein trifft die PSEUDOELEMENTE nicht — und dort liegt in dieser Datei
     ein guter Teil der Bewegung. Die Luecke war halb bekannt: `.cell::after`
     wird im selben Block einzeln stillgelegt, `.clue-struck .clue-text::after`
     (0,5 s Streichanimation), `.dossier-summary::after` (0,18 s Drehung) und
     `.cipher-key summary::before` liefen weiter. Einzeln nachzutragen ist genau
     die Aufzaehlung, die beim naechsten Pseudoelement wieder unvollstaendig
     ist; die drei Selektoren decken die Klasse. */
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-delay: 0s !important;
    transition-duration: 0.01ms !important;
  }
  /* The wildcard above shortens durations, it does not stop them: an `infinite`
     animation at 0.01ms still restarts a hundred thousand times a second, which
     is a repaint storm rather than a still image. EVERY `infinite` animation in
     this file therefore has to be named below and pinned to a resting frame —
     the rule is mechanical, not a judgement call about which loops are worth
     stopping, and `test/audio.test.mjs` fails the build if a new one appears
     without an entry here. */
  .fx-fire, .fx-flame, .fx-sway {
    animation: none !important;
    transform: translateZ(calc(var(--z, 0) * var(--zu)));
  }
  /* Der Verbindungs-Puls. Seine Ruheframe ist die VOLLE Deckkraft und nicht die
     halbe: die Lampe sagt „die Leitung steht", und ein Zustand, der im
     Ruhebild halb erloschen aussieht, sagt in genau dem Moment das Gegenteil.
     Der Zustand bleibt ohne Bewegung vollstaendig ablesbar — er haengt an der
     FARBE (gruen/gelb) und an `title`, nie am Pulsieren. */
  .mp-puls[data-zustand="verbunden"] {
    animation: none !important;
    opacity: 1;
  }
  /* Die Ruheframe der Figur ist NICHT die der Moebel darueber: sie steht auf
     `--pawn-floor` statt auf `--z`, und sie traegt den Gegenzug zur
     Kameraneigung. Ohne die zweite Zeile hier legt `animation: none` sie nicht
     still, sondern flach — die Basis-Transformation kaeme aus der Regel bei
     `.pawn-body`, aber `transform` waere hier ueberhaupt nicht gesetzt und
     stuende auf dem Wert der Grundregel. Der Ruhewinkel ist 0deg und nicht der
     Anfangs-Keyframe (-1.4deg): eine stillgestellte Figur soll gerade stehen,
     nicht dauerhaft schief. */
  .pawn-body {
    animation: none !important;
    transform: translateZ(var(--pawn-floor)) rotateX(calc(-1 * var(--tilt)));
  }
  /* The flicker is two animations on two elements, so stopping the prism alone
     would leave the light still pulsing on its faces. */
  .fx-fire > *, .fx-flame > * { animation: none !important; filter: none; }
  /* The loading ring is the one infinite animation that is not scenery, and it
     was the one this block forgot. It stays visible rather than hidden: the
     overlay's sentence is the actual message, and an empty gap where a token
     was would read as a half-loaded dialog. Frozen mid-sweep, so the ring is
     still legible as an arc rather than as a plain circle. */
  .spinner {
    animation: none !important;
    transform: rotate(135deg);
  }
  /* Lifting furniture under the cursor is decoration, not feedback — the cell
     already has its own hover ring. */
  .cell:hover .furn { transform: none; }
}

/* Windows High Contrast drops box-shadow and background-image entirely — and
   every state on this board is a box-shadow. Without this block the victim, the
   conflict and the drop target become indistinguishable from an empty cell, so
   each one is restated as a border, which HCM keeps. */
@media (forced-colors: active) {
  .cell { border: 1px solid CanvasText; }
  /* Die Urheber-Marke, und sie steht hier ABSICHTLICH nicht im Rahmen-Kanal:
     „wer hat gesetzt" ist keine Meldung, die mit Leiche, Konflikt oder Sperre um
     die Feldkante streitet — sie sitzt in der Ecke und gehoert nicht in die
     Rangleiter. Die Selektoren sind deshalb verschachtelt (`.cell-von-mir
     .urheber-marke`) und nicht die Klasse selbst; der Leser dieser Datei liest
     nur exakte `.cell…`-Selektoren als Rahmen-Regel, und das ist genau richtig.

     Das Tragende ueberlebt hier ohnehin ohne Zutun: die Unterscheidung ist der
     BUCHSTABE, und den faerbt kein Kontrastmodus weg. Die Systemfarben unten
     stellen nur wieder her, dass die Marke ueberhaupt eine Flaeche hat —
     `background-color` wird im Kontrastmodus erzwungen, die Marke waere sonst
     ein Buchstabe ohne Traeger mitten auf dem Feld. */
  .urheber-marke { border: 1px solid CanvasText; background: Canvas; color: CanvasText; }
  .cell-von-mir .urheber-marke { background: Highlight; color: HighlightText; }
  .cell-von-dir .urheber-marke { background: Canvas; color: CanvasText; }
  /* Die grösste Lücke dieses Blocks, und sie war unsichtbar, weil sie aus einer
     ANDEREN Regel weiter unten folgt: `.furn` ist dort `display: none`, also
     verschwindet mit dem Prisma auch der einzige Hinweis darauf, dass dieses
     Feld durchsucht werden kann. Übrig blieb ein leeres Quadrat mit einem
     `cursor: pointer`, den niemand sieht — die Suche, also die halbe Mechanik
     des Spiels, war im Kontrastmodus nicht auffindbar.
     Sie steht bewusst NICHT im Rahmen-Kanal. „Hier steht ein Möbel" ist keine
     Meldung, die mit Leiche, Konflikt oder Sperre um denselben Platz streiten
     müsste, sondern eine bleibende Eigenschaft des Feldes — und im Rahmen wäre
     sie von jeder dieser Meldungen überschrieben worden. Am sichtbarsten dort,
     wo es am meisten weh tut: ein Sessel, auf dem jemand sitzt, ist weiterhin
     durchsuchbar, trüge aber nur noch die Personenkante. `outline` überlebt den
     Kontrastmodus genau wie `border`, liegt aber daneben statt darin, also
     bleibt der Hinweis unter jeder Zustandskante stehen.
     Der Preis dafür stand bis zu diesem Zyklus offen benannt hier: der
     Fokusring `.cell:focus-visible` war ebenfalls eine `outline` und trug mit
     der Pseudoklasse die Spezifität (0,2,0) — auf dem fokussierten Feld verdeckte
     er den Hinweis, und zwar ausserhalb dieses Blocks und damit ausserhalb jeder
     Reihenfolge-Regel, die hier verhandelt wird. Er ist bezahlt: der Ring ist
     jetzt aus dem Kontrastmodus ausgezäunt (styles.css:2284 `@media not all and (forced-colors: active)`)
     und zeichnet hier unten auf `.cell::before`, einer eigenen Box, die um
     nichts konkurriert. Der Verlust war ein echter, und er betraf am Ende nicht
     nur die Möbel: als `.cell-victim` später denselben Kanal bezog, erbte die
     Leiche denselben Ausfall, ohne dass irgendwo eine Zeile dazukam. Ein
     zweiter Kanal erbt alle Fehlerarten des ersten — auch die, die man beim
     ersten Mal bewusst in Kauf genommen hat. */
  .cell-furniture { outline: 1px dashed CanvasText; outline-offset: -5px; }
  /* Und weil dieser zweite Kanal nun existiert, muss die Buchführung ihn auch
     benutzen. `cell-searched`/`cell-trace` stehen unten am Anfang der
     Rangleiter und verlieren den Rahmen gegen `.cell-person` — das ist keine
     Randlage, sondern der Regelfall: `const seatCells = sample(`
     (`js/engine.js`) setzt bis zu zwei Möbelfelder als Sitzplätze auf
     LÖSUNGSzellen, jedes Brett aller vier Schwierigkeiten trägt also gelöst
     zwei Felder, die gleichzeitig Möbel und Person sind. Ohne die zwei Zeilen
     hier wäre dort nicht erkennbar, ob schon gesucht wurde — und genau daran
     hängt, was der nächste Klick tut: `p.furnitureByCell[cell] &&`
     (`js/ui.js`) verzweigt über dasselbe Prädikat.
     Beide Klassen werden von `cell-trace' : 'cell-searched`
     (`js/ui.js`) innerhalb von `if (furniture)` gesetzt, tragen die Kontur
     also ohnehin schon; es bleibt beim Umfärben.
     Die drei Möbelzustände bleiben untereinander unterscheidbar: unberührt
     gestrichelt/CanvasText, durchsucht durchgezogen/GrayText (dieselbe
     „erledigt"-Farbe wie im Rahmen), Fund 2px gestrichelt/LinkText. */
  .cell-searched { outline-style: solid; outline-color: GrayText; }
  .cell-trace { outline-width: 2px; outline-color: LinkText; }
  /* Die Leiche verliert den Rahmen gegen `.cell-conflict`, und das ist erreichbar:
     `findConflicts` (`js/engine.js`) liest ALLE Platzierungen, die Leiche
     eingeschlossen; `btn.classList.add('cell-conflict')` (`js/ui.js`) setzt
     beide Klassen im selben `if (occupant)`.
     Gewinnt dort der Konflikt, sagt das Feld im Kontrastmodus nur noch „doppelt
     belegt" — dass darunter der Tote liegt, ist verschwunden, denn `.pawn` ist
     hier abgeschaltet (styles.css:7876 `.pawn`). Bisher stand an dieser Stelle
     das `aria-label` als Deckung; das ist genau die Ausrede, die neun Zeilen
     weiter oben für die Möbel zurückgewiesen wurde, und sie wird auch hier
     nicht angenommen.
     Der zweite Kanal ist auf der LÖSUNGSzelle frei, und nur dort. Gegen die
     MÖBEL-Klassen aus zwei Gründen, die zusammen jeden Weg abdecken, auf dem ein
     Möbel auf ein Feld kommt: `const seatCells = sample(` (`js/engine.js`) zieht
     die Sitzplatz-Möbel aus `cells.filter((c) => c !== victimCell)`, und die
     losen Möbel kommen aus den FREIEN Feldern, zu denen die Leichenzelle nicht
     zählt.
     Hier stand bis v7 ein dritter Grund, der das Feld überhaupt erst
     festnagelte: die Leiche liesse sich nicht aufnehmen und nicht versetzen. Der
     ist mit dem Fundort-Hinweis weg — der Spieler setzt das Opfer selbst
     (`state.placements[state.selected] = cell` (`js/ui.js`)) und darf dabei auf
     ein teilbares Möbel zielen. Die Kollision ist also erreichbar, seit sie es
     laut dieser Stelle nicht mehr sein sollte, und das ist der Grund, warum sie
     hier steht statt weggekürzt zu sein.
     Aufgelöst ist sie schon, und zwar in der Reihenfolge: `.cell-victim` kommt
     nach `.cell-furniture`/`.cell-searched`/`.cell-trace`, gleiche Spezifität,
     also gewinnt die Leiche den zweiten Kanal. Das ist keine Panne, sondern
     dieselbe Rangfolge, die dieser Block zwölf Zeilen weiter unten für
     durchsucht/gesperrt/Ziel ausdrücklich wählt — die Leiche ist die Marke, die
     überlebt. Bezahlt wird sie mit dem Möbel-Hinweis auf genau diesem einen
     Feld, und der Preis ist tragbar: die Lösungszelle trägt nie ein Möbel, das
     Feld ist also immer eine FALSCHE Setzung, die der Spieler mit dem nächsten
     Klick wieder aufnimmt (`btn.classList.add(person.isVictim ? 'cell-victim' : 'cell-person')`
     (`js/ui.js`) setzt die Klasse rein aus der Platzierung). Und gegen den Fokusring, den einzigen
     verbliebenen Mitbewerber um diesen Kanal, ist es frei, seit dieser auf
     `.cell::before` umgezogen ist (styles.css:2284 `@media not all and (forced-colors: active)`, und unten
     in diesem Block) — ohne diesen Umzug wäre die Kontur hier auf dem
     fokussierten Feld genauso verschwunden wie zuvor der Rahmen.
     Form bleibt unterscheidbar: Möbel 1px gestrichelt, durchsucht 1px
     durchgezogen, Fund 2px gestrichelt, Leiche 4px gepunktet.
     Gepunktet und nicht durchgezogen, weil die Leiche unten schon einen
     durchgezogenen RAHMEN trägt: zwei konzentrische Striche gleicher Strichart
     um dasselbe Feld lesen sich als ein dickerer Strich, und der zweite Kanal
     wäre ausgegeben, ohne etwas zusätzlich zu sagen. 4px, weil keine andere
     Meldung in keinem der beiden Kanäle diese Breite belegt — die Leiche ist
     die eine Marke, die man auch dann noch findet, wenn das Feld gleichzeitig
     durchsucht, gesperrt oder Ziel ist. */
  .cell-victim { outline: 4px dotted Mark; outline-offset: -6px; }
  /* Ein Feld trägt im Kontrastmodus nur EINEN Rahmen, und alle Regeln hier sind
     Einzelklassen — gleiche Spezifität, also entscheidet allein die Reihenfolge
     in dieser Datei. Damit ist die Reihenfolge unten keine Formalie, sondern die
     Rangordnung selbst: was später steht, gewinnt.
     Die Rangordnung folgt den Kombinationen, die `js/ui.js` tatsächlich erzeugt,
     nicht dem Gefühl. Zwei Paare sind dabei zwingend, weil sie gemeinsam auf
     einer Zelle landen können:
       · `cell-conflict` wird von `btn.classList.add('cell-conflict')`
         (`js/ui.js`) INNERHALB des `if (occupant)` gesetzt, direkt nach
         `person.isVictim ? 'cell-victim' : 'cell-person'` (`js/ui.js`) — der
         Konflikt kommt also nie allein vor. Stand er vor `.cell-person`, wurde
         die Doppelbelegung, das primäre Fehlersignal eines Sudokus, im
         Kontrastmodus in JEDEM Fall von der gewöhnlichen Personenkante
         überschrieben. Genau das war hier der Fall.
       · `cell-trace`/`cell-searched` (Buchführung: hier war ich schon) können
         mit `cell-blocked`/`cell-target` (die lebende Frage, solange eine Figur
         in der Hand liegt) auf demselben Möbelfeld zusammentreffen. Die Frage
         schlägt die Buchführung, sonst verliert das gesperrte Fundfeld genau
         die Antwort, um die es im Auftrag ging.
     `.cell-target`/`.cell-blocked` stehen nur auf LEEREN Feldern — der Zweig
     `if (state.selected && !occupant)` (`js/ui.js`) vergibt sie;
     `.cell-person`/`.cell-victim`/`.cell-conflict` nur auf besetzten — die
     beiden Gruppen treffen sich nie, ihre Reihenfolge zueinander ist daher
     frei und folgt hier nur der Lesbarkeit: die Breite wächst monoton mit der
     Dringlichkeit, 1px Buchführung → 3px Meldung. */
  /* Abgesucht heisst erledigt. Im gewöhnlichen Rendering trägt das eine
     Helligkeitsminderung auf `.bx-top`/`.bx-front` — genau die Flächen, die
     hier nicht gezeichnet werden. „Durchsucht" und „noch zu durchsuchen" waren
     im Kontrastmodus also dasselbe Bild, und die Jagd zerfiel in erneutes
     Klicken auf alles. `GrayText` ist die Systemfarbe für „nicht mehr
     verfügbar" — dieselbe, die die Sperre trägt; die dünnere, gestrichelte
     Kante hält den Unterschied zu deren gepunkteten 3px. */
  .cell-searched { border: 1px dashed GrayText; }
  .cell-trace { border: 2px dotted LinkText; }
  .cell-person { border: 2px solid CanvasText; }
  /* Diese Kontur bleibt, obwohl die Zielmarke im gewöhnlichen Rendering ihren
     Ring verloren hat, und die Asymmetrie ist Absicht statt Vergessen. Der
     Einwand dort war, dass ein inset-Ring auf lauter benachbarten Feldern ein
     zweites Gitter über das vorhandene legt — er zeichnete nach, was ohnehin da
     war. Im Kontrastmodus gibt es dieses „ohnehin" nicht: das System verwirft
     `background-color`, also fällt die 7%-Aufhellung weg, die draussen die
     ganze Aussage trägt. Bleibt die Kante weg, bleibt gar nichts, und die
     Redundanz, die draussen der Defekt war, ist hier der einzige Kanal. */
  .cell-target { border: 3px dashed Highlight; }
  /* Im Kontrastmodus fällt die Tönung weg — dort trägt die Sperre allein der
     Rahmen. `GrayText` ist die Systemfarbe für „nicht verfügbar", also genau
     das Wort, das hier gemeint ist, und sie steht ausdrücklich gegen das
     gestrichelte `Highlight` der Zielmarke darüber: gepunktet und grau gegen
     gestrichelt und hell, unterscheidbar auch ohne jede Füllung. */
  .cell-blocked { border: 3px dotted GrayText; }
  .cell-victim { border: 3px solid Mark; }
  /* Zuletzt, und mit eigener Kantenform statt geteilt mit dem Opfer. Hier stand
     `3px double` mit der Begründung, unterhalb von 3px bleibe für die Lücke kein
     Platz. Die Rechnung stimmte — nur galt sie für eine flache Ebene. Das Brett
     liegt unter `rotateX(--tilt)`, und das staucht jede waagerechte Kante auf
     das cos-fache: aus den drei ~1px-Bändern eines `3px double` werden auf der
     Ober- und Unterkante ~0.9 Gerätepixel, Band und Lücke verschmieren, und die
     LAUTESTE Meldung des Bretts wäre von einem schlichten `solid` nicht mehr zu
     trennen. `double` bräuchte hier ~7px, um sein Versprechen zu halten.
     Gestrichelt löst das Problem, statt es zu vergrößern: das Muster läuft
     ENTLANG der Kante und wird von der Drehung um X gar nicht gestaucht, die
     volle Breite bleibt die Dicke. 4px, weil breiter als das Ziel (3px
     gestrichelt) und damit lauter — der Konflikt ist die dringlichere Meldung. */
  .cell-conflict { border: 4px dashed Mark; }
  /* Walls are folded planes painted with a gradient, and HCM drops
     background-image — they would vanish. Unfolding them back into flat borders
     on the cell restores the room outline, which is what they are there for. */
  .wl { background-image: none; transform: none; }
  .wl-t { top: 0; height: 0; border-top: 4px solid CanvasText; }
  .wl-b { bottom: 0; height: 0; border-top: 4px solid CanvasText; }
  .wl-l { left: 0; width: 0; border-left: 4px solid CanvasText; }
  .wl-r { right: 0; width: 0; border-left: 4px solid CanvasText; }
  /* Prisms and pawns are gradient fills with no border, so HCM leaves nothing
     but empty boxes. The cell keeps its own marker classes, so the geometry is
     dropped rather than restated as noise. */
  .furn, .pawn, .furn-shade { display: none; }
  /* The legend dot and the rank bar are pure fills, which flatten to one colour
     — a border keeps at least their shape readable. This said `.legend-swatch`
     until now, a class the markup stopped using when the legend gained its room
     material, so high-contrast users had a row of invisible dots. Dead selectors
     in a media query nobody renders in are the ones that stay dead: renaming it
     here rather than deleting it keeps the accommodation, which is the point of
     the block. */
  .legend-dot,
  /* Same reason as the two above, and the tick needs it most: forced colours
     replace the fill AND the text colour with the system pair, so the token
     would otherwise become a bare glyph floating at the card's edge, indistinct
     from the profession icon on the other side of it. */
  .suspect-mark,
  /* And the same for the marked name: HCM replaces the 0.16 fill and the white
     text with the one system pair, so the mark would become ordinary prose in
     the middle of a sentence — the highlight would still be applied and still
     be invisible, which is worse than not offering it. */
  .clue-name,
  .rank-bar { border: 1px solid CanvasText; }
  /* Und die FUELLUNG darin. Der Absatz oben begruendet den Rahmen damit, dass
     „the legend dot and the rank bar are pure fills" — die Rangleiste besteht
     aber aus zwei Teilen: dem Kasten (`.rank-bar`) und dem Balken darin
     (`.rank-fill`, ein Verlauf aus `--accent-deep` und `--brass`). Im
     Kontrastmodus faellt der Verlauf weg, und ohne eigene Kante ist der Balken
     von seinem Kasten nicht mehr zu unterscheiden: der Fortschritt zum
     naechsten Rang war dort keine Information mehr, sondern eine leere Leiste.
     `CanvasText` wie die Nachbarn, und nur auf der Kante — eine Flaeche waere
     im Kontrastmodus schwarz auf schwarz. */
  .rank-fill { border-right: 2px solid CanvasText; }
  .btn-primary { border: 2px solid ButtonText; }
  .suspect.is-selected,
  .btn-ghost.is-active { border: 2px dashed Highlight; }
  /* Und damit die Reihenfolge im Kontrastmodus wieder eine Reihenfolge ist.
     `.cell` ist sonst `preserve-3d` (styles.css:1998 `transform-style`), und
     der Kommentar dort sagt genau, was das bedeutet: „Depth order is now
     geometry, not stacking." In einem 3D-Kontext ordnet die Lage im Raum, nicht
     der Stapelindex — das `z-index: 3` des Fokusrings unten war deshalb keine
     falsche Zahl, sondern eine, die niemand liest. Der Ring lag unter der
     Notizmarke (75 zu) und unter Feld- wie Raumbeschriftung (je 64 zu), also
     ausgerechnet unter dem, was auf einem fokussierten Feld am ehesten steht.
     Naheliegend wäre gewesen, ihn stattdessen höher zu heben. Das scheitert an
     der Perspektive, nicht am Geschmack: bei `perspective: 1700px` und
     `--zu` bis 2.4px vergrössert ein Hub von 76 zu um 12% — der Ring schwebte
     sichtbar über sein eigenes Feld hinaus und markierte die Nachbarn mit.
     Unter 2% zu bleiben hiesse, bei rund 14 zu aufzuhören, und dort liegt er
     immer noch unter allen dreien.
     Also andersherum: hier flach, dann ordnet der Stapelindex wieder. Das passt
     zu dem, was dieser Block ohnehin tut — Prismen und Figuren sind oben
     abgeschaltet, die Wände zu flachen Rahmen aufgefaltet. Es bleibt im
     Kontrastmodus schlicht nichts mehr übrig, das eine Tiefe bräuchte. Die
     Beschriftungen verlieren ihren Hub und stauchen sich um die cos-Differenz
     der Neigung; sichtbar ist das nicht, denn sie stehen dann bündig auf dem
     Feld, über das sie ohnehin sprechen. */
  .cell { transform-style: flat; }
  /* Der Fokusring, umgezogen. Er liegt auf einer eigenen Box und nimmt damit
     weder den Rahmen- noch den outline-Kanal der Zelle in Anspruch — die vier
     Klassen, die dort ihre Aussage tragen, bleiben auf dem fokussierten Feld
     sichtbar. `::before` ist auf `.cell` sonst unbenutzt; `::after` wäre nicht
     frei, dort liegt der Raumumriss (styles.css:2307 `.cell::after`), der hier
     abgeschaltet wird, aber nur unter dieser Bedingung.

     Zur Lage. Eine frühere Fassung stand auf `inset: 1px` und begründete das
     damit, die Zustands-Konturen säßen mit -5px/-6px ohnehin tief im Feld. Das
     rechnete nur den outline-Kanal und überging den Rahmenkanal, der bei 0
     beginnt und bis 4px reicht — und es überging, dass `inset` von der
     POLSTERKANTE misst, also bereits innerhalb des Rahmens. Der Ring wandert
     damit mit der Rahmenbreite des jeweiligen Zustands nach innen: auf einem
     leeren Feld um 1px, auf einem Konfliktfeld um 4px. Sein Band liegt also
     nicht fest bei [i, i+w], sondern bei [b+i, b+i+w].

     Genau in dieser Wanderung lief er in die outlines hinein. `.cell-furniture`
     belegt mit -5px/1px das Band [5, 6], `.cell-victim` mit -6px/4px das Band
     [6, 10]; zusammen [5, 10]. Der alte Ring belegte [1+1, 4+1+2] = [2, 7] und
     schnitt das. Wo zwei Striche einander überdecken, verschwindet einer von
     beiden, und welcher, entscheidet die Malreihenfolge statt jemand hier —
     dieselbe Klasse also, mit der dieser ganze Zyklus begann.

     `b + i >= 10` muss für jedes b aus {1,2,3,4} gelten, das fordert i >= 9;
     10 gibt einen Pixel Luft. Das Band wird damit [11, 17] und liegt frei.
     Berührung wäre erlaubt, Überlappung nicht — deshalb wird auf Schnitt
     geprüft und nicht auf Abstand.

     Zur Form. `2px dashed Highlight` stand zeichengleich auch auf
     `.suspect.is-selected` und `.btn-ghost.is-active`, und `.cell-target` teilte
     Farbe und Strichart. Eine Marke, die anderswo dasselbe sagt, sagt nichts
     mehr allein. `3px solid Highlight` kommt im Block kein zweites Mal vor:
     die nächsten Nachbarn sind `.cell-victim` (3px solid Mark),
     `.cell-person` (2px solid CanvasText) und `.cell-target` (3px dashed
     Highlight) — jeder weicht in mindestens einem der drei Züge ab. Unter der
     Neigung bleiben 3px * cos(26°) = 2.70px stehen, deutlich über der halben
     Pixelschwelle für einteilige Striche. */
  .cell:focus-visible::before {
    content: '';
    position: absolute;
    inset: 10px;
    border: 3px solid Highlight;
    pointer-events: none;
    z-index: 3;
  }
}
