# Pitfalls — known errors and gotchas

Compressed log of things that have broken before in this project. Read before significant work; append the moment you discover a new one. See `CLAUDE.md` for format rules.

## Region navigation (F6)

- **Regions can NEST (catalogue pagination sits inside results) — `indexOfFocus` must resolve the INNERMOST region via `el.closest('[data-region]')`, never a first-match containment scan** — the scan returned the OUTER region, so F6 from pagination computed "results + 1 = pagination" and looped forever (Shift+F6 worked, masking it). Fixed in BOTH controllers (prototype region-nav.js + shared _region-nav.js, Jun 2026).

## Animated number (_num.js)

- **A `fill:'both'` exit that ends ON TOP of remaining content stays visibly parked until the LAST staggered column lands** — the horizontal lead exit slid across the tail and held there (the "previous digit stuck with the zero" bug). Exits must end OUTSIDE the composite (lead always exits left for slide motions) or inside their own clip (cells self-clip).
- **`color-mix(… currentColor)` in WAA keyframes is NOT interpolable** — browsers fall back to discrete (the colour pops to the peak and back with no blend). Resolve both endpoint colours to plain `rgb()` in JS (hidden-span computed style + numeric mix) before animating `color`. Hit on the heat effect; fixed in `flash()`.
- **Never measure the committed content with the HOST box rect** — on blockified flex-item hosts `el.getBoundingClientRect().width` is the stretched box (full tile), not the digits: it fed `newW ≈ 150px` into the tail shift = −126px (number slid in from outside the box). Measure content via the `.num__in` wrapper rect (and old content via a hidden sizer); use the host rect only for line-height/travel.
- **Never anchor digit columns via host-level `direction`/`text-align`** — `.num` hosts can be blockified FLEX ITEMS (e.g. `.nx-tile__fig` in a flex-column tile) whose box stretches far wider than the digits: an rtl/right-align hack slams the number to the far edge of that box for the animation, then snaps back on cleanup. Anchor scaffolding to the CONTENT (in-flow `.num__tail` + translateX old→new layout); the gliding box then makes the motion follow whichever edge the consumer aligns to.
- **Replay-while-paused/slow-mo "frozen glitch" was the GOVERNOR, not Num** — new anims were caught ≤1 frame late and frozen/re-rated a few ms in (displaced ghost, opening snap). Fixed in both governor tick loops: park young (≤64ms) caught anims at `currentTime = 0`. See decisions "Animation playback controls". Diagnose via the anim's `currentTime` ≠ 0 while `playState:'paused'`.
- **Never capture an outgoing value with raw `textContent` before an innerHTML re-render — use `Num.read(el)`.** While a host is mid-roll its DOM is scaffolding (`.num__in` new text + `.num__ghost` old text), so `textContent` returns the CONCATENATION of both numbers (the count technique mid-ladder value, too). Symptom: a brief giant garbled number ("Zobrazeno 1–25 z 1 9753992 32") on rapid changes — the garbled capture became the next roll's ghost. `read()` reports `el.__numText` (committed) while `__numClean` is set. Fixed in `_pagination.js changeTo` + comp-num.html's demo capture; `Num.set` consumers are immune (set/transition cleanup first).

## Prototype rebuild (ui_kits/cos-library v2)

- **`_select.js` writes `data-value` into the trigger label** — `itemText()` PREFERS `item.dataset.value` over textContent, and `commit()` sets `.value` to it. For a combo whose stored value ≠ visible label (the catalogue sort), put the machine value in a DIFFERENT attr (`data-sort`) and read it from the `aria-selected` item on `combo:change`; reserve `data-value` for combos whose label IS the value (locale CS/EN).
- **`_switch.js` in the prototype is now REQUIRED (Jun 2026 header rework)** — the React-bound ThemeSwitch was replaced by a vanilla island (LocaleCombo pattern) that the shared controller wires; binding rides the new `tsw:change` event + `el.__tsw.set()`. The OLD rule "don't load _switch.js beside the React theme switch" applied only to that retired component — don't resurrect a React-owned `.tsw`; any future switch must be an island.
- **Queued screenshot steps can fire AFTER a later reload** — a `multi_screenshot` that errors "No preview pane available" may still execute its step JS once the pane becomes ready, mutating state mid-verification (a fresh boot appeared to start in table view; it was my own queued `click()` landing late). After failed capture calls, RELOAD before trusting state probes.
- **run_script's shrink-guard blocks legitimate large deletions** (extracting inline CSS out of a comp page shrinks it >50%) — write to a temp file and `copy_files` move over the original. Also: **run_script file helpers REJECT non-ASCII paths** ("disallowed characters" — e.g. `downloads/Digitální knihovna ČOS - ….html`); use `copy_files`/`read_file` for diacritic filenames.
- **React + vanilla islands:** anything a shared controller mutates attributes on (combo aria-expanded/aria-selected, .fav aria-pressed) must be rendered via ref+innerHTML (`display:contents` wrapper), NOT as JSX attributes — a React re-render would reset them to the initial vdom values. Pattern: components.jsx `LocaleCombo`, pages2.jsx `FavouritePanelRow`.

## JS block comments — token wildcards

- **Never write `--st-*/--col-*` (a `-*` wildcard followed by `/`) inside a `/* … */` comment — the `*/` sequence TERMINATES the comment** and the rest of the file becomes garbage code (`Uncaught SyntaxError: Unexpected token '*'`). Bit `_quick-tile.config.js` (header prose "same mechanism as --st-*/--col-*") → `FP_COMPOUNDS['quick-tile']` never registered → the whole Inspector script died at `QTM.motion` while the page still LOOKED fine (resting markup shows final figures). Write `--st-* and --col-*` or `--st-* / --col-*` (with spaces). Applies to CSS comments too.
- **A page can render perfectly with its entire JS dead** — quick-tile's resting state IS the final state, so the screenshot passed while count-up/Replay/Inspector were all broken. Always check console logs (`get_webview_logs` / `done`), never judge a scripted page by a static capture alone.

## Reference parsing (_related.js)

- **Never parse catalogue relationship strings with flat regex — parentheses NEST.** Real data: "STANAG 3733 Ed. 3 (AEP-3733(A))" — the old `\(([^)]*)\)` matcher truncated at the inner `)`, yielding garbage ("AEP-3733(A"). Use the depth-counter tokenizer in `_related.js` (`extractParens` / `topSplit`); also tolerate UNbalanced tails ("AEP-4381(A)(1" exists in the data).
- **`/\bEd\.?\s*[A-Z0-9]+/i` is a double trap.** (1) With `/i`, `[A-Z]` ALSO matches lowercase. (2) `\b` fires after Czech letters (non-word chars in ASCII regex), so "před" has a boundary before "ed" and prose can match. Fix: `[Ee]d` + UPPERCASE-only capture + hard end-boundary `(?=[\s,;)]|$)`, no `/i`.
- **A suffix paren on an AP code is an EDITION, not a nested ref** — "ALCCP-01(B)" = ALCCP-01 Ed. B; digits-only "(1)" = amendment (ignore). Classify paren groups by CONTENT (single uppercase letter vs digits vs anything else → recurse), not position.
- **Top-level commas are ambiguous three ways** — new ref ("…, STANAG 7158 Ed. 1"), list continuation with inherited type/prefix ("STANAG 4021 Ed. 3, 4022 Ed. 4" / "AQAP 110, 119"), or edition tail attaching to the PREVIOUS ref ("ČOS 130008, 1. vydání, Oprava 1" / "STANAG 4477, Ed.1"). Decide AFTER stripping edition tokens: code present → new ref (bare digits inherit ctx); no code → attach to previous.
- **Corpus "unresolved" refs are mostly genuine register gaps, not parser bugs** — cancelled STANAGs (4159/4174), superseded AQAP 110-series, ČOS sub-parts ("130033-1"), AOP-58 etc. verified absent from `library-data.js`. Check `test-related-parse.html` § 2 (filter: only unresolved) before "fixing" the parser.

## Metadata modal nav (data-preview.html) — master-detail stack

- **Never leave a finished `fill:"both"/"forwards"` WAA animation alive on an element — it can become a "phantom": still applying its fill while ABSENT from `getAnimations()` (Chromium housekeeping, seen after real-user blur/occlusion of a closed modal), so cancel-by-enumeration can't reach it and animation-origin beats even inline `height:auto`.** Symptom: dialog reopens frozen at the previous document's height with empty space; no inline style, no enumerable anims, clone lays out fine, `display` toggle doesn't help — only a NEWER animation overrides it. Fix pattern (swapModalBody height tween): store an explicit handle (`body._dpmHAnim`), cancel BY REFERENCE at the top of every swap, and cancel the anim on its own `finished` promise (register AFTER the inline-`height:""` clear on the same promise → no jump; backstop timeout still only clears inline so slow-governor mid-flight never snaps). Same handle pattern as `dc._dcAnim`. Diagnosis trick: `el.animate([{height:'Xpx'},{height:'Xpx'}],{fill:'forwards'})` overriding the value while `getAnimations()` is empty proves a phantom fill. Could NOT be reproduced synthetically (6 attempts incl. exact recipe + 6s idle) — the housekeeping triggers on REAL browser lifecycle (OS focus loss, occlusion, render-throttling of a blurred surface), which untrusted synthetic events + a focused scripted page never produce. General rule: bugs rooted in browser-internal lifecycle may be unscriptable — capture the user's live broken state (eval_js_user_view) and diagnose forensically instead of burning time on scripted repro.
- **Verifying close-on-Back gives a FALSE positive if you read `modal.hidden` too soon.** `closeModal` removes `.is-open` SYNCHRONOUSLY but flips `modal.hidden=true` on a 200ms hide-timer (lets the fade play). An offscreen popstate→close check at <200ms still sees `hidden===false` and looks like "Back didn't close". Read `classList.contains('is-open')` (immediate) for "is it closing", and wait >200ms before asserting `hidden`. (Cost ~2 spurious "bug" reports before I clocked it.)
- **`.dpm__back` collapse must animate the FULL box, not just width.** It's a `.dpm__btn` (1px border) in a flex bar — `width:0` alone leaves the 2px L+R border (residual snap) AND the bar `gap` reserves its slot. Fix: collapse `width`+`border-width`+`margin-right` together AND set bar `gap:0` (re-add the one needed gap as `.dpm__pos{margin-right:10px}`). Equal-specificity gotcha: `.dpm__back:not(.is-shown){opacity:0}` ties `.dpm__btn[disabled]{opacity:.4}` → the back rules MUST come later in source to win.
- **Don't bind modal prev/next to `active()` (whole catalogue) when the ask is "browse the filtered list".** The level-0 list is `state.list`; a row's `data-idx` is ALREADY a `state.list` index (don't re-map through `active().indexOf`). Cross-references that leave the filter are handled by PUSHING a detail level, not by jumping the master cursor.
- **History snapshot must re-resolve idx by `id` on popstate**, not trust the stored idx — the underlying list (filtered set / a related set) can change while the dialog is open. Store `id` per level, re-find it in the derived list, then clamp.

## Pinned/dock pager + modal slide (data-preview.html)

- **Settle the pinned pager AFTER the rows actually render, not synchronously after `Collection.refresh(...)`.** `refresh(container, renderFn)` DEFERS `renderFn` until its out-phase `finished` (or backstop) — so on a filter/search change the rows aren't swapped yet when `refresh` returns. Calling `pagerSettle()` on the next line measured the OLD table height; when the list GREW (e.g. removing a 5-doc "Nahrazený" filter → 401 docs) the still-short table read as `dockable` → the bar docked at the old foot and scrolled out of view, only re-pinning on the next scroll. FIX: pass `function(){ renderRows(0); pagerSettle(false); }` as `renderFn` so settle runs post-render with the real height. (The reduced-motion + no-Collection paths already render synchronously before settle — fine.)
- **Hide the whole pager when results fit ONE page (≤ PER), not only at 0.** A lone "1 / 1" control is noise. Gate `mountPager` on `state.list.length > 0 && totalPages > 1`; below that, animate the panel out + clear `#pager` (`:empty` collapses it). Also gate PinDock's `isActive` on a `pagerVisible` flag so it never PINS or reserves a `min-height` slot for the empty bar (an empty `#pager` has `offsetHeight 0`, so PinDock's `barH()` fallback `|| 56` would reserve a phantom 56px slot if it ever pinned it). REAPPEAR ANIMATION must fire AFTER the pager settles into its final home, NOT inside `mountPager`: `mountPager` runs at the TOP of `applyFilters`, before the deferred `pagerSettle` re-parents the bar into the fixed float host — animating there played the fade in the DOCK position then instantly jumped to pinned, masking it (the "no reappear animation" report). Fix: `mountPager` only sets a `pagerJustShown` flag; `consumePagerShow()` (called right after every `pagerSettle` in `applyFilters`) plays `pagerPanelIn()` in place.
- **Put the pager's VISIBLE shell + its show/hide fade-slide on an INNER `.pager-bar__panel`, never on `#pager` itself.** `#pager` is the node PinDock MOVES (dock↔float) and FLIP-animates via `bar.animate(...)`; `bar.getAnimations()` (non-subtree) then CANCELS any competing animation on `#pager` — including a show/hide opacity tween — but NOT animations on its children. So a child `__panel` can fade/slide independently of a simultaneous dock-FLIP (they co-occur: removing a filter both shows the pager AND triggers a settle). `#pager` stays a transparent positioning box; `initPagination`/`applyResponsive` are unaffected (they query descendants + read `nav.parentElement.clientWidth`, now the panel).
- **A page-local `.count { display:inline-flex }` leaked into the compound pager's `.pag .count` and ate the label's whitespace** ("Zobrazeno1\u201325z 2597"). `_pagination.css`'s `.pag .count` sets no `display`, so the unscoped page rule won → inline-flex makes each text run + `<strong>` separate flex items and inter-item whitespace collapses (same family as the `.where__num` note). FIX: scope the page's toolbar count to `.cat-toolbar .count` so it can't reach `.pag .count`. RULE: never ship an UNSCOPED `.count` (or any class a shared compound also uses) in a page that consumes that compound.
- **`_collection.js refresh()` re-fired its in-phase a few seconds after every filter/search** (a SECOND restagger). The backstop `setTimeout(inPhase, out\u00d712+\u2026)` was gated on `!container.__rfDone` but `__rfDone` was NEVER set, so BOTH `last.finished.then(inPhase)` AND the backstop ran. FIX: arm `container.__rfDone=false` at refresh start and set it `true` on first `inPhase` (once-guard). Any WAA-`finished`-plus-`setTimeout`-backstop pair MUST share a once-flag or both fire.\n- **The active-filter-pills height animator (RO-driven) loops unless guarded.** Animating `.afp` height via WAA, then measuring its \"natural\" height in the ResizeObserver to detect the next change, re-reads the ANIMATED height (a running WAA `height` animation overrides inline style) \u2192 target keeps changing \u2192 infinite re-animate. FIX: an `animating` flag that makes the RO callback bail while our own tween runs; measure natural height by temporarily clearing `style.height` (safe only when NOT animating). Clip `overflow` ONLY during the tween (`.afp.is-animating`) so the pills' focus rings aren't clipped at rest; collapse an empty row to 0 height (no `display:none`) so add/remove can animate.\n- **The `.dpm__body` (tabindex -1 programmatic scroll target) showed a focus ring around the WHOLE modal content on keyboard sibling nav.** `.dpm__body:focus { outline:none }` killed only the outline; the global `@layer base` ring is a `box-shadow`, which survived \u2192 a ring bloomed around the content when the body got `:focus-visible`. FIX: `.dpm__body:focus, :focus-visible { outline:none; box-shadow:none }` (unlayered \u2192 beats the base ring). Any tabindex -1 scroll/focus target that you don't want ringed must suppress `box-shadow`, not just `outline`.

- **A `position:fixed` pager INSIDE `.results` resolves against `.results`, not the viewport — because `.results` is `container-type:inline-size` (= layout containment = a containing block for fixed/absolute descendants).** So `bottom:14px` landed 14px from the bottom of `.results` (≈ the table end), not the screen. FIX: keep the pinned bar in a `#pager-float` host that lives OUTSIDE `.results` (a child of `.wrap`, uncontained) and MOVE the bar node between `#pager-dock` (in flow) and `#pager-float` (fixed); set the float's `left/width` in JS to track the `.results` column. Rule: anything that must be VIEWPORT-fixed cannot be a descendant of a `container-type` (or `contain:layout/paint/strict`) element.
- **Freeze the pager BEFORE the row re-render, not after.** The page-change handler does `state.page=to; pagerFreeze(); Collection.paginate(…renderRows…)`. If `pagerFreeze()` ran AFTER `paginate`, a DOCKED bar would have already moved with the reflowed table (the exact jump we're killing) before we captured its `top`. Pinning it (move to float at current `top`) FIRST makes it fixed during the reflow → zero jump. The next user scroll (`pagerSettle`) FLIP-glides it home.
- **Dock-readiness must key off the reserved DOCK SLOT's rect, not a tw.bottom + slack formula.** First attempt (`tw.bottom + 14 + pagerH <= vh - 14`) never docked at the bottom of a long list: the reserved dock `min-height` pushed the table bottom up by `pagerH`, so the slack (28px) always exceeded the available `margin-top` (20px). Use `pagerDock.getBoundingClientRect().bottom <= vh - 6` — the in-flow slot being fully scrolled into view IS the dock condition, and it's measured the same whether the bar is in the slot (docked) or the slot is reserved-empty (pinned).
- **Modal slide ghost must be pinned to the scroll offset + have its padding on the CONTENT layer.** `swapModalBody` clones the outgoing `.dpm__content` as an absolute ghost; it only aligns with the incoming in-flow copy if (1) padding sits on `.dpm__content` not `.dpm__body` (absolute origin = the body's padding box, so body padding would offset the ghost) and (2) the ghost gets `top:-scrollTop` so it overlays what was actually on screen. Mark the ghost `inert` (its links stay in `.dpm__body a[href]` queries otherwise) and drop any leftover ghost at the START of the next swap so holding an arrow key can't stack copies. Cleanup runs on `aOut.finished` (governor-scaled) + a `dur×12+400` backstop (frozen-clock).

## Inspector controls (_inspector-controls.jsx) — WidthLimiter

- **`WidthLimiter` collapsing a COLUMN-FLEX query-container target to a hairline (Jun 2026).** The "Table width" slider drove the catalogue table to a single vertical line once moved off "Full". Cause = a 3-way interaction: `WidthLimiter.apply` set `max-width:Npx` + `margin-left/right:auto` on `.tbl-wrap`; the auto margins CANCEL the default `align-items:stretch` on the parent column-flex `.card`, so the item falls back to its CONTENT width; but `.tbl-wrap` is also `container-type:inline-size`, and inline-size containment makes its content contribute ZERO width → the box collapses. Fix: set `width:100%` alongside the `max-width` cap (gives a definite cross size to clamp; identical to the old width:auto behaviour for plain block targets like `.card`). The modal titlebar width control was unaffected (it sizes the modal frame, not a flex item). Lesson: centring a flex item with `margin:auto` removes its stretch — pair it with an explicit `width` whenever the item is (or might be) a size container.

## Collection-motion compound (_collection.js)

- **`border-collapse: collapse` breaks row animation: collapsed cell borders are painted by the TABLE box, not the row** — during reveal/paginate the zebra fills faded with each `tr` while the row LINES sat fully opaque (Chrome paints collapsed borders outside the row's opacity/transform group). Fix: `.tbl` is `border-collapse: separate; border-spacing: 0` (visually identical — only border-bottom is used). Applies to ANY table whose rows get WAA opacity/transform.

- **A host animation placed ON `catWrap` (the element Collection.init bound) is CANCELLED instantly by `Collection.refresh`/`paginate` — they call `catWrap.getAnimations({subtree:true}).forEach(cancel)` (freshRun), and `{subtree:true}` includes the element ITSELF.** Symptom (the pill-row ⇄ table CURTAIN slide): `el.animate()` returned, `el.__slide` was set, but `playState` was immediately `idle` / `currentTime` null and the transform never moved — Collection's freshRun nuked it in the same tick (`applyFilters` runs `renderPills` → slide, THEN `Collection.refresh`). Fix: put the host animation on a thin OUTER wrapper (`.tbl-slide` around `.tbl-wrap`) that is NOT in `catWrap`'s subtree. Bonus: keeps the host's transform from colliding with Collection's own row/block transforms. Rule: never animate `catWrap` or its descendants from the host — wrap and animate the parent.
- **Two-set page techniques (slide-through / flip) own the SWAP timing — an entrance-only mental model loses the old content.** restagger/slide-in/crossfade animate the NEW rows in, so the host can re-render the page THEN call `paginate`. But slide-through needs BOTH pages on screen: it clones the current layer as the outgoing ghost, runs `renderFn`, then animates ghost-out + incoming-in. So the OLD content must still exist when `paginate` runs → for these techniques the host passes `renderFn` and lets the orchestrator call it (clone → renderFn → animate), NEVER pre-renders. The pager-seam wiring (pager emits → host re-renders → Collection animates) is fine for entrance-only but must defer rendering into `renderFn` for slide-through.
- **A slide-through `renderFn` that does `wrap.innerHTML = …` DESTROYS the ghost** (the ghost is appended as a child of the same wrap). renderFn MUST swap rows in place (`Table.renderRows` / a `<tbody>` swap) so the `.tbl` node persists and the ghost survives. `contentLayer` returns the FIRST `.tbl` (the real incoming) because the ghost is appended AFTER it — order matters.
- **`freshRun` must clear page-transition scaffolding, not just cancel animations.** An interrupted flip/slide-through leaves a `.col-flip`/`.col-transit` class + a residual inline `transform` on the layer (and maybe a ghost). Cancelling the WAA alone doesn't undo those. `freshRun` calls `clearTransit` (remove ghost, drop classes, reset `transform`/`pointer-events`/`transform-origin`/`backface`) so EVERY phase (incl. a following entrance-only restagger) starts clean. `clearTransit` also runs on finish.
- **Auto-reveal must gate on `document.visibilityState === 'visible'`.** The offscreen preview/verifier iframe is hidden → WAA `currentTime` frozen → an auto-played reveal parks every row at `opacity:0` (blank table in screenshots). Skipping autoplay when hidden keeps the base (visible) state for capture; a real tab still plays it once. (Same frozen-clock family as the status-timeline blank note.)
- **flip is two halves on the SAME layer with a midpoint content swap.** `a1` rotates the current page edge-on (rotateX→±90°, fade), its `.finished` (governor-scaled) runs `renderFn` + the second half back to flat — guarded by the run token + a `setTimeout(dur×12+400)` backstop (NOT a `dur/2` timer, which fires early once the governor slows the clock). `backface-visibility:hidden` + an `opacity` dip hide the edge-on frame; `perspective` lives on the wrap (`.col-flip`).
- **Geometry stagger reads `getBoundingClientRect` at play time — layout is NOT frozen offscreen (only the WAA CLOCK is), so `by-row`/`by-column`/`from-center` band correctly even in the hidden verifier iframe.** `banding()` collapses coords within 6px into one visual row/column; a 1-D table degenerates cleanly (each row its own band → by-row==sequential, by-column==all-at-once). Verified on the cover grid: 4 covers in 3 cols × 2 rows → by-column delays `[0,55,110,0]` (the wrapped 4th cover is row-2 col-0 → band 0). Stagger must be computed BEFORE the entrance transform is applied (items still at base layout) — `playEntrance`/`refresh`/`paginate` all order it first.
- **Shared `_collection-inspector.jsx` helpers are CLOSURE-PRIVATE, not just prefixed.** Babel `<script type="text/babel">` blocks share ONE global scope after transpile, so a top-level `const numOf`/`function MotionPresets` in the shared file WOULD collide with same-named globals in a consuming card. Wrap the whole file in an IIFE and `Object.assign(window, { CollectionInspector })` — only the component leaks. The component is the SINGLE owner of `Collection.init` on a preview card (it knows the `item` selector via props), so the card's own script must NOT also init (double-init + double-reveal). Grids opt into the `--col-*` token defaults via a `[data-col-motion]` attribute on the grid container.

## Inspector / configurable content / standalone chrome (_inspector.js)

- **Standalone force-state MUST exclude chrome subtrees or it locks you out.** The shared `_force-state.js` disabled pass sets native `disabled` on every `button/input/select/textarea`. Standalone, the floating cluster + Inspector/Tweaks panels live in the SAME document — so without an exclusion the pass disables the State button itself (and the panel) and you can't cycle back to Default. Fix: every pass filters out `el.closest('#__fp-cluster, .twk-panel, .tip, [data-fp-chrome], [data-fp-no-force]')`. No-op inside the playground iframe (no chrome there) → behaviour identical. `clear()` is exempt (it only touches the engine's own `data-fp-*` markers, which were never placed on chrome).
- **`clear()`/restore must read the engine's markers, NOT re-query by state.** A component can BAKE a static `data-state="hover"`/`.btn--focus` demo into its source (comp-buttons does). `clear()` only reverts elements carrying `data-fp-orig-state`/`-no-state`/`-added-focus` etc., so baked static demos survive a force→default cycle. (A naive `querySelectorAll('[data-state="hover"]')` cleanup would wipe them — verified they persist: 6 baked hover demos remained after cycling.)
- **`hasForceable()` gates the State button** — probe is `STATE_STAMP_SELECTOR + ', button, input, textarea, select, [role=combobox]'` minus chrome; pages with only chrome buttons (type/colour/space/brand specimens) report false so the button stays hidden. Don't widen it to match decorative non-interactive elements.

- **Standalone dark mode left a white strip:** `_card.css` sets `html, body { background: var(--gov-color-neutral-0) }`, but `_dark.css` scopes the dark neutral remap to `<body>` (and `html[data-theme="dark"] body`), so `<html>`'s OWN `--gov-color-neutral-0` stays the light value → a white band wherever the body box doesn't fill the viewport. `_inspector.js` re-pins `html[data-theme="dark"]{background:#1a2030}` (literal, transition-proof). Reading `getComputedStyle(body)` to mirror it is the fragile alternative (transition reads the from-value).
- **A global `document.getAnimations()` governor pauses/scales the previewed file's OWN chrome too** (Inspector/Tweaks panel, floating cluster, tooltip) — freezing the controls while you inspect a paused/slowed component. BOTH the playground governor (`eachAnim` over the modal frame) and `FP_PLAYBACK` filter by `effect.target.closest('.twk-panel, #__fp-cluster, .tip')` and skip those. (`a.effect.target` can throw / be null — guard it.)
- **A collapsed `grid-template-rows:0fr` `TweakSection` keeps its controls TAB-focusable + tooltip-able** (they're in the DOM, just clipped). Set `inert` on the collapsed body (DOM property via ref+effect), not just `aria-hidden` (which doesn't block focus).

- **A global `document.getAnimations()` governor (FP_PLAYBACK / the playground) will scale & PAUSE the CHROME's own animations too** — the floating cluster's button transitions, the Tweaks/Inspector panel, the tooltip — making the controls freeze while you inspect a paused/slowed component. Filter `eachAnim` by `effect.target.closest('#__fp-cluster, .twk-panel, .tip')` and skip those. (Reading `a.effect.target` can throw / be null — guard it.)
- **The tooltip `.tip` z-index (was `2147483600`) sat BELOW the Tweaks/Inspector panel (`2147483646`), so tips on panel-internal controls rendered BEHIND the panel.** Bumped `.tip` to `2147483647` (top of the stack). Any new always-on-top overlay that wants tooltips must stay below that.
- **Two `keydown` listeners on `document` (`_inspector.js` chrome keys + `_playback.js` playback keys) must handle DISJOINT keys, both guard typing targets (incl. `.twk-panel`), and `_playback.js`'s must be gated on `!?fp`** — otherwise inside the playground modal it double-fires with the playground's `handleShortcut` (which is also bound to the iframe doc) and two governors fight over the same animations.
- **The UA `[hidden] { display:none }` LOSES to any class/element rule that sets `display` — this is the EXACT bug that leaked the Inspector demo into the playground.** `<div class="col" id="inspector-demo" hidden>` stayed visible because `.col{display:flex}` beats `[hidden]`. Same trap bit the floating cluster: a hidden capability button (`.fpc-btn[hidden]`) kept its `display:inline-flex` and still occupied layout, OFFSETTING the slots below it. Fixes: (1) hide configurable content via a class/attribute `!important` rule (the `data-fp-pg` / `data-fp-reveal-config` contract), NOT the `hidden` attribute; (2) for any atom whose class sets `display`, add an explicit `selector[hidden]{display:none}`. Never rely on `el.hidden = true` for an element with a `display`-setting class.
- **`TweaksPanel`'s `__edit_mode_available` host message targets `window.parent`, which is NOT this window when the file is in an iframe (DS tab / playground / modal) — a same-window listener misses it.** The standalone Inspector cluster's Tweaks-button detection silently failed inside the preview iframe (works only in a true top-level tab). Fix: TweaksPanel ALSO sets `window.__fpTweaksPanelMounted = true` + dispatches a same-window `fp-tweaks-available` event on mount (when `hostProtocol`); `_inspector.js` detects native panels off THAT, not the postMessage. Rule: for in-page (same-window) chrome reacting to a panel, use a same-window event/flag — `window.parent.postMessage` is a HOST (cross-frame) protocol.
- **Verifying a TweaksPanel close (or any `setTimeout`-driven unmount/commit) in the offscreen preview iframe reads as "didn't close" — background timers are THROTTLED (~1s min) while the doc is hidden.** The panel's 280ms exit (`mounted→false`) fires ~1s+ later under capture/eval, so a `<1s` re-read still sees `.twk-panel` in the DOM. NOT a bug. Read the SYNCHRONOUS signal instead (`data-show` flips to `0` immediately on close) — don't conclude a toggle is broken from the still-mounted node. (Same family as the frozen-rAF / frozen-WAA-clock notes below.)

## Compound slice / Status.render (timeline)

- **`align-self` does NOTHING when the parent isn't a flex/grid container — it silently no-ops to "start".** Compact pagination (`.pag-compact`, inline-flex) aligned via `align-self:center/flex-end` worked inside a `.col` (flex parent) but was ALWAYS left in the Inspector well (a plain block) — `align-self` is only honoured by a flex/grid ITEM. FIX: make it a block-level fit-content box and use auto margins (`display:flex; width:fit-content; margin-inline:auto`) which centre in BOTH a block and a flex parent. Rule: to centre a shrink-to-fit control whose parent's display you don't control, use `width:fit-content` + `margin:auto`, never `align-self`.\n- **Detecting "did the label wrap" by comparing it to the FIRST button only catches TRAILING labels.** `checkStacked` compared `count.offsetTop` to the first button's — fine when the label is last (trailing) and wraps below, but for a LEADING label (order:-1, first) the label stays on row 1 and the BUTTONS wrap past it, so the diff stayed 0 and the label never stacked (reported "leading label doesn't wrap"). FIX: detect multi-line by HEIGHT (`nav.offsetHeight > firstBtn.offsetHeight*1.5`) — placement-agnostic, fires whenever the control occupies >1 row, so the leading label also gets its own line / hides.\n- **`display:inline-flex` on a text container TRIMS the whitespace between flex items.** `.pag-compact .where__num` was `inline-flex` (for centering); a label like `Stránka *{current}* z {total}` → `Stránka <strong>4</strong> z 12` rendered as "Stránka4z 12" / "4/ 12" because each text run + the `<strong>` become separate flex items and inter-item whitespace collapses to nothing (whitespace WITHIN a run is kept — hence "z 12" survived). The OLD hardcoded counter dodged it with `&nbsp;`. FIX: `display:inline-block; text-align:center`. RULE: never put template/literal text with significant spaces inside a flex container; use inline/inline-block, or `&nbsp;`.

- **A template-string label with literal text around a {token} produces a DANGLING literal when the token is empty.** The pagination `full` preset is `{totalItems} dokumentů · stránka *{current}* z {total}` — for a nav WITHOUT `data-docs`, `{totalItems}`→'' but the literal " dokumentů · " stays → " dokumentů · stránka 1 z 12". Same for `range` (`… z {totalItems}` → "… z " when no item count; needs BOTH page-size for from/to AND totalItems for the tail). The old `countText` switch hid this with an inline `(docs ? … : '')`. FIX: graceful-degrade the BUILT-IN presets by FORM before rendering (`full`→`page` when `totalItems==null`; `range`→`page` when `from==null || totalItems==null`; compact `→mini`) — NOT by trying to strip dangling literals from arbitrary text. Custom templates own their tokens (no auto-degrade) — that's the contract. Rule: a literal-with-token template only degrades cleanly if you fall back to a DIFFERENT template when a required token is missing; never emit a half-filled literal.

- **An auto-playing rendered timeline shows BLANK in the offscreen verification iframe.** WAA `currentTime` is frozen there, so the Inspector's on-mount `Status.play(tl)` parks every step at its `opacity:0` start keyframe. NOT a bug — it animates fine in a real tab. To inspect the end state offscreen: `document.querySelectorAll('.timeline').forEach(tl=>tl.getAnimations({subtree:true}).forEach(a=>a.finish()))` then screenshot. The reference-grid examples use `initStatus` ONLY (no auto-play) so they render immediately — prefer that for anything that must be visible without interaction.
- **Motion tokens MUST live on `.timeline` (the element `_status.js` reads), not `:root`.** An element's OWN custom-prop declaration beats an inherited override — so a `--st-*` set on `documentElement` LOSES to the stylesheet default on `.timeline`. The Inspector therefore sets `--st-*` as inline style on each `.timeline` element. Same custom-property resolution trap as the dark-token freezing note.
- **`_status.css` loads BEFORE `ui-kit.css` in the prototype** — both define `.rstat` (identical recipe); the later sheet wins, and ui-kit's was the established one. Don't reorder, or the request-status pill silently changes owner.
- **Migrating a React component onto a vanilla renderer: render via `innerHTML` + `initStatus(host)` in `useEffect`, return a bare `<div ref>`.** Don't rebuild the tree in JSX (that re-forks structure). `window.Status` is a normal-script global loaded before the Babel scripts, so it's defined by the time effects run — but guard `if (!window.Status) return` anyway.

## Shared filter components (_filter-group.js / _filter-pills.js) + dataset preview

- **GROW reservation trample: slideCurtain must NOT re-measure the row when a pinned wrap-grow already holds the reservation (fp33, Jun 2026 — the SECOND data-preview "jump", on the ADD path).** Host ordering differs per consumer: data-preview measures hf, calls `expandIn` (→ `maybePinForWrap` reserves the row's final height — measured WITH the entrant in flow — then pins it absolute), and only THEN calls `slideCurtain`. The grow branch's "clear-first-then-read" re-measure (`minHeight=''; minHeight = offsetHeight`) then read the row WITHOUT the pinned entrant → OLD height (27px not 63px) → trampled the pin's correct reservation → the row was held a line short for the entire grow and SNAPPED +36px at unpin (table, pills, everything below). Diagnosed from the user's paused view: slide keyframes correct (−36→0) but `afp.style.minHeight === '27px'` with `__fpGrowPin` active. FIX: grow branch keeps an existing reservation when `tokenEl.__fpGrowPin && tokenEl.style.minHeight` (ownership stays with the pin, released at unpin); the clear-then-read stays for the unpinned order (harness: slideCurtain before expandIn). RULES: (1) verify slide continuity at BOTH ends — T≈0 AND settled-after-unpin vs the slide's landing spot (`jumpEnd`); start-only checks miss reservation bugs entirely; (2) any code that re-measures row height must first ask "is an entrant currently pinned out of flow?"; (3) the two consumers call expandIn/slideCurtain in OPPOSITE orders — shared logic must be correct under both.

- **A DEFERRED host re-render measures h0 AFTER the compound marks `.is-leaving` → delta 0 → the table JUMPS instead of sliding (fp32, Jun 2026 — the data-preview "jumps from the top of the pills" artifact).** The ×-click chain in data-preview hops a task (× → `filterpill:remove` → un-check → change → filter pipeline → `renderPills` ~2ms later), so by measure time the compound has already marked the pill `.is-leaving` and `afpOuterH` EXCLUDES it: h0 = FINAL height = hf → `slideCurtain(0)` → no table tween while the row's flow height drops the full delta in one frame. Ground-truthed by instrumenting `slideCurtain` (delta=0 logged) + an event-time trace (`leaving:0` inside dispatch, `leaving:1` + slideCurtain 2–3ms later). FIX: capture h0 **synchronously in the `filterpill:remove`/`filterpill:clearall` listeners** (the compound dispatches them BEFORE marking `.is-leaving` precisely for this) into `afp.__h0 = {h, t}`; `renderPills` consumes the stash when fresh (<300ms), falls back to a live measure for checkbox-direct changes (nothing leaving yet → correct). The comp-filter-pills harness never hit this — its listeners already measure at event time. RULE: any host whose re-render is not synchronous with the pill event MUST stash h0 at event time; never measure "before-mutation" height after a task hop.

- **Verifying slide continuity: measure the slide element's `getBoundingClientRect().top + window.scrollY`** (document coords) — a viewport-relative top gave a phantom −379px "jump" when the page scrolled between measures. Continuity check = |top(T≈0) − top(before click)| ≤ 1px, then samples at increasing currentTime must interpolate monotonically.

- **Encoding an easing into keyframe VALUES requires the outer `easing` to be `linear`, and quantization must target DEVICE pixels (fp29).** Two traps in the pixel-snapped slide: (1) leaving the original `slideEase` as the outer timing while the per-pixel `steps(1)` keyframes already encode the curve applies the ease TWICE (badly distorted motion); (2) rounding to whole CSS px still shimmers on fractional-DPR displays (1.25/1.5 Windows scaling) — quantize `round(v·dpr)/dpr` so values like −0.6667 CSS px land exactly on −1 device px. Also: `getKeyframes()` serializes transforms with ~6 significant digits, so string-parsing keyframe values to assert grid alignment FALSELY fails — assert on sampled computed `m42` instead.

- **"Rapid adds" is the NORMAL case at slow governor speeds — entrances must SERIALIZE, not fall back (Jun 2026, build `fp25-commit-entrances`).** User report: glitches "not related to rapid adds" — but with the governor paused/slowed, ANY two interactions overlap (their paused view showed TWO width-0 entrants at the same spot, neither pinned: each add had hit fp24's `__fpGrowPin`/running-grow bail → legacy concurrent grows garbling + a mid-grow line hop). The bail-to-legacy design made the fix invisible exactly where users inspect it (the slow-speed test bed). FIX: `fpCommitEntrances(row)` — at the START of any user-level mutation (compound's `removeActivePill`/clear-all branch/`addPill`/`restoreDemo`; hosts BEFORE measuring `h0`: harness `ch-add`, data-preview `renderPills`; backstop in slideCurtain's shrink branch) commit every in-flight entrance to its end state: bump `__fpRun` (old finalize → no-op), cancel the grow (fill:'both' dropped → natural base = clean snap-to-end), drop `fp-clip`, `fpUnpinGrow`. Choreographies serialize; a commit is a clean snap, never a garble; at 1× it's barely visible. The in-probe bails STAY (they protect same-task bursts like restore). RULE: never gate a choreography on "no other animation in flight" as a *skip* — slow playback makes overlap the common case; commit the predecessor instead. Also: the harness "Add pill" now skips pool entries already present (duplicate pills read as a glitch). Exported: `FilterPills.commitEntrances`.

- **Pill-row ⇄ table CURTAIN GROW — wrap-case entrance is a PINNED grow + successor FLIP (Jun 2026, build `fp24-grow-pin-flip`); testing it offscreen REQUIRES manual unpin.** The old "fit then wrap" quirk (a new-line pill grew in flow at the end of the current line and hopped down mid-grow; same for a push that wrapped "Zrušit vše") is fixed by `maybePinForWrap` in `expandIn`: while the entrant sits in flow at NATURAL size (= final layout), a synchronous set→read→restore probe collapses its box to 0 and compares TOPs (its own + every in-flow sibling's — LEFT shifts are the normal smooth push and don't trigger). On a hop: reserve the row's final height if `row.style.minHeight` is empty (plain row; in a curtain host slideCurtain's grow branch already set it), pin the entrant `position:absolute` at its final `offsetLeft/Top`, FLIP displaced siblings old→final with a HELD `fill:'forwards'` transform, cancel everything by reference at unpin (`fpUnpinGrow`, also exported as `FilterPills.unpinGrow`). GOTCHAS ground-truthed: (1) the row reservation has TWO possible owners — release funnels through `fpReleaseReserve`, which DEFERS while a pin is active (`row.__fpWantRelease`) so a slide finalizing a few ms before the grow can't drop the reserved line under the pinned pill; (2) ≤1 pin per row — `maybePinForWrap` bails if `row.__fpGrowPin` is set OR any sibling has a running `__fpGrow` anim (restore bursts / rapid adds fall back to the legacy in-flow grow; a mid-flight final-layout probe would read half-grown siblings and mis-place the pin); (3) `collapseOut`/`expandIn`/slideCurtain's shrink branch all `fpUnpinGrow` first so a superseded pin always commits to flow; (4) **in the offscreen iframe `onfinish` NEVER fires** (documented frozen-clock pitfall), so the unpin only runs via fpFinalize's hidden poll (~3.3s) — a test that adds pills rapidly leaves the previous pin held and the next add silently takes the legacy path (looks like "pin didn't engage"); settle between adds with `FilterPills.unpinGrow(row)` + clearing `minHeight` before asserting. Verify the choreography by frame-stepping immediately after the click — the entrant's `top` must be CONSTANT at its final line from t0 and successors' rects must interpolate.

- **Pill-row ⇄ table CURTAIN — MIDDLE removal with `delta===0` JUMPED lines: never gate the shrink pin+FLIP choreography on the height delta (Jun 2026, build `fp23-flip-delta0`).** Repro (multiline): row L1=ČOS·Rok·Jazyk / L2=Oblast·Stav·CLEAR; × the MIDDLE pill (Rok) → row stays 2 lines (`delta=0`) → `slideCurtain`'s old `if (!delta) return` bailed BEFORE the shrink branch, so the leaving pill stayed in flow (same-line successor CZ glided fine via natural reflow) but the wrap-reflow is DISCRETE: frame-stepping showed `Oblast` hop {24,221}→{229,185} in ONE frame (~t150) and `Stav` jump to the L2 start simultaneously. The FLIP machinery existed and was correct (the delta<0 path glides survivors diagonally across lines) — it just never ran. FIX (3 places): (1) `slideCurtain` runs the shrink choreo for `delta <= 0` gated on `tokenEl.querySelector('.is-leaving')` (so a same-line ADD with delta 0 — no leaving items — keeps the natural in-flow push), and the `if (!delta)` bail moved AFTER the choreo (table transform still skipped at 0); (2) the comp-filter-pills harness `reslide` and (3) data-preview's `slideTable` must NOT gate the call on `d`/`delta` — cache-bust `?v=fp23`. RULES: the height delta only decides the TABLE slide, never whether survivors need choreography — gate the remove choreography on leaving items existing; any "skip when delta is 0" shortcut at a CALL SITE silently disables it. Harness now has a **Remove middle** button (`#ch-rm-mid`) so this case is exercisable without hand-clicking a specific ×. Verify via frame-stepping: a survivor's `top` must INTERPOLATE across frames, never change in a single step.

- **Pill-row ⇄ table CURTAIN SHRINK — the leaving-item slide must be scoped to leaving `.filter-pill`s, NEVER the trailing "Zrušit vše" button (Jun 2026, builds `fp21`→`fp22-clearall-pin-noslide`).** Repro (needs slow speed — Inspector/governor ≤1×, never reproduces "instant"): clear the test-bed's only pill → clear a *different* instance's pills → re-add a pill to the test-bed ⇒ the re-shown "Zrušit vše" lands off-screen or stacked over the pill, with a stuck `transform: matrix(1,0,0,1,-98,0)`, **no inline style, and an EMPTY `getAnimations()`** = the classic replaceable-animation-removal ghost (a `fill:forwards` WAA anim auto-removed from `getAnimations()` but still applying in the cascade origin). Cause: `slideCurtain`'s SHRINK branch pulled ALL `.is-leaving` items out of flow (`position:absolute` + a `fill:forwards` `translateX(→ anchor)` slide) via `tokenEl.querySelectorAll('.is-leaving')`. When the row empties, the clear-all is ALSO `.is-leaving` (it's collapsing via its OWN width animation, owned by the controller), so it got the leaving-slide too. On the next add the button is REUSED — its inline `cssText` is reset by `showClearAll`, but that forwards transform anim is never cancelled and ghost-fills the −98px. The "cross-instance / not isolated per-instance" smell the user reported was a RED HERRING — it's pure timing on the test-bed's OWN row; operating another instance just added the delay that let the reuse happen before any cleanup. FIX (TWO steps — the obvious first cut REGRESSED, note both): (a) `fp21` scoped the leaving-slide to `tokenEl.querySelectorAll('.filter-pill.is-leaving')` so the clear-all is never SLID — this killed the ghost but REGRESSED into the OLD bug: with the clear-all left IN flow while the pills go `position:absolute`, it becomes the LONE in-flow child and reflows to the START of the row ("Zrušit vše jumps to the row start" when emptying via Clear/Zrušit vše or removing the last pill). (b) `fp22` is the correct split: a leaving clear-all IS pinned `position:absolute` at its CURRENT offset (so it neither jumps NOR holds the row tall — the row drops to final height and the table slides the full way) but gets NO `translateX`/transform of ANY kind — it just collapses (width/opacity, controller-owned) where it stands. Position:absolute is plain inline that `showClearAll`'s `cssText=''` wipes on reuse; a transform anim is NOT (it ghost-fills). So: pin ALL leaving items; SLIDE only the leaving `.filter-pill`s (the clear-all is excluded from the `translateX`). RULE: the curtain's out-of-flow leaving-slide is for removed PILLS only; the clear-all's appearance/disappearance is the controller's job (collapseOut/showClearAll) — the curtain must not touch it. Verified: clear-all now only ever carries a `box` (width/opacity) animation, never a `T` (transform); computed transform stays `none` through the full repro; final layout = pill · 8px gap · clear-all.

- **Pill-row ⇄ table CURTAIN — GROW must RESERVE the row's final height; clear-all must not re-reveal on a same-line add (Jun 2026, build `fp18-grow-reserve`).** Two reported defects, both because the per-pill box-grow desyncs from the table slide. SETUP (both `comp-filter-pills.html` AND `data-preview.html`): host measures `h0` → inserts new pills at NATURAL width → measures `hf` → `slideCurtain(delta)` → THEN `expandIn` each new pill, which CLIPS it back to width 0 and grows it. So despite the CSS comment claiming "the row sits in its FINAL layout the whole time," `expandIn` drops the row from `hf` back to `h0` and it grows gradually. **(1) Adding a pill that WRAPS (a new line, OR just pushes "Zrušit vše" to line 2): the table jumped UP over EXISTING lines ("table covers half the first row").** Cause: `slideCurtain` starts the table at `translateY(-delta)` ASSUMING the row is already `hf` tall, but at t=0 it's only `h0` (1 line) because `expandIn` clipped the new pill → table yanked up by a full line over the single existing line. FIX (shared `slideCurtain`, GROW branch only): `tokenEl.style.minHeight = tokenEl.offsetHeight` (read at call time = `hf`, new pills still natural) RESERVES the row's final height so the box stays `hf` for the whole slide and the pills (incl. a wrapping clear-all) grow/reflow INTO the reserved space the table reveals top-down. Verified via frame-stepping: table visual-top now STARTS at the bottom of line 1 (`coversLine1:false`) instead of riding up over it. This is DIFFERENT from the reverted `fp11` `style.height` pin (pitfall below): (a) it's `min-height` not `height` (content can still exceed it), and (b) the host measurement helpers (`outerH`/`afpOuterH`) now STRIP `min-height` before reading so the reservation can NEVER inflate the next `h0`/`hf` (the exact failure that killed `fp11`). FOLLOW-UP (build `fp19-grow-reserve-top`): a bare `min-height` reservation made the EXISTING pills JUMP DOWN at t=0 then snap back up — the row is `align-items:center`, so the extra cross-axis space let the default `align-content` (normal⇒stretch) centre the single existing line in the taller box. FIX: the GROW reservation ALSO sets `tokenEl.style.alignContent = 'flex-start'` (released with the min-height everywhere) so the existing line stays glued to the TOP and the reserved space sits BELOW it (where the new line lands) — verified by frame-stepping: existing pills' viewport-top is invariant across the whole slide. flex-start == normal when there's no extra space, so it reverts cleanly. RULE: reserving extra height on an `align-items:center` (or any non-`flex-start` cross-align) flex row WILL re-centre/stretch existing lines — pin `align-content:flex-start` alongside the reservation. SHRINK FOLLOW-UP (build `fp20-shrink-flip`): the symmetric remove case — removing a pill that COLLAPSES a line made the "Zrušit vše" button (and any non-leaving successor) SNAP instantly to its new spot (up a line, or to the start of the current line "under the shrinking pill"). Cause: the shrink branch pins the leaving pill `position:absolute` so the row drops to final height NOW (needed so the table slides the full way) — which reflows every NON-leaving successor to its final position instantly. (A GROW doesn't have this: the growing pill stays IN flow and pushes successors frame-by-frame.) FIX: FLIP the non-leaving in-flow children — record `getBoundingClientRect` BEFORE pinning the leaving items, force the reflow, then `el.animate([{transform:translate(dx,dy)},{transform:translate(0,0)}], {dur, collapseEase, fill:'backwards'})` (dx/dy = old−new) so each successor SLIDES into the closing gap paced with the pill's width-collapse. Restart-safe via `el.__fpFlip` (cancel+supersede) + `fpFinalize`. `fill:'backwards'` reverts to no transform at the end (no residue). Verified by frame-stepping the button: transform interpolates old-offset→(0,0) across the duration instead of staying 0 (a jump). RULE: whenever you pull an element OUT of flow to force an instant reflow, FLIP the in-flow siblings that consequently jump. RESERVATION RELEASE is belt-and-suspenders: cleared on the slide's REAL finish (`fpFinalize`, guarded by `__fpSlide===a` so a superseded slide can't drop a newer grow's reservation), at the START of any SHRINK branch, on the reduced-motion path, AND on a zero-`delta` early-return (`if(!delta){ tokenEl.style.minHeight=''; return }`) — the last one is essential: a same-line add right after a wrapping one calls `slideCurtain(delta=0)` and must release the prior reservation or the row stays pinned a line too tall. **(2) Adding a SECOND pill on the SAME line re-played the whole "Zrušit vše" reveal (from 0 width/opacity) instead of just sliding it right.** Cause: `showClearAll`'s recovery guard (the `fp-clip`/`offsetWidth>4` checks that exist to UN-stick a wrongly-collapsed button) can't tell "legitimately mid-reveal" from "stuck collapsed" — both are `fp-clip`+narrow. FIX: tag enter animations `a.__fpGrow=1` in `expandIn`; `showClearAll` now ALSO bails when a grow is genuinely in flight (`clearBtn._anim.__fpGrow && playState∉{finished,idle}`). A stuck button has NO running grow (its `_anim` is a finished/lingering COLLAPSE), so recovery still fires. Net: on a same-line add the clear-all just reflows right (pushed by the growing pill) — no reveal restart. ANSWER to "do we consider the button's position when capturing final height?": YES and we SHOULD — the button is in the row flow so `afpOuterH`/`naturalHf` already include its wrap in `hf`; the jump was never the measurement, it was the row not being held at `hf` during the grow. **Can't be seen in the offscreen verifier** (frozen clock → grow/slide never advance); verify by FRAME-STEPPING (`a.pause(); a.currentTime=T`) or in a foreground tab.

- **Pill-row ⇄ table CURTAIN — two bugs found via the test bed (Jun 2026).** (1) A "freeze the row at its final height for the slide" attempt (`tokenEl.style.height = offsetHeight`) BACKFIRED: setting `style.height` makes the NEXT `offsetHeight` read the PINNED value, so the wrap never registers and `delta` collapses to just the margin — the table "jumps to make space" and slides only ~12px. REVERTED (build `fp11-curtain-nopin`); never pin the same element you then measure for the next slide. (2) The `comp-filter-pills.html` test bed tracked the pre-mutation height in a running `lastH` var that DRIFTED out of sync (logged `rowBefore=0 rowAfter=39` but `delta=16`). Fix: measure `h0` FRESH right before each mutation (capture `outerH(row)` in the Add handler / at the `filterpill:remove|clearall` event BEFORE `is-leaving` collapses layout), exactly like `data-preview`'s `renderPills` does (`afpOuterH(afp)` at the top) — `data-preview` was always correct; only the harness's tracker was buggy. RULE: derive the slide delta from a fresh before/after measurement, not a persisted tracker. (3) The REVERSE (1→0 / any shrink) jumped at the END: the leaving pill stays IN FLOW while it collapses (compound removes it only at the collapse's end), so the row keeps full height and the table slides only to the OLD spot (touching the pill's border), then jumps up when the pill is finally removed. Fix (shared `slideCurtain`, build `fp17-curtain-shrink-pace`): on a GROW (delta>0) the table does the curtain transform; on a SHRINK (delta<0) pin each `.is-leaving` item `position:absolute` at its EXPLICIT current left/top, then slide the items RIGHT of the leftmost one LEFTWARD over it (`translateX`) using the COLLAPSE easing (`--afp-collapse-ease`) so the clear-all paces with the pill (the fast enter ease raced ahead; UNPINNED auto-origin stacking garbled). The table transform also uses the collapse easing for a shrink. Measurements exclude all `.is-leaving` (`afpOuterH`/`naturalHf` query `.is-leaving`) so delta is the full shrink. NO row-height animation. (Rejected alternatives: pinning their left/top to PREVENT the overlap — user wanted the overlap; and animating the row HEIGHT down so the table follows in flow — user said "do not animate row's height".) STILL OPEN: a genuinely WRAPPING add (pill bumps to line 2) changes the row height MID-slide as the pill box-grows from width 0, which no single upfront delta can track — needs a reactive "table follows the row height" approach. **Can't verify motion in the preview iframe (frozen WAA clock) — confirm deltas/visuals in a focused tab.**

- **`_filter-pills.css`'s `.row[aria-label="Aktivní filtry"]` selector silently MISSED in the dataset preview because the CSS file was decoded as Latin-1 — the UTF-8 `í` (bytes C3 AD) mojibaked to `Ã­` in the PARSED selector, so it never matched the DOM's real `í` (U+00ED).** Symptom: active-filter pills touched (no `margin-right:8px`) and `column-gap` stayed `normal`, while motion still looked fine (the JS reads `--afp-*` off the same selector but has hardcoded FALLBACKS equal to the token values, so the miss was invisible there). The .js file decoded as UTF-8 (classic scripts default to UTF-8, so `querySelector('[aria-label="Aktivní filtry"]')` matched and pills animated) but the .css did NOT — no charset on the response and external CSS doesn't default to UTF-8 in this serving setup. DIAGNOSIS that nailed it: `getComputedStyle` showed `column-gap:normal`/`margin-right:0` even though `el.matches('.row[aria-label="Aktivní filtry"]')` was TRUE and an injected `#afp[aria-label="Aktivní filtry"]{…}` (typed precomposed) DID apply — then dumping `sheet.cssRules[].selectorText` showed `AktivnÃ­` (the mojibake). Raw file bytes (readFile, UTF-8) were correct (charcode 237) — a DECODE-time bug, not a stored-bytes bug. FIX: prepend `@charset "UTF-8";` as the literal FIRST bytes of `_filter-pills.css` (cache-bust `?v=fp8`). comp-filter-pills.html dodged it only because it has its own local `.row{gap:8px}`. RULES: (1) any preview CSS whose SELECTORS contain non-ASCII (Czech aria-labels here) MUST start with `@charset "UTF-8";` or escape the char (`\ED` etc.) — don't trust the server/UA to default to UTF-8 for `<link>` CSS; (2) when a selector verifiably matches (`.matches()`) yet its declarations don't apply, dump `sheet.cssRules[].selectorText` to catch a mojibaked selector; (3) JS string-compare selectors are safe (scripts default UTF-8) — the asymmetry CSS-broken/JS-fine is the tell.

- **At EXTREME slow speed (Inspector ÷10 STACKED with the DevTools Animations panel ÷10 ⇒ ≈ ×1/100) the pill grow/collapse AND the table-curtain slide "ended abruptly and jumped".** Cause: the wall-clock `setTimeout(go, dur*12+400)` backstop (in the compound's `onceDone` and the host's old `slideTable`) assumed a SINGLE ≤×12 slowdown — but the two rate controls MULTIPLY, so a ~24s animation got cancelled by a ~3.3s timer → snap-to-end. (Same family as the line-91 "wall-clock hand-off fires early once slowed" pitfall, now ground-truthed with stacked rates.) FIX: a shared `fpFinalize(anim, fn, ms)` (module-level in `_filter-pills.js`) finalizes on `anim.onfinish` (speed-correct at ANY playback rate) and its poll ONLY force-finalizes when `document.visibilityState === 'hidden'` (offscreen frozen-clock fallback, where onfinish never fires) or the anim is already `finished`/`idle` — it NEVER snaps a still-running OR governor-PAUSED VISIBLE animation. `onceDone` now delegates to it; `ms` is just the frozen-case first-poll delay, not a deadline. RULES: (1) NEVER gate animation cleanup on a wall-clock duration estimate — speed controls stack, so no fixed margin is safe; drive cleanup off `onfinish`/`finished` and only wall-clock-fallback when hidden; (2) distinguish "paused/slow but visible" (keep waiting) from "frozen offscreen" (finalize) via `visibilityState`, not via "currentTime hasn't advanced" (which also matches a user PAUSE). The table slide is now the SHARED `window.FilterPills.slideCurtain` (one impl for data-preview + the comp-filter-pills test bed). **Can't be observed in the offscreen verifier iframe** (hidden ⇒ frozen clock ⇒ fpFinalize takes the hidden path anyway): the difference is visible-only — verify in a foreground tab at slow speed.

- **The "Zrušit vše" ghost — FINAL definitive cause: a finished `fill:forwards` collapse animation LINGERS in the CSS *animations* cascade origin, invisible to `getAnimations()`.** Decisive live probe: a NON-important inline `width:auto;opacity:1` set on the button was OVERRIDDEN (stayed 0/0), but `!important` inline WON — the exact cascade signature of the *animations* origin (above normal inline/author, below `!important`). Yet `el.getAnimations()` AND `document.getAnimations().filter(target===el)` both returned `[]`, and a `cloneNode` of the button in the SAME parent rendered 76px/opacity 1. So a WAA collapse (`el.animate([nat],[ZERO],{fill:'forwards'})`) had finished, been auto-removed from `getAnimations()` (replaceable-animation removal) WHILE its effect kept FILLING `width:0;opacity:0`. Every `getAnimations().forEach(cancel)` therefore missed it; when the button was later shown, `expandIn`'s onfinish `a.cancel()` reverted to a "base" the lingering collapse had pinned to 0 → "shows during the add animation, snaps to 0 at the end". FIX: keep the animation handle on `el._anim` and cancel THAT by reference (`killAnim(el)` at the top of collapse/expand + in the host reconcile), never trusting `getAnimations()`; and make the collapse SELF-CANCEL (`a.cancel()`) in its onfinish right after committing the terminal state (hidden/remove), so it can never linger. RULE: a finished `fill:forwards` WAA animation can drop out of `getAnimations()` while still filling — always retain the handle and cancel by reference; prefer committing a collapse's terminal state via the `hidden` attribute + cancelling the animation over leaving a fill held. Build marker `window.__fpBuild === 'fp5-ref-cancel'`; cache-bust `?v=fp5`. PREVENTIVE: the same risk pattern (`fill:forwards` whose teardown trusts `getAnimations()`) was audited project-wide — `_segmented.js`/`_pagination.js`/`brand-header.html` already cancel by stored handle (safe); the doc-context collapse in `data-preview.html` (`setDocContext`) was hardened to cancel its fill via an explicit `dc._dcAnim` handle (was only list-based + `commit`-cancel).
- **The "Zrušit vše" ghost — earlier GROUND-TRUTHED layer (still part of the fix): `Animation.cancel()` is NOT synchronously reflected in `getComputedStyle`** — measuring an element's natural box right after cancelling its in-flight animation reads the stale ~0 width → the next collapse/expand keyframes become `0px→0px`, a no-op pinning the box to 0. FIX: `naturalFrame(el)` caches the natural box once at a verifiably-clean moment (width>1px) on `el.__fpNat` (content is constant) and reuses it. Measure WITH `.fp-clip` (carries `display:inline-flex`; without it an `all:unset` button is `display:inline` → width "auto"). Plus the host `reconcileClearAll(afp)` reads filter truth LIVE (`activeFilters()`, not a snapshot) and force-shows when filters are active (the pill/checkbox desync). RULES: never measure natural size right after cancelling a WAA animation (cache a clean measure); a state-machine backstop must read its source of truth live.
- **`expandIn`/`collapseOut` weren't restart-safe → a SUPERSEDED call's deferred `onceDone` backstop could clobber the element after a NEWER call took over** — the intermittent "Zrušit vše vanishes while filters are still active" report. Sequence: count→0 starts `collapseOut(clearBtn)` (`is-leaving`); count→>0 calls `showClearAll` → `expandIn` re-shows it; but the FIRST expand/collapse's `setTimeout(go, backstop)` (≥3s wall-clock, fires even at fast speeds eventually) still ran its cleanup — stripping `fp-clip` + clearing inline box styles off the element the NEWER animation now owns. FIX: a per-element run token — `const myRun = (el.__fpRun = (el.__fpRun||0)+1)` at the top of BOTH `expandIn` and `collapseOut`, and `if (el.__fpRun !== myRun) return;` as the first line of each `onceDone` callback. A stale backstop/onfinish becomes a no-op once a newer enter/leave starts. (This is the same restart-safe run-token pattern CLAUDE.md mandates; the existing `is-leaving`-check guard in `syncClearAll`'s collapse callback was necessary but not sufficient — the EXPAND cleanup had no such guard.)
- **"Zrušit vše" left every pill stuck in `is-leaving` limbo on hosts with NO demo affordances (data-preview.html).** The clear-all branch in `_filter-pills.js` did an UNGUARDED `(resetBtn || firstSuggested()).focus()` to park focus after clearing. In the dataset preview the active row has neither a `.reset-row` ("Obnovit") nor a "Navržené filtry" suggested row, so both operands are `null` → `null.focus()` **throws a TypeError mid-handler**, AFTER the pills were marked `is-leaving` but BEFORE the `pills.forEach(collapseOut … p.remove())` line ran. Net symptom: filters DO clear (the `filterpill:clearall` event already fired first) and clear-all DOES hide (via the host's `renderPills → api.syncClearAll`, a separate microtask), but the pills are orphaned (`is-leaving`, never collapsed/removed) and the row never recovers. FIX: guard it like `removeActivePill` already does — `const fallback = resetBtn || firstSuggested(); if (fallback) fallback.focus();`. RULE: every `(a || b).method()` where both operands are optional DOM lookups must be null-guarded; an uncaught throw in a click handler silently aborts the rest of the handler and leaves half-applied state. (Reproduce in the USER's foreground view, not the preview iframe — the iframe's frozen WAA clock makes pills look stuck even on healthy code, masking the real throw.)
- **Programmatic `cb.checked = val` does NOT fire `change` — keyboard / "vše" filter ops never reached a consumer.** `_filter-group.js`'s `setChecked` set `.checked` directly, so Enter/Ins/Del/Backspace and the per-group "vše" reset updated the chips but the dataset preview (listening on `change`) never re-filtered. Fix: `setChecked` now dispatches a bubbling `change`. Any control that flips `.checked`/`.value` in script MUST dispatch `change` or downstream listeners silently miss it. (No loop: the cb's own change handler only re-syncs row + clear button, never re-sets.)
- **A `type="search"` input paints a NATIVE clear glyph (WebKit cancel button) ON TOP of a custom `.clear` button → a doubled ×.** Suppress it in the shared field: `input[type="search"]::-webkit-search-cancel-button { -webkit-appearance: none; appearance: none }` (also `::-webkit-search-decoration`). Added to `_field.css` so every search consumer is covered.
- **Pills that MIRROR external state (filter checkboxes) must reconcile, not innerHTML-rebuild.** The shared `_filter-pills.js` controller animates removal (collapseOut) then removes the node; if the host re-renders by wiping `innerHTML` on the resulting `change`, it destroys the animating pill. Host renders a DIFF (add/remove deltas only) and skips `.is-leaving` pills; the controller emits `filterpill:remove`/`filterpill:clearall` so the host can uncheck the source box WITHOUT a full rebuild.

- **Multiple `_filter-pills.js` instances in ONE card cross-talked because the CLICK handler was delegated on `root` (the `.card`), not on `activeRow`.** With two `.row[aria-label="Aktivní filtry"]` in a card (e.g. the comp-filter-pills Inspector well + the reference "Active →" row), each `wireGroup` added a card-level click listener whose `.reset-row`/`.add-row`/`.clear-all` branches were NOT scoped to its own row — so one row's "Přidat"/"Obnovit"/"Zrušit vše" click bubbled to the card and fired BOTH closures (adding/clearing in the other row too). Only the `.x` branch had an `activeRow.contains()` guard. FIX: delegate the click listener on `activeRow` (every active-row control IS its child, so scoping isolates the instance — guard now unnecessary). The keydown handler was already on `activeRow` (unaffected). The SUGGESTED-pill toggle lives OUTSIDE activeRow (its own `.row[aria-label="Navržené filtry"]`, shared by all instances in the card) — moved to a suggested-row listener wired ONCE per card via a `dataset.fpSugWired` flag, so a suggested pill toggles its `aria-pressed` exactly once even with N instances (two card-level closures would have toggled it twice = no-op). Rule: a per-instance compound controller must delegate on the INSTANCE root, not a shared ancestor; anything genuinely shared across instances (here the suggested row) gets a separate once-wired listener.

## Animated collapse in an auto-height container (frozen-clock verification)

- **`grid-template-rows: 1fr → 0fr` does NOT collapse in an AUTO-height grid container — `0fr` resolves to CONTENT height** (confirmed: `getComputedStyle().gridTemplateRows` read `570px` for `0fr`). The popular grid-rows collapse trick needs a definite container/free-space basis it doesn't have here. Use a declarative class (`height:0; overflow:hidden`) for the resting state + a JS height tween for smoothness instead.
- **An ACTIVE WAA animation overrides BOTH inline style AND a CSS class for the animated property — even while frozen at its start keyframe — so a "declarative collapsed" class can't win until the animation is CANCELLED.** In the hidden/throttled preview pane the animation freezes at `currentTime 0` (start keyframe) and `setTimeout` is throttled, so an animated height collapse shows its START (un-collapsed) forever in tooling and `getComputedStyle().height` reads the frozen value (e.g. `540px`) despite `height:0` on the class. Make the RESTING state a declarative class so it's correct without any callback, and have the commit (onfinish + a wall-clock `setTimeout` backstop) CANCEL the animation so the class/auto resolves cleanly.
- **Verifying such state via eval: (1) the preview pane is hidden → WAA frozen + timers throttled, so trust the logic, not the captured frame; (2) `?rm` forces the reduced-motion synchronous path (no animation) so the end state is inspectable; (3) the view-switch MutationObserver is ASYNC — read state AFTER `await tick()`, not synchronously right after `.click()`, or you measure the pre-switch state.** Guard collapse state off the actual DOM class (`show !== el.classList.contains('is-collapsed')`), never a separate boolean var — repeated/duplicate switches desync the var from reality.

## Playground chrome (playground.html popovers / nav / modal)

- **A finished `fill: 'backwards'` (or 'none') WAA animation DROPS OUT of `getAnimations()` — so the timeline-scrubber can't seek it and it freezes at its final state when scrubbing back.** Root cause of the Jun 2026 "previously-revealed steps stay put / don't retract" scrub bug: `_status.js`'s dot/label/callout entrances used `fill:'backwards'`, which only fills BEFORE the active phase; once finished they stop affecting rendering → the browser deems them irrelevant → `doc.getAnimations()` omits them → the playground scrubber (which seeks getAnimations()) never touches them. The connectors used `fill:'both'` so THEY alone retracted (the asymmetry looked like "compound steps not reset"). FIX: every scrub-able entrance animation must use `fill:'both'` so the final keyframe stays applied and the animation stays "relevant"/seekable. Safe because every end keyframe is the IDENTITY (translateX(0)/scale(1)/opacity(1)) = the CSS base, so the held value is a visual no-op AND the transform is never removed (no finish-snap). Bonus: a no-op `clock` with `fill:'none'` then correctly drops after settling, trimming the dead tail so scrubbing from the end immediately reverses the LAST real animation instead of stepping through padding.
- **A wall-clock `setTimeout` hand-off that matches an animation's NORMAL duration fires EARLY once the playground slows that animation — it must be WAA/transition-driven or a generous backstop only.** Found auditing animations (Jun 2026): `_breadcrumbs.js` revealed via a CSS `max-width` transition (~300ms) but dropped the clip + moved focus on `setTimeout(finish, 340)`; at 0.1× the transition runs ~3000ms while the timer still fired at 340ms → focus moved + overflow un-clipped mid-reveal (clipped focus ring). Same class of bug in `brand-header.html`'s ink glide (`ink.animate` 420ms) committed on `setTimeout(fin, 1000)` — at 0.1× the WAA anim is 4200ms so the timer snapped the ink to its target at 1s. Fixes: breadcrumbs now hands off on `transitionend` (filtered to `propertyName==='max-width'`) with a run-token (`nav.__run`) + a 4000ms BACKSTOP; brand-header keeps `onfinish` (scaled) and bumped the backstop to 5200ms (> 420÷0.1). RULE: the governor scales `playbackRate`, so `onfinish`/`transitionend` track the slowed clock but `setTimeout` does NOT — drive commits off the WAA/transition event and make any `setTimeout` a backstop longer than the SLOWEST speed (÷0.1 = ×10, so ×12 + margin). (`_filter-pills.js` was already safe: its `DONE_FALLBACK` 3000 > 240÷0.1 = 2400.)
- **`Space` as a global pause toggle must bail on activatable elements or it double-fires / steals native activation.** `handleShortcut`'s Space branch sits after the `isTypingTarget` bail (covers input/select/textarea/tweaks) BUT buttons/links aren't typing targets — so it also checks `e.target.closest('button, a[href], [role=button], summary, label')` and returns, letting the focused control use Space to activate. Without it, pressing Space on the pause/help/width button would both activate it AND toggle pause. `Pause` (media key) has no such guard — it's never an activation key.

- **A `background-position` percentage sweep does NOT slide a stripe fully off-screen — it parks half-visible at both ends.** The modal loader bar (`.fp-modal__loader`, bottom edge of the filename box) was a 42%-wide gradient swept via `background-position: -42% 0 → 142% 0`. CSS background-position % is relative to `(container − image)`, NOT the container, so `-42%` only pushes the stripe ~24% off and the bright centre stays visible — the loop visibly snapped (reported "starts/ends half visible, not smooth"). Fix: make the stripe a real positioned box (`width:42%; left:0`) and animate `transform: translateX()` — which IS relative to the element's own width. `-100%` parks it just off the LEFT (right edge at 0); `+238%` (=100%/42%) parks its left edge at the RIGHT — fully off both ends, so the infinite loop has no visible start/end. Parent `.fp-modal__code` clips (`overflow:hidden`). Rule: for a "slide fully out and back in" sweep, use translateX on a sized box, never background-position percentages.

- **Forcing a state in the modal clobbers static demos unless the force-machine tracks what it added.** The modal shows the WHOLE file (no whitelist), so the static `.field--focus`/`.field--error`/`.is-focus` demos are visible — and the old `clearForcedClassesIn` (strip class from every FOCUS_TARGETS match) + `applyErrorTo` (toggle on every `.field`) wiped them at default/off state. Fix: focus pass skips already-classed elements and marks its own additions (`data-fp-added-focus`); error pass marks `data-fp-forced-error`. Symptom was "focus ring / red border disappears on opening the preview". Don't blanket-toggle demo classes.
- **A source file that calls `.focus()` on load STEALS keyboard focus from the modal titlebar.** comp-switch focused `#focusDemo` on load to show a live ring; in the modal that yanked focus off the close button into the iframe. Gate such auto-focus on standalone only — under `?fp` paint the ring via the static `.is-focus` modifier instead (`focusDemo.closest('.sw').classList.add('is-focus')`). Pattern for any "always-show-focus" demo.
- **Controls-only hides the WHOLE switch state-matrix (`.smx`), not just its labels.** Earlier the labels were `visibility:hidden` to keep grid alignment, but the matrix is doc-only (a comparison grid) and the user wants it gone in controls-only — so `.smx{display:none}`. (If you ever hide only SOME matrix cells, remember the grid uses auto-placement: a `display:none` cell shifts the rest; use `visibility:hidden` for partial hides.)
- **Lengthening the modal CLOSE animation can resurrect the blank-iframe flash — the `about:blank` teardown timeout must outlast the close.** `closeSourceModal` blanks `modalFrame.src` on a `setTimeout`; with the close at 0.46s the old 240ms fired mid-fade → white flash. Bumped to 520ms. Any teardown clearing modal CONTENT must run AFTER the close animation.
- **Modal-bar speed/theme pills must drive the TOOLBAR group, not apply directly.** The modal `data-fp-modal-speed` change handler routes through `syncToolbarInput('[data-fp-speed]', …)` so the `?rm` reload, `speedTick` loop and announcer are reused; `applySpeed`/`applyTheme` keep BOTH pills checked (no loop — they set `.checked` without re-dispatching).

- **The modal Esc "is an overlay open?" check must match ONLY live floating dropdowns (`.combo-list[data-open="true"]`), never `[role="combobox"][aria-expanded="true"]`.** comp-select.html ships a STATIC "open by default" docs demo — a `.combo-list[data-static="true"]` with a PERMANENT `aria-expanded="true"` on its trigger. Matching `aria-expanded` made that always-present demo register as "an overlay is open", so Esc never closed the modal (it always deferred to a dropdown that can't be closed). Live dropdowns set `data-open="true"` on open / remove it on close, so that's the only reliable "is something actually open" signal.
- **Navigating to a section must land its top edge on the toolbar's bottom border (offset = `toolbarEl.offsetHeight`, no extra gap).** The old `+14` left a 14px gap that exposed BOTH the fixed toolbar's bottom border AND the section's top border → a visible doubled line with space between. Zero gap merges them into one line.
- **Section 01 (Links) is now a REAL iframe (`comp-links.html`), not inline `.fp-prose`.** Inline prose got a DIFFERENT active-section ring than the iframe sections (an iframe clips its own inset box-shadow; a div doesn't) AND clicking non-link whitespace didn't mark the section active (nothing focusable there). As an iframe it inherits the standard `.is-active .fp-frame` ring and the inject-time `pointerdown`/`focusin` active-section marking for free. Forced-disabled on inline `<a>` (not a native form control) is handled by the playground's disabled pass via a `[data-fp-disable]` opt-in attribute (same aria-disabled + tabindex treatment as `[role="combobox"]`); the links also need `.prose a` in `STATE_STAMP_SELECTOR` + `FOCUS_TARGETS` for forced hover/pressed/focus.

- **The section-detail collapse hid the header with `display:none`, which let `.fp-sect__body` AUTO-PLACE into the FIRST (shrinking → 0px) grid track and collapse the frame instead of expanding it.** `.fp-sect` is a 2-col grid (`220px 1fr`); the hide animation sets the header `display:none` AND animates the columns to `0px 1fr`. A `display:none` child is removed from grid layout, so the body (previously the 2nd item) became the only in-flow item and auto-placed into track 1 — which was animating to 0 → frame width collapsed to 0. Fix: pin children to their tracks explicitly (`.fp-sect__h{grid-column:1}`, `.fp-sect__body{grid-column:2}`) so the body stays in the `1fr` track no matter what happens to the header. Rule: any grid whose track sizes you animate AND whose children can go `display:none` MUST pin every kept child's `grid-column`, or auto-placement silently re-slots it into the wrong (often collapsing) track.
- **Toolbar responsive collapse is JS-measurement-driven, NOT `@media` breakpoints — breakpoints made the header transiently TALLER while dragging wider.** With flex-wrap + fixed breakpoints, there were width intervals where the toolbar wrapped to 2 lines BEFORE the next breakpoint collapsed a control back to 1 line — so widening the window briefly grew the header taller (the reported bug). Replaced with `reflowToolbar()`: recompute from level 0 (everything shown), then add ordered compaction classes (`fp-c-tight/counter/eyebrows/err/state/title`) while `toolbarWraps()` is still true. Recomputing from scratch each resize makes the result deterministic per width, so adding width can only REMOVE steps → header height is monotonic in width. The whole pass is synchronous (forced reflow reading `offsetHeight` in the loop, no paint between) so the "show-all-then-collapse" never flickers. `toolbarWraps()` compares `toolbarEl.offsetHeight` to `maxChild + vertical padding + 4px` tolerance. Driven by `window.resize` (width); the ResizeObserver on the toolbar only keeps `--fp-toolbar-h` in sync with height changes (don't drive reflow from it — reflow changes height → would loop).
- **Toolbar segment pills (`.fp-seg label`, `.fp-check`) showed no focus ring and near-invisible hover because their radio/checkbox is `opacity:0;pointer-events:none`.** `:focus-visible` never surfaces on a visually-hidden input, and hover was a color-only change. Fix: mirror the ring onto the LABEL with `label:has(input:focus-visible){box-shadow:var(--shadow-focus);z-index:1}` (the hidden radio IS still keyboard-tabbable, so `:has()` fires), and give hover a neutral fill (`--gov-color-neutral-100`) distinct from the primary-tinted checked state. Same pattern for `.fp-check` (error toggle) with `--gov-color-neutral-100` hover.
- **A two-option radio "toggle" (theme light/dark) can't flip by re-clicking the active option — a radio no-ops on re-click.** To make clicking the active icon flip to the other, add a `click` listener on the LABELS that compares the label's value to the source-of-truth state var (`currentTheme`), NOT `input.checked` (the label-vs-input click order makes `.checked` ambiguous mid-click): `if (input.value === currentTheme) { e.preventDefault(); toggleThemeUI(); }`. `preventDefault` stops the radio re-confirm; `toggleThemeUI` flips. Clicking the inactive label falls through to the normal `change` path.
- **Modal close felt abrupt because the panel animated transform ONLY, then `visibility:hidden` snapped the fully-opaque panel off at the end.** The backdrop faded but the panel didn't. Fix: add `opacity` to the panel (`0`↔`1`) and include it in the panel transition so it fades out in step with the transform before visibility flips. (Consistent with the existing backdrop fade — these are real-interaction overlays, not capture targets, so the opacity-park caveat doesn't apply.)
- **Esc inside the modal closed the WHOLE modal even when a combo dropdown was open — because the combo's own Esc handler (target phase, on the trigger) runs BEFORE the playground's bubble-phase `handleShortcut` and has already flipped `aria-expanded` to false by the time the bubble handler queries it.** So querying live state in the bubble handler always saw "no overlay open" and closed the modal. Fix: a CAPTURE-phase keydown listener on the modal iframe doc stashes `doc.__fpEscOverlay` = whether an overlay was open, recorded BEFORE the target-phase combo handler runs; `handleShortcut` reads that pre-state for iframe-origin Esc (and live-queries for titlebar-origin Esc, where no combo handler ran) and skips closing the modal if an overlay was open. Don't `stopPropagation` in the capture listener — that would also kill the combo's own target-phase close.
- **Modal focus management is modal-ONLY (inline section frames don't trap).** Added in `injectModal` (guarded by `doc.__fpModalNav`): a bubble-phase Tab handler that wraps focus within the previewed component (`focusablesIn(doc)` last→first, first→last on shift-Tab), and F6 in `handleShortcut` (gated on modal open, placed BEFORE the typing-target bail so it works from inside a field) that hops focus between the iframe content and the titlebar close button using `e.target.ownerDocument === modalFrame.contentDocument` to tell which region focus is currently in.
- **The inline Links section (01) is `.fp-prose`, not an `.fp-frame`, so the `.is-active .fp-frame` ring never applied to it.** Any active-section / state styling keyed to `.fp-frame` must also target `.fp-prose` (or any non-iframe section body) explicitly.

- **To stop the page scrolling behind an open modal, LOCK the scroll container — don't try to swallow scroll keys.** A keyboard-only `handleShortcut` guard only fired when focus was on the modal CHROME; clicking INTO the modal iframe moved focus there (iframe keydowns don't reach the parent handler) and, when the previewed file couldn't scroll further, the browser propagated the scroll to the parent page. It also did nothing for the mouse wheel. Fix: `openSourceModal` adds `fp-modal-open` to `<html>`+`<body>` (`overflow:hidden !important`), removed in `closeSourceModal` — blocks keyboard AND wheel regardless of focus; the modal iframe keeps its own scroll.
- **An iframe with inline `visibility:visible` OVERRIDES its hidden parent.** `injectModal` sets `modalFrame.style.visibility='visible'` (anti white-flash on open). On close, the parent `.fp-modal` goes `visibility:hidden` — but the child's explicit `visible` wins, so the (now `about:blank`) iframe stayed painted as a blank solid overlay over the page. `closeSourceModal` must reset `modalFrame.style.visibility=''` so it inherits the parent's hidden state.
- **Never animate the section-detail collapse by squeezing the header into a 0px grid column.** Block children (num/name/sub) wrap to ~0 width → the header grows VERY TALL → the grid row inflates; and an `opacity:0` (but still displayed) header keeps reserving its height, so short sections stay tall with empty space. Sequence instead: fade the header out while it's still in-flow at full width, THEN `display:none` it and animate the column width with the header already out of flow (`applyDetails` + `fp-details-fading`/`fp-details-collapsing`/`fp-no-details`).
- **Tooltip flicker on click:** a control's `focusin` re-ran `place()` after a mouse click (the click focuses it), re-firing the tip entrance animation right after the action. Fix in `_tooltip.js`: gate the focus-show on `e.target.matches(':focus-visible')` (true only for keyboard focus) and add a `pointerdown` → `hide()` so a click dismisses the tip; it reappears only on pointer re-entry (mouseover fires on entry only).
- **When inserting a block before an existing `if (cond) { … }` in `handleShortcut`, re-include the `if (cond) {` anchor line in the replacement.** A str-replace that swallowed the `if (k === 'Escape') {` line left the Escape body dangling → `Uncaught SyntaxError: Unexpected identifier 'document'` pointing at the NEXT statement (`document.addEventListener('keydown', …)`), not at the actual broken block. Don't hunt the reported line — look at the statement just above it.
- **NEW floating popovers (nav menu `.fp-nav`, width popover `.fp-widthpop`) must NOT transition `opacity` from 0 — same parking trap as tooltips/iframes.** First build animated `opacity 0→1` on the `[data-open]` class; DOM showed `data-open` set + 29 list items + correct rect, but `getComputedStyle().opacity` stayed `0` (parked) so the menu was invisible in capture (and the documented risk says it can park in the real pane too). Fix = make `opacity`/`visibility`/`pointer-events` RULE-DRIVEN (instant, NOT in the `transition` list) and animate `transform` only. The existing `.fp-modal`/`.fp-keys` overlays predate this and DO fade in the real browser (user praised them) — leave them; only the transform-only rule is guaranteed under capture. Don't conclude a popover is broken from a capture without checking computed opacity first.

## CSS layers / token contracts

- **`playground-tweaks.jsx`'s button-CSS generator MUST be token-driven (`@layer tweaks { --btn-* }`), NEVER unlayered `.btn--primary { background; box-shadow }`.** Root cause of the Jun 2026 report "primary/danger show no focus ring, disabled wrong, dark hover wrong — but fine on comp-buttons.html". comp-buttons.html's generator was migrated to tokens; the PLAYGROUND copy (`fpBuildButtonCSS`) still emitted unlayered per-property `.btn--primary{box-shadow}/.btn--danger{...}` (only those two variants) injected at each iframe's body-end. Unlayered beats ALL layered rules, so that direct `box-shadow` clobbered the layered `.btn:focus-visible` ring's `var(--_shadow)` (→ no ring), the direct `background` beat `.btn:disabled` (→ wrong disabled), and it used the LIGHT shadow table in both themes (→ wrong dark hover). secondary/ghost were unaffected (the generator never wrote them) — that asymmetry is the tell. Fix: mirror comp-buttons.html's `generateCSS` exactly — write only `--btn-*` token values into `@layer tweaks`, LIGHT scope `body:not([data-theme="dark"])` (fills+shadows) + DARK scope `body[data-theme="dark"]` (shadows only, using a SHADOW_DARK table). Lesson: any cross-iframe/runtime CSS that restyles a LAYERED component must set its TOKENS in `@layer tweaks`, never re-declare the component's own properties unlayered.

- **The global focus ring `:where(button,[role=button],input,select,textarea,[tabindex]):focus-visible` (and `a:focus-visible`) now lives in `@layer base` (`colors_and_type.css`) — it USED to be UNLAYERED and that bit twice.** (1) It blocked `@layer`-wrapping `.field`: the `.field input:focus { box-shadow:none }` suppression couldn't beat an unlayered ring from inside a layer. (2) Being unlayered, it beat EVERY layered component ring — so `.btn` silently showed the generic blue ring instead of its tokenised variant ring (danger should be RED). Moving it to `@layer base` fixed both: layered components (`.btn`, the wrapped `.field`) win with their own ring; UNLAYERED not-yet-migrated controls (`.seg`/`.pag`/`.filter-pill`/`.clear`/checkbox/radio/switch) still beat base and are unchanged; plain elements still get the default. **When migrating any NEW component that owns a focus ring, put its ring in `@layer components` and it will correctly beat the base default — no `!important`, no unlayered escape hatch.** Watch for the inverse: a component that RELIED on the old unlayered ring winning (none found, but check `box-shadow` on focus after wrapping).
- **Verifying a theme SWAP (`data-theme` light↔dark) via `getComputedStyle` reads the FROM-value of any transitioned property — same hidden-document freeze as the box-shadow note below.** Cost a scare during the field migration: after `body.setAttribute('data-theme','light')`, a `.field`'s `background-color` read back as the DARK `#14192a` and the screenshot showed dark fields on a light page — looked like a frozen/broken token. It wasn't: `--field-bg` computed correctly to `#ffffff`, but the field's `background-color` *transition* (100ms) was frozen at its dark start because the preview doc is hidden during eval/capture. Fix to verify the real cascade value: set `el.style.transition='none'` + `void el.offsetHeight` BEFORE reading (then bg read `rgb(255,255,255)`, disabled `rgb(243,247,252)`, border `solid` — all correct). For screenshots, RELOAD into the target theme (localStorage `cos-preview-theme` + `location.href`) so no transition is mid-flight, rather than toggling `data-theme` on the live page.

- **The global `.bi` icon reset is now in `@layer reset` (RESOLVED Jun 2026) — it USED to be UNLAYERED and that blocked wrapping comp-table.** While `.bi { display:inline-flex }` was unlayered, wrapping a component that overrides `.bi`'s `display` (table's `.tbl thead th .h-icon { display:none }`; `.h-icon` IS a `.bi`) made the override LOSE to the unlayered `.bi` (unlayered beats layered) → header icons leaked at wide width. Moving `.bi` into `@layer reset` fixed it: a LAYERED `.bi` override (`@layer components`) now beats reset cleanly, while still-UNLAYERED `.bi` overrides keep winning over reset. comp-table is now `@layer components`-wrapped. **Lesson still stands for the inverse:** if you ever move `.bi` back out of reset, or add an UNLAYERED atom a layered component overrides, the override silently stops applying — verify `display`/the overridden property on every consumer after any reset/layer change.

- **A LAYERED override of a still-UNLAYERED shared atom silently stops applying — unlayered beats layered regardless of specificity.** Caught migrating doc-card: its `.dc:hover .vchip` rules override `_chips.css`'s `.vchip` (unlayered). Wrapping doc-card in `@layer components` would have made the hover chip restyle LOSE to the unlayered chip base, so chips wouldn't react on hover. Rule: only `@layer`-wrap a component once every shared atom it overrides is also layered; until then keep its rules unlayered (it can still take the token/theme/state migration — those don't depend on layering). Cover-card was safe to layer because its `.cv*` rules touch no unlayered atom.

- **Custom-property FREEZING — a `:root` token that holds `var(--gov-*)`/`var(--color-*)` captures the LIGHT value and never flips in dark.** Root cause of the June 2026 regression where secondary/ghost button **dark hover/pressed went near-white** (`#eaf2fb` instead of dark `primary-50 = rgba(82,142,210,0.12)`). Mechanism: `--btn-secondary-bg-hover: var(--gov-color-primary-50)` was declared on `:root` (html). The dark scale remap lives on `body[data-theme="dark"]` — it does NOT reach html — so the `var()` resolved to LIGHT `primary-50` *on html*, and that frozen computed value inherited down to the button even in dark. The rest-bg escaped only because it had an explicit dark token entry. The OLD `_dark.css` avoided this entirely because its `var(--gov-color-primary-50)` sat on `body[data-theme="dark"] .btn:hover` — resolved ON THE BUTTON, where the dark scale IS inherited. **Rules:** (1) a `--btn-*`/component token may hold a flip-sensitive `var()` ONLY if you ALSO redeclare it with a literal in `@layer theme`; (2) otherwise reference the gov/semantic token DIRECTLY in the component rule (`.btn--secondary:hover { --_bg: var(--gov-color-primary-50) }`) so it resolves on the element; (3) the focus-ring spacer is inlined as `var(--color-bg-surface)` for the same reason. Verify after any token migration: stamp `data-theme="dark"` and read `getComputedStyle` of each state — a light hex (`#eaf2fb`, `#fff`) where you expect a dark/translucent value means a frozen token.
- **`none` is INVALID inside a comma-separated `box-shadow` list — it kills the whole declaration.** When composing a shadow from tokens (e.g. the focus ring layering `--_depth` under spacer+halo), the "no depth" value must be a transparent layer `0 0 #0000`, never `none`. A pressed+focus combo on `.btn` set `--_depth: none`, producing `box-shadow: 0 0 0 2px …, 0 0 0 5px …, none` → invalid → no ring at all. Use `0 0 #0000` for any empty shadow layer; reserve bare `none` for a standalone single-value shadow.
- **A `box-shadow` whose VALUE is a `var()` still transitions when the custom property changes — but you cannot transition the custom property itself.** So the token-driven focus-ring bloom works because `box-shadow` is declared on `.btn:focus-visible` and its value (built from `--_shadow`/`--_ring-halo`) swaps on the state selector → the `box-shadow` transition fires. Do NOT try to `transition: --_shadow` (no `@property` registration exists for a shadow type; it would snap). Rule: indirect the value tokens, keep the animated property declared on the element/state.
- **Layer vs `!important` interaction is INVERTED — know it before relying on layers to win.** Normal declarations: UNLAYERED beats all layered; among layered, LATER layer wins. Important declarations: the order REVERSES — among layered-important, EARLIER layer wins, and **layered-important beats UNLAYERED-important**. This is why `.btn:disabled { … !important }` in `@layer components` still beats comp-buttons.html's unlayered generated `.btn--primary { background … !important }`, so the disabled `!important` must stay until tweak generation moves into `@layer tweaks`.
- **Registering the `@layer` order statement is inert by itself — but the moment you move a rule INTO a layer, every UNLAYERED rule (incl. per-card `<style>` blocks and the current un-migrated `_dark.css`) starts winning over it.** So migrate a component's dark rules at the SAME time you layer it — don't layer a component while its dark overrides are still unlayered elsewhere, or dark will beat the component base in confusing ways. Buttons were migrated wholesale (tokens+theme+component together) for this reason.

## Prototype preview app (ui_kits/cos-library)

- **"Clicking a related document does nothing" (Jun 2026) had TWO compounding causes, both in data-preview.html.** (1) `relatedHTML` resolved the navigable index against the FILTERED `state.list` (`state.list.indexOf(rd)`), so reaching a doc via search/filter left its cross-links with no `data-relidx` (target outside the filtered set) → click no-op. Fix: the dialog now browses the WHOLE catalogue (`active()`); see decisions.md. (2) `findByDisplayCode` matched only `codeOf(d)`, which STRIPS the type prefix for ČOS — so related refs like `"ČOS 051638"` never matched a ČOS doc whose `codeOf` is bare `"051638"` (413/1374 unresolved). Fix: also match `(d.type+" "+d.code)`. Verifying note: most related targets are NOT in the catalogue at all (first resolvable source was idx 1545 / `ap-aaitp-02`), so test with a doc KNOWN to have a resolvable ref, and a synthetic resolution count (`codeOf`-map + `type+code`-map) to confirm the rate before/after.
- **Metadata modal "clicking the dialog advances to the next record, and it sticks" (Jun 2026, data-preview.html).** A mouse click on the prev/next nav button leaves DOM focus ON that button (clicking a non-focusable detail row does NOT move focus away — confirmed: focus stays on `.dpm__btn`). The dialog keydown handler doesn't claim `Space`, so a follow-up Space/Enter (natural — to scroll the dialog body) re-activates the still-focused Next → advances; focus never leaves → every press advances ("sticks"). Intermittent because it only arises after you've used a nav button by mouse. Fix: on POINTER activation (`e.detail>0`) of prev/next, move focus to the scrollable `.dpm__body` (give it `tabindex=-1`) so Space scrolls instead of re-firing the button; keyboard activation (`e.detail===0`) keeps focus on the button for repeat nav. Also hardened: the Tab focus-trap looped only `.dpm__btn` (related-doc links + background `tr[tabindex=0]` were reachable → focus escaped the modal) → trap now spans `.dpm__btn:not([disabled]), .dpm__body a[href]` and wraps when focus is outside; background `.wrap` gets `inert` while open. Verifying: synthetic `keydown` does NOT trigger native button activation, so a Space test won't reproduce the advance in the offscreen iframe — reason from focus location instead.
- **Verifying a TRANSITIONED property (e.g. `box-shadow`) via `getComputedStyle` in `eval_js` reads the FROM-value, not the resolved one** — same root cause as the WAA-pause note below: the preview document is `hidden` during eval, so CSS transitions freeze at their start value and never advance. Cost a long debugging detour on the cover-card `.btn` dark hover/focus shadows (the dark rules were correct; `getComputedStyle` just reported the pre-transition light value, and even inline/`!important` probes "didn't apply"). To verify a transitioned property's CASCADE value, set `el.style.transition='none'` (+ a `void el.offsetHeight` reflush) BEFORE reading — then computed reflects the rule that actually won. For `:focus-visible` rings, also call `el.focus({focusVisible:true})` and assert `el.matches(':focus-visible')` in the same eval.
- **Never animate a content surface's opacity from 0 — it parks at 0 in capture/eval/hidden-document contexts and reads as "the whole results column is blank/missing."** Hit on `.view-anim` (the catalogue card↔table↔cover switch), which was `@keyframes viewFadeIn { from { opacity:0 } }`. The 25 cards were in the DOM with real layout (`offsetHeight` 3063px) but `getComputedStyle(.view-anim).opacity` read `0` even with `document.hidden:false`, so both the screenshot AND a verifier would see an empty column. Same root cause as the tooltip opacity-parking pitfall. Fix: make the entrance **transform-only** (`translateY(6px)→0`), opacity stays static at 1 — worst case under a frozen clock is a 6px offset, never invisibility. Applies to ANY remount-triggered entrance animation on real content.
- **A stale preview iframe hides CSS fixes.** After editing CSS, `save_screenshot` captures the ALREADY-LOADED page — the fix won't show until you `show_html` (reload) first. Don't conclude a CSS fix failed from a screenshot taken without a reload.
- **The segmented view-switch / pagination thumb glide can't be verified from a post-delay screenshot** — the WAA timeline pauses while the preview document is hidden during capture, so the thumb parks mid-glide (the destination segment looks inactive, the source still tinted). The CONTENT switching (table renders) proves `onChange` fired; that's the real signal, not the thumb position. Same documented behaviour as comp-segmented / comp-pagination.

## Reduced motion

- **Standalone reduced mode (`?rm=1` via `_playback.js`/Inspector) only flipped JS branches — pure-CSS transitions kept animating** (the filter-group "vše" appear/disappear was the reported case): a reload can't make `@media (prefers-reduced-motion: reduce)` match, and only the PLAYGROUND promoted those rules into its frames. `_playback.js` now promotes them itself on boot when `?rm` is set (recursing into `@import` — the 1ms system policy lives in colors_and_type.css behind one; same `#__fp-rm-promote` id, skipped under `?fp`). Any NEW standalone chrome must keep this promote-on-rm step.
- **One policy, one place — never re-add a per-component `@media (prefers-reduced-motion)` block.** The system reset is in `colors_and_type.css` (`*{transition/animation-duration:1ms; delays:0}`). Scattered local blocks are exactly what caused the reported inconsistency (filter-pills snapped the focus ring; buttons/inputs kept blooming). If a component needs MORE than duration-zeroing, it's almost certainly JS-driven motion → use a JS guard, not CSS.
- **CSS `transition-duration`/`animation-duration` overrides do NOT touch Web Animations API motion** (`element.animate()`). The segmented rectangular thumb + pagination slide/sweep are WAA — the global reset can't snap them. They must JS-guard on `?rm` OR `matchMedia('(prefers-reduced-motion: reduce)')` and skip the glide. comp-segmented was silently still gliding under reduced motion until this guard was added.
- **The focus playground can't flip the OS `prefers-reduced-motion` query**, so `promoteReducedCSS()` lifts matching `@media` rules out of their wrapper into a late `<style>`. Because the system policy lives in `colors_and_type.css` (pulled in via `@import` in `_card.css`), that function MUST recurse into `IMPORT_RULE` (type 3, `r.styleSheet.cssRules`) — it already recursed `SUPPORTS_RULE` but not imports. Without the recursion the `reduced` test shows nothing for the global policy.
- **A focus ring won't bloom if the element's base `transition` omits `box-shadow`.** `.related a` transitioned only `border-color`/`background`, so its `--shadow-focus` snapped on with no animation in normal motion too. Wrapper components that own a ring must include `box-shadow` in the base transition (140ms) AND override to 220ms expo-out on the focus selector.

## Focus / state playground — forcing hover/pressed/disabled

- **The playground iframe autosize measures `body.offsetHeight` (in-flow), NOT `body.scrollHeight`, + explicitly adds open-overlay bottoms.** `scrollHeight` includes the overflow of absolutely-positioned descendants — and a CLOSED combo dropdown (`position:absolute; visibility:hidden`, sitting ~150px below its trigger) inflated it, reserving dead space below a short toolbar (reported on section 15). `offsetHeight` is the body's own box (in-flow only) so the hidden dropdown doesn't count; all 28 frames measured identical off==scroll EXCEPT the toolbar (80 vs 207). To keep an OPEN dropdown from being clipped by the now-tight frame, `measure()` loops `.combo-list[data-open="true"], [data-fp-overlay-open]` and grows to their `getBoundingClientRect().bottom + 8`, and an attribute `MutationObserver` (filter `data-open`/`aria-expanded`) re-measures on open/close (the body ResizeObserver doesn't see an attribute flip). Net: frame is tight when closed, grows to fit when open, shrinks back on close. - **The playground also forces `html, body { overflow: visible }` inside each iframe** — `_card.css` sets `overflow:auto` on body (standalone card scrolls in the viewport), but that CLIPS an open dropdown to the body box whenever body is shorter than the dropdown. Symptom: the list-toolbar combo opened, the iframe grew to fit it (autosize working), yet the dropdown was still cut off — clipped by `body{overflow:auto}` (body ~80px, dropdown ~154px), NOT by the iframe and NOT by `.tbar-wrap`'s `container-type` (which doesn't paint-clip). The autosize already sizes the frame to include open overlays, so making body overflow visible lets the dropdown paint into the grown frame. Two separate clips had to be cleared for one dropdown: the iframe height (autosize) AND body overflow.

- **A card/organism with a `[data-state~="hover"]` mirror STILL does nothing on forced hover unless it's in `STATE_STAMP_SELECTOR`.** `.dc` (doc-card) and `.cv` (cover-card) already had their hover mirrors (card bg + chip-activate; img translateY + title underline) but were absent from the stamp list, so the playground never put `data-state` on them — forced hover was a no-op. Adding `.dc`/`.cv` to the list fixed both. Always check BOTH halves (mirror in source + entry in stamp list).
- **Zebra/striping `tr:nth-child(even):hover` rules only matched ODD rows under forced hover.** The table-density generator (both `playground-tweaks.jsx` `fpBuildTableCSS` AND `comp-table.html`) sets `tr:nth-child(even) { background }` + `tr:nth-child(even):hover { background: primary-50 }`. Forced hover stamps `data-state` (not `:hover`), so the even-row override (unlayered, higher-specificity for the injected one) kept its rest bg while odd rows took the base row-hover. Fix: every `:nth-child(even):hover` must be `:nth-child(even):is(:hover, [data-state~="hover"])`. Same `:is()` treatment the base row-hover already uses.
- **A disabled `.tb-select` still hover-responded (bg + chevron nudge).** Its `:hover`/`[data-state="hover"]` rules (and the chev nudge) lacked the disabled guard that `.field`'s hover rules carry. Add `:not(.tb-select--disabled):not([aria-disabled="true"])` to every interactive `.tb-select` rule. General lesson: when you add a disabled state to a control late, audit EVERY `:hover`/`:active` rule for the `:not(disabled)` guard.

- **`all: unset` (and any unlayered local `box-shadow` on a control) silently kills the `@layer base` focus ring — unlayered beats layered.** Hit on the toast close `×` (`all: unset`), the list-toolbar `.view-switch button` (`all: unset`), and the brand-header local `.btn` (`box-shadow: 0 1px 0…`). All three showed NO focus ring (forced OR real keyboard) until an explicit `:focus-visible, [data-state~="focus"] { box-shadow: var(--shadow-focus) }` was re-asserted in the component's own (unlayered) CSS at matching specificity. When a control resets its box-shadow or uses `all: unset`, it OWNS its ring — re-add it locally.
- **Custom `[role="combobox"]` triggers are NOT native form controls — the playground's native-disable pass (`button/input/textarea/select`) and `.field--disabled` class don't stop their JS handlers or focusability.** comp-select's combo trigger (a `div`/`span role="combobox" tabindex="0"`) stayed clickable (dropdown opened) and tab-focusable when "Disabled" was forced. Fix has TWO halves: (1) the playground's disabled branch now also sets `aria-disabled="true"` + `tabindex="-1"` (saving the original) on every `[role="combobox"]` and adds `tb-select--disabled` to `.tb-select` (the compact variant had NO disabled rule at all); (2) the combo JS bails at the TOP of its click + keydown handlers on `aria-disabled === "true"` (the old wire-time `.field--disabled` check missed runtime-forced disable). Cleanup restores tabindex/aria in `clearForcedClassesIn`.
- **The whitelist filter runs ONCE on iframe `load`, BEFORE a React-rendered component mounts — so for `comp-table.html` (JSX) it finds zero `data-playground` keepers and BAILS to the full-preview fallback, leaking the title + the container-width `.demo-bar`.** Don't rely on the filter for React files. Preview-only chrome is hidden SYSTEMICALLY instead: `.card > .label` (title), `.card > .col > .label-sm` / `.card > .label-sm` / `.annot` (section annotations), and `.demo-bar` (per-file width slider — redundant with the toolbar's own Width control) are `display:none !important` unconditionally in `FORCE_STATE_CSS`. NOT `.help` — it can be example microcopy inside a whitelisted control (consent checkbox).
- **Re-confirmed: organisms only respond to forced state once their interactive leaves are in `STATE_STAMP_SELECTOR` AND the source mirrors `:hover/:active/:focus` with `[data-state~="hover"/"pressed"/"focus"]`.** Added this pass for `.crumbs a`/`.crumbs .more`, `.ftr a`, `.toast button.close`/`.toast .body a`, `.bar a`/`.nav a`/`.locale__btn`, `.empty__body a`, `.metabox__row`, `.quick-tile`, `.lib-card`, `.tbl tbody tr`, `.view-switch button`. Metabox already had the mirror and only needed the stamp-list entry — check the CSS before assuming both halves are missing.

- **`autosize()` needs WALL-CLOCK re-measures for iframes whose body mounts via React AFTER load (`comp-table`, `comp-cover-card`).** The frame fires `load` before its React content renders, so the synchronous initial `measure()` reads a too-small body (e.g. 36px) and the `ResizeObserver` that should grow it is DEFERRED while the preview document is hidden (capture/eval/background) — leaving the frame collapsed to ~36px and the section looking blank. Static/FP-gated frames don't hit this (their content is final at `load`). Fix in `preview/playground.html`: after the initial `measure()`, also `[120,360,800,1500].forEach(ms=>setTimeout(measure,ms))` — setTimeout fires on the wall clock regardless of visibility, so it reliably catches the late React mount. Keep the RO too (it handles live expansion in the visible doc).
- **`.fp-ready` (visibility:hidden→visible) is added in a `requestAnimationFrame`, so EVERY iframe reads `ready:false` and stays invisible in a hidden/capture context** — rAF is paused while the doc is hidden. NOT a bug: in the user's visible view rAF fires and frames reveal. Don't conclude frames are broken from a `save_screenshot` showing blank iframe columns; verify content via `iframe.contentDocument` reads instead.
- **Don't conclude toolbar icons are broken from a screenshot OR from `getComputedStyle(el,'::before').content`.** In `comp-focus-playground.html`, html-to-image captures don't render bootstrap-icon `::before` glyphs (documented under "Filter pills"/"Tooltip rollout"), and in the sandbox `getComputedStyle(i,'::before').content` returns `""` *even for a glyph that visibly paints* (the title bullseye). Verify icons by checking the `<i>` exists with `fontFamily: "bootstrap-icons"` + non-zero box, not by capture or pseudo-content read.
- **`@media (prefers-reduced-motion)` CANNOT be made to match from JS — don't try to monkey-patch `matchMedia` to fake it.** Patching `matchMedia` post-load doesn't retro-flip components that captured the MediaQueryList at load, and it never affects CSS `@media` evaluation at all. The real test in `comp-focus-playground.html` RELOADS the frame with `?rm=1` (so JS reduced branches run end-to-end — the component must read a query hook, like comp-pagination's `FP_RM`) and PROMOTES each component's own `@media (prefers-reduced-motion)` rules (lifts them out of the wrapper). See decisions "Focus / state playground".
- **The synthetic `instant` motion shim uses `1ms`, never `0`/`none`.** `transition-duration:1ms` (not `0`/`none`) keeps `transitionend`/`animationend` firing so component JS hand-offs that wait on those events aren't stranded; WAA-driven motions are handled separately via `playbackRate` (≈12) so their `.finished` promise still resolves. (Only `instant` injects this shim now — `reduced` reloads instead.)

- **Don't force hover/pressed with a hardcoded `body[data-fp-force-state="hover"] .x { … !important }` CSS mirror.** It only restyles the parent element, so it silently misses sub-elements (field icon glyphs, the search clear `×`, the search button), dark-mode shadow/glow variants, and error styling — and it drifts out of sync the moment a source file changes. This WAS the root cause of the May 2026 bug report (dark hover/pressed wrong, input/search icons not recolouring, search focus rings absent, etc.). The fix: `comp-focus-playground.html` `stampState()` sets `data-state="hover"`/`"pressed"` on `STATE_STAMP_SELECTOR` leaves, and every source mirrors its `:hover`/`:active` with `[data-state="hover"]`/`[data-state="pressed"]` (same rule, same specificity). See `notes/decisions.md` "Focus / state playground".
- **`_dark.css` `:hover`/`:active` rules need the `[data-state]` mirror too** — forced hover in DARK mode reads nothing otherwise. The blanket dark rules for `.field`/`.tb-select`/`.filter-pill` hover sit in `_dark.css` (not the per-card sheet), so the mirror has to be added there. Easy to forget because the light-mode mirror lives in the component file.
- **A component's disabled visual must hang off `:disabled` (or the stamped wrapper class), not a bespoke `.x--disabled` demo class** — the playground forces disabled with the NATIVE `disabled` attribute + `.field--disabled`/`.checkbox--disabled`/… wrappers. `comp-buttons` only had `.btn--disabled` (class) → native-disabled buttons showed no change in light mode, and Primary stayed bright blue because the generated Tweaks CSS `.btn--primary { background … !important }` `(0,1,0)` outranks a non-important rule. Fix needed `.btn:disabled { … !important }` at `(0,2,0)`. `comp-search` had NO `.field--disabled` rule at all.
- **Forced focus is a CLASS, never a pseudo-class.** The playground stamps `.is-focus`/`.field--focus`/`.btn--focus` (see `FOCUS_TARGETS`). A source that styles focus only via `:focus-visible`/`:focus-within`/`[aria-expanded]` shows NO ring when forced. Add the class mirror. (Closed gaps: search field/clear/button, select `.tb-select`, filter-pills reset-row, switch `.sw`/`.tsw`/`.tsw3`.)
- **Field focus rings read SUBTLE on white and that's correct, not a bug.** `--shadow-focus` is a 2px surface-colour spacer + a 5px `rgba(35,98,162,0.45)` halo; on a white field on a white card the spacer is invisible and the halo is a quiet blue glow (it only "pops" on coloured buttons where the white spacer contrasts). Don't "fix" this by thickening the field ring — it matches comp-inputs system-wide.
- **save_screenshot can't reliably capture `:focus-within` / programmatic `:focus`** — the capture blurs the iframe, so the pseudo-class drops. Verify focus rings via the stamped CLASS (`.field--focus`) or by reading `getComputedStyle().boxShadow`, not by `el.focus()` + screenshot. Nested same-origin iframes in the playground also render blank in the `show_html` sandbox (they load fine in the real preview) — verify component states by opening the component file DIRECTLY and stamping classes/attrs, not through the playground frames.

## Segmented control / sliding thumb

- **WAA sliding-thumb hand-offs (segmented AND comp-pagination) can't be verified through the automated/preview tooling — the preview document is `hidden` during eval/screenshot/sleep calls, which PAUSES the animation timeline** (`currentTime` stays 0, `playState: "running"` forever, so `anim.finished` never fires and `.is-sliding` / the thumb look permanently stranded). This is NOT a bug — `document.hidden` is `false` for a real user looking at the page, the clock runs, finished fires, cleanup happens. Verify the MID-slide state instead (click → immediately read `.is-sliding`/thumb text/width in the SAME eval); don't conclude the hand-off is broken from a post-sleep read. Confirmed via `document.hidden: true` + `currentTime: 0` after a 400ms in-eval wait on comp-pagination. (The slide self-heals on refocus if a user backgrounds the tab mid-slide — same accepted tradeoff as the segmented thumb.)

- **In the offscreen preview iframe, `requestAnimationFrame` callbacks NEVER fire and WAA `currentTime` stays 0** (confirmed Jun 2026 building the elastic switch: `rafCount:0`, `currentTime:0`, `playState:"running"` after a 200ms in-eval wait). So ANY rAF-driven or WAA-clock-driven animation is frozen during eval/screenshot — you cannot frame-verify it here. Verify the frame-INDEPENDENT paths instead: anything written synchronously (e.g. the switch's drag `pointermove` writes inline vars immediately — readable via `getComputedStyle`) and the final resting state.
- **This engine does NOT reflect INTERPOLATED animation values in `getComputedStyle` — for WAA `element.animate` on `transform`/`opacity`, WAA on registered `@property` custom props, AND CSS `@keyframes` on either** (all read the base value mid-animation; confirmed across all three). Corollary for design: if an animation MUST be verifiable/inspectable, drive the visible change by writing **inline styles** from a rAF/clock sampler rather than relying on the engine to interpolate a keyframe — inline writes are the only thing `getComputedStyle` reports. (Real browsers interpolate all three fine; this is purely a preview-tooling limitation.) The elastic switch uses a no-op `host.animate(...)` purely as a playground-scalable CLOCK and writes inline vars from its sampled `currentTime`.
  - **CSS `transition` reads are the same trap and bite when VERIFYING hover/state colours.** A property under `transition:` returns its START value when read synchronously right after you flip the state — and the frozen compositor never advances it, so `getComputedStyle` reports the OLD colour forever (cost a long debug detour on `comp-filter-pills` active-pill hover: border read primary-500 even though the rule correctly set primary-600, and an injected `!important` rule "did nothing" because the transition held t=0). To verify a state's END colours, inject `* { transition: none !important }` BEFORE flipping `data-state`/class, then read. Don't conclude a cascade rule lost — confirm with transitions off first.

- **Don't hand the slide off to the solid fill on a wall-clock `setTimeout`.** DevTools "slow animations" (and any throttling) stretches the CSS transition but NOT `setTimeout`, so a 320ms fallback fired ~14% into a throttled slide and snapped the tint to the destination before it arrived ("doesn't slide even half"). Drive the glide with the **Web Animations API** and key the hand-off off `anim.finished` — it tracks the animation clock, so it stays correct under throttling. Guard interrupts with an `if (anim !== mine) return` token + `anim.cancel()` on the previous (and `.catch(()=>{})` since cancel rejects `.finished`). Early-return when `toBtn === fromBtn`.
- **SNAP the destination's solid fill in at the hand-off** — the thumb hides instantly (opacity, untransitioned) but the active button's `background-color` has a 100ms transition (and the `:focus-visible` rule a MORE-specific one), so the fill faded up AFTER the thumb was gone, leaving a visible gap — ~1s and glaring under throttling, and different mouse-vs-keyboard because of the focus rule's transition. Fix: add a one-frame `.seg--snap button { transition: none !important }` (the `!important` beats the focus transition) when removing `.is-sliding`, then clear it via double-rAF **plus a `setTimeout` fallback** (rAF is paused in a backgrounded tab and would strand snap-on).
- **For a rectangular segmented control, make the sliding thumb TRANSIENT, not persistent.** Rest state = the active segment's own solid fill; reveal the thumb only during the glide (`.is-sliding`), then hand back to the solid fill when the animation finishes. A persistent thumb forces pixel-perfect alignment at every rest position and makes dividers cross it / forces hiding the active's dividers — all of which generated bug reports. Transient = rest is bulletproof, motion-only imperfection is invisible. (Pill is different: no solid fill behind buttons, so its thumb IS the rest background and stays persistent.)
- **Injecting a thumb as `container.firstChild` breaks `:first-child` on the buttons** — `comp-segmented.html` injects `.seg__thumb` as the `.seg`'s first child, so `.seg button:first-child` matched nothing and the first segment silently lost its left border-radius (and its focus ring's left corners). Use `:first-of-type` / `:last-of-type` (counts only buttons) for any per-position styling on a container that also holds a non-button thumb. `:last-child` kept working, so only the LEFT side looked broken — easy to misdiagnose.
- **Re-place the sliding thumb on `document.fonts.ready`** — Bootstrap-icon glyphs (and the webfont) widen the buttons after first paint; the rAF-timed initial `place()` measures pre-load widths, leaving the thumb too narrow/offset until the first click. Wrap the re-place in `seg--no-anim` so it snaps rather than slides.
- **Thumb corner radius must track the active segment's position** — uniform radius exposes a sliver of track at the inner divider ("doesn't take shape"). Set `borderRadius` per-place: `3px 0 0 3px` first / `0 3px 3px 0` last / `0` middle (match the container's inner radius = outer − border).
- **Clear the 1px dividers flanking the active/focused segment** with `border-right-color: transparent` on `.is-active`/`:focus-visible`/`.is-focus` AND their left neighbour via `:has(+ button.is-active)` etc. `transparent` keeps the box so thumb measurement is unchanged; otherwise gray lines cross the thumb and peek inside the focus ring.
- **Don't zoom-diagnose by scaling `<body>`** — a `transform: scale()` on an ancestor makes `getBoundingClientRect()` return scaled coords, so any `fonts.ready`/resize re-`place()` computes a multiplied `--w` and the thumb balloons. That's a measurement artifact, not a bug. Verify at scale 1, or clone the node into an unscaled wrapper.

## Tooltip / data-tip rollout

- **A tip mis-places when its OWN size changes after the first measurement — root cause was WEB-FONT REFLOW, not (only) the trigger animating.** `place()` measures `tip.offsetWidth/Height` once; if Czechia Sans (label) or the mono keycaps load AFTER, the tip reflows bigger. The OLD code derived `left`/`top` from the measured size (`left = r.left - tw - gap`), so a later reflow left the position stale → the grown box slid over the trigger / off the right edge. It bit the **State button specifically** (its `D|H|P|F|X` shortcut row is the widest tip → biggest delta; narrow tips like Inspector's "I" barely shift). **Fix = anchor the trigger-adjacent edge** (the edge the arrow is on) via `right`/`bottom` for left/top placements instead of a size-derived `left`/`top`: the tip grows AWAY from the trigger, arrow stays locked, correct on first paint with no re-measure (the box grows from the pinned edge automatically — pure CSS). Only the perpendicular centering axis stays size-derived; its arrow re-aims via the rAF follow loop (also handles the trigger MOVING) + a `ResizeObserver` on the tip. Don't reach for timed `setTimeout`/`fonts.ready` re-aim hacks — they were superseded by edge-anchoring (and a single `fonts.ready` shot fires too early when fonts are already cached).

- **The redundant-tip guard must read `innerText`, not `textContent`.** `textContent` includes a label hidden by `display:none`, so a chip that collapses label→icon at narrow widths kept matching the guard and NEVER showed its tip (hit on `comp-table.html` validity/availability chips — they tip only via collapse, but `textContent` always equalled the tip). `innerText` returns only rendered text, so the tip appears exactly when the label goes `display:none`. Fixed in `preview/_tooltip.js` + mirror `ui_kits/cos-library/tooltip.js`. Don't revert to `textContent`.

- **Guard the hover handlers against internal child boundary crossings — `mouseover`/`mouseout` bubble and fire on EVERY child edge.** On a trigger with children (icon `<i>` + button padding, label span + icon), moving the pointer within one trigger fired `mouseout` on the child then `mouseover` on the next, so the controller called `hide()` then re-scheduled the 120 ms show — a visible flash on every internal move. Symptom reported on the icon-only segmented switch in `comp-segmented.html`. Fix in `_tooltip.js` (+ mirror `ui_kits/cos-library/tooltip.js`): in both handlers, `if (e.relatedTarget && el.contains(e.relatedTarget)) return;` — `relatedTarget` is where the pointer came from (over) / is going to (out); if it's still inside the same trigger the move is internal, ignore it. Don't switch to `mouseenter`/`mouseleave` on `document` (they don't delegate/bubble) — keep the delegated `mouseover`/`mouseout` + relatedTarget guard.
- **Never blanket find/replace `title=` → `data-tip=`.** `title=` is overloaded: `<TweaksPanel title>` / `<FilterGroup title>` are **component props** (panel/group headings) and `<iframe title>` is the iframe's **accessible name** — converting any of these silently breaks the prop or a11y. Only convert `title=` that renders a hover label on a real DOM element. When scripting the replace, target the exact element string (`<span className={cls} title={m.label}>`), not the bare attribute, and log the remaining `title=` count to confirm only the intended props are left.
- **The `<title>` head element starts with `title` too** — when replacing ` title="` use a **leading space** so `<title>…` (no `="`) is never touched.
- **Icon-collapsible controls need `aria-label` added when converting** — the native `title` was doubling as the accessible name; `data-tip` is presentational only. Without `aria-label`, the control becomes nameless once its label span goes `display:none` at narrow widths.
- **Measure tooltip size with `offsetWidth/offsetHeight`, not `getBoundingClientRect()`** — the `.tip` has a `scale()` transform; the rect is post-transform and gives wrong placement math. Offset sizes ignore transforms.
- **Don't animate the tooltip's opacity with a `transition` off a 0 base.** It parks at the from-value (computed `opacity:0` while `data-show="1"`) in throttled/capture/eval-probe contexts — and this reproduced even in the real user pane via `eval_js_user_view`, not just the verifier iframe. Switching to a CSS *animation* didn't help (a frozen animation pins its `from` keyframe, so `opacity:0` in the keyframe → still invisible). Working pattern: keep `opacity`/`visibility` **rule-driven** (instant, never animated) and animate **transform only**. Then a frozen clock degrades to a tiny static offset, never invisibility.
- **`data-tip-pos` placement: set the unused caret-axis var to nothing, don't leave it stale.** When a tip flips between top/bottom (uses `--tip-arrow-x`) and left/right (uses `--tip-arrow-y`) across re-`place()` calls on ONE shared `.tip` node, `removeProperty` the axis that doesn't apply — otherwise a left-placed tip keeps the previous `--tip-arrow-x` and the (now-irrelevant) value lingers. `_tooltip.js place()` sets one axis and removes the other every call.
- **The token classifier attaches a `/* @kind … */` comment to the property it IMMEDIATELY follows** — put it right after the `--tip-origin: …;` declaration, NOT at the end of a multi-declaration rule. A rule like `{ --tip-origin: x; --tip-in-y: -4px; /* @kind other */ }` annotates `--tip-in-y`, leaving `--tip-origin` unclassified (check_design_system flags it). Per-placement `.tip[data-placement="…"]` rules each need the comment glued to `--tip-origin`.

## Iconography / glyph alignment

- **A meta-row `.x i` rule silently recolours an icon INSIDE a nested chip/badge that lives in the same row.** The library-card "Nové" `.nchip` sits inside `.lib-card__added`; the meta rule `.lib-card__added i { color: fg-tertiary }` matched the nchip's OWN `<i>` (same specificity as `.nchip i`, but later in source order → won) → the sparkle glyph went grey instead of white. Fix: scope the meta-icon rule to the DIRECT child (`.lib-card__added > i`) so it can't reach into a nested component. Rule: when a row styles its `<i>`, use `>` if any descendant might bring its own icon.

- **Bootstrap `<i class="bi">` glyphs ride HIGH when centered in a fixed-size box — root cause is the inline line-box strut, fixed system-wide in `colors_and_type.css`.** The `<i>` is inline, so its height = inherited line-height (~15.3px at 14px text), but the glyph `::before` is only ~12.5px and sits on the text baseline + Bootstrap's `vertical-align:-.125em`, so it parks above the box centre. Flex-centering the parent centres the too-tall `<i>` box, NOT the glyph. Fix = `.bi { display:inline-flex; align-items:center; justify-content:center }` (in BOTH `colors_and_type.css` copies — root + `ui_kits/cos-library/`): the `::before` becomes a flex item, strut/baseline/`vertical-align` drop out, `<i>` collapses to the glyph and centres it. Don't re-add per-component `top`/`margin-top`/`translateY` nudges to "fix" a high icon — the global rule already handles it; vertical nudges now risk over-correcting.
- **When you need a centered standalone glyph, just center its parent** (`display:flex/inline-flex; align-items:center; justify-content:center` on the button/chip). The icon itself is already glyph-sized after the normalization, so the parent centres the glyph directly.

## Type rendering — Czechia Sans

- **Use Bold (700) only at ≥ 24 px**, Semibold (600) at ≤ 22 px — at smaller sizes Bold closes up the terminal dot on `i / í` against the stem and crowds Czech diacritics. H3, H4, brand wordmark, table column headers, filter-group titles, metabox labels all fall under the Semibold tier. Canonical reference: `preview/type-headings.html`. This is the type-system inflection point, not a stylistic preference.
- **Apply OpenType features explicitly per variant.** Text variant uses `font-feature-settings: var(--otf-text)` (`dlig kern ordn ss04 ss11 ss20`); Logotype variant uses `var(--otf-logotype)` (`dlig kern ordn` only). `:root` defaults to text variant; `.czechia-logotype`, `.text-display`, `h1` switch to logotype. Without these the glyph shapes silently fall back to defaults and the two variants look identical.
- **Don't let italics synthesize.** Each italic weight is registered as a separate `@font-face` with `font-style: italic` — never omit those declarations or the browser slants the upright face and the result is ugly.

## CSS specificity

- **A custom-property value specified DIRECTLY on a child beats the same property set INLINE on its parent — even though inline > stylesheet for the SAME element.** Inheritance only kicks in when the child has NO directly-matching declaration; a directly-specified value (from ANY rule that matches the child) always wins over an inherited one, including an inherited *inline* value. Bit the theme switch (`comp-switch.html`): JS animated `--tsw-x` by writing it inline on the `.tsw` BUTTON each frame, but the resting rule `.tsw[aria-checked="true"] .tsw__thumb { --tsw-x }` set it directly on the child thumb → the thumb read its own 22px and ignored the inline frames, teleporting to the end-stop. Fix: put state/resting var rules on the SAME element the JS writes inline on (move them to `.tsw[aria-checked="true"]`, let `inherits:true` carry them to the thumb). General rule: **animate a CSS var on element X → every rule that sets that var must target X (or an ancestor of X), never a descendant of X.**

- **`all: unset` on an element defeats the `[hidden]` attribute** — `all: unset` resets `display` to `inline`, and that author declaration outranks the UA `[hidden] { display:none }` rule, so toggling the `hidden` attribute never hides the element. Hit on `comp-filter-group.html`'s "vše" reset button (styled `all: unset`) — it stayed visible after clearing/unchecking. Fix: re-assert a hide rule at ≥ the `all: unset` rule's specificity. **Hide it with `visibility: hidden` (display kept `inline`), not `display: none`** — the button shares a flex line with the title text and is ~1px taller, so `display:none` collapsed the line height and the whole group jumped by 1px each time the button toggled. `visibility:hidden` keeps the box reserved (no shift) and still drops the control from tab order + AT.

- **A box-shadow focus ring on a `position: relative` row gets painted over by a LATER sibling's solid background.** Paint order = DOM order, so a row below the focused one with `.is-active { background }` covers the bottom strip of the focus halo (visible gap on `comp-filter-group.html`). Fix: give the focused element `z-index: 1` so it lifts above auto/0-index siblings. Don't bump it higher than tooltips/popovers (they're body-appended with far larger z). Applies to any sibling-row list that tints active items AND owns its own ring.
  - **Generalised across the system — focus z-index lift uses TWO tiers.** Forced focus (`.is-focus`, which `comp-focus-playground` stamps on EVERY sibling at once) → `z-index: 1`; the element that ACTUALLY has focus (`:focus-visible` / `:has(input:focus-visible)`) → `z-index: 2`, so its real ring renders on TOP of the forced rings on its neighbours in the playground. Carried in `comp-pagination.html` (`.pag button` — was the reported bug: 4px gap + solid primary-500 active button; also `.pag-compact button`), `comp-segmented.html` (`.seg button`, `.seg-pill button`), `comp-filter-group.html` (`.filter-item`), `comp-filter-pills.html` (`.filter-pill`), `comp-related.html` (`.related a`). z-index works on **flex items without `position`**; `position: relative` added anyway for explicitness.
  - **`overflow: hidden` on a grouped control's container clips the children's outer box-shadow rings entirely** — no z-index rescues them. `comp-pagination.html`'s `.pag-compact` used `overflow: hidden` to round its corners and the inner buttons' focus rings were invisible. Fix: drop `overflow: hidden` and round the FIRST/LAST child's outer corners instead (`border-*-radius` on `:first-child`/`:last-child`). Watch any other grouped/segmented control that hides overflow.
  - **A preview file relying only on the global `:focus-visible` ring shows NOTHING when the playground forces `.is-focus`.** `comp-pagination.html` and `comp-related.html` had no `.is-focus` rule, so the playground's "focus" state rendered no ring on them. Every component in `comp-focus-playground.html`'s `FORCE_*` list must carry an `.is-focus` rule mirroring its `:focus-visible` ring. (Switches still define only `:focus-visible` — `.sw`/`.tsw` forced focus is a known remaining gap, not a z-index issue.)

- **CSS opacity transitions on `<iframe>` elements can get parked in a permanent "pending" state in Chrome** when the from-value is declared in CSS *before the iframe finishes loading* and the to-value class is then added by JS. `getAnimations()` shows one CSSTransition stuck with `playState: "running", currentTime: 0, pending: true` — and the computed opacity never moves off the from-value, even with `!important`. Hit on `<iframe>` elements in `preview/comp-focus-playground.html` while trying to fade iframes in after the whitelist filter ran. Use `visibility: hidden → visible` instead (binary, no animation system involved), or apply opacity inline only AFTER the iframe has loaded. Don't reach for `void iframe.offsetHeight` layout flushes — the cleaner fix is dropping the transition entirely; a fade isn't perceptually doing work here anyway.
  - **Don't generalise this to all attribute-triggered transitions.** We did, twice — once around `comp-select.html`'s dropdown opacity and once around the same component's `.field` border + chev rotate. Both turned out to animate correctly in the real preview pane (verified with `eval_js_user_view`: `pending: false`, currentTime advances frame-by-frame, computed value interpolates and lands on the to-value). The verifier subagent's iframe environment reported the transitions as `pending: true` indefinitely, but the user's preview pane does not. If a verifier report contradicts what the user sees, the user wins — run `eval_js_user_view` to settle it before patching CSS to "fix" a non-existent bug.
  - **Probing-too-fast also produces false positives.** If you call `el.click()` and immediately read `getAnimations()` synchronously, you'll see new CSSTransitions in their initial `pending: true, currentTime: 0` state — which is the normal "scheduled, will start next frame" state, not a stuck animation. Wait one frame (`await new Promise(r => setTimeout(r, 30))`) before checking, and confirm again at e.g. 100 ms and 300 ms to see if the transition is actually progressing.

- **Per-card `<style>` hover rules lose to `body[data-theme="dark"] .x` rules** — the dark-mode body-scoped selector is (0,2,1), the per-card hover is (0,2,0), so hover highlights disappear in dark mode. Re-assert hover at matching specificity in `_dark.css` (`body[data-theme="dark"] .tb-select:hover { ... }`). **Same trap for `.field--disabled`** — per-card `.field--disabled { background: neutral-50 }` is (0,1,0) and loses to the blanket `body[data-theme="dark"] .field` at (0,2,1), so disabled fields read identical to enabled ones in dark. Fix is in `_dark.css` under "Disabled — the blanket …" — uses `var(--color-bg-page)` for a sunken well + dashed border + tertiary text on input/textarea/select (the input-text rule also has the same specificity collision; without re-pinning, disabled inputs look fully bright). Covers comp-inputs, comp-textarea, comp-select.
- **Don't transition `background` between `linear-gradient` and a solid color** — CSS can't interpolate; the browser flashes through transparent mid-transition. Either keep both states as gradients (or both as solid), or omit `background` from the transition list. See `.btn` in `preview/comp-buttons.html`.

## Tweaks panel (tweaks-panel.jsx)

- **A page-level theme-aware `kbd` element rule leaks into the fixed-styling Tweaks/Inspector panel** — in dark mode the remapped tokens rendered an unreadable keycap on the panel's unchanging glass. Any tag styled by a bare element selector in the page CSS (`kbd`, `code`, …) must get an explicit `.twk-panel`-scoped fixed-style rule (color + background + border) when used inside panel content. Fixed in `__TWEAKS_STYLE` + `.fp-anim-hint kbd`.

- **The `T`-key reopen handler must NOT re-post `__edit_mode_available` when opening** — that announce makes the host reply with its CURRENT toolbar state, which is "off" right after a dismiss, so it echoes `__deactivate_edit_mode` straight back and the panel "blinks and hides" on the same keypress (can't reopen via keyboard). The mount-time effect already announces availability once; reopening is purely local (`setOpen(true)`). Closing still posts `__edit_mode_dismissed` (that path is fine — it correctly syncs the toolbar off). Fixed in the `onKey` handler in `TweaksPanel`. NB: this blink does NOT reproduce in the show_html sandbox host (it doesn't echo state) — only in the real Claude Design host; reason about the protocol, don't conclude "works" from the sandbox.

## CSS grid — auto-fill / responsive demo sliders

- **A two-column `grid-template-areas: "heading achip"` does NOT let the title flow UNDER the right-hand chip** — the title is confined to the `1fr` heading track, leaving dead space below the chip on a 2-line title. To get the title to wrap around/under a top-right chip, use `display:block` with the chip `float:right` (DOM-FIRST so the float wraps the following text) + a small negative bottom-margin to shrink the float's wrap region to ~1 line, and `.card::after{clear:both}` to contain it. This is the DEFAULT layout (not just a narrow-mode rewrite) for both `.dc` (doc-card) and `.lib-card` — earlier both used the grid at wide widths and only floated when narrow, so wide cards had the dead-space bug.

- **`repeat(auto-fill, minmax(MIN, <fixed-length>))` packs by the fixed MAX, not by MIN** — so `minmax(184px, 520px)` in an ~880px container yields ONE 520px column, not four 184px ones. Only `minmax(MIN, 1fr)` packs by MIN and lets tracks grow to fill. Hit twice fixing comp-cover-card's card-width slider.
- **For a responsive card grid where the slider must show FLUID resize (not just column-count changes), use `repeat(auto-fill, minmax(min(var(--card-w), 100%), 1fr))` and DON'T cap the card with `max-width`.** Cards then fill their track (1fr → zero between-card gaps), reflow as the cap OR container changes, AND resize fluidly within each column-count band — so dragging the container slider visibly rescales the cards (and their container-query text) before a column is added/dropped. `min(…,100%)` lets a lone card shrink below the floor on a very narrow stage instead of overflowing. The cap acts as the per-card *target/floor* width, not a hard max. **Avoid two traps that LOOK like fixes but aren't:** (a) fixed columns `repeat(auto-fill, var(--card-w))` removes gaps but FREEZES card width at exactly the cap, so the container slider only changes the column count — no fluid resize (the reported regression); (b) `minmax(MIN,1fr)` + `.cv{max-width:cap}` re-introduces gaps the moment the cap is smaller than the grown 1fr track. There is no pure-CSS way to get a TRUE max cap + fill + reflow + no-gap simultaneously — pick the fill model and relabel the control "Card width" (it's a floor, not a max).

## Container queries

- **A shared-track grid that IS its own query container can't change its `grid-template-columns` at breakpoints** (size queries never match the container itself — see next bullet). Two consequences for `_related.css`'s table-like list: (1) inter-column spacing must be MARGINS on cells with `column-gap: 0`, so a `display:none` column collapses WITH its spacing (column-gap would leave a double gap); (2) any responsive bound on a track must be intrinsic to the template — e.g. the code column's ceiling is `fit-content(min(160px, 26%))`, where the % term shrinks with the container instead of a breakpoint swapping the track.
- **`@container` rules only match DESCENDANTS of the size container, never the container element itself.** So if a collapse rule needs to qualify on a wrapper-level condition (e.g. comp-status's all-done focus = `.timeline:not(:has(.curr,.moreinfo,.rejected)) .step:last-child`), the queried element (`.timeline`) must sit a level BELOW the `container-type` element. `_status.js` injects a `.timeline-wrap` (`container-type:inline-size`) around each timeline so `.timeline` is selectable inside its own `@container`. Putting `container-type` directly on `.timeline` would have made every `.timeline…` selector inside the query dead.
- **A size container reads ITS rendered width, which comes from ITS parent's layout** — fine in block/stretch contexts (card `.col`), but a container that lands in a shrink-to-fit parent collapses to content width and the query never narrows. Verify the consumer gives the wrapper a real width.
- **A cover/card with an INNER container can't drive collapse of SIBLINGS of that inner element.** comp-lib-cover's `.cv__img` is `container-type:inline-size`, but the below-cover strip (`.cv__meta`) is a SIBLING of `.cv__img`, not a descendant — so `@container` on it resolves to the next container UP (none → viewport), not the cover. To collapse the strip at narrow card widths, make the CARD itself a container (`.lib-cv{container-type:inline-size;container-name:libcv}`) and query `@container libcv`. Nested containers (card + inner `.cv__img`) coexist fine; each element resolves to its nearest ancestor container.
- **A size container's query LENGTH is its CONTENT-box width — subtract the card's own padding when picking breakpoints.** comp-lib-card is the container AND has `16px 20px` padding, so a 430px card reads as ~390px inside `@container`. The first breakpoints (chosen as if border-box) fired ~40px too early — the availability chip + meta labels collapsed on cards that visually had plenty of room. Lower each `max-width` by the horizontal padding (~40px) vs the apparent card width. (Doc-card doesn't hit this: its container is the zero-padding `.doc` LIST, so query length ≈ the cards' outer width.)

## Hyphenation

- **The catalogue table title cell carries its OWN `lang="cs"` (set in `_table.js` rowHTML, Jun 2026) + `hyphens:auto` in `_table.css`.** `Table.render` is reused by the dataset preview AND the prototype, whose host `<html>` may not be Czech — putting `lang="cs"` on `.title-text` itself guarantees the Czech hyphenation dictionary engages everywhere, not just in comp-table.html. (`overflow-wrap:break-word` is also set so an unbreakable token still wraps.)

- **`hyphens:auto` needs `lang` on EVERY preview that shows it \u2014 audited Jun 2026.** The tweak broadcasts `hyphens:auto` to title surfaces (`.dc .heading`, `.lib-card__title`, `.cv__title`, `.tbl .col-title-cell .title-text`), but it's INERT without a document `lang`. comp-table.html (the table title surface IS in the broadcast) was missing it \u2192 titles never hyphenated. Added `lang=\"cs\"` to every component preview rendering Czech titles/descriptions/body paragraphs: table, related, metabox, cta-panel, empty-state, notices, toast, status, quick-tile, breadcrumbs, search. (doc-card / lib-card / cover / lib-cover / list-toolbar / type-specimen already had it.) Token/colour/space pages stay lang-free (English/specimen).

- **`hyphens: auto` is INERT without a `lang` on the document (or element).** The playground Tweaks "Hyphenation" control broadcasts `hyphens:auto` onto `.dc .heading` / `.lib-card__title` / `.cv__title*`, but comp-doc-card.html and comp-lib-card.html shipped `<html>` with NO `lang`, so toggling it did nothing (the cover cards worked only because they ship `<html lang="cs">`). Fix = add `lang="cs"` to every card file whose titles should hyphenate. Verify by measuring a title's `offsetHeight` at a fixed narrow width with `hyphens:manual` vs `auto` — a height delta proves hyphenation engaged (Czech breaks long words like "standardi-zace" into more lines, so auto can be TALLER, not shorter).

## Component extraction (shared _x.css / _x.js)

- **The `saveFile`/`write_file` shrink-guard refuses an overwrite that drops a file below half its size — which a CSS/JS extraction almost always does.** Two ways through: (1) split the removal into ≥2 committed passes each <50% (tedious — the tail keeps exceeding 50% as the file shrinks), or (2) BEST: build the final file at a NEW path (`comp-x.NEW.html`, a create → no guard), then `delete_file` the original + `copy_files {move:true}` NEW→original. The delete+move pattern is clean and was used for almost every extraction here.
- **A "card went all black / component invisible" screenshot is usually a TRANSIENT preview resource-load failure, not a regression.** `get_webview_logs` showing `_card.css` / `_dark.css` (long-standing, every-card files) "failed to load" is the tell — re-`show_html` and it renders. Don't start debugging token cascade from one bad capture; confirm with a reload first. (Seen during the Jun 2026 extraction: comp-status captured black with light-theme token values + `__statusInit:false`, all three caused by a one-off failed load of `_dark.css`+`_status.js`; clean on reload.)
- **When extracting, the boundary is component-vs-demo, not "everything in `<style>`".** Keep the card's demo scaffolding inline (`.row`/`.col`/`.grid`/`.demo-bar`/width-slider JS/`.see-also`/the app-shell grid in region-nav); move only the component selectors. region-nav + filter-pills interleave demo and component CSS, so they needed two-range cuts, not one.

## Dark mode / tokens

- **`--shadow-1/2/3` are theme tokens, not constants — dark re-values live in `_dark.css` @layer theme (black-based rgba(0,0,0,…) layers).** The light recipes are navy-inked (rgba(12,24,56,…)) and CANNOT read on a dark backdrop, so before the re-value every token consumer cast an invisible shadow in dark. Also: a shadow only reads when the casting surface is LIGHTER than its backdrop — spec tiles sit on a `--color-bg-subtle` well for that reason. Never hand-pin a dark shadow on a component that could ride the token instead.
- **Post-paint theme application + 100–160ms background/border transitions = a BRIGHT light→dark flash on every control face (Jun 2026, the "white flash in dark mode" report).** Two paths had it: (1) the playground **Preview modal** — `injectModal` stamped `data-theme` on the freshly painted file with NO transition suppression (the inline frames' `data-fp-loading` contract never covered the modal); ground-truthed live: 25 `transitionstart` events (background-color/border-color on every `.field`) per modal open in dark. (2) **standalone files** — `_inspector.js` applies the persisted theme after first paint. FIXES: modal = `data-fp-loading` on the modal body during inject (rule in MODAL_INJECT_CSS), released a frame after reveal + a 900ms wall-clock backstop (rAF throttles hidden); standalone = `setThemeInstant()` (temp `*{transition:none!important}` style + reflow + 60ms removal) for the BOOT theme only — suppress TRANSITIONS only, never animations (entrances must keep running). RULE: any code that stamps `data-theme` onto an already-painted document must suppress transitions for the swap.
- **Hover/state fills were NOT the flash** — verified by stamping `data-state` and sampling computed + mid-transition backgrounds across 13 components in dark (all ≤ 0.21 luminance). Don't remove the hover background transitions; they're fine.
- **`.bar` class collision painted the spacing-scale bars near-black in dark** — `_dark.css` pins the brand-header's `.bar` (gov.cz strip) to `#050810`, and space-scale.html reused the class for its size bars → renamed `.sp-bar`. Same family as the helper-class collision rule: check _dark.css selectors before reusing short class names in token/spec cards.
- **Combined-Tweaks chip treatments (outlined/dot) need an explicit dark bg override** — `_dark.css`'s filled tint `body[data-theme="dark"] .dc-chip--cos {background:#253751}` (0,2,1) beats the broadcast `:is(scope) .dc-chip {background:transparent}` (0,2,0), so dark kept the tinted box behind dot/outlined chips. Both builders now emit `body[data-theme="dark"] ${S} .dc-chip { background: transparent }` (0,3,1+) in the non-filled branches.

- **A control that flips to a LITERAL light surface (`background:#fff`) in BOTH themes must use a LITERAL dark text colour, never `var(--gov-color-neutral-900)` — the token inverts to near-white in dark → invisible label on the white surface.** Hit on the language switcher's open trigger (`.tb-select--on-dark[aria-expanded="true"]` in `_select.css`): the trigger is white when open in light AND dark, but its `color: var(--gov-color-neutral-900)` resolved to near-white in dark → blank "CS". Fix = the literal `#0c1838` (the light-mode neutral-900 value). Same family as the cover-card white-on-white note below and the always-dark `.bar` thumbs (which route through `--tsw-puck-dark`). Rule: when a surface does NOT flip, its foreground must NOT flip either — inline a literal or use a theme-stable token.
- **An `--on-dark` placement modifier still inherits the base component's OTHER state declarations — audit the full state set.** `.tb-select--on-dark` overrode bg/border/color but NOT `box-shadow`, so the base `.tb-select[aria-expanded="true"] { box-shadow: var(--shadow-focus) }` leaked a stray BLUE ring onto the open dark-strip trigger. Re-assert `box-shadow` (a white ring) in the `--on-dark` open rule. When adding a placement modifier, walk every base state rule (hover/pressed/focus/open/disabled) and decide which to re-skin.
- **"C…" / "Relevan…" in a low-res screenshot of a `.tb-select` is the down-CHEVRON glyph next to the value, NOT clipping.** `.tb-select .value` has `overflow:hidden;text-overflow:ellipsis`, so it LOOKS plausibly clipped — but verify with `value.scrollWidth === Math.round(value.getBoundingClientRect().width)` (equal = no overflow) before "fixing" a non-bug. Cost two false alarms (CS, Relevance) during the brand-header refactor; both measured equal.
- **Translucent `rgba(…, 0.22)` dark-mode chip fills read as a faint gradient on gradient surfaces.** The filled doc-type chips (`.dc-chip--cos/stanag/stanrec/ap` in `_dark.css`) used see-through fills; over a flat body they look fine, but over a gradient surface (cover-card `.cv__body`, hover tints, zebra rows) the surface bleeds through and the chip appears to have a "very slight gradient." Fix: use OPAQUE pre-blended hexes (≈22% tint composited over `#1a2030`) — `#253751 / #333a49 / #363651 / #293d46`. Rule of thumb: dark chip/badge fills should be opaque, not translucent, so they can't pick up whatever sits behind them.
- **A per-card `<style>` that hard-codes `white` (or a `… white` gradient) on a CONTENT surface goes white-on-white in dark, because the text rides `--color-fg-default` (→ near-white in dark).** Root cause of the June 2026 comp-cover-card dark regression: `.cv__body` valid-card backgrounds are `linear-gradient(180deg, color-mix(var(--doc-x) 4%, white), white 60%)` with NO `_dark.css` override, so the near-white `.cv__code`/`.cv__title-mini` text vanished on the white paper. Non-valid covers escaped only because `.cv__bg-fade` forces their body transparent. These overrides existed once and were dropped during the focus-playground tuning pass — re-added under `_dark.css` "comp-cover-card.html" (body re-based on `--gov-color-neutral-0` + doc tint, footer `.cv--cos .cv__foot-r` pin, `.cv__over-av` frosted-white→frosted-dark chip). **Cascade gotcha:** the per-card inline `<style>` loads AFTER `_dark.css`, so a dark rule like `body[data-theme="dark"] .cv--cos .cv__body` (0,3,1) out-ranks the inline `.cv--invalid .cv__body{background:transparent}` (0,2,0) — you MUST re-assert the non-valid `background:transparent` in `_dark.css` at matching specificity or invalid covers lose their gradient. Audit other "complex" cards (doc-card, lib-card, quick-tile, metabox, cta-panel) for the same dropped-override pattern.
- **Don't use `documentElement.scrollHeight` to autosize an iframe whose height you're also setting from outside.** It's a feedback trap: html fills its containing block, so once `iframe.style.height = X`, `html.scrollHeight` clamps to at least X — reading it back gives you X, not the real content. Iframe wedges at whatever size it currently has, no matter how small the content actually is. Use `body.scrollHeight` only. Hit this in `preview/comp-focus-playground.html` — diagnostic was `body.scrollHeight: 72` vs `documentElement.scrollHeight: 208`, iframe stuck at 208px.

- **`<html>` doesn't inherit `--*` tokens scoped to `body[data-theme="dark"]`.** `_card.css` paints `html, body { background: var(--gov-color-neutral-0) }`, and `_dark.css` remaps that variable under `body[data-theme="dark"]` — but the variable's *html-element* scope still resolves to the light value, so `<html>` stays white in dark mode. Visible only when something exposes the html canvas (here: `comp-focus-playground.html` autosizing iframes a few px taller than body, leaving a strip of html showing under body). Fix: mirror the `data-theme` attribute onto `documentElement` AND add an explicit `html[data-theme="dark"] { background: #1a2030 }` rule (the literal hex of dark `--gov-color-neutral-0`). Same trap will recur anywhere a preview file exposes the html canvas — fixed-height frames, sticky/fixed layouts with gaps, etc.
- **Don't reach for `min-height: 100vh` on body inside an autosized iframe** as a quick "make bg fill" patch. The playground autosizer measures `body.scrollHeight` and sets iframe height = that + a few px; body min-height: 100vh then resolves against the iframe viewport, body grows to fill, scrollHeight grows, autosizer grows iframe again, infinite feedback loop. Paint the html element instead, or measure differently.

- **Border tokens must read against both card surface (`#1a2030`) and field well (`#14192a`)** — the previous `#2a3142 / #3a4255` landed within ~2 ΔL of both bgs and 1 px strokes disappeared on inputs / dividers / separators. Current values are `#404a64` (default) and `#54607c` (strong). Re-test any new "well" background against these.
- **`--gov-color-neutral-900` inverts to near-white in dark** — anywhere it was used as literal "near-black navy" (footer fill, top gov.cz state bar, dark wordmark tile, the F6 region-announcer pill — both _region-nav.css `.announcer` and the prototype `.region-announcer`, fixed Jun 2026) breaks. Pin a fixed dark hex (`#050810`) in `_dark.css` instead of riding the token. **Same trap on theme-switch thumbs:** the `.tsw`/`.bar .tsw` thumb used `neutral-900` for its icon colour ("dark icon on a light puck"); in dark that icon went near-white and vanished on the still-light puck — it only worked because the old `_dark.css` blanket pinned literal hexes. Fixed by routing the puck/icon through `--tsw-puck-dark` (neutral-900 light → `#1a2030` dark) + `--tsw-puck-light` (white → `#e6ebf2`), which flip WITH the theme so "the dark one" stays dark in both. Rule: any colour that means "stay dark" (or "stay light") regardless of theme must be a theme-flipping token pair, never a raw scale step that inverts.
- **Per-card `<style>` blocks tend to hard-code `background: white` or literal hexes** instead of going through tokens — dark mode then leaves blinding white tiles under near-white text. Either route the rule through `var(--color-bg-surface)` / `var(--color-bg-subtle)` in the per-card sheet, **or** add a `body[data-theme="dark"] .your-selector { background: var(--color-bg-subtle); }` rule to `_dark.css`. New preview cards must do one or the other; if you add a card and don't, dark mode will look broken.
- **Some surfaces must NOT flip in dark.** `space-shadows.html` shadow tiles stay light (shadow ink is `rgba(12,24,56,x)` — invisible on dark). `brand-lev.html` Lev-mark tiles are intentionally white/black/white as brand color treatments. CSS-painted color swatches in `colors-*.html` keep their hard-coded hex. Soften their borders, don't flip the fill.

## Babel / JSX inside `<script type="text/babel">` — and any JS template literal

- **HARD RULE: after editing any prose inside a `\`…\`` template literal, grep for backticks inside that template before saving.** Has now bitten *four times in this project*, three of them in this exact `FORCE_STATE_CSS` block in `preview/comp-focus-playground.html` (most recently a `.demo-cluster` strip-comment that quoted `.tsw`/`.tsw3`/`.sw` with backticks). Backticks-as-quotes feel natural for code identifiers in prose comments — that instinct is wrong here. Quote CSS-selector identifiers in template-literal comments with `.bare` or `'single'` quotes, never backticks. Verification snippet (run before declaring an edit done):
  ```js
  const t = await readFile('preview/comp-focus-playground.html');
  const m = t.match(/const FORCE_STATE_CSS = `([\s\S]*?)`;/);
  log((m[1].match(/`/g) || []).length); // must be 0
  ```

- **The "no backticks in template literals" rule applies to PLAIN `<script>` blocks too, not just Babel.** Hit this in `preview/comp-focus-playground.html` while editing a JS comment inside the `FORCE_STATE_CSS = \`...\`` template — backticks around `_card.css` / `data-playground` terminated the string and threw `Unexpected identifier '_card'` at parse time. Same fix as the Babel case: quote with `"` or `'`, or drop the quoting. Different symptom though — plain scripts surface a real `SyntaxError` in the console immediately, unlike Babel scripts which fail silently. **Recurred Jun 2026 in `preview/playground.html` FORCE_STATE_CSS** — a new CSS-comment quoted selectors with backticks (`` `.card > .label` `` / `` `.label-sm` ``), throwing `Unexpected token '.'`. Quote CSS selectors in these comments with `'single'` or `.bare`, never backticks. Verify after editing: `t.match(/const FORCE_STATE_CSS = \`([\s\S]*?)\`;/)[1].match(/\`/g)` must be null/empty.

- **Never put a backtick inside a template literal** — not even inside `/* … */` or `// …` "comments". Babel doesn't parse comments inside template literals; the backtick terminates the string, the rest of the script becomes garbage, the `<script type="text/babel">` block silently fails to compile, and React never mounts.
  - Quote identifiers with `"double"` or `'single'` quotes inside template literals, or leave them bare. Never backticks.
  - Backticks in comments **outside** template literals (top-of-file JSDoc, regular JS comments) are fine.
  - Has bitten us **4×** in `preview/comp-buttons.html` while editing the dynamic-CSS template literal — every time while "fixing" focus-ring CSS and reaching for backticks to quote identifiers like `outline`, `box-shadow`, `none` in an explanatory comment.
  - Symptoms: Tweaks panel disappears (React never mounted); dynamic CSS overrides (pressed states, focus rings, dark-mode tweaks) stop applying — only static `<style>` rules remain; `get_webview_logs` shows `SyntaxError: Unterminated template`.
  - Verification after editing any template literal in a Babel script:
    1. `grep` the file for `` ` ``; every match should be a template delimiter or code outside the template.
    2. `show_html` the file and call `get_webview_logs` — Babel errors surface immediately.
    3. Tweaks panel rendering is the live canary. If it's gone, parsing broke.
