# Decisions — project rules and conventions

Compressed log of how things should be done in this project. Read before significant work; append the moment a new rule or convention is settled. See `CLAUDE.md` for format rules.

## Standalone offline exports (super_inline_html) — source-copy convention

- **Compiled exports live in `downloads/`, named `Digitální knihovna ČOS - <surface> (offline).html`** (`prototyp` · `dataset preview`) — linked from index.html's "Ke stažení" section with the `download` attribute. Re-running `super_inline_html` should output straight to that path. `downloads/` IS part of the deployment upload (see DEPLOY.md).
- **Each offline build has a committed `_standalone-src-*.html` source copy next to the page** (`ui_kits/cos-library/_standalone-src.html` = index.html · `_standalone-src-data-preview.html` = data-preview.html). Edit the copy + re-run `super_inline_html` to refresh; never edit the compiled output.
- **The copy MUST drop the `@dsCard` first-line comment** — otherwise the export source becomes a duplicate card in the DS tab.
- **Preload `preview/_force-state.js` via plain `<script src>` BEFORE `_inspector.js`** — `_inspector.js` lazy-loads it relative to its own `script.src`, which is a blob/inline URL after bundling (404 standalone). The loader short-circuits when `window.FP_FORCE_STATE` exists. (`_tooltip.js` is safe whenever the page already loads it directly — `window.__tipInit` guard.)
- **Strip `?v=` cache-buster query strings from CSS/JS hrefs in the copy** — the bundler resolves bare relative paths.
- **Add `data-presets="react"` to `text/babel src=` tags** in the copy (matches the index.html export precedent; content gets inlined by the bundler).
- **A previewed standalone build shares this browser's `fp-standalone-*` localStorage** — e.g. `fp-standalone-help-hidden=1` collapses the page header/lede/helper notices. Not a bundling bug; a downloaded copy on a fresh origin renders with defaults.

## Prototype quality pass (Jun 2026) — header on the latest compounds

- **Nav ink extracted to shared `preview/_nav-ink.js`** (`window.NavInk.attach(nav, {activeSelector}) → {move(animate), detach}`): self-injects its CSS (`#__nav-ink-css`, module-private `.nav--ink`/`.nav__ink` rules), WAA glide (governor-scaled, reduced-motion snaps, interrupt-safe, 5200ms backstop), auto re-track on fonts.ready + nav ResizeObserver. Host owns `.active` toggling + calls `move(true)`. Consumers: brand-header.html (inline copy deleted) + prototype Header (React: attach in mount effect, `move(true)` on route change, skip first run).
- **`_switch.js` `.tsw` controller now EMITS `tsw:change`** (bubbling CustomEvent, detail `{on}`, fires on tap/keyboard AND mid-drag midpoint crossings) **and exposes `el.__tsw = {set(on), isOn}`** (no-op when already there — breaks observer echo loops). This is the binding seam for hosts with real state; the prototype header theme switch is a vanilla island (controls.jsx) consuming both (event → `cosApplyTheme`; `<html>[data-theme]` MutationObserver → `__tsw.set`).
- **Prototype header is NON-STICKY (user-decided)** — `position: relative; z-index: 40` keeps the old stacking (combo dropdown) without pinning; `.detail .cta-panel` sticky offset dropped 96px → 16px. A collapsible reveal-on-scroll-up header may come later.
- **Catalogue zero-results = the shared `.empty` component** (`emptyResultsHTML()` in catalog.js): table embeds the new **`.empty--bare`** modifier (no own frame; added to `_empty-state.css` + showcased in comp-empty-state.html) inside `<td colspan=6 class="tbl-empty-cell">`; card/cover views get the framed block (`.cv-grid .empty { grid-column: 1/-1 }`). CTA = "Odstranit aktivní filtry" when filters active / "Zobrazit vše" when only a search phrase (clears BOTH via `clearAllCriteria()` — input event on `#q` + uncheck actives). Old `.tbl-empty`/`.res-empty` rules deleted from ui-kit.css + dark-mode.css (data-preview keeps its own inline copy). `#lib-empty` (pages2.jsx) now uses the component too.
- **Browser-history model (catalogue + routes):** `nav()` pushes + sets `window.__cosPush` (app.jsx scrolls to top ONLY on pushed route/arg changes; back/forward keep scroll restoration). Hash shape `#/katalog[/<page>][?q=<query>]` — **page changes PUSH** (user-decided: Back walks pages; via the new shared `Pagination.goTo(nav, page, hint)` API which commits through `changeTo` so animations + `pagination:change` fire identically), **query/filter/sort changes REPLACE** (no back-stack flood). catalog.js owns the seam: `selfHash` swallows its own pushes, `applyingHash` suppresses writes while applying popped state; its `hashchange` listener bails on non-katalog routes (React unmounts it).
- **Catalogue state survives route changes via a module-scope `persist` snapshot** (qRaw/sort/view/filters/scrollY, saved in dispose) — URL page+q are authoritative on back/forward/reload, persist supplies the rest; fresh tab clicks land on page 1 with criteria restored. Restore order in mount: checked boxes + sort `aria-selected` BEFORE `initCombos` (data-sort channel), view via seg `btn.click()` AFTER `initSegmented` (showView early-returns), page after `applyFilters(true)` via `mountPager(total, p)`, then `writeHash(false)` normalizes; scroll restored only when `!__cosNavWasPush`.
- **Search UX (user-decided):** Enter / „Hledat" submit → `scrollToResults()` smooth-scrolls the LIST TOOLBAR (incl. its margin) to viewport top (filters may scroll out — results are the main content). **Toolbar search `#q2`** (new shared `.search--sm` variant in `_field.css`, showcased in comp-search.html): mirrors hero `#q` both ways, q2 re-dispatches through #q so there's ONE input path; `.toolbar__left` now grows (`flex:1 1 auto`) so the field breathes; hidden ≤640 by the tbar ladder. **Query pill `#qfp`** = own row ABOVE the filter pills (never reflows them): reuses `.filter-pill` vocabulary (key „Hledání" + live phrase + ×→clears search via #q input event); the ROW height-animates in/out (WAA, run-token restart-safe, reduced-motion snaps; no curtain call — per-frame layout moves the results naturally). Header search deferred until the collapsible header exists.
- **F6 regions (prototype):** Header is region #1 (`data-region="Hlavní menu"` on `.site-header`, entry = first nav tab), Footer is last (`data-region="Patička"`); wrap-around already existed (`gotoRegion` modulo). NEW generic per-region attributes in BOTH controllers (prototype region-nav.js + shared `_region-nav.js`, demoed on comp-region-nav's search region): `data-region-entry="<sel>"` — first Tab from the focused region container lands there (header → Katalog tab, Search hero → `#q` past the breadcrumbs); `data-region-typeto="<sel>"` — printable keystroke while the region container is focused moves focus into the field (keystroke lands in it). `[data-region]` scroll-margin-top dropped 96→16 (non-sticky header).
- **CTA panel (comp-cta-panel.html + prototype):** 8 showcase panels now — public · public+fav-to-add · public+fav-on (carries the `data-playground` whitelist, moved OFF the first public panel — user-decided) · login · controlled · controlled-PENDING (status `--login` + hourglass: waiting ≠ act-now amber) · controlled-GRANTED (status `--public` + `bi-shield-check`) · metadata. `.cta-panel__fav` EXTRACTED from ui-kit.css into the shared `_cta-panel.css`; comp page loads `_favourite.css/.js` (auto-wires). Favourites copy (user-specified): „Přidat do _Mé knihovny_." / „V _Mé knihovně_ — uloženo jako oblíbené." with the library name LINKING to `#/knihovna`.
- **Controlled-doc detail follows its request:** DocDetailPage gets `requests`; match by `docId` or display `docCode`. approved → granted panel (Číst/Stáhnout + note „Schváleno na základě žádosti <id>"); submitted/in-review/more-info → pending panel („Moje žádosti" primary + note link); rejected → default (re-request allowed). Request links go to `#/zadosti/<id>`; RequestsPage `focusId` scrolls (manual `scrollTo`, never scrollIntoView) + flashes `.req-card.req-flash` (rn-flash vocabulary); cards carry `id="req-<id>"`.
- **My Library controls (user-decided):** sort combo `#lib-sort` (shared _select.js, data-sort channel) with comparators in library.js — `code` „Označení" (type order ČOS→STANAG→STANREC→AP → natural code → title; DEFAULT) · `title` „Název" (= the reference "title order": title→type→code) · `expiry` „Brzy vyprší" (closest `expiresOn` first, never-expiring LAST, then title order) · `added` „Posledně přidané" (addedOn desc) · `fav` „Oblíbené" (favourites first by favourited-at desc) · `changed` „Posledně změněné" (doc `updated` desc); all ties fall back to title order. Filters: availability segmented `#lib-avail` (Vše/Veřejné/Řízená — public vs granted+pending) + doc-type chip toggles `#lib-types` (`.lib-tf` reset-buttons around shared `.dc-chip`, dimmed at rest / full when `aria-pressed`, owned by library.js not React). Criteria changes go through `refresh()` (Collection restagger). Filtered-zero shows an INLINE `.empty--info` („Zobrazit vše" → `data-act="lib-clear"` resets q+avail+types, sort stays); the zero-data `#lib-empty` stays for a truly empty library.
- **`addedOn` mock dates added to MY_LIBRARY (library-data.js) + mockLibrary (data-source.js)** — deterministic Czech dates (granted entries = grantedOn); session favourites carry real `now()` from CosFav (`addedOn` ISO). `czTs()` in library.js parses Czech "d. m. yyyy" AND ISO.
- **`.empty[hidden] { display:none }` added to `_empty-state.css`** — the component's class display otherwise beats the UA `[hidden]` rule (known pitfall, hit by `#lib-empty`).
- **Request card promoted to a shared compound (`_request-card.css/.js` + comp-request-card.html, playground §47 "Compound collections"; Tools renumbered 47→48):** `window.RequestCard = { render(r, {docId, detail}), init(root, {play}), timelineSteps(r), statusMeta(s) }`. render is the structure SoT (chips via `window.Chips`, status pill `.rstat`, timeline via canonical `Status.render` INLINE, decision notice `_notices.css`); init wires `initStatus` per `.req-card__timeline` host (idempotent, `data-rc-wired`) + optional `playTimeline`. Built-in Czech status-meta fallback (dataset `REQUEST_STATUS_META` overrides). The action button is `[data-act="open-doc"][data-id]` — the HOST owns navigation by delegation. components.jsx `buildRequestTimeline`/`RequestStatusChip` are now thin delegates; ui-kit.css `.reqcard-title` + the `.req-card/.req-flash` rules moved into the compound CSS. `.req-card__meta .code-cell__num` is nowrap (display codes contain a space). styles.css imports the partial.
- **My Requests page:** status filter = shared segmented `#req-seg` WITH COUNTS (user-decided) — Vše / V řízení (submitted+in-review+more-info) / Schváleno / Zamítnuto; new shared **`.seg__ct`** count badge in `_segmented.css` (quiet tabular numeral, showcased in comp-segmented.html). Cards render via `RequestCard.render` into a `dangerouslySetInnerHTML` list + `RequestCard.init({play:true})` effect; filtered-zero shows the inline `.empty` („Zobrazit vše" → clicks the Vše segment); zero-data uses the `.empty` component too.
- **Feedback round (Jun 2026):** query pill needed `.qfp[hidden]{display:none}` (same `[hidden]`-vs-class-display pitfall as #lib-empty — it ghost-showed always + after clear) + centring margins (`.qfp{margin-top:12px}`, `.qfp:not([hidden]) + .afp:not(.has-pills){margin-top:4px}` so the gap below matches). **F6 nested regions:** `indexOfFocus` now resolves the INNERMOST region via `el.closest('[data-region]')` in BOTH controllers — pagination sits inside results, a containment scan returned the outer region and F6 looped. **Detail metabox:** pinned `.metabox--comfort` (UNLAYERED block in _metabox.css — must beat the unlayered Tweaks override sheets; values = METABOX_DENSITY.comfortable), Collection row reveal (sequential, 22ms/300ms) re-played per doc; vchip/achip row-hover `--active` treatments added to _metabox.css (only cchip/dc-chip had them). **Related:** Collection reveal on the rows; `rowsHTML` unresolved refs now render WITHOUT href + chevron (`aria-disabled`), `.is-unresolved` keeps rest look under hover (selection stays — content rule). **My Library toolbar** moved onto the shared `.tbar-wrap` card + tbar ladder (`.lib-tbar` wraps, search grows left, sort label = icon+text, type chips collapse to `__short` ≤760); new shared **`.tb-select--fit`** in _select.css (no-ellipsis trigger, width follows selection); library search now goes through `refresh()` (restagger like other criteria).
- **Deployment (Cloudflare Pages, user-requested):** the project is fully static — `DEPLOY.md` at root documents the upload (deploy the ROOT so relative paths resolve; exclude `.scratch/ scraps/ screenshots/ backups/ uploads/ tools/ notes/` + compiler artifacts). New root `index.html` = minimal hub (playground · prototype · data-preview) for the Pages root. **Environment auto-sense already existed** (playground `PROJECT_ID` from `/projects/<id>/` in its URL); polished: outside Claude Design the source-link/Tools tooltips say "open file in new tab" and the modal button relabels to "Open in new tab" (`buildClaudeDesignLink` returns null → href falls back to the raw relative path). Inspector/Tweaks chrome ships in the deployed version (user-decided).
- **Header CTA = canonical `_buttons.css`** (`.btn--primary.btn--sm` signed-out / `.btn--ghost.btn--sm` signed-in); the local `.nav-actions .btn` re-implementation was deleted. Tabs: active = 600 + bold-ghost width reservation (`.nav__lbl::after` + `data-text`); gov-bar + institution lines gained `__full`/`__short` collapsed forms (≤380px via media query — the prototype bar is viewport-wide, no container).

## Animated number (`_num.js` / `_num.css` / `_num.config.js`) — utility-count transitions (Jun 2026)

- **Utility counts (toolbar "Nalezeno N", facet `.ct`, pager label) animate with a SHORT directional transition, NEVER the quick-tile count-up** — they REACT to user action; the hero count-up vocabulary (long, from zero) reads as loading there. New compound on the contract: `window.Num = { set(el,next,opts), transition(el,oldText,opts), read(el), motion(el,tokens) }`, `FP_COMPOUNDS.num`.
- **Capture outgoing values through `Num.read(el)`, never raw `textContent`** — mid-flight hosts report scaffolding (new + ghost concatenated). See pitfalls "Animated number".
- **Two host APIs, by renderer type:** persistent hosts call `Num.set` (commits text, animates from old); innerHTML-REBUILDING renderers call `Num.transition(el, oldText, {dir})` on the FRESH node (el already shows new). _pagination.js uses the latter: `renderLabel` wraps every `{token}` in `<span class="num" data-num-k>`, `changeTo` captures old values BEFORE the re-render and rolls them after.
- **SETTLED DEFAULTS (user-decided Jun 2026):** system-wide config/:root defaults = **per-digit + fade + deliberate** (`motions.default:'digits'`, `effects.default:'fade'`, preset `deliberate`; `_num.css` :root tokens mirror it — 560/70ms etc.). Effects COMBINE as a space-separated list (`data-num-fx="heat fade"`). Per surface: **quick tiles** = digits + `heat fade`, 800ms/100ms, heat = `--qt-accent` BOTH directions with a 5000ms cooldown (`--num-heat-dur` on `.quick-tile`, user-set Jun 2026; the Inspector heat-cooldown slider max is 6000 to cover it), limit `none` — and the REVEAL plays the SAME recipe: with _num.js loaded, `_quick-tile.js playCount` transitions 0 → target via `Num.transition` (stagger slot rides a no-op WAA clock + wall-clock backstop); the quantized 1/2/5 ladder is now the no-_num.js FALLBACK only (its `hot` count style — `--qt-count-hot: 85`, `countStyles.default: 'hot'` — keeps the fallback in the accent too). comp-quick-tile Inspector: "Number" section = Motion select (Num enum) + Effect combos + num sliders; Count-style radio retired there (attrs stamped in `_quick-tile.js wire()`, tokens on `.quick-tile` in `_quick-tile.css`; showcased by the 10th comp-num tile „Quick-tile recipe · ČOS“) · **filter groups** = slide + fade + limit 0 (shake/colour), stamped in `_filter-group.js init` — the old `.filter-item .ct { --num-dur: 200ms }` subtle override is RETIRED · **pagination label** = defaults + `data-num-limit="0"` stamped in `_pagination.js init` · **list toolbar counts** = defaults + limit 0 (comp-list-toolbar markup; catalog.js updateCount; data-preview #count). `--num-limit-color` default now rides `var(--gov-color-error-500)`.
- **Limit / threshold ("zero animation", Jun 2026) — a FOURTH slot, edge-triggered:** ancestor config `data-num-limit="<n>"` + `-cmp` (`=`/`<=`/`>=`) · `-motion` (none/shake/elastic — axis + overshoot sign FOLLOW the main motion: slide-family → horizontal, roll-family → vertical; elastic = ONE excursion along the incoming travel direction: expo-out into the apex, ease-in-out back to an exact stop — no repeated wiggle, no scale) · `-fx` (none/color/weight) · `-hold` ("on" = effect persists while the condition holds — folds "permanent colour" as flash→class). Motion+flash fire when the condition BECOMES true (incl. crossing); hold classes (`.num--limit`, `.num--limit-w` — `!important`, host colour is usually owned by component/page rules; transition armed via `.num--limited`) track the committed value on every change, even instant/reduced — and `Num.refreshLimit(scope)` re-stamps them when the CONFIG changes without a value change (Inspectors must call it). Tokens `--num-limit-color`/`--num-limit-dur`/`--num-limit-amp` (px; shake peak, elastic apex = 2×, 0 = motion off)/`--num-limit-ease` (shake profile + elastic RETURN segment; `auto` = per-motion defaults — `--num-ease` deliberately does NOT reach limit motions); limit anims are `__numHeat`-marked (outlive cleanup). Demo: comp-num `=0` button + Inspector Limit section.
- **Colour animations must use JS-resolved rgb() endpoints — a `color-mix(… currentColor)` keyframe is NOT interpolable** (discrete fallback = only the peak pops). `flash()` resolves both colours via a hidden span + numeric mix; shared by heat + limit.
- **MOTION ⊥ EFFECT ⊥ timing (Jun 2026 rework):** motions = `roll` (default, vertical odometer) · `slide` (horizontal; increase enters from the right) · `roll-inc`/`slide-inc` (INCREMENTAL: every digit animates, cascading per `--num-stag` — roll-inc left→right reading order, slide-inc from the entering edge) · `digits` (per-digit odometer paired from the RIGHT so units stay units; changed columns only; on length change the in-flow `.num__tail` slides shiftX→0 while the width glides, exiting leading digits ride it as `.num__lead`, entering ones sit in flow before it) · `digits-slide` (horizontal per column, staggered from the entering edge) · `count` (1/2/5 ladder) · `count-1` (strict ±1) · `none`. Effects overlay ANY motion: `none` · `fade` · `blur` · `heat` (directional colour flash via `color-mix` + slower cooldown that OUTLIVES cleanup (`__numHeat`-marked anim); intensity scales with Δ unless `--num-heat-scale: off`; colours per consumer: `--num-heat-up/down`). Resolution: opts → `[data-num-motion]`/`[data-num-tech]` / `[data-num-fx]` ancestor → config default. Legacy names map (`roll-fade`→roll+fade, `fade`→none+fade); explicit `opts.technique:'none'` stays FULLY instant (rewind semantics).
- **Direction = value delta (increase: old exits up, new enters from below), EXCEPT the pager label follows the PAGE direction** (next = forward roll) — user-decided. Pass `{dir}` to override.
- **Width is NOT a mode any more (rework):** the clip AND the width glide live on `.num__in` (content wrapper), all geometry content-relative with anchor-aware shiftX (hugging/left → oldW−newW · text-align right → 0 · center → half). Inline hosts move their neighbours (a hugging box follows its content); slot-reserving consumers (ghost sizer min-width, `equalizeCompact`) simply don't move. `data-num-w` is now a no-op; `widthModes` removed from config.
- **Noise control:** changed-values-only (identical old/new = no-op) + facet counts tuned subtler via `.filter-item .ct { --num-dur: 200ms }` in _filter-group.css (ancestor-level token tuning).
- **Token defaults live on `:root` in `@layer tokens` (NOT on `.num`)** — deliberate inverse of the `--qt-*` on-element pattern: hosts are many tiny spans, so an ANCESTOR (scope wrapper, `.ct` rule) must be able to override by inheritance/specificity. `--num-dy: auto` = travel derived from the measured line box (guarantees full exit at any line-height).
- **Compact pager counter is NOT rolled** — it already has its own sweep + number-slide; rolling inside would double-animate. Only standard-label `.count` hosts animate.
- **Consumers wired:** comp-num.html (showcase: 5-technique comparison + in-context toolbar/pager/facets + schema Inspector), comp-pagination, comp-list-toolbar ("Change count" demo), comp-quick-tile ("Change values" header button + Inspector "Value change" section — _num.css/js loaded, ±1–18 nudges via `Num.set`, `data-qt-target` re-stamped so Replay counts to the committed value), comp-filter-group ("Change counts" header button — counts dip to 0 to demo the limit shake/flash), prototype catalog.js (`updateCount`/`setFacetCount`), data-preview.html (search count persistent spans + facets; its pager rolls via the compound). Playground section 33 (Navigation & filtering; 33–46 renumbered to 34–47).

## Babel parse harness (preview/test-babel-parse.html)

- **Static syntax gate for the whole Babel surface — run it after every JSX/inline-script edit** (the CLAUDE.md checklist makes it mandatory): fetches each page in its `PAGES` list, `Babel.transform`s every `text/babel` block + referenced `.jsx` (each parsed once), `new Function`-compiles plain inline scripts. Nothing executes; failures land in the table, `console.error`, and `document.title` (`BABEL PARSE: ALL PASS (N)` / `N FAIL`).
- **Why it exists:** `done`'s console sample races the async Babel transform — the duplicate-`const setColTok` SyntaxError passed `done` clean while it killed comp-quick-tile's whole Inspector script (page looked fine: resting state = final state).
- **Maintain `PAGES`** when a page gains/loses `babel.min.js`; `backups/` deliberately excluded. `type=module` inline scripts would false-positive under `new Function` — none exist in this project (and shouldn't; script imports avoid `type=module` anyway).

## Non-selectable controls (user-select) — system-wide rule (Jun 2026)

- **Every CONTROL suppresses text selection (`user-select: none` + `-webkit-`); CONTENT surfaces never do.** Rapid/double clicks on a control must not highlight its label. Controls covered: `.btn` (_buttons.css) · pagination buttons (`.pag button`, `.pag-compact button`) · segmented (`.seg button`, `.seg-pill button`) · favourite `.fav` · breadcrumb `.more` + `.crumb-reset` · quick tile (whole tile incl. the big count) · search `.clear` + `.suggest__item` · toast `.close` · combobox `.combo-item` (trigger already had it) · filter-pills `×`/`.clear-all`/`.reset-row`/`.add-row` (pill had it) · filter-group rows + `.reset` · switch/checkbox/radio labels · header tabs (already had it).
- **Deliberately SELECTABLE (content, not controls): catalogue table rows, metabox rows, doc/lib/cover card text, prose links.** They're clickable but carry copyable data (codes, titles) — never suppress selection there.

## Links — underline never under the icon (system-wide rule, Jun 2026)

- **An underline never runs under an icon glyph.** INLINE anchors are already safe: the @layer reset makes `.bi` `inline-flex` (atomic inline) and decoration doesn't propagate into atomic inlines (footer external-link arrow, prose links).
- **Flex-container anchors blockify children (decoration DOES propagate into flex items) → underline a LABEL SPAN instead:** gov.cz strip = `.bar__full`/`.bar__short` (brand-header.html) and `.bar__lbl` (prototype components.jsx + ui-kit.css). Any new icon+text link that's `inline-flex` must follow the span pattern.
- **Never "fix" this with an unlayered `a .bi { display:… }`** — unlayered beats every LAYERED `.bi` display override (`.h-icon` hiding, future components) and breaks the reset's flex glyph centring.

## Empty state (comp-empty-state.html + _empty-state.css)

- **Clear-filters CTA copy: "Zobrazit vše" (inline variant) · "Odstranit aktivní filtry" (block variant with icon). Never "Vyčistit" / "Vyčistit filtry"** — "vyčistit" reads as scrubbing; the actions actually SHOW everything / remove active filters (user-directed, Jun 2026). Matches the filter-group "vše" semantics.

## Prototype rebuild on the shared compounds (ui_kits/cos-library v2, Jun 2026)

- **Prototype improvement backlog lives in `ui_kits/cos-library/ROADMAP.md`** (11 numbered items, effort/status table + dedicated sections, sequencing notes). Keep its status column updated as items land; new prototype feature ideas go THERE, not here.
- **The prototype is REBUILT to consume the shared compounds end-to-end** (user-directed; old version archived in `backups/cos-library-v1/`). React = app shell + static scaffolds ONLY; the catalogue (`catalog.js`) and library (`library.js`) are VANILLA controllers porting data-preview's documented bridge patterns. The ported React controls (Segmented / Pagination / Combobox / FilterGroup / FilterPills in controls.jsx + their ui-kit.css mirrors) are DELETED — never re-port a shared compound into React; mount the vanilla controller from an effect and bridge state via events/MutationObserver.
- **NEW shared structure renderers (Option-B SoT) added in this pass:** `_chips.js` (`window.Chips` — dc-chip/vchip/achip/cchip/code-cell/edition-pill HTML; `_table.js` cell builders now DELEGATE to it, exposed as `Table.cells`), `_doc-card.css` + `_doc-card.js` (`DocCard.render` + the wrap-aware meta fitter; extracted from comp-doc-card.html — the _cover.css precedent), `_cover.js` (`Cover.render` catalogue cover + `Cover.renderLib` library cover), `_lib-card.js` (`LibCard.render`). Comp pages stay the curated static showcases; every DYNAMIC consumer calls the renderer.
- **Combined-Tweaks generators are ONE shared module: `preview/_tweaks-css.js` (`window.FP_TWEAKS_CSS`)** — chip/table/metabox/button/mono/cover builders + BTN matrices + presets + DEFAULTS (filled · compact · zebra). playground-tweaks.jsx AND the prototype's tweaks.jsx consume it (panels keep only UI + application mechanism — iframe broadcast vs local `<style>` elements). Title-selector + chip-scope lists are per-consumer ARGUMENTS. Load it with a plain `<script src>` BEFORE the Babel layer.
- **`Related.collectRefs(d, find)` + `Related.rowsHTML(refs)` promoted into `_related.js`** — the relationship aggregation (related[] + adopts/replacedBy/implementedByCos → tagged, de-duped refs) + the `.related` row builder (`data-relid` for navigation). The prototype's detail page consumes them; data-preview still carries its pre-promotion copy (kept untouched as the QA surface — future cleanup candidate).
- **Favourites:** `favourites.js` → `window.CosFav` (localStorage `cos-favourites`, `cosfav:change` event). A favourited PUBLIC doc becomes a library entry (`access: public`); un-favouriting removes it (Collection-animated). ★ lives on the detail cta-panel (public docs only) + library cards/covers; `_favourite.js` owns the visual toggle/burst, the host syncs the store from `aria-pressed` AFTER the shared handler ran.
- **Catalogue = 3 views on ONE Collection-per-container model:** table (`Table.render` shell once + `renderRows` per page, rows post-decorated with data-id/tabindex/role), cards (`DocCard.render` + `DocCard.init`), covers (`Cover.render`). Each container gets its own Collection identity — table: no stagger + slide-in; cards: sequential; covers: by-row. The curtain slide targets the OUTER `.res-slide` wrapper (never a catWrap — Collection cancels subtree animations). View switch = shared segmented + the MutationObserver bridge; sort = shared `_select.js` combo (custom `data-sort` attr — see pitfalls) ; pager = Pagination compound + PinDock with `firstRow()` reading the ACTIVE view container.
- **Dark mode has TWO synced owners in the prototype:** the header `.tsw--on-dark` (React-bound, controls.jsx) and the `_inspector.js` cluster — both write `cos-preview-theme` + stamp `data-theme` on html+body; the switch mirrors Inspector changes via a MutationObserver on `<html>[data-theme]`. `_switch.js` is deliberately NOT loaded (it wires every `.tsw` as a non-theme UI demo and would fight React).
- **Inspector in the prototype** = `_playback.js` + `_inspector.js` + a `CosInspector` TweaksPanel (`hostProtocol={false}`, registerToggle → `__fpRegisterInspector`) with the shared `<AnimationControl>` + the filter-pills Curtain `BodyAttrControl`s. The combined `PrototypeTweaks` panel (host-protocol, toolbar-toggled) keeps its prototype-only **Data** section (real ⇄ mock via `COS_setDataset`).
- **region-nav stays LOCAL** (`region-nav.js`, delegated/live-query — re-render-safe for the React shell). The shared `_region-nav.js` has a different contract (static `[data-region-nav]` containers, `.region` cards, wire-time region capture) — do not swap them.
- **ui-kit.css is APP CHROME ONLY** (header/footer/hero/layout/forms/detail/request + catalogue page chrome: `.afp`, `.res-slide`, `.pager-bar*`, toolbar ladder, lib grids). Everything else is linked shared partials; `styles.css` gained `_collection/_doc-card/_pindock/_table` imports. `.form-field` replaced the old local `.field` wrapper (collides with the shared `.field` input well).

## Playground as DS index (playground.html) — foundations + grouped nav + Tools (Jun 2026)

- **Shortcut & mouse-action mentions in PROSE use keycaps, OS-aware (Jun 2026).** Any helper text / section sub / tooltip that names a shortcut or mouse action renders it as `<kbd>` — including `Click` and `Ctrl + Click`. Shared cap style: bare `kbd` rule in `_card.css` (preview cards) + `.fp-sect__sub kbd / .fp-tool__d kbd` in playground.html. `window.FP_OS` (defined in `_tooltip.js`, loaded everywhere tips are) detects macOS and maps Ctrl→⌘ / Alt→⌥: tooltip `data-tip-key` tokens map at render; static `<kbd>Ctrl</kbd>` elements are rewritten by `FP_OS.fixKbd()` on DOMContentLoaded (call it after injecting shortcut markup dynamically). NEVER write dual "⌘/Ctrl" labels — one platform-correct cap; handlers keep accepting `ctrlKey || metaKey`. Mouse-action tips use the data-tip-key grammar (`data-tip-key="Click / Ctrl Click"`).
- **Doc-codes (Typography 04) tweaks were PROMOTED to the Combined panels (Jun 2026).** type-codes.html is now STATIC (no React/local panel): tags are the shared `.dc-chip` (+`.dc-chip__dot`), so the global "Type chip" treatment + dark inks apply (`.codes` added to fpBuildGeneralCSS's scope); the scale tweak became three side-by-side examples (compact/balanced/display — usage tiers, not preference); mono font is a NEW "Mono font" General control in BOTH combined panels (playground-tweaks.jsx broadcast + prototype tweaks.jsx) that re-values `:root{--font-mono}` and PREPENDS the Google-Fonts `@import` (must be the sheet's first rule) when non-default. Default Roboto Mono emits nothing — colors_and_type.css stays the source of truth at rest.
- **Token/spec cards must render hex values in the system "code box" chip** (`.hexcode`: mono 11px, surface bg, border, radius 6) — not spread "bg … #hex" rows (colors-doc-types, colors-semantic). Swatch label ink on hardcoded fills is LITERAL `#0c1838`, never neutral-900 (inverts in dark).
- **space-shadows.html shows TOKENS, not recipes**: tiles ride `var(--color-bg-surface)` + `var(--shadow-1/2/3)` / `var(--shadow-focus)`/`-error` on a `--color-bg-subtle` WELL (a tile must be lighter than its backdrop for a shadow to read). `--shadow-1/2/3` now have black-based DARK re-values in `_dark.css` @layer theme (the navy light inks are invisible on dark) — every consumer (cards, covers, toasts, skip-links, quick-tile hover) casts properly in dark, prototype included (it links _dark.css first). Any hardcoded shadow/ring in a spec card is design-system misalignment.
- **Section order = system tiers**: Typography (01–04) → Colors (05–09) → Spacing & elevation (10–12) → Brand (13–16, gov.cz header/footer live HERE now) → Basics → Inputs & forms → Navigation & filtering → Status & feedback → Cards & content → Compound collections (related, table) → Tools (46). 45 iframe sections + the Tools pseudo-section. Pre-rewrite copy: `backups/playground.pre-foundations.html`.
- **Every `.fp-sect` carries `data-fp-group="<Group>"`**; `renderNav` emits a non-selectable `.fp-nav__grp` heading whenever the group changes (group name is folded into each item's search label, so typing "colors" filters to the group). Keyboard cursor walks `.fp-nav__item` buttons only — headings are plain divs.
- **Tools = pseudo-section with `.fp-tool` link CARDS (no iframe) + a "Tools" nav group**: data-preview · prototype index · test-related-parse. The `TOOLS` array registers `FILE_META` entries (pills T1–T3) so they open in the SAME Preview modal; `restoreModal` accepts tool files; `buildClaudeDesignLink` maps `../x` → `x` (else `preview/` + path); the modal code box shows the project-relative path (leading `../` stripped). Click contract identical to source links: plain click → modal, ⌘/Ctrl/Shift-click → href (Claude Design / new tab).
- **Static element files are whitelisted on their content container** (`.row`s in type-headings/-body, `.variants` in the specimen, `.codes` in type-codes, `.scale`/`.grid` in colors-*, `.tokens`/`.r` in space-*, `.brand-row`/`.row2` in brand-*); class-less usage legends got `.is-helper` (hidden by MODAL_HELPERS_CSS / Show all).
- **Late-keeper re-filter**: React-rendered keepers (type-codes `.codes`, comp-table `.tbl-wrap`) mount after load, so `inject()` retries the whitelist filter on a wall clock ([150,400,900,1600] ms) when the body is neither `data-fp-filtered` nor `data-fp-unfiltered` and a `[data-playground]` appears late — then reconciles Show-all + re-measures.
- **`A` = Show-all toggle** (`toggleShowAll()` + announcer; same action as the grid toolbar button, tip carries the key). Cheat sheet gained the A row, a Tab walk-through row, and "Esc — innermost first".
- **Never reuse helper-selector class names for SAMPLES**: type-body's eyebrow sample was `.eb`, which MODAL_HELPERS_CSS hides as helper chrome → renamed `.t-eb`. Check new sample classes against the MODAL_HELPERS_CSS list.

## Quick-tile compound slice + collection:entrance event (Jun 2026)

- **Quick tile is on the compound contract**: `_quick-tile.js` (controller) + `_quick-tile.config.js` (`FP_COMPOUNDS['quick-tile']`) + token defaults on `.quick-tile` in `_quick-tile.css`. All timing rides `--qt-*` custom props read off the TILE at play time (`--qt-count-dur/-pow`, `--qt-rise-dur/-dist/-ease`, `--qt-in-stag/-dur`, `--qt-count-blur/-hot`) — Inspector overrides go INLINE PER TILE (own declaration beats inherited; the `--st-*` rule).
- **Count-up is QUANTIZED, not per-frame**: eased raw value floored to a 1/2/5-ladder step sized to the REMAINING distance (`stepFor` ≈ rem/7, `rem ≤ 14 → 1`) → big round increments first, +1 ticks at the end, exact final commit; `textContent` written only when the quantized value changes. Deceleration = ease-out exponent `--qt-count-pow` (numeric token → sliderable).
- **Count styles are a separate vocabulary from timing** (technique⊥timing): `countStyles` in the config = named bundles over `--qt-count-blur` (px motion-blur, decays with remaining distance) + `--qt-count-hot` (% accent mix, "hot → calm" from the doc-type ink to resting gray). Rendered via `.quick-tile__count.is-counting` + per-tick inline `--qt-blur-now`/`--qt-hot-now`; the rule sits AFTER the hover recolor (equal specificity (0,3,0)) and sets `transition:none`.
- **Between-tile entrance = the SHARED Collection compound**, never re-implemented: grid carries `[data-col-motion]`, page binds `Collection.init(grid,{item:'.quick-tile'})`. NEW generic seam: `_collection.js playEntrance` EMITS bubbling **`collection:entrance` {phase, items, delays, dur}`** (reveal/refresh/paginate, after starting the items, not under reduced motion); intra-item compounds LISTEN — `_quick-tile.js` has a document-level listener that replays each tile's count with the SAME per-item delay. Orchestrator emits, content compounds listen (mirror of the pager seam).
- **`QuickTile.init(root, {autoReveal:false})` marks tiles MANUAL** (`__qtManual`) so the controller's own DOMContentLoaded auto-init won't reveal them — used when a host (Inspector/Collection) owns reveal timing. data-preview keeps plain `init(grid)` (auto IO/in-view reveal, unchanged). API: `init / reveal / play(countEl,{delay}) / playTile(tile,{delay}) / replay(root,{stagger})`.
- **comp-quick-tile.html** follows the comp-lib-card pattern (Inspector = single Collection owner, no `[data-fp-configurable]` region) + a comp-status-style header Replay button (`#qt-play` → full reveal replay). Panel sections: Count-up (presets, style radio + blur/hot sliders, duration/deceleration, Replay counts) · Tile content (rise + cascade) · Tiles entrance (Collection reveal vector + `--col-rv-*` timing, Replay reveal) · grid WidthLimiter.

## Metadata modal = master-detail navigation stack (Jun 2026)

- **The doc-preview dialog (`data-preview.html`) is a master-detail browser driven by a NAV STACK** — replaced the old flat `modalIdx` (which browsed the whole `active()` catalogue). Each level = `{kind, parentId, idx, id}`; its live list is DERIVED on demand (`listOf`) so it always reflects current data:
  - Level 0 `kind:"filtered"` → list IS `state.list` (the FILTERED + SORTED table list). prev/next + shortcuts move in the exact order rows are shown. `data-idx` on a row is already a `state.list` index → `openModal(idx)` uses it directly (no more `active().indexOf`).
  - Related-doc click PUSHES a `kind:"related"` level whose siblings = the RESOLVABLE related set of the doc you clicked FROM (`resolvedRelated(parent)` via `findByDisplayCode`, unresolvable cross-refs dropped). `enterRelated(target)` finds `target` in that set for the start idx. Clicking a detail's own related set pushes again (detail-of-detail).
  - Back (← button / Backspace / browser Back) POPS one level (`goBack()` just calls `history.back()` — popstate does the work); popping past level 0 closes.
- **`syncTablePage()` only nudges the table at the filtered level** (idx is a `state.list` index there); detail levels browse sets that may sit outside the current filter, so no sync.
- **Position label is context-aware**: `Dokument X z Y` at master, `Související X z Y` in detail.
- **Browser-history model**: every push (open + each related hop) `pushState(serializeStack())` carrying the FULL stack snapshot; prev/next REPLACE (`replaceHistory`) so sibling browsing doesn't flood the back-stack. popstate rebuilds `navStack` from the snapshot, re-resolving each level's idx by document `id` (survives a re-filter) then clamping. Dismiss from the dialog's own UI (X/Esc/backdrop) unwinds every pushed entry via `history.go(-depth)`, swallowed by `closingViaHistory`. The old per-doc `dpmState`/`resolveIdx`/`curDepth` model is retired.
- **Back (←) button** = first child of `.dpm__bar`, before the ID box. `.dpm__back` (also a `.dpm__btn`) collapses its FULL horizontal box (width + border-width + margin-right → 0, `:not(.is-shown)`) so the ID field slides left into the freed space; expands to 32px in detail. Rules sit AFTER `.dpm__btn[disabled]` so collapsed `opacity:0` wins the equal-specificity tie. Bar `gap` dropped to 0 (was 10px) + `.dpm__pos{margin-right:10px}` re-adds the pos↔nav gap, so the collapsed button leaves NO residual flex-gap. Disabled when hidden → excluded from the focus trap + not tabbable. The loader sweep reverses (`pulseLoader(true)`) on any pop.

## Collection-motion compound + table-as-compound (Jun 2026)

- **`.tbl` uses `border-collapse: separate; border-spacing: 0` — NEVER collapse (Jun 2026).** Collapsed cell borders are painted by the TABLE box and ignore per-row opacity/transform, so row lines sat fully opaque while the rows animated. Separate borders paint inside each row's stacking context and animate with it; at rest the render is identical (only `border-bottom` is used).
- **Striping "bare"/none means NO row lines at all** — the faint `tr + tr td` inset box-shadow hairline was removed from `_table.css` AND `_tweaks-css.js`; the metabox branch was already truly bare and the table now matches.
- **The paged-collection animation model is now a CONTRACT** — full rationale in `notes/architecture.md` "Collection-motion". Summary of the binding decisions:
  - Animation lives in a NEW standalone `Collection` compound (`_collection.js` + `_collection.config.js`, `FP_COMPOUNDS.collection`), NOT inside the table and NOT inside pagination. It orchestrates 3 phases (reveal / refresh / paginate) on ANY container's items (table rows, card grid, cover wall). Content-agnostic: `Collection.init(container, {item, pager})`.
  - Pagination EMITS `pagination:change {from,to,dir,hint}` (bubbling) and keeps its own thumb/sweep motion; the orchestrator LISTENS. Don't move content animation into `_pagination.js`.
  - TWO motion vocabularies: per-item entrance (reveal+refresh) is a COMPOSABLE effect vector (`move`/`fade`/`zoom`/`stagger`, each independently toggleable — "keep just the slide" / "just the fade"); page-transition (paginate) is a small ENUM of techniques (restagger default · slide-in · slide-through(deferred) · flip · crossfade · zoom). Technique = keyframe SHAPE; preset/token = TIMING — orthogonal.
  - Refresh = quick out→in RESTAGGER, NO FLIP/position-tracking (out of scope). `slide-through` (double-buffered whole-page) DEFERRED to a later step.
- **The table is now a real compound** (was CSS-only): `Table.render(host, rows, opts)` is the STRUCTURE SoT (vanilla, builds the canonical `.tbl` tree — the React card + the prototype both CALL it, nobody re-types it), `_table.js` exposes `window.Table`, `_table.config.js` (`FP_COMPOUNDS.table`) holds strings + the density/stripe/chipStyle knobs + demo rows. The table itself owns NO motion — motion comes from `Collection`.
- **`--col-*` motion tokens live on the CONTAINER** (the `.tbl-wrap` / grid), read at play time, governor-scaled, Inspector-retuned — same rule as `--st-*` on `.timeline`.
- **Prototype wired end-to-end** (`ui_kits/cos-library/data-preview.html`, Jun 2026): the catalogue table now drives reveal/refresh/paginate through `Collection`. `renderRows()` is DOM-ONLY; the pager's `pagination:change` event (NOT the old `data-current` MutationObserver) calls `Collection.paginate(catWrap, dir, hint, () => renderRows(dir))` — render DEFERRED into renderFn so slide-through can double-buffer. Filter/search/dataset change → `Collection.refresh(catWrap, renderFn)`; initial load → guarded `Collection.reveal`. The catalogue's established motion identity (whole-block directional slide, NO per-row stagger) is preserved via config: `setEffect` stagger:'none' + `--col-*-stag:0` + technique `slide-in`. The bespoke `animateRows()` was retired.
- **Shared Inspector** (`_collection-inspector.jsx`, `window.CollectionInspector`): ONE schema-driven authoring panel reused by every preview card (table / card grid / cover grid) — the harness twin of `_inspector-controls.jsx`. Props: `containerSelector` + `item` (it OWNS `Collection.init` on a preview card — single owner, so the card's own script doesn't re-wire), `defaults` (per-surface seed, e.g. grids pass `rv_stagger:'by-row'`), optional `widthTarget`. Helpers are CLOSURE-PRIVATE (Babel scripts share global scope — prefixing alone is fragile); only the component leaks onto window. comp-table/comp-lib-card/comp-lib-cover all mount it; the inline table Inspector was removed.
- **Geometry stagger + full technique enum** (Jun 2026): `staggerOrder` is geometry-aware (`by-row`/`by-column`/`from-center` bucket items into visual rows/cols by `getBoundingClientRect` via the `banding()` helper, 6px tol), so ONE orchestrator drives 1-D tables AND 2-D grids; a 1-D table degenerates cleanly (by-row==sequential). Paginate techniques complete: restagger · slide-in · crossfade · `zoom` (directional scale 0.92↑ / 1.06↓) · slide-through · flip. Grids opt into the `--col-*` tokens via `[data-col-motion]` (in `_collection.css`'s `:where(.tbl-wrap, [data-col-motion])`).

## Inspector / Tweaks split + standalone chrome (Jun 2026)

- **Force-state engine extracted to shared `preview/_force-state.js` (`window.FP_FORCE_STATE`, Jun 2026).** Single source for "force Default/Hover/Pressed/Focus/Disabled" — both the playground AND the standalone cluster consume it. API: `apply(doc, state)` (clears then forces — `default` clears only), `clear(doc)`, `STATES`, `hasForceable(doc)`. It ships NO visuals: state styling lives in each component's own source CSS (`[data-state~="…"]` mirrors + `.*--focus`/`.*--disabled` modifier classes); the engine only stamps attrs/classes and tracks them via `data-fp-orig-state`/`-no-state`/`-added-focus`/`-forced-disabled`/`-combo-disabled` for exact restore. **Every pass SKIPS chrome (`#__fp-cluster, .twk-panel, .tip, [data-fp-chrome], [data-fp-no-force]`)** — critical because standalone shares the document and the disabled pass would otherwise disable the cluster/panel controls you need to cycle back out; no-op in the playground iframe (no such ancestors). playground.html now loads `_force-state.js` and `applyStateTo`/`clearForcedClassesIn` are thin delegators (the big `STATE_STAMP_SELECTOR`/`FOCUS_TARGETS`/`stampState` blocks were DELETED from it — keep the selector lists in the engine only).
- **Standalone cluster "State" button (`_inspector.js`):** a 5th cluster sub-button (slot after Hide-helper), shown ONLY when `FP_FORCE_STATE.hasForceable(document)` is true (so it never appears on type/colour/brand spec pages). It's a single CYCLING button whose glyph is the current state's letter (D/H/P/F/X via `.fpc-letter`, mono); click cycles `STATES`; `is-on` when ≠ default; tip = "State: <name> — click to cycle". **Keyboard mirrors the playground exactly:** D/H/P/F/X letter mnemonics (`e.key`) + **positional 1-5 and Numpad 1-5** (`e.code` → `CODE_STATE`, layout-independent so a Czech `+ěščř` physical row still maps) jump directly (re-press the active one → Default); **Numpad `.` cycles**. All gated on `FORCE && stateBtn visible && !inTypeWidget(el)` (bails inside input/textarea/select/contenteditable/`[role=combobox|listbox]` so typeahead widgets keep their letters) — none collide with the cluster's `I`/`\`/`T`/backtick. `_force-state.js` is loaded lazily as a sibling script (`new URL(..., SELF.src)`) on build, like `_tooltip.js`.

- **Three new shared modules carry the per-file chrome (DRY across all preview cards):**
  - `_inspector.js` (vanilla) — the standalone floating control CLUSTER (top-right, collapsed behind the dark-mode button until hover/focus) AND the configurable-content visibility contract. **Sole owner of dark mode now — it SUPERSEDES `_theme-toggle.js`; every preview card loads `_inspector.js` instead (mechanical swap, Jun 2026).** Buttons: Dark (always, key `\`), Hide-helper (always; reuses the modal's `MODAL_HELPERS_CSS` selector list), Inspector (only when `[data-fp-configurable]` + `window.__fpInspectorToggle` exist; key `I`), Tweaks (only when a native tweaks-panel is detected). Suppressed entirely under `?fp` (the playground owns chrome) — but the visibility contract still runs there.
  - `_playback.js` (vanilla) — `window.FP_PLAYBACK` standalone animation governor (speed 0.1/0.25/1/instant/reduced + pause), mirroring the playground toolbar's governor for a file viewed on its own. `instant` reuses the SAME `#__fp-motion` 1ms shim id; `reduced` reloads with `?rm=1`.
  - `_inspector-controls.jsx` (React) — `<AnimationControl>` (speed pills + pause, drives FP_PLAYBACK) and `<WidthLimiter target>` (universal "expanded content width" cap, replaces per-file `.demo-bar` sliders). Load after tweaks-panel.jsx + _playback.js.
- **Configurable content contract:** wrap the file's interactive, Inspector-driven instance in `[data-fp-configurable]`. Visible standalone; HIDDEN in the playground overview (never shown); modal-toggle-revealed. Enforced by `_inspector.js`'s rule `html[data-fp-pg]:not([data-fp-reveal-config]) [data-fp-configurable]{display:none!important}` (`data-fp-pg` stamped under `?fp`). The playground modal's Inspector button sets `data-fp-reveal-config` on the iframe `<html>` + calls `iframe.contentWindow.__fpInspectorToggle(true)`.
- **Inspector panel = `TweaksPanel title="Inspector" hostProtocol={false} disableKeyToggle registerToggle onOpenChange`.** `hostProtocol={false}` keeps it from being mistaken for a generic Tweaks panel (no `__edit_mode_available` / `fp-tweaks-available`). It exposes its toggle via `window.__fpInspectorToggle` (playground modal) + `window.__fpRegisterInspector` (standalone cluster). Toggled by `I` standalone / the modal Inspector button (NEVER `T` — `T`/backtick stay reserved for a file's native Tweaks panel). `AnimationControl` is rendered ONLY when standalone (`!?fp`) — in the modal the playground toolbar owns playback.
- **Playground additions:** Combined-Tweaks header push-button (between Show-all and Section-details; same action as `T`/backtick → `window.__fpTweaksToggle`, state mirrored via PlaygroundTweaks `onOpenChange`). Modal-titlebar Inspector push-button (right of Hide-helper; `bi-sliders`; `aria-disabled` when the file has no Inspector; key `I`; coexists with the Combined Tweaks panel — separate documents/layers). Clickable modal section-number pill opens the SAME nav menu — reparented to `<body>` + `position:fixed` z-index 130 to escape the toolbar's stacking context (z-50) and clear the modal (z-100).
- **`.insp-note` added to `MODAL_HELPERS_CSS`** so "Hide helper text" strips the Inspector microcopy once the configurable content is revealed.
- **Standalone playback shortcuts + scrubbing (`_playback.js`):** exposes `seek(mode)` + `cycleSpeed()` and binds the relevant subset of the playground's keys — `Space`/`Pause` (toggle), `Num +` cycle + `6`–`0` direct speed, and `Home/End/↑/↓/←/→/PgUp/PgDn` timeline scrubbing (only while paused). GATED on `!?fp` so inside the modal the playground's `handleShortcut` (bound to the iframe too) owns these keys instead. The governor's `eachAnim` EXCLUDES `#__fp-cluster, .twk-panel, .tip` (via `effect.target.closest`) so the chrome's own motion stays live at 1× while the component is slowed/paused.
- **Inspector Playback control (`<AnimationControl>`):** pause + speed pills; while paused the pills are replaced by `[start][−10][−1][+1][+10][end]` scrub buttons (`<ScrubButtons>`, driving `FP_PLAYBACK.seek`). In the playground MODAL the file renders `<ModalScrubControl>` instead (pause/speed are in the titlebar there) — it learns the paused state from the playground via `postMessage('fp-modal-playback')` and posts `fp-modal-seek` back; the playground broadcasts on `setPaused` + answers `fp-modal-playback-request`.
- **Modal Inspector button / `I` has TWO models depending on the file (Jun 2026).** **Panel-only files** (NO `[data-fp-configurable]` region — the button just summons the panel; e.g. comp-table, comp-lib-card) → a 2-STATE mirror of the panel: open it / close it. Crucially, closing the panel via its OWN ✕ drops the button to UNPRESSED — the file's `onOpenChange` calls `window.parent.__fpModalInspectorSync(open)`, which (for panel-only files) syncs `aria-pressed`. **Content-reveal files** (have `[data-fp-configurable]`; e.g. comp-pagination/status/filter-pills) keep the THREE-STATE contract so the panel can be re-summoned after its ✕ without hiding the revealed content: off→reveal content + open panel; content shown but panel CLOSED→re-open panel only (content stays, button stays pressed); both shown→turn off. The playground distinguishes via `modalInspectorHasConfig()` (`querySelector('[data-fp-configurable]')` in the modal doc); `__fpModalInspectorSync` bails for content-reveal files (leaving their button pressed). Both still read `__fpInspectorOpen` (the file reports panel open-state on its window via `onOpenChange`).
- **Cluster + Playback tooltips are our styled `.tip`** (not native `title`): `_inspector.js` loads `_tooltip.js` as a sibling (`new URL('_tooltip.js', currentScript.src)`) before the `?fp` early-return (the modal scrub control is tooltipped too) and sets `data-tip` + `data-tip-key` on every button. `.tip` z-index was raised to `2147483647` so tips clear the panel (`2147483646`). Dark-mode alt shortcut `Num -` mirrors the playground.
- **Nav menu over the modal:** `Ctrl+Shift+P` anchors the menu to the titlebar pill (floats above the modal) when the modal is open; `Esc` closes the nav BEFORE the modal (the nav check moved ahead of the modal check in `handleShortcut`, and the nav search input's own `Esc` calls `stopPropagation`). Replay copy unified to English ("Replay"); `WidthLimiter` min is `100px`.
- **Both speed/pause governors EXCLUDE the previewed file's own chrome** (`.twk-panel, #__fp-cluster, .tip`, matched via `effect.target.closest`) — the playground governor (`eachAnim` over the modal frame) AND `FP_PLAYBACK` standalone. So the Inspector/Tweaks panel, cluster and tooltips stay live at 1× while the COMPONENT's motion is slowed/paused.
- **Shortcuts fire from inside the Tweaks/Inspector panel.** The blanket `.twk-panel` keyboard bail was removed everywhere (`isTypingTarget` in playground, `isTextEntry` in `_playback.js`, `isTyping` in `_inspector.js`): bail ONLY for controls that consume the key natively — `<input>` (text/range/number…), `<select>`, `<textarea>`, contenteditable, `[role=slider|spinbutton]` — NOT panel `<button>`s. Space/Enter on a button is still guarded by the per-key `closest('button,…')` activation check, so it activates the control instead of double-firing.
- **Collapsed `TweakSection` bodies are `inert`** (DOM property, set via ref+effect) so their controls aren't TAB-focusable / tooltip-able while the grid-rows-0fr section is collapsed.
- **Standalone dark mode + scrollbars (`_inspector.js` `STANDALONE_BASE_CSS`):** injects `html[data-theme="dark"]{background:#1a2030}` (the dark neutral tokens are scoped to `<body>` in `_dark.css`, so `<html>`'s own `var(--gov-color-neutral-0)` bg stays LIGHT → a white strip) and hides the window scrollbar (`html,body{scrollbar-width:none}` + webkit) — the panel keeps its own custom scrollbar.
- **Inspector playback controls match the Combined-Tweaks idiom:** speed pills + scrub buttons reuse the `.twk-seg` segmented look (well bg, white active thumb); the pills↔scrub (and modal hint↔scrub) swap has an `fpCtlIn` entrance (opacity from `.4` so it can't park invisible under a frozen clock); the pill/scrub containers are `overflow:visible` so the panel focus-ring `box-shadow` isn't clipped.

## Compound components (organisms / panels) — architecture contract (Jun 2026)

- **Full deep-dive: `notes/architecture.md` → "Compound architecture".** Binding summary below; read the deep-dive before building/refactoring any compound.
- **4-tier vocabulary:** Foundations · Components (atoms/molecules) · **Compounds** (orchestrate components + own `_x.js` controller + `_x.config.js` schema + often animation — filter-pills, pagination, status, toolbar, header, cards) · Patterns/Layouts. Use the word **"Compound"**, not "organism", in `@dsCard group=` + prose.
- **Compound contract = 4 parts, one of each, never forked:** Style `_x.css` (self-contained `@layer`), Behavior `_x.js` (`initX(root,opts)` + `el.__x` API + CustomEvents), Structure (see SoT), Config `_x.config.js` (new). Style+behavior are ALREADY shared verbatim by preview + prototype — generalize, don't reinvent.
- **Structure SoT = Option B (decided + user-confirmed Jun 2026):** data-shaped compounds (timeline, filter-pills, pagination, table) own a **render fn in `_x.js`** (`window.X.render(data,opts)`) that ALL consumers call — preview slice + prototype, nobody re-types the tree. Static compounds (header, empty-state, CTA) keep the `comp-*.html` slice as SoT. The schema FEEDS the renderer (verbosity/detail/text = render opts, not forked markup). Motivating case: the status timeline lived in 3 drifted copies (comp-status.html hand-typed · data-preview.html `buildTimeline` · legacy `ui-kit.css .timeline__*`).
- **`_x.config.js` manifest** read by runtime + prototype + Inspector: `{name,tier,motion:{tokens,presets,default},knobs,strings}` on `window.FP_COMPOUNDS[id]`. **Motion = CSS custom-prop tokens** (NOT hardcoded JS consts like `_status.js` `STAG/DUR/EASE`) so the governor scales them + presets are token bundles (like `FP_BTN_PRESETS`) + the Inspector rewrites them live. Verbosity = a `detail` knob (generalize pagination's `data-responsive` profiles). Text fragments/i18n live in `strings:{}`.
- **Config cascade (last wins):** system defaults (schema + `:root`) → instance (`data-*` / `initX(el,opts)`) → live overrides (Inspector / `FP_TWEAKS.applyToFrame`).
- **Two tooling surfaces, separate by intent:** Governor = GLOBAL playback (speed/pause/scrub). **Inspector = per-compound authoring** (preset/timing/easing/verbosity/text), contextual panel auto-generated from the schema, reuses `tweaks-panel.jsx`; do NOT fold into the global broadcast `FP_TWEAKS` panel (app-wide style only).
- **First slice = status timeline** (tokenize consts → `--st-*`; add `Status.render`; retire duplicate `buildTimeline` + legacy `ui-kit.css .timeline__*`; write `_status.config.js` + Inspector). Pattern then copied to filter-pills/pagination/table.

## Pagination compound slice + label model — 3rd compound on the contract (Jun 2026)

- **Layout knobs REDESIGNED per user feedback (Jun 2026) — see "Pagination layout model" below.** The original `align` (start/center/end) + `labelPlacement` (after/before/above/below) + `responsive` toggle + `mode` radio were reworked into: ONE 3-way `layout` (responsive/standard/compact), `align` start/center/end/**justify**, `labelPlacement` before/after only, a new `labelOverflow` wrap/hide, and a **minimal** compact variant. Below describes the original slice; the layout section captures the redesign.

- **Third compound migrated onto the contract** (after status + filter-pills). Files: `_pagination.css` (motion tokens + align/placement classes), `_pagination.js` (label template engine + `window.Pagination` API + token-driven motion), `_pagination.config.js` (manifest + label presets), `comp-pagination.html` (local schema-driven Inspector + reference examples incl. a new "Item range" one). Table is the remaining slice.
- **THE LABEL MODEL (the headline design decision) — template + presets + cascade, three opt-in tiers** (full rationale below under "Pagination label model"). Replaces the hardcoded `countText(eb,…)` 3-variant switch. Deep but the default `auto` reproduces today's behaviour exactly.
- **Motion tokens on `.pag, .pag-compact`** (the element `_pagination.js` reads): `--pag-slide-dur` (standard active-thumb glide) · `--pag-sweep-dur` (compact wash) · `--pag-num-dur` (compact number slide) · `--pag-fade-dur` (de-activation crossfade) · `--pag-ease`. Read per page-change via `pagMotion(nav)` (getComputedStyle); the old `660`/`1200`/`800` literals are FALLBACKS only. `fadeButton` gained a `nav` arg to read them.
- **`window.Pagination = { init, render, motion, configure }`** (contract API, mirrors `window.Status`). `configure(nav, cfg)` is an idempotent FULL-apply: writes the dataset channels the renderers read (`data-align`/`data-label-placement`/`data-label`/`data-page-size`/`data-compact-label`), toggles responsive (wires/disconnects the stored `nav.__pagRoInstance`) + mode, then re-renders through the SAME path. `motion(nav, tokens)` sets the `--pag-*` inline. The Inspector drives the live `#insp-pag` entirely through this — nobody re-types the render tree.
- **Alignment + placement are orthogonal class-toggle knobs** (`applyLayout`): `align` start|center|end (`.pag--center`/`--end`; compact uses `align-self`), `labelPlacement` after|before|above|below (`.pag--label-*`; standard only — compact keeps its counter between prev/next). Read from `data-*` or `nav.__align`/`__labelPlacement`.
- **Responsive driver generalized to a CASCADE.** `pickProfile(w, cascade)` maps width brackets to cascade indices (rich/mid/simple) instead of hardcoded `eyebrow:'full'|'page'|'mini'`; `__eyebrow` renamed `__labelForm`. Default cascade `['full','page','mini']` = today's behaviour. Override via `data-label-cascade` (JSON or comma/pipe list) or `nav.__labelCascade`. A FIXED `data-label` (≠'auto') pins one form at all widths (mode + siblings still adapt).
- **Strings/i18n in config** (`strings`: `labelPresets`, `prev`/`next`/`prevAria`/`nextAria`/`navAria`); both renderers read them via `pagStr(k, fb)` with hardcoded Czech fallbacks. The controller works without the config file loaded (JS fallbacks for presets + strings).
- **Backward-compatible:** the prototype (`data-preview.html`) builds a plain `<nav class="pag" data-total data-current>` with no `data-docs`/`data-label` → `labelForm` returns 'none', so the controller renders buttons only (unchanged) and the prototype keeps its own `#pager-range`. FOLLOW-UP opportunity: that hand-rolled "Zobrazeno X–Y z N" is now EXACTLY the canonical `range` preset — the prototype could drop `#pager-range` and use `data-label="range" data-page-size=…`.

## Pagination layout model — align / placement / wrap / minimal (redesigned Jun 2026)

- **ONE 3-way `layout` knob** (`responsive` | `standard` | `compact`) replaced the separate `responsive` toggle + `mode` radio (mode only worked when responsive was off — superfluous). `Pagination.configure` derives back-compat from the old `responsive`/`mode` pair when `layout` is absent.
- **ALL THREE layouts are width-observed (Jun 2026 refinement) — each flexes WITHIN its variant, not frozen.** `configure` sets `data-responsive` = `''` (responsive) / `'standard'` / `'compact'`; `applyResponsive` picks the profiler by that value: `pickProfile` (full cascade — variant CAN change, incl. minimal <200px) · `pickStandardProfile` (stays standard, only reduces sibling page count: 2/1/0 at ≥480/≥360/<360) · `pickCompactProfile` (stays compact, only drops the prev/next TEXT to chevrons at <232; NO minimal, and it leaves the counter form to the author's `compactLabel`). So minimal is reached only via the FULL cascade or an explicit `compactLabel:'none'`.
- **`align`: start | center | end | JUSTIFY.** Justify pushes ONLY the label to the far end via an auto-margin (`.pag--justify:not(.pag--label-before) .count{margin-left:auto}` trailing; `.pag--justify.pag--label-before .nav-prev{margin-left:auto}` leading) so the button cluster stays put and wraps INTERNALLY — the "text left, buttons right, 12/› wrap below" reference layout. `labelPlacement` sets which side the label is on.
- **Compact alignment can't use `align-self`** — the parent may be a plain BLOCK (e.g. the Inspector well), where `align-self` is inert (always-left bug). FIX: `.pag-compact.pag--center/--end { display:flex; width:fit-content }` + `margin-inline:auto` / `margin-inline-start:auto` — works in a block AND a flex parent. justify on compact is a no-op (counter is structurally between the buttons).
- **`labelPlacement` = before | after ONLY** (dropped above/below as explicit options — they're the WRAPPED state now). before→leads (order:-1), wraps ABOVE; after→trails, wraps BELOW.
- **Wrap is align-aware AND placement-symmetric (Jun 2026).** `checkStacked` now uses HEIGHT-based multi-line detection (`nav.offsetHeight > firstBtn.offsetHeight*1.5`), NOT an offsetTop diff against the first button. The offsetTop-diff version only caught TRAILING (where the label itself wraps); for LEADING the label is first and the BUTTONS wrap instead, so the label stayed pinned inline (reported bug). Height-based fires for both → the leading label also wraps to its own line / hides. Stacked rules then align BOTH lines per `align` (start→left, center→center, end→right; justify→centred when stacked). Old `.pag--stacked` hardcoded `justify-content:center` — the legacy leak.
- **`labelOverflow`: wrap (default) | hide.** `hide` drops the label (`.pag--label-hidden .count{display:none}`) instead of stacking when tight — applies to leading too now.
- **Wrap/stack works for NON-responsive navs too** — the ResizeObserver now observes EVERY `[data-total]` nav (was `[data-responsive]`); `onResize` branches responsive→`applyResponsive`, else→`checkStacked`; `render()` also calls `checkStacked` after each standard render. (With the 3-way layout, the Inspector always sets a `data-responsive` value, so all its instances are observed.)
- **Minimal compact variant (`.pag-compact--minimal`) = buttons only, no counter.** Via `compactLabel:'none'`, auto-used by the FULL responsive cascade below 200px (not fixed-compact). No counter to sweep → a page change washes a primary-50 stripe across the OPPOSITE button (`sweepButton`: next→prev), clipped by an inner `.pag-sweep-clip` so the button's box-shadow focus ring isn't clipped (overflow:hidden on the button would clip it).
- **Compact label spacing bug fixed:** `.where__num` `inline-flex`→`inline-block` (flex trimmed the spaces around the bolded `{token}` — see pitfalls).
- **Inspector: a `?` help icon (`.tpl-help`, `bi-question-circle`) next to "Custom template"** carries `data-tip` listing the placeholders (`{current} {total} {totalItems} {pageSize} {from} {to}` + `*…*` bold) — uses the shared `_tooltip.js` (loaded by `_inspector.js`, document-delegated, `.tip` appended to body so it escapes the panel overflow). Passed as JSX to `TweakText`'s `label` (TweakRow renders the label inside a `<span>`, so a node works).
- **Container-query question (answered):** the cascade is ALREADY container-width-based (RO on `parentElement.clientWidth`), NOT viewport media queries. It can't be pure CSS `@container` because the cascade switches VARIANTS / button counts (different MARKUP via re-render), and container queries only restyle — they can't re-render. A future "fit-based" cascade (render → measure overflow → step down until it fits) would replace the px thresholds with intrinsic fit but adds reflow cost; deferred.

## Pagination label model (the text approach — _pagination.js renderLabel + _pagination.config.js)

- **A label is a TEMPLATE STRING with exactly two features beyond literal text:** `{token}` interpolation + `*emphasis*` → `<strong>`. Everything else is literal and HTML-escaped — NO logic in templates, pure safe substitution. This is the whole engine (`renderLabel(tpl, ctx)`): `esc()` the literal, then `*…*`→`<strong>`, then `{token}`→escaped value (unknown token → '').
- **Token context (`labelCtx(nav)`):** `{current} {total} {totalItems} {docs}(alias) {pageSize} {from} {to}`. `from`/`to` are DERIVED from `data-page-size` (`from=(current-1)*size+1`, `to=min(current*size, totalItems)`) — null when no page-size. This is what unlocks "Zobrazeno 1–25 z 401".
- **Named PRESETS cover the 90% case** (`config.strings.labelPresets`, JS fallback `LABEL_PRESETS_FB`): `mini`=`*{current}* / {total}` · `page`=`Stránka *{current}* z {total}` · `full`=`{totalItems} dokumentů · stránka *{current}* z {total}` · `range`=`Zobrazeno *{from}–{to}* z {totalItems}`. A consumer usually just NAMES one (`data-label="range"`).
- **`labelTemplate(form)` resolves:** a preset NAME → its template; a raw string containing `{`/`}`/`*`/whitespace → used verbatim (the escape hatch); `'none'`/empty → null (no label); unknown bare name → default (`full`).
- **Collapsing = an ordered CASCADE** (richest→simplest) the responsive driver steps through, NOT hardcoded per-width forms. Default `['full','page','mini']`. Separating "what it says" (form/template) from "how it collapses" (cascade) is the key simplification.
- **Graceful degradation for the BUILT-IN presets only** (custom templates own their tokens): in `renderDefault`, `full`→`page` when `totalItems==null`; `range`→`page` when `from==null || totalItems==null`. `compactForm(nav)` does the same `→mini`. This reproduces the old `countText` behaviour (it omitted the doc prefix when `data-docs` was absent) and prevents a dangling "… z " / " dokumentů ·" literal. See pitfalls.
- **Compact counter uses the SAME engine** — its own form via `data-compact-label` / `nav.__compactLabelForm` (default `mini`). `equalizeCompact` sizes the `.where__num` by rendering the compact template at the WIDEST ctx (current=total, from/to at the last page) so a custom compact template still reserves enough width.
- **Inspector exposes all of it:** Layout (responsive toggle / mode / align / placement), Label (form select / custom-template text / page-size / compact-label), Motion (presets + glide + sweep + easing + Replay). `customLabel` (non-empty) overrides the form select.

## Filter-pills compound slice — 2nd compound on the contract (Jun 2026)

- **Second compound migrated onto the contract** (after status; deep-dive: `notes/architecture.md` → "Compound architecture"). Files: `_filter-pills.css` (motion tokens), `_filter-pills.js` (token-driven `motionOf()`), `_filter-pills.config.js` (manifest on `FP_COMPOUNDS['filter-pills']`), `comp-filter-pills.html` (local schema-driven Inspector + always-visible reference rows). Same shape as the status slice — copy to pagination / table next.
- **Motion tokens on the active row** `.row[aria-label="Aktivní filtry"]` (the element the controller reads — NOT `:root`, same own-declaration-wins trap as `--st-*`): `--afp-dur` · `--afp-ease` (enter/expand-in) · `--afp-collapse-ease` (leave/collapse-out). Declared in `_filter-pills.css` as the system DEFAULT with `/* @kind other */` each; the Inspector writes them back as INLINE style on every active row (`applyMotionAll`).
- **`_filter-pills.js` reads tokens per-call via `motionOf()`** (getComputedStyle on `activeRow`); the old `CB`/`COLLAPSE_EASE`/`DUR` consts are now `FB_*` FALLBACKS only (a consumer that doesn't load `_filter-pills.css`). `collapseOut`/`expandIn` use `M.dur`/`M.ease`/`M.collapseEase`. **Backstop = real-finish, NOT wall-clock (superseded the old ×12 rule, Jun 2026).** `onceDone` delegates to a shared `fpFinalize(anim, fn, ms)` that finalizes on `anim.onfinish` (correct at any rate) and only force-finalizes when `document.visibilityState==='hidden'` — because the Inspector speed and the DevTools Animations panel rate STACK (÷10 × ÷10 ⇒ ×1/100), so any fixed wall-clock margin fires mid-animation and snaps it. `M.backstop` is now just the frozen-case first-poll delay. The prototype (data-preview.html) consumes the controller unchanged — it declares no `--afp-*`, so it falls back to the CSS defaults automatically (backward-compatible).
- **data-preview h0 stash + dark backdrop (fp32) · grow-reservation keep (fp33, Jun 2026).** fp33 (shared `_filter-pills.js`, build `fp33-pin-reserve-keep`, cache-bust `?v=fp33`): slideCurtain's GROW branch keeps the reservation set by a pinned wrap-grow instead of re-measuring (the re-measure reads the row without the pinned entrant — see pitfalls "GROW reservation trample"). fp32: (1) The `filterpill:remove`/`filterpill:clearall` listeners stash `afp.__h0 = {h: afpOuterH(afp), t}` synchronously at event time (pre-`.is-leaving`); `renderPills` consumes it when fresh (<300ms), else live-measures — fixes the ×-click table JUMP (see pitfalls "deferred host re-render"). (2) `body[data-theme="dark"] #view-catalog { --afp-backdrop: var(--color-bg-surface) }` — this page's dark body EMPIRICALLY paints surface (#1a2030), not the `--color-bg-page` the shared feather default assumes (light = page = default, no override). Backdrop overrides are per-consumer + per-theme; always verify against the COMPUTED body bg, not the stylesheet's declared token.
- **Curtain Inspector controls are SHARED (fp31, Jun 2026) — backport closing the animation-challenge work.** `AttrPills` (presentational body-attribute pill row, style-embedded `.fp-attr-*` so no page-local CSS needed) + `BodyAttrControl` (self-persisting wrapper: localStorage `fp-attr-<attr>`, body-attr sync, default = attribute removed) live in `_inspector-controls.jsx`. The schemas (`motion.curtainShadow` / `motion.slideSnap`, now with `label`) live in `_filter-pills.config.js` (`FP_COMPOUNDS['filter-pills']`). Consumers: comp-filter-pills keeps its `useTweaks` wiring (panel-state persistence) and renders the shared `AttrPills`; **data-preview now loads `_filter-pills.config.js?v=fp31` and its Inspector has a "Curtain" section** (`BodyAttrControl` × 2 — shadow set + slide snap), so every behavior AND switch from the test bed is available there. Per-page persistence is intentional (different stores). Pattern for future body-attribute knobs: schema in the compound config → `BodyAttrControl` in any Inspector.
- **"Zrušit vše" matches the pill's outer height BY CONSTRUCTION (fp30, Jun 2026).** The text-only clear-all was ~4px shorter than a pill, so a wrap-line it occupied ALONE was shorter than a pill line — row-height deltas (and curtain travel) varied by a few px depending on which item wrapped last (user-reported "extra movement"). Fix in `_filter-pills.css`: same vertical anatomy as the pill instead of a magic number (DPR rounding makes hard-coded px drift — a "28px" pill is 27.33px at dpr 1.5): `.clear-all` gets inline-flex centering, a `1px solid transparent` border (≡ pill border), and an 18px zero-width `::before` strut (≡ the pill's `.x` button). Verified equal to the subpixel (27.3333… ≡ 27.3333…). Keep in sync if `.filter-pill`/`.x` anatomy changes. CSS cache-bust `?v=fp30`.
- **Pixel-snapped curtain slide (fp29, Jun 2026), OPTIONAL — default ON.** The continuous translateY tween sat at fractional offsets between frames, so the compositor bilinear-filtered the table's 1px top border across two device-pixel rows — a luminosity shimmer, worst in dark mode. Fix in `slideCurtain`: the easing curve is precompiled into a per-pixel keyframe list (`fpSnapFrames`) — uniform offsets, values quantized to the **DEVICE** grid (`round(v·dpr)/dpr`; CSS-px snapping still shimmers at fractional DPR like 1.5), `steps(1, jump-end)` between keyframes, endpoint rounded so the rest state lands on-grid (kills the end-of-slide sharpening pop). **The outer easing MUST be `linear` when frames encode the ease** — an eased outer timing would apply the curve twice. ~1 keyframe per device px, capped at 240. Still pure WAA (governor/reduced-motion/frame-stepping/finalize all unchanged); `fpEaseFn` parses cubic-bezier + keywords, unparseable easings fall back to the continuous tween. Switch: `data-afp-snap="cont"` on `<body>` restores the continuous subpixel tween (schema `motion.slideSnap` in `_filter-pills.config.js`; Inspector "Slide motion" Snapped/Continuous pills, persisted as `snapMode`; generic `AttrPills` row now backs both this and the shadow set). Verified at dpr=1.5: sampled mid-flight m42 ≡ whole device px (e.g. −0.666667 = −1 device px). Cache-bust `?v=fp29`.
- **Entrances SERIALIZE via `fpCommitEntrances` (fp25, Jun 2026).** Any new user-level mutation first commits in-flight entrances to their end state (clean snap: cancel grow → natural base, unpin, cancel held FLIPs, bump `__fpRun`). Call sites: compound mutation entry points + hosts before their `h0` measure (`ch-add`, `renderPills`) + slideCurtain shrink backstop. Exported `FilterPills.commitEntrances(row)`. Supersedes fp24's bail-to-legacy for overlapping adds (kept only for same-task bursts like restore). Harness "Add pill" skips already-present pool entries. Cache-bust `?v=fp25`.
- **Wrap-case entrance = PINNED grow + successor FLIP (fp24, Jun 2026).** `expandIn` probes (synchronously, while the entrant is in flow at natural size) whether a box-grow would hop lines mid-flight — its own top OR any in-flow sibling's top changing between zero-box and natural layouts. If so it pins the entrant absolute at its FINAL coordinates, grows it there, and FLIPs displaced siblings old→final (held `fill:'forwards'`, cancelled by reference at unpin). Row reservation is two-owner (slideCurtain grow in curtain hosts / the pin in plain rows) with deferred release via `fpReleaseReserve` + `row.__fpWantRelease`. `FilterPills.unpinGrow(rowOrPill)` is the exported escape hatch (tests / hosts). Same-line adds keep the legacy in-flow grow; overlapping interactions serialize via fp25's commit (see above) — only same-task bursts (restore) still bail to legacy. The "row sits in its FINAL layout the whole time" curtain model is now literally true for wrap-adds. Backup of the prior build: `backups/_filter-pills.fp23-flip-delta0.js`; cache-bust `?v=fp24`.
- **`slideCurtain` semantics (fp23, Jun 2026): the shrink pin+FLIP choreography runs for ANY removal (`delta <= 0`, gated on a `.is-leaving` item in `tokenEl`) — the delta only decides the TABLE slide.** Call sites (harness `reslide`, data-preview `slideTable`) must call it even when `delta===0`, else a middle removal that reflows survivors across lines without changing row height jumps un-animated (see pitfalls). Harness has a **Remove middle** button (`#ch-rm-mid`) for this case. A same-line add with delta 0 has no leaving items → natural in-flow push, unchanged.
- **The pill-row ⇄ table CURTAIN slide is a SHARED helper** `window.FilterPills.slideCurtain(slideEl, delta, tokenEl)` (module-level in `_filter-pills.js`) — translate-to-`0` with `fill:'backwards'`, restart-safe (`start = curTransY − delta`), motion tokens read off `tokenEl`, cleaned up via `fpFinalize`. **It FREEZES the row's final height for the duration of a GROW (delta>0) — `tokenEl.style.height = offsetHeight` + `align-content:flex-start`, released on finish (Jun 2026 fix).** Without it, the pill box-grows from width 0 and a WRAPPING row un-wraps back to one line mid-slide, collapsing the table's layout so it "jumps to make space" while the transform only covers the margin. Reserved space sits at the bottom under the descending table; at release the grown pill's natural height already equals the frozen one (no jump). Requires callers to invoke `slideCurtain` while the row is at natural height — so `renderPills` runs `slideTable` BEFORE `expandIn` (reordered Jun 2026); the harness's `reslide` already did. data-preview's `slideTable` is a thin wrapper around it; **`comp-filter-pills.html` has a "curtain test bed"** (a live pill row over a simulated `.ch-table`, Add/Remove/Clear + a "Curtain overlay" toggle that flips which layer is on top) that drives the SAME helper — so the pill-row ⇄ table choreography is testable in isolation (esp. at slow speed, away from the real table's Collection contents). Standalone-only (hidden under `?fp`).
- **`activeRow.__fp.restore`** exposed (= `restoreDemo`) so the Inspector's Replay re-runs the entrance of the well's pills without synthesizing clicks. The `__fp` API is otherwise unchanged (`expandIn`/`collapseOut`/`syncClearAll`).
- **Inspector = LOCAL panel in comp-filter-pills.html, gated `!?fp`** (mirror of comp-status.html). A `[data-fp-configurable]` "well" on top holds the single Inspector-driven active row (hidden in the playground overview, modal-revealed); the existing reference rows stay ALWAYS-visible as the showcase. Auto-generated from `_filter-pills.config.js`: motion preset pills + duration slider + enter/leave easing selects + Replay. **Motion-only — `knobs: []`** (the pill set is real data, not authoring config; the Content section renders only when `M.knobs.length`). The Inspector chrome CSS (`.col`/`.ex-head`/`.play`/`.insp-well`/`.insp-note`/`.insp-preset*`) was copied from comp-status.html.
- **Config manifest `window.FP_COMPOUNDS['filter-pills']`:** `{ id, name, tier, css, js, strings, samplePills, motion:{tokens,presets,controls,default}, knobs:[] }`. `strings` holds the Czech fragments (Zrušit vše / Obnovit / Přidat / row labels); `samplePills` is the "Přidat" demo pool (the controller keeps its own copy as fallback). Two easings ⇒ presets bundle all three tokens (gentle/snappy/deliberate/instant).
- **Demo affordances (`.reset-row`/`.add-row`) can live in a `[data-fp-demo]` toolbar ABOVE the pill row, not just inside it (Jun 2026).** `wireGroup` resolves `demoScope = activeRow.closest('[data-fp-demo]') || activeRow`, finds reset/add there, and DELEGATES clicks on `demoScope` (the row's ×/clear-all still reach it by bubbling; the toolbar's reset/add are direct children). Keeps them OUT of the pills' flex-wrap so they don't shuffle as pills wrap. In comp-filter-pills.html they're renamed to English ("Restore"/"Add") since they're demo CHROME, not the component. Fully backward-compatible — a row with reset/add inside it (no wrapper) behaves exactly as before. They're also dropped from the arrow-key `items()` ring (demo buttons, plain Tab/Enter).
- **The curtain test bed is `[data-fp-configurable]` (Jun 2026)** so the playground Preview modal reveals it on the Inspector toggle (the contract un-hides ALL configurable regions via the global `data-fp-reveal-config`); visible standalone, hidden in the overview. Its controls (Add / Remove / Clear + the "Curtain overlay" toggle, on ONE `.ch-ctrls` row ABOVE the pills row, the toggle flowing after the buttons) drive `window.FilterPills.slideCurtain` — isolating the pill-row ⇄ table slide for slow-speed testing (esp. Shift+Pause super-slow), away from the real table's Collection contents. The Inspector's `applyMotionAll` retunes EVERY `.row[aria-label="Aktivní filtry"]`, so the harness row obeys the Inspector's motion knobs too.

## Status-timeline compound slice — reference implementation (Jun 2026)

- **First compound built on the contract above** (deep-dive: `notes/architecture.md` → "Compound architecture"). Files: `_status.css` (motion tokens), `_status.js` (`Status.render` + token-driven motion), `_status.config.js` (manifest), `comp-status.html` (Inspector + reference grid). Copy this shape to filter-pills / pagination / table next.
- **Motion tokens on `.timeline`:** `--st-dur` / `--st-stagger` / `--st-ease` / `--st-beacon` (defaults declared in `_status.css`). `playTimeline` reads them per-call via `motionOf(tl)` (getComputedStyle); the old `STAG`/`DUR`/`EASE` consts are FALLBACKS only. Beacon period rides `var(--st-beacon)`. The governor's `playbackRate` still scales the resulting WAA — tokens set the intrinsic timing, governor sets playback speed.
- **`Status.render(steps, opts)` = structure SoT.** `steps` = `[{state,label,sub?,icon?,num?}]`; state ∈ done·curr·moreinfo·pending·rejected; curr/moreinfo/rejected → `.callout` (curr/moreinfo also beacon); `opts.detail` = full | labels | minimal (verbosity); `opts.playground` stamps `data-playground`. Exposed as `window.Status = {render, play, init}`; legacy `window.playTimeline`/`initStatus` kept for existing call sites.
- **All three consumers CALL it — zero hand-typed step markup remains:** comp-status.html (reference grid from `config.demoFlows`), data-preview.html (`buildTimeline` now only SHAPES `steps` → `Status.render`), prototype React `<Timeline>` (`innerHTML = Status.render(steps,{detail})` then `initStatus(host)`; `buildRequestTimeline` emits `sub` not `date` and omits pending subs). App→steps shaping stays per-app; only STRUCTURE is shared.
- **Legacy `.timeline__*` RETIRED** from `ui-kit.css` (timeline block + extended-states block) and `dark-mode.css`. `index.html` now loads `_status.css` + `_status.js` (css BEFORE `ui-kit.css` so ui-kit's `.rstat`, same recipe, still wins). The request page (pages2.jsx `<Timeline>`) gained the richer canonical timeline (beacon/callout/connectors); verified light + dark.
- **Inspector = LOCAL panel in comp-status.html, gated `!?fp`** (suppressed in the playground iframe, like comp-buttons.html). Auto-generated from `_status.config.js`: motion preset pills + token sliders/select (from `motion.controls`) + Replay, plus the knobs (`detail` radio, `flow` select). Motion tokens apply to EVERY `.timeline` on the page; detail/flow re-render the live demo one. The component's own preview file IS the contextual per-compound Inspector — the playground-chrome generalization (one Inspector reading each section's config) is the documented future step.
- **Config manifest on `window.FP_COMPOUNDS.status`:** `{ name, tier, strings, demoFlows, motion:{tokens,presets,controls,default}, knobs }`. `strings` holds the Czech text fragments (never hardcode in markup); `demoFlows` are the Inspector/preview demo data only.

## Dataset preview (ui_kits/cos-library/data-preview.html)

- **Pill-row ⇄ table animation = the CURTAIN slide (Jun 2026).** When the pill row changes height the OPAQUE table SLIDES to its new position (it does not jump); the pill row sits in its FINAL layout the whole time and the table — painted ABOVE it and opaque — covers it, then reveals it top-down as it slides to rest. The PILLS keep their own compound box-grow/collapse (untouched). Distance is measured UPFRONT so the table travels exactly far enough.
  - **Mechanism (all in `renderPills`):** capture `h0` = row outer height BEFORE mutating → add/remove pill nodes (new ones at NATURAL size, expandIn deferred) → toggle `.afp.has-pills` (the 14px margin; a JS class, NOT `:has`, so the FINAL height is right the instant we measure — even mid clear-all when leaving pills still carry `.filter-pill` and would keep `:has` true) → measure `hf` = final outer height (leaving pills pulled out of flow, new pills natural; handles the WRAP case) → NOW play `expandIn` → `delta = hf − h0` → `slideTable(delta)`. `afp.__shiftDir` (+1 grow / −1 shrink) is recorded for the anti-fight.
  - **`slideTable` translates `.tbl-slide`, a thin OUTER wrapper — NOT `.tbl-wrap`.** `.tbl-wrap` IS Collection's `catWrap`; `Collection.refresh` (fired right after `renderPills`) cancels `catWrap.getAnimations({subtree:true})`, which would kill a slide placed on `.tbl-wrap` itself (it went `idle` instantly — see pitfalls). The wrapper is outside that subtree, so the slide survives; it also keeps the slide transform from colliding with Collection's own row/block transforms. The wrapper carries `position:relative; z-index:1` (the curtain stack: table block paints above `#afp`). WAA keyframes `translateY(start)→translateY(0)`, `fill:'backwards'` (show `start` pre-paint, no flash), duration/easing read from the pill motion tokens `--afp-dur`/`--afp-ease` so the two read as ONE gesture. **Restart-safe:** `start = curTransY(.tbl-slide) − delta` (continue from current visual offset), cancel the prior `__slide`, governor-safe `setTimeout` backstop, `reduce()` → no transform (instant jump).
  - **Curtain = pure paint order + opacity AT REST; in-flight it carries a transient ELEVATION shadow (fp26, Jun 2026).** `.tbl-wrap` is `background: var(--color-bg-surface)` (opaque) and `.tbl-slide` comes after `#afp` in the DOM with `z-index:1`, so the table simply covers the row and uncovers it as it moves. No `overflow:hidden` reveal. While the slide is in flight, `slideCurtain` toggles `fp-sliding` on the slide element — a soft UP-CAST top-edge shadow (`_filter-pills.css`, `--afp-curtain-shadow`, dark-theme override) makes the table read as a sheet passing above the pills (sticky-header idiom); it eases in/out via a `transition: box-shadow 220ms` each consumer declares on its slide element (`.tbl-slide` / `.ch-table`), and is removed on the slide's REAL finish (guarded — a superseded slide's late finalize can't strip a newer slide's class). This is NOT the retired "seam softener": that softened a STATIC seam permanently under the old height-tween model; fp26 is motion-scoped and leaves rest state untouched. Test bed has an "Edge shadow" A/B checkbox (`.ch-stage.no-eshadow`).
  - **Curtain shadow COLOR SETS (fp27 · "panel" redefined as a FEATHER, fp28, Jun 2026):** the default set "panel" uses EXACTLY the backdrop color behind the pills — NO darkening, NO alpha (user-verified; supersedes the original nested-`color-mix` derivation). Rationale: the effect is a curtain FEATHER, not elevation — the blurred edge must be invisible over the bare background (identical color) and only materialize where it overlaps the pills, fading them out ahead of the opaque table edge. **The backdrop is CONSUMER-SPECIFIC**: shared default is `var(--afp-backdrop, var(--color-bg-page))` (the canonical body backdrop in colors_and_type.css + data-preview); a consumer whose pills sit on another surface sets `--afp-backdrop` (comp-filter-pills `.ch-stage` sets `var(--color-bg-surface)` — its preview-card body paints neutral-0 ≡ surface in both themes). Both tokens are theme-remapped, so there is NO dark-mode override in `_filter-pills.css` for the panel set. "ink" keeps the fp26 elevation look (slate/black + alpha) for A/B; "none" disables. Switched via **`data-afp-shadow` on `<body>`** (panel = no attribute) — NOT an inline custom prop: the shadow token is declared on the slide element's own CSS rules, so an inherited/inline override loses to own-declaration-wins (the `--afp-backdrop` indirection is exactly the inheritable escape hatch). Schema in `_filter-pills.config.js` (`motion.curtainShadow {attr, options, default}`); the comp-filter-pills Inspector renders it as a preset-pill row (`ShadowSet`, persisted as `shadowSet`). CSS cache-bust `?v=fp28`. When testing themes by hand, set/clear `data-theme` on BOTH `documentElement` and `body` (the Inspector owns `<html>`'s).
  - **Anti-fight (restored):** `applyFilters` reads `afp.__shiftDir`; when the row GREW (table sliding DOWN) it sets the refresh entrance to `move:'none'` (fade-only) so the rows' upward restagger doesn't fight the descending table. Shrink / no-shift keeps `move:'up'`.
  - **PRESERVED:** all "Zrušit vše" correctness fixes (`ensureClearAll`, `reconcileClearAll`, the `__caHeal`/`__caHeal2` dual-pass backstop, the compound's restart-safe `expandIn`/`collapseOut` + guarded clear-all branch). **KEPT:** the animation **Inspector** (`AnimationControl` → `FP_PLAYBACK`, key `I`).
  - **History:** earlier endeavours (now gone) — a `.afp` height-tween animator (ResizeObserver, `afp.__reflow`) that slid the table by tweening the ROW's height; and a "seam softener" (anchored page-bg gradients → full-row `mask-image` → a `--seam-glow` box-shadow halo on `.tbl-wrap`). The curtain replaces both: the table moves itself, the row stays put, nothing softens a seam.
- **Pager reappear (still in force):** a hidden→shown pager panel is mounted at `opacity:0` and only faded in by `consumePagerShow()` AFTER it settles into its final pinned/docked home — else it paints at full opacity in the dock next to a still-short table, then jumps when the table grows.


- **The metadata dialog navigates the WHOLE catalogue (`active()`), NOT the filtered `state.list`.** Reason: related-document cross-links (and prev/next) must work even when the target is outside the current table filter/search — resolving against `state.list` was why "clicking a related doc did nothing" when reached via search. `modalIdx` indexes `active()`; row-open maps the filtered row index through `active().indexOf(doc)` (`openFromRow`); position shows the full-catalogue count; page-sync only nudges the table when the doc is in `state.list` (`state.list.indexOf(active()[modalIdx]) >= 0`); `relatedHTML` emits `data-relidx = active().indexOf(rd)`; history `resolveIdx`/`dpmState` use `active()`.
- **`findByDisplayCode` is prefix-aware** — related entries reference ČOS docs as `"ČOS 051638"` but `codeOf(ČOS)` is the BARE `"051638"`, so a codeOf-only match missed 413/1374 references. Match `codeOf(d) === dc OR (d.type+" "+d.code) === dc` (and normalise NBSP→space); resolution rose 961 → 1358 (the remaining 16 are genuinely-absent docs).
- **Metadata-dialog navigation is wired into BROWSER HISTORY** (so Back returns to the document a related-doc link was clicked from). Model: every `dpmState` entry carries `{dpm:true, id, idx, depth}`. `openModal` pushes (depth 1); a RELATED-doc hop pushes (depth+1) — these are the only back-stack entries. Prev/next + arrow/Home/End/PgUp/PgDn browsing `replaceState` the current entry (default in `goToDoc(i, hist)` when `hist` is omitted), so linear browsing never floods the stack. `popstate`: a `dpm` state ⇒ open/navigate to `resolveIdx(state)` (resolve by `id` first — survives index shifts — then clamped `idx`) with `hist:'none'`; a non-dpm state ⇒ `closeModal(true)`. Closing from the dialog's OWN UI (X/Esc/backdrop) calls `closeModal()` which unwinds via `history.go(-curDepth)`, guarded by `closingViaHistory` to swallow the single popstate that fires — so a later Back can't resurrect the dialog. Back from the FIRST opened doc closes the dialog (its entry sits directly above the pre-modal entry). `relatedHTML` links without `data-relidx` (related target not in the catalogue) are non-navigable: the click handler `preventDefault`s ALL `.related a` (kills the placeholder `href="#"` page-jump) and only navigates when `data-relidx` is present.
- **"Označení" shows the BARE `code` only, project-wide** — the Typ chip already encodes the document type, so prefixing it ("STANAG 1008") is redundant. `codeCell()` + the modal metabox both render `d.code`; `codeOf()` (type + code) is KEPT only for row `aria-label` + search hay. The prototype (`pages2.jsx`) already uses `<CodeCell code={doc.code}>` — correct; breadcrumbs keep the full display code.
- **Active-filter pills CONSUME the shared `_filter-pills.js` controller** (keyboard nav + WAA add/remove), not a bespoke renderer. `renderPills()` is a RECONCILER: it diffs the desired pill set (from `activeFilters()`, each carrying `fg`+`v`) against the rendered pills, adds/removes only the delta (animated via the controller's exposed `activeRow.__fp.{expandIn,collapseOut,syncClearAll}`), and skips in-flight `.is-leaving` pills so a removal animation is never wiped by a re-render. The controller emits `filterpill:remove` / `filterpill:clearall`; the host bridges those back to the source checkboxes (uncheck → `change` → re-filter). The active row needs class `row` + `aria-label="Aktivní filtry"` so the shared margin-based spacing applies; hide via `:not(:has(.filter-pill))`, never `:empty` (a persistent hidden `.clear-all` lives in it).
- **Search count uses a ghost sizer** (`.count__ghost` widest string + absolutely-positioned `.count__live`) so the result count growing 0→total never reflows the search field beside it.
- **Detail modal titlebar** = monospace record-ID box (`.dpm__id`, mirrors the Playground filename chip) on the LEFT hosting a one-shot L→R loader (`dpmLoad`, pulsed on every doc change as movement feedback) · spacer · "Dokument X z Y" pushed RIGHT next to the nav buttons. Modal pins its TOP (`align-items: flex-start` + fixed top padding) so nav buttons don't move under the cursor as content height changes. Body stacks: big `.dpm__title` (Název) → framed metabox → `.dpm__related` (shared `_related.js`, only when `d.related.length`; resolve each related `code` to its doc via `findByDisplayCode` for the type chip + validity + click-to-navigate). Keyboard hint label is "položka", not "doklad".
- **Pager = the Pagination COMPOUND with its label model** (`data-label="range"` → "Zobrazeno X–Y z N" from `data-docs` + `data-page-size`; `data-align="justify"` pushes the range label to the far end while buttons cluster left; `data-label-placement="after"`; `data-label-overflow="wrap"`; `data-responsive`). No bespoke `.pager-range` span / `updateCount`-for-range any more — `renderRows` only updates the toolbar count. **Mounted only when `state.list.length>0 && totalPages>1`** — a single-page result hides the whole control (animated out → `#pager` cleared → `:empty` collapses it); a lone "1 / 1" pager is noise. `pagerVisible` tracks the shown state and gates PinDock's `isActive` so it never pins/reserves a slot for the empty bar. The visible shell + show/hide fade-slide live on an inner `.pager-bar__panel` (a child of `#pager`), NOT on `#pager` itself — PinDock FLIP-animates + cancels animations on `#pager` (non-subtree `getAnimations`), so the panel must be a child to fade independently of a co-occurring dock-FLIP (see pitfalls).
- **Page-level doc text that carries its OWN styling opts into "Hide helper text" via the `.is-helper` marker class** (added to `_inspector.js` `HELPER_HIDE_CSS` + playground `MODAL_HELPERS_CSS`, kept in lockstep; pure `display:none!important`, no visual side-effect unlike `.help`/`.label`). In `data-preview.html` the page header (`.ph-wrap`: h1 + lede) and the "loaded dataset" notice (`#cov`) carry it; the segmented view switch (`.viewtabs`) and the category quick-tiles (`#qt-grid`) deliberately DON'T — they're functional chrome, not doc text.
- **Pager PIN/DOCK is the shared PinDock controller** (`preview/_pindock.js` + `_pindock.css`, `window.PinDock.create`) — EXTRACTED from the inline impl (Jun 2026) for reuse + SSOT. The bar (`#pager`) has TWO homes the controller MOVES it between: `#pager-dock` (in flow after the table) and `#pager-float` (a `position:fixed` host with class `pindock-float`, OUTSIDE `.results` — which is `container-type:inline-size`, so a fixed child would resolve against it, not the viewport; see pitfalls). States `docked` (scrolled to the end) · `pinned` (floating `bottom:14`, clamped so it never rises over the first table row) · `frozen` (fixed at a captured `top`). Dockable = dock slot's `bottom <= vh-6`. On a PAGE CHANGE the host calls `.freeze()` BEFORE the row swap so the non-uniform-row reflow can't move it; the next scroll `.settle()`s it (FLIP). Host passes element refs + `column`/`firstRow`/`isActive` callbacks; PinDock owns the scroll/resize listeners. Replaces the old `position:sticky` + `clampPager`.
- **Related-documents section is PARSED from relationship metadata, not just `d.related`.** The relationship data lives in string fields `adopts` / `replacedBy` / `implementedByCos` carrying editions + parentheticals (\u010cOS 130008 `adopts:"STANAG 4333 Ed. 1"`, `replacedBy:"\u010cOS 130033-1"`; \u010cOS 051608 `adopts:"ACMP-6 Ed. 2 (STANAG 4427 Ed. 2)"`). `relationRefs(d)` collects from `related[]` + those three fields; `parseRefCodes` splits each string into candidates (primary + parenthetical secondary) and `cleanCode` strips `Ed. N` / `N. vyd\u00e1n\u00ed` / `Oprava N`; resolution uses the widened `findByDisplayCode` (`codeOf` | `TYPE code` | bare `code`). Each row gets a relation tag (Souvis\u00ed / P\u0159ej\u00edm\u00e1 / Zav\u00e1d\u00ed / Nahrazeno / Zav\u00e1d\u00ed \u010cOS); de-dupe by RESOLVED doc id (\"\u010cOS 614003\" \u2261 \"614003\"); unresolvable refs render `.is-unresolved` (muted, non-navigable). Those three fields were dropped from the metabox text rows (now navigable here); `sourceDocs` stays as text. The catalogue \"M\u00e1 souvisej\u00edc\u00ed dokumenty\" filter still keys off explicit `d.related` only.
- **Detail modal animates panel HEIGHT** (top pinned → reads as grow/shrink): `swapModalBody` captures `body.offsetHeight` before the swap, measures incoming natural height, WAA-tweens between them in parallel with the slide (non-blocking). Backdrop BLUR also animates (`backdrop-filter` 0\u21922px gated on `.dpm.is-open`). The `.dpm__body` (tabindex -1 scroll target) suppresses the global base focus ring with explicit `box-shadow:none` (outline:none alone doesn't kill the box-shadow ring).
- **Detail modal body = double-buffered directional slide** (`swapModalBody`): document nav (prev/next within a context) → VERTICAL cross-slide (up=next, down=prev); context nav (descend into a related doc / Back out) → full HORIZONTAL slide (left=descend, right=ascend). The outgoing copy becomes an absolutely-positioned, `inert` ghost pinned to the current scroll offset (`top:-scrollTop`); the incoming `.dpm__content` slides in over it. Padding lives on `.dpm__content` (NOT `.dpm__body`) so the absolute ghost aligns with the in-flow copy. WAA-driven (governor-scaled), reduced-motion → instant, restart-safe (run token + drop any leftover ghost first, so holding ↑/↓ never stacks). Transition is chosen by the caller: `setIdx`→up/down, `enterRelated`→left, `goBack`/popstate→right (by nav-stack length delta; `none` when re-showing from hidden).
- **Quick-tiles run the QuickTile controller** (`window.QuickTile.init` after every `buildQuickTiles`) so the headline figure COUNTS UP on reveal — the latest component behaviour, re-fired on dataset switch.
- **The dataset switch (segmented control) uses the LARGEST DS size, `.seg.seg--lg`.**
- **Table content-change animation** is unified (every row gets the SAME keyframes/timing, no stagger, so dividers travel together), transform + gentle opacity (floor 0.4, never 0 → never invisible in a frozen capture), and DIRECTIONAL: filter/search change slides up; next page slides in from the right, prev from the left (dir passed from the pager `data-current` MutationObserver).
- **View tabs sit ABOVE the dataset notice + tiles** (they change what's shown below). The document-context block (notice + quick-tiles) COLLAPSES for non-document views (žádosti / knihovna) — see pitfalls "Animated collapse in an auto-height container" for why the resting state must be a declarative class.

## Animation playback controls (playground.html)

- **The governor PARKS freshly-caught young animations at `currentTime = 0` (Jun 2026).** Both tick loops (playground `speedTick` + `_playback.js tick`) catch new animations up to a rAF frame late; freezing (paused) or re-rating (slow-mo) them where they happen to be left a permanently displaced mid-roll frame (the Replay-while-paused "glitch") / an opening snap. On catching an anim ≤64ms old, rewind to 0 (`parkYoung`/`parkYoungAnim`). Animations the user pauses mid-flight via setPaused are older than the guard and never touched. Keep BOTH copies in sync.
- **Pause / play is a ROUND icon button (`.fp-playbtn`) placed BEFORE the speed pills** — one in the sticky toolbar (`#fp-pause`), one in the modal titlebar (`#fp-modal-pause`); a single `togglePause()` drives both and `updatePauseButtons()` keeps their `aria-pressed` + icon (`bi-pause-fill` ↔ `bi-play-fill`) + tip in sync. Paused = WARNING-tinted fill (`--gov-color-warning-50/500/700`) so a hold reads differently from the primary-tinted state/toggle pills. `updatePauseButtons()` is also called in `openSourceModal` so the modal button shows the live state on open.
- **One unified motion governor (`speedTick` rAF loop) does BOTH speed-scaling and pause** — for every animation in `motionTargets()` (FRAMES + modalFrame when open) it sets `playbackRate = synthRate()` (0.1/0.25/instant=12, else 1) AND, while `paused`, `pause()`s anything that has (re)started running. `loopWanted()` = `paused || synthRate()!==1`; the loop self-stops when neither needs it. This is why a speed change is picked up by already-running animations (the loop re-applies the rate every frame) and why animations created WHILE paused (hover effects, beacons) still get held.
- **`setPaused(true)` immediately pauses currently-running anims** (governor then catches new ones); **`setPaused(false)` only `play()`s ones in `playState==='paused'`** — never finished ones, so resume never re-fires a completed entrance.
- **Changing speed (pill OR key) RESUMES** — `applySpeed` calls `setPaused(false)` before flipping `currentSpeed`, so the new rate is what plays.
- **Keys (in `handleShortcut`, AFTER the `isTypingTarget` bail): `Space`/`Pause` toggle pause.** `Space` bails when focus is on an activatable element (`button, a[href], [role=button], summary, label`) so it stays the native activation key there; fields/selects/tweaks already bailed. `Pause` always toggles pause; **`Shift`+`Pause` toggles SUPER-SLOW instead** (see next bullet).
- **Super-slow `0.02` (×1/50) is a 6th speed, added globally (toolbar + modal pills + standalone Inspector `__FP_SPEEDS` + `_playback.js`), Jun 2026.** Leftmost/slowest pill, shown as the **hourglass icon** (icons mark the EDGE speeds — super-slow / instant / reduced — while 1/10 · 1/4 · 1× are text labels; ×1/10 moved icon→“1/10” text when super-slow took the hourglass). DELIBERATELY excluded from the `Num +` cycle (`SPEED_CYCLE` / `order` stay `['0.1','0.25','1','instant']`) and from the number keys (6–9/0) — its ONLY shortcut is **`Shift`+`Pause`**, a TOGGLE that drops to ×1/50 and pops back to the speed it came from (`prevSpeedBeforeSuper` in playground / `prevSpeed` in `_playback.js`; `reduced` maps back to `1`). Both governors implement it identically (`toggleSuperSlowUI` / `FP_PLAYBACK.toggleSuperSlow`); `synthRate` + `bigStepFrames` extended in both. `Pause` alone still toggles pause.
- **Scrub keys are active ONLY while `paused`** (else they keep normal scroll/nav): `Home`/`↑`→start · `End`/`↓`→end · `PgUp`/`PgDn`→±step · `←`/`→`→±one frame. **The frame step SCALES WITH the playback rate (`frameStepMs() = FRAME_MS × synthRate()`), so ±1 frame = one DISPLAYED frame at the CURRENT speed** — 16.67ms at 1× · 1.67ms at 1/10 · 0.33ms at 1/50 (slower playback ⇒ finer scrubbing, the whole point of slowing to inspect). Fixed Jun 2026 — was a CONSTANT 16.67ms regardless of speed (and `bigStepFrames` even scaled UP, 50× at 1/10), so a step at 1/50 jumped 50 displayed frames ("±1 frame moves by much more"). `PgUp`/`PgDn` (±step) = `BIG_FRAMES`(=10) × the frame step. BOTH governors match (playground `seekAll` + `_playback.js` `seek`). `seek('end')` lands one frame before the global end so it never trips `finish()` (which fires onfinish cleanup).
- **Scrubbing drives a SINGLE global clock (`scrubT`), NOT each animation's own currentTime** — this is the fix for the Jun 2026 status-timeline scrub-back bug. A compound entrance (request-status stepper) creates per-step animations that share one start but have DIFFERENT endTimes; stepping each animation's own currentTime + clamping to its own end DESYNCED them (pressing "back" from the finished state pulled short early animations back into their active phase — a connector visibly retracting — while long ones barely moved). `applyScrub()` sets every animation to `min(scrubT, ownEnd−0.5)` (infinite anims take the raw clock), so all stay pinned to the same global time. `scrubT` is reset to `null` on every pause-state change and lazily re-read via `liveGlobalT()` (= MAX currentTime across finite anims = the true global time, since the longest anim isn't clamped until the end).

## Design-system compiler — root entry + token hygiene (Jun 2026)

- **Deleted the orphan Figma export `assets/logos/dia-logo-ref.jsx` (Jun 2026).** It was the DS's only registered "component" (`LogoDIALogotypeBasicVersion`) but `import Size5xl from "./Size5xl/Size5xl.jsx"` never resolved (those nested files were never imported), so it rendered 7 undefined children and the compiler flagged it. Nothing referenced it — the real logo is `assets/logos/lev-jvs.svg` (fetched by brand-header). The DS now has 0 JS components (it's intentionally CSS-token + HTML-card based); don't re-add a half-pasted Figma component without its full sub-tree.

- **Root `styles.css` is the DS entry point.** It `@import`s `colors_and_type.css` (which registers the `@layer` order FIRST) then `preview/_card.css`, `preview/_dark.css`, then every other `preview/_*.css` partial (alphabetical) — so a consumer links ONE file instead of 31. Each partial is still self-contained (own `@layer tokens/theme/components`), so linking a subset works too. Add new component partials to `styles.css`. Do NOT import `ui_kits/cos-library/*.css` (that's a demo app, not DS core).
- **`/* @kind … */` annotation rule (token classifier):** a token is flagged "couldn't be classified" if ANY of its declarations is unclassifiable-by-value (a keyword/number/string, not a colour/length/etc.) AND lacks an annotation. So annotate EVERY such declaration, not just one — e.g. `--field-disabled-bd-style` (solid light + dashed dark), `--tip-origin` (per-placement), `--tsw-p` (`:root` default + `.tsw[aria-checked]`). Format: `value; /* @kind color|spacing|radius|shadow|font|other */` on the same line after the `;`, inside a SELECTOR scope (`:root`, `[data-*]`, or a component selector). The `@kind` comment must be the FIRST comment after the `;` — appending it after an existing descriptive trailing comment does NOT register (hit Jun 2026 with `--num-dur`/`--num-stag`: `value; /* desc */ /* @kind other */` stayed flagged; `value; /* @kind other */ /* desc */` cleared). Component-scoped re-values of a registered token (e.g. `.quick-tile { --num-dur: 800ms }`) count as declarations too — annotate them as well. It does NOT attach on an `@property` at-rule line — give the token a plain-`:root` home instead. `other` = intentionally uncategorised (border-style, animation progress, transform-origin).
- **Component-internal custom props are NOT design tokens — the "78 component-scoped props" advisory is a permanent, accepted false-positive.** The compiler flags ~78 props declared under component selectors (`.btn--primary{--_bg}`, `.btn--primary:hover{--_shadow}`, `.quick-tile{--qt-accent}`, `.lib-cv .cv__body{--lcv-g1}`, `.cv--cos .cv__img{--band-bg}`, …). These are the DS's documented "intermediate custom property" contract (architecture.md "Token contract per component"): a base rule reads `var(--_bg)` and variants/states SET it. Leave them scoped — do NOT:
  - hoist the COLOUR ones (`--_bg/--_fg/--_bd`, `--qt-accent`, `--band-bg`) to `:root` as `var(--gov-*)` — that triggers the documented FREEZING bug (light value captured, never flips in dark; the secondary/ghost regression).
  - try to "register" them by adding base-scope defaults — TESTED Jun 2026: adding `--_bg/--_fg/--_bd/--_shadow/--_depth` defaults to `.btn` raised the token count (295→300) and added a `--_shadow: none` classify warning but did NOT lower the 78 (the count is the DECLARATIONS under component selectors, not whether the name is registered). Reverted.
  The only way to reach 0 is to delete the intermediate-prop pattern (inline direct `background`/`box-shadow`/`color` per variant+state), which dismantles the system's core architecture, reintroduces the freezing/duplication bugs, and bloats the CSS — not worth it. Global tokens are ONLY `--color-*/--gov-*/--space-*/--fs-*/--lh-*/--ls-*/--radius-*/--shadow-*/--font-*` in `colors_and_type.css` (+ dark re-values in `_dark.css`).

## Favourite toggle (_favourite.css + _favourite.js) + "new" chip (_chips.css)

- **The ★ favourite toggle is a SHARED component `.fav` (`_favourite.css` + `_favourite.js`)** — used by the library card + library cover (replacing the old per-file `.lib-card__star`/`.lib-cv__star`). State lives on `aria-pressed` (CSS swaps `bi-star`↔`bi-star-fill` + warning ink). Registered in the playground's `FOCUS_TARGETS` (`.fav`→`is-focus`) AND `STATE_STAMP_SELECTOR` so forced hover/pressed/focus work (the old per-file star classes were absent → forced states did nothing, the reported bug). Showcased in comp-buttons.html ("Favourite" row).
- **Add-to-favourites BURST (reinforced feedback):** `_favourite.js` adds `.fav--burst` on the FAVOURITING click (restart-safe: remove → reflow → add; cleared on `favGlow` animationend). CSS plays (1) `favPop` — the filled star spins + scales into place (`rotate(-150deg) scale(.35)` → overshoot → settle); (2) `favGlow` — a gold beacon ring radiates out via expanding `box-shadow` spread (echoes the request-status active-step beacon). Global reduced-motion policy zeroes both.
- **"Nové" is a shared `.nchip`** (`_chips.css`, `@layer components`) — small filled primary-600 mono pill + sparkle glyph; label wrapped in `.nchip__label`, with a `.nchip--collapsed` icon-only short-pill variant (used on the tiniest covers; showcased in comp-chips.html "Status" row). Library card uses it inline by the date; library cover uses `.nchip.lib-cv__new` (local rule = absolute position + cqw sizing only).
- **The favourited star gets a GOLD focus ring, the empty state keeps BLUE** — `.fav[aria-pressed="true"]:focus-visible` overrides `--shadow-focus` with a gold halo (same idea as the danger button's red ring). Both shown in comp-buttons.html's Favourite row.

## Cover card extraction → `_cover.css` shared (Jun 2026)

- **The cover visual is now a SHARED single source of truth in `preview/_cover.css`** (was inline in comp-cover-card.html). It holds the cover `@layer tokens`/`@layer theme` (--cv-tint, frost vars) + the whole `@layer components { .cv … }` block (band/body/code/title-mini/foot, availability + validity overlays, AND the below-cover `.cv__meta`/`.cv__title`/`.cv__cta`). comp-cover-card.html now just `<link>`s it and keeps page-demo CSS (`.card`, `.demo-bar`, the `.cv-grid` sizing, `.annot`) + its tweaks panel inline. Edit the cover in `_cover.css`, not the page.
- **Two consumers:** `comp-cover-card.html` (catalogue "cover" view — all validity states + the validity-gradient tweak panel) and `comp-lib-cover.html` (My-library "cover" view). The FP_TWEAKS/`_ds_bundle` cover-var broadcast still works (it sets `:root{--tw-* !important}`, read by `_cover.css`).

## Library cover card (comp-lib-cover.html + _lib-cover.css)

- **v3 (Jun 2026):** ★ = shared `.fav`; "Nové" = shared `.nchip`. Meta labels are NEVER shown on covers (icon + date only). CTAs collapse to ICON-ONLY (kept on ONE line incl. ★) at `@container libcv ≤250px` — buttons needed `.btn__label` spans. The cover hover gradient lives on `.cv__body::before` (WHITE paper, NOT over the band; body children lifted `z-index:1`). Its POSITION + SIZE are FIXED — only the stop saturations animate, via `@property`-registered `--lcv-g1/--lcv-g2` percentages (0%→24%→42% on rest/hover/press) so the glow blooms IN PLACE + its edge shifts, never translating (the earlier transform-translate version "moved" the whole glow, which the user disliked).
- **Refinement pass (Jun 2026) — parity with the library card + cover motion.** Below-cover strip now carries a ★ favourite toggle (`.lib-cv__star`, same pattern as the card — `aria-pressed` swaps `bi-star`↔`bi-star-fill` + warning ink) in the actions row, and an icon meta row (`.lib-cv__added`/`.lib-cv__expire` = icon + collapsible `.lib-cv__mlabel` + `.lib-cv__mval`); labels collapse at `@container libcv (max-width:250px)`, dates stay. A **"Nové" tag** (`.lib-cv__new`, primary-600 pill, cqw-scaled) sits absolutely in the cover body, just above the foot ("above the bottom part of the cover"). Favourite/new state is per-card data.
- **Cover hover / press motion (mirrors the quick tile), scoped to `.lib-cv` so the catalogue cover-card is untouched:** the code colourises to the doc-type ink (`.cv__code → var(--band-bg)`, which lives on `.cv__img`); the `.cv__rule` grows (`width` 24→60px hover, →120px press); and a doc-type diagonal gradient grows in from the top-left on `.cv__img::after` (z-index 3, 135° axis fades before the top-right so it spares the availability chip; opacity rule-driven, transform animates the growth). Press deepens all three.

## Library cover card (comp-lib-cover.html + _lib-cover.css) — original build

- **The library "cover" view reuses the shared `.cv` cover + a library strip below it** (`_lib-cover.css`): title (2-line clamp from `.cv__title`) + `.lib-cv__actions` (Číst `btn--primary` + Stáhnout `btn--ghost`, equal-width `flex:1`) + `.lib-cv__meta-line` (added-date / expiry, amber `.lib-cv__expire--soon` warning, same `\F33A` glyph as the library card). Card carries `.cv .lib-cv`.
- **Library SIMPLIFICATION = valid covers only** — saved docs are valid, so NO validity gradient / state overlays (the `.cv--invalid/replaced/draft` machinery in `_cover.css` is simply never triggered). Same simplification spirit as the library card.
- **Bottom-alignment of the strip:** `.lib-cv{height:100%}` + grid `align-items:stretch` + `.lib-cv .cv__meta{margin-top:auto}` — covers are equal width → equal (3:4) height, so a row of covers shares one CTA + meta baseline.
- **`.lib-cv` is its OWN size container** (`container-type:inline-size; container-name:libcv`) so the below-cover strip can collapse at narrow card widths (`@container libcv (max-width:150px)` stacks actions + meta). The cover's `.cv__img` container only reaches elements INSIDE the cover — a `@container` query on `.cv__meta` descendants needs a container on `.lib-cv`, NOT on `.cv__img`.
- **Registered as playground section 29** ("Library cover card"); the table moved to 30. New shared CSS (`_cover.css`, `_lib-cover.css`) added to `_ds_manifest.json` globalCssPaths.

## Library card (comp-lib-card.html + _lib-card.css) — rebuilt on the catalogue card (Jun 2026)

- **v3 (Jun 2026):** ★ is now the shared `.fav` (forced states work); "Nové" is the shared `.nchip`. The achip now FLOATS top-right at ALL widths (not just narrow) so a long title FLOWS AROUND / UNDER it (the grid layout squeezed the title into a column with dead space under the chip — the reported bug). Grid min relaxed to `minmax(min(280px,100%),1fr)` — the old `minmax(320px,…)` floored the card at ~360–390px; the `min(…,100%)` lets a lone column shrink below the floor.
- **"Nové" collapse fix (Jun 2026):** the badge-collapse was in the `≤285` block — but that's where the meta STACKS, giving the badge its own full-width row (most room) → collapsing there was backwards + fired "too soon" (the playground's iframe chrome makes a "368px container" yield ~285px CONTENT-box, the query length). Removed it from `≤285`; the badge now stays full through the stacked range and only collapses at `@container libcard ≤210px` content (≈250px card outer) as a genuine-tiny safety.

- **Refinement pass (Jun 2026).** Availability chip is now DOM-FIRST in `.lib-card__top` (like doc-card) so in float mode the title flows AROUND it and it sits top-right (the earlier bug: achip was after the heading, so its float landed at the code line). Code-cell rebuilt to the doc-card `.num` + edition/amendment `.pill`/`.pill--am` (NO "ČOS"/"STANAG" prefix — the chip carries the type). Added a ★ favourite toggle (`.lib-card__star`, `aria-pressed` swaps `bi-star`↔`bi-star-fill`) at the end of the actions row, a **"Nové"** badge by the added date (`.lib-card__new`), and an **icon meta** row: `.lib-card__added`/`.lib-card__expire` = `<i>` + collapsible `.lib-card__mlabel` + `.lib-card__mval`. The single-card demo SLIDER was REMOVED (it capped only the first real card → "not taking all the space"). `lang="cs"` added so the hyphenation tweak engages.
- **Collapse breakpoints account for the content-box** (card is the container AND has 40px h-padding): meta labels → icon-only `@ ≤370`, achip float+icon / STANAG→NAG `@ ≤330`, action labels→icons + meta stacks `@ ≤285` (all on CONTENT width — see pitfalls).

- **`.lib-card` is REBUILT on the document-card design** — same 2-col grid heading (`.lib-card__top`: doc-type `.dc-chip` + `.lib-card__title`, `.achip` top-right via `grid-area`, mono `.lib-card__code` sub-row) and the SAME container-query collapse (each card is its own `container-name:libcard`; ≤360 → STANAG short-form + achip icon-only + achip floats; ≤280 → action labels drop to icons + meta stacks). It keeps the library detail: `.lib-card__actions` (Číst primary + Stáhnout ghost) + `.lib-card__meta` (added-date / expiry, `--soon` amber).
- **Bottom-aligned CTA + meta across a row:** `.lib-card` is `display:flex; column`; `.lib-card__bottom{margin-top:auto}` pins actions+meta to the card bottom, and the grid `align-items:stretch` makes cards equal height, so CTAs + meta line up regardless of title length. Meta values are `white-space:nowrap` (never wrap mid-phrase).
- **Hover = catalogue-card parity** — card bg → primary-50; achip adopts its `--active` background/border; the filled doc-type chip takes the dark-only inset-border active state (mirrors comp-doc-card). Surface rides `--color-bg-raised` (white→subtle), shadow `--shadow-1`.

## Metabox (comp-metabox.html + _metabox.css)

- **Metabox rows are Collection-animated (Jun 2026)** — comp-metabox.html wires the SHARED Collection compound + CollectionInspector exactly like the catalogue table card (`containerSelector=".metabox"`, `item=".metabox__row"`, WidthLimiter "Metabox width" 320–980, key `fp-metabox-width`). Markup stays static; the Inspector owns binding + the initial reveal.
- **Metadata VALUES reuse the catalogue-card visual vocabulary:** doc-type `.dc-chip` (+ descriptive name) for the type row; mono `.mb-code` (= doc-card `.num`) for the designation; `.mb-pill`/`--am` (= doc-card `.pill`) for edition/version but with EXPANDED wording (the descriptor word is muted, the figure full-ink — "2. *vydání*", "*Změna* 1"); the classification `.cchip` for the marking. LABELS (`dt`) carry a leading bootstrap-icon.
- **Chips inside values adopt their `--active` treatment on ROW hover** (mirrors the card): `.metabox__row:is(:hover,[data-state~="hover"]) .cchip…` gains the active bg/border; the doc-type chip takes the dark-only inset border. Specificity within `@layer components` beats the chip base.

## Quick tile (comp-quick-tile.html + _quick-tile.css)

- **Count-up reveal (`_quick-tile.js`, `window.QuickTile {init,reveal,play}`, Jun 2026).** On reveal each `.quick-tile__count` counts 0→target with an **easeOutCubic** value curve (fast→settling) while the number **fades + rises** in. Driven off a no-op WAA "clock" (`el.animate(...)`) whose `currentTime` the playground governor scales via `playbackRate` + freezes on pause — a hand-rolled rAF/setTimeout timer would NOT be governor-scaled (same trick as `_status.js playTimeline`). Reduced motion (`?rm`/`prefers-reduced-motion`) shows the real value immediately (WAA `element.animate()` is NOT collapsed by the global CSS reduced-motion policy — gate in JS). **Reveal model:** in-view-at-load tiles reveal SYNCHRONOUSLY (a `getBoundingClientRect` viewport test), below-the-fold tiles via IntersectionObserver — because IO never fires in the offscreen capture env. **Never pre-write "0" at init** (the real value is the safe resting state for never-revealed tiles + frozen envs); `playCount` dips to 0 only once it runs, and a 1500ms wall-clock backstop snaps to the final value if the WAA clock is still frozen at 0. Grouping reuses the separator already in the source figure (`sepOf`) so intermediate frames match. Wired into comp-quick-tile.html ONLY (the prototype rebuilds tiles on every filter — would re-animate noisily).

- **Gradient v3 (Jun 2026):** the `::before` is now a RADIAL corner glow (`radial-gradient(… at 0% 0%)`, transparent by 58% radius) instead of a linear 135° band — the linear version left a faint hard SEAM along the right edge mid-animation. Press grows larger (`scale(1.22)`, was 1.08) and deepens (`--qt-g1` 30→50%).

- **Hover/press gradient refinement (Jun 2026).** The diagonal `::before` tint is now built from `color-mix(--qt-accent …, transparent)` with state-driven saturation vars `--qt-g1`/`--qt-g2` (corner / mid) — more saturated than the old `--qt-accent-bg` token. Hover slides it in (transform); PRESS deepens it (`--qt-g1` 28→48%, `--qt-g2` 10→20%) AND grows it (`scale(1)→1.08`) for an animated push. Opacity stays rule-driven (the parking trap); only transform animates.

- **Doc-type flair:** each tile carries a `.quick-tile--cos/stanag/stanrec/ap` modifier setting `--qt-accent` (the doc ink) + `--qt-accent-bg` (the tonal `--doc-*-bg` tint, both theme-aware). The TYPE eyebrow wears `--qt-accent`.
- **Number** is BOLD (700) + a step LESS dark (`--color-fg-secondary`) so weight, not darkness, carries it; it colourises to a toned-down accent on hover (`color-mix(--qt-accent 72%, fg-secondary)`).
- **Dramatic hover:** a diagonal tonal gradient (`linear-gradient(135deg, --qt-accent-bg, transparent 62%)`) on `::before` slides in from the top-left corner (transform-only — opacity is RULE-DRIVEN, never transitioned-from-0, per the parking trap), card lifts `translateY(-2px)` + deeper shadow, border → accent mix. Children are `z-index:1` above the `::before`.
- **The "Otevřít katalog →" arrow is NEVER underlined on hover** — the link recolours to the accent and the arrow `i` nudges `translateX(3px)`; no `text-decoration`.

## Component playground (preview/playground.html) — v3 chrome + keyboard

- **`Space` pause toggle must yield to ANY Space-consuming control, by role — not just `button/a/label` (Jun 2026).** The guard before `togglePause()` was `closest('button, a[href], [role="button"], summary, label')`, which leaked `Space`→pause when focus sat on a custom `[role="combobox"]` select trigger (a `<div>` — not caught by `isTypingTarget`'s tag check either). Widened the bail selector to the full set of widgets that own Space: `select, input, textarea, [contenteditable]`, plus roles `button/checkbox/radio/switch/tab/combobox/listbox/option/menuitem(+checkbox/radio)/spinbutton/slider/textbox/searchbox`. Real checkboxes (filter group) already bailed via `isTypingTarget` (tag==='input'); the combobox was the actual leak. Probe: focus the element, dispatch a synthetic `Space` keydown, assert `#fp-pause` aria-pressed is unchanged + `defaultPrevented` is true (the component consumed it).
- **Preview modal open-state persists across reloads (`localStorage['fp-modal-open']`, Jun 2026).** `openSourceModal` stores the open file; `closeSourceModal` clears it. A boot IIFE (`restoreModal`, after `restoreActiveSection`) re-opens the modal on that file via `setTimeout(…,0)` (NOT rAF — rAF is throttled in the offscreen capture, so the modal wouldn't restore there). Keyed on the source FILENAME (stable across section reordering). Runs after the section restore so closing the modal lands the page on the matching section.
- **Section source-file links use the styled `.tip` (`data-tip`), not native `title` (Jun 2026).** The dynamically-built `a.fp-sect__src` carries `data-tip="Open preview · ⌘/Ctrl-click opens it in Claude Design"` (different from the link's visible filename, so the `redundant` suppression doesn't hide it). `_tooltip.js`'s document delegation picks up the dynamically-created link automatically.

- **"Show all" toggle (`#fp-show-all`, grid icon, next to details toggle) — broadcast, no reload (Jun 2026).** Flips every inline frame between the curated playground slice and the FULL source file with helper text hidden (same view as the modal "controls only" toggle). `applyShowAllTo(iframe)` reaches into each same-origin iframe: removes `data-fp-filtered` from body (un-hides everything `applyPlaygroundFilter` stamped `data-fp-hidden`; tracked via `data-fp-unfiltered` so it restores) AND injects `MODAL_HELPERS_CSS` (shared with the modal) into `#__fp-show-all-helpers`. **Must also pin `.card{min-height:0!important}` in that injected style** — dropping `data-fp-filtered` re-enables `_card.css`'s `.card{min-height:100%}`, which feedback-loops the autosizer. Re-measures via `iframe._fpMeasure()` (stashed in `autosize`) + a few `setTimeout` re-measures (RO is deferred while hidden). State persists in `localStorage['fp-show-all']`; `inject()` re-applies on every (re)load.
- **Active section persists across reloads (`localStorage['fp-active-section']`, Jun 2026).** `setActiveSection` stores the section's stable `data-screen-label`; a boot IIFE re-marks it + `scrollToSection(sect, true)`, re-aligning at `[200,600,1200,2000]ms` as frames autosize. Bails on first user `wheel`/`touchmove`/`pointerdown`/`keydown` so it never fights a deliberate scroll.
- **Width slider pins the topmost visible section on drag-start to stop view bounce (Jun 2026).** `pointerdown`/arrow-`keydown` on `#fp-width` → `pinWidthAnchor()`: `topmostPinnableSection()` (the section spanning the toolbar's bottom edge) is smooth-scrolled to align under the toolbar and held in `widthAnchorSect`. `applyWidth` anchors on `widthAnchorSect` (fallback: `activeSection`) and, while pinned, holds the anchor's top at the toolbar bottom (`targetTop = toolbarEl.offsetHeight`) so every width step converges there — no jumping, no fight with the drag-start smooth-scroll. Released on `pointerup`/`blur` (NOT `change` — that would re-pin+re-scroll on every keyboard arrow press).
- **Preview iframes hide their scrollbars in BOTH the inline frames (`FORCE_STATE_CSS`) and the modal (`MODAL_INJECT_CSS`).** `scrollbar-width:none` + `::-webkit-scrollbar{display:none}` on `html,body,*` — scrolling still works via wheel/keyboard, the bar is just removed (it surfaced intermittently from sub-pixel rounding on autosized frames and was distracting). The OUTER playground page keeps its scrollbar.
- **Forced state must only touch what IT added — never static demo classes (Jun 2026).** In the modal (full-file view, nothing whitelisted-out) the static demos that bake in `.field--focus` / `.field--error` / `.is-focus` were being clobbered: `clearForcedClassesIn` stripped focus classes from ALL `FOCUS_TARGETS` matches and `applyErrorTo` toggled `.field--error` on ALL fields. Now the focus pass skips elements that already have the class and marks the ones it adds (`data-fp-added-focus`); clear removes ONLY marked classes. `applyErrorTo` adds to `.field:not(.field--error)` with a `data-fp-forced-error` marker and removes only marked. The `data-state` stamp path already preserved originals (`data-fp-orig-state`/`-no-state`). Rule: any force-state mutation needs an add/remove marker so static demos survive.
- **Preview modal width control is now the same button+popover as the toolbar** (`.fp-widthbtn`/`.fp-widthpop`, ids `fp-modal-width-*`) — not an inline slider. `setModalWidth` updates the btn value + `.is-capped` + reset visibility; outside-click and Esc close it (Esc closes the popup BEFORE the modal). Mirrors the toolbar pattern exactly.
- **Preview titlebar is responsive via a CONTAINER QUERY on `.fp-modal__bar` (`container: fpbar / inline-size`), not viewport media queries** — chrome sheds based on the bar's actual width. Ladder: "Open in " drops @1140 → CD label drops (icon-only) @980 → speed pills hidden @720 → CD dropped entirely @600 (least essential) → dividers @560 → filename code rect @470 → width control @410. The CD link carries `data-tip="Open in Claude Design"` (shows only when collapsed to icon, per the tooltip redundancy rule) and is sized to 28px height like the close button.
- **Section-number pill nav tooltip lives ONLY on the active section + the modal pill (Jun 2026).** Both `.fp-sect__num` and `.fp-modal__num` are `user-select:none`. The tip is NOT stamped on all 29 resting pills (noise) — `setActiveSection` adds `data-tip="Previous / next section"` + `data-tip-key` to the newly-active pill and removes them from the rest; the modal pill carries it permanently in markup. `data-tip-key` = `, / . | Ctrl PgUp / Ctrl PgDn`. `_tooltip.js` (parent doc) renders it on these non-focusable spans.
- **`data-tip-key` shortcut-format conventions (extended Jun 2026, see `_tooltip.js`):** `|` separates ALTERNATIVE shortcut sets → rendered **"or"** (each set wraps as a unit, so "or" stays at the end of line 1 and the next set drops to line 2); a `/` token BETWEEN two keys is an ACTION separator (prev / next) → rendered muted **"/"** (an isolated `/` with no neighbour is the slash KEY → keycap, so the details-toggle `"/ | ["` still works); consecutive keys in one set are a chord → joined by a muted **"+"** (`Ctrl + Shift + P`). Existing space-separated chords (`Ctrl Shift P`) now auto-render with `+`. CSS: `.tip__altgrp` (nowrap group), `.tip__plus`, `.tip__actsep` in `_tooltip.css`.
- **`openSourceModal` syncs `activeSection` to the shown file** (`SECTIONS.find(s => s._srcFile === file)`) — direct source-link clicks were leaving it stale, so modalStep stepped from the wrong section and closeSourceModal scrolled to the wrong place. `closeSourceModal` then `scrollToSection(activeSection, true)` so the page lands on whatever section you navigated to inside the modal.
- **Reduced-motion toggle while the modal is open reloads ONLY the modal; background frames are lazy-reconciled.** `applySpeed` crossing the reduced boundary with the modal open sets `framesDirty=true` and reloads just `modalFrame` (not all 25 iframes the user can't see). `closeSourceModal` reconciles: if the live reduced state matches `framesLoadedReduced` (toggled on then back off), it skips the reload entirely; otherwise `reloadFrames()` once. `framesLoadedReduced` tracks the bg frames' current `?rm` state.
- **"Controls only" toggle (`#fp-modal-helpers`) hides the full documentation vocabulary** via `MODAL_HELPERS_CSS` (display:none unless noted): `.label` (broad — covers the card title AND the notices `.col>.label` section header), `.label-sm`, `.annot`, `.note`/`.notes`, `.help`, `.lbl`, `.see-also`, `.kbd-hint`, `.col>h4`/`h3` + `.card>h4`/`h3` (section headings), `.card label:not([class])` (bare field labels above a control — `.sw`/`.checkbox`/`.radio` keep their class and stay), `.sub-label` + `.collapsed-label` (chip row / collapsed-active labels), `.counter` (textarea char count), `.card hr` (dividers), `.smx` (the whole switch state-matrix — a doc-only comparison grid). `.demo-cluster` is FLATTENED (transparent bg/no padding), not hidden, since it wraps the theme switches. Re-applied every injectModal; state persists in `modalHelpersHidden`.
- **Numpad operator shortcuts (Jun 2026):** `Num +` cycles the SYNTHETIC speed modes (`SPEED_CYCLE = ['0.1','0.25','1','instant']`, skips `reduced`; `cycleSpeed()` mirrors `cycleState()`), `Num −` toggles theme, `Num *` opens/closes the active-frame preview. All read `e.code` (`NumpadAdd`/`NumpadSubtract`/`NumpadMultiply`), after the `NumpadDecimal` state-cycle in `handleShortcut`.
- **Preview modal titlebar = mini playground header (Jun 2026 redesign).** Left: a filled-primary **section-number pill** (`.fp-modal__num`, mirrors the active-section pill) + a **title block** — bigger `.fp-modal__name` (section name, ellipsises) and a greyer monospace **"code" rectangle** (`.fp-modal__code`) holding the filename. Right group: width slider, an **icon-only helper-visibility toggle** (`#fp-modal-helpers`, `.fp-iconbtn` — injects `__fp-modal-helpers` style hiding `.card>.label,.label-sm,.annot,.note,.help,.eyebrow,.eb,.label--row` so only controls remain for clean responsive testing; state persists in `modalHelpersHidden`, re-applied every `injectModal`), `.fp-div`, the SAME `.fp-seg--speed` + `.fp-seg--theme` icon pills as the toolbar (`data-fp-modal-speed`/`data-fp-modal-theme`, synced both ways via `applySpeed`/`applyTheme`; speed drives the toolbar group through `syncToolbarInput` to reuse reload/announce), `.fp-div`, then **"Open in Claude Design" moved next to the close button** and responsively collapsed (`.fp-modal__cd-pre` "Open in " drops <1180px, `.fp-modal__cd-label` drops → icon-only <1000px). The old `.fp-modal__file` mono title, `.fp-modal__seg` Light/Dark text seg and the `bi-filetype-html` icon are gone.
- **Modal iframe loader bar** (`.fp-modal__loader` along the bottom edge of the `.fp-modal__code` rect): `setModalLoading(true)` in `openSourceModal`/the reduced-reload path → indeterminate gradient sweep (`@keyframes fpModalLoad`), cleared by `setModalLoading(false)` in `injectModal`'s reveal rAF. Lives in the PARENT doc so the playground speed scale doesn't touch it. Not shown on same-file re-sync (no reload).
- **Reduced-motion now propagates into the open modal.** `openSourceModal` builds the src via `modalSrcFor(file)` (appends `&rm=1` in reduced mode, mirroring `frameSrc`), `injectModal` calls `promoteReducedCSS(doc,true)` for reduced (was instant-only → modal kept playing at 1× while inline frames sat still), and `applySpeed` reloads the open modal (`modalFrame.src = modalSrcFor(modalFile)`, `modalFile` tracks the current file) when crossing the reduced boundary.
- **Modal + cheat-sheet close animations are the SLOW direction (Jun 2026).** Asymmetric transitions: base (closing) rule is long expo-out (`cubic-bezier(0.22,1,0.36,1)`, panel/card 0.46s/0.4s transform + ~0.34s opacity, backdrop 0.42s/0.36s, visibility delay matches), the `[data-open]` (opening) rule overrides to snappy (~0.24s). The keys card now fades opacity on close too (was transform-only → snapped). **`closeSourceModal`'s `about:blank` timeout was bumped 240→520ms** so the src isn't blanked mid-fade (would flash an empty white iframe while the panel is still on screen).
- **Cheat-sheet content reformat (Jun 2026).** Single-key groups (force-state, theme/motion) are a 2-column `.fp-keys__matrix` of `.fp-keys__cell` (keys LEFT, label nowrap) — alternatives read "1 or D" via `.fp-keys__sep`. Numpad keys are a SINGLE keycap (`<kbd>Num +</kbd>`, never split). Navigation + Panels rows (`.fp-keys__row`, `grid-template-columns: 152px 1fr`) put keys LEFT too; multi-key chords use `.fp-keys__combo` with a small muted `.fp-keys__plus` "+" between caps (Ctrl + Shift + P); a single key + chord alternative read ", or Ctrl + PgUp". Rows/cells highlight on hover; keycaps are 10px/1.5px 5px.
- **Toolbar title is a 30px-high flex pill** (`.fp-navtrigger`, `height:30px;padding:0 8px`, no negative margins) so it vertically centers with the other ~28px controls under the toolbar's `align-items:center`.
- **`Ctrl/⌘ + Shift + P` opens & focuses the section-nav** (command-palette feel) — handled in the modifier branch of `handleShortcut` (before the `ctrl||meta return`), calls `openNav()` which focuses the search.
- **Numpad `.` cycles through ALL states** (`default → hover → pressed → focus → disabled → …`, order in `STATE_CYCLE`); `cycleState()` advances from `currentState`. The standard `.` (and `,`) stay on prev/next section. Catch `code === 'NumpadDecimal'` BEFORE the `Period || k === '.'` next-section branch — with NumLock ON the numpad decimal reports `key === '.'` and would otherwise navigate sections; `code` is layout/NumLock-stable so it's the reliable discriminator.
- **Compact-header pills are circular:** theme is icon-only 24px circular sun/moon pills (`.fp-seg--theme`, `bi-brightness-high-fill`/`bi-moon-stars-fill`, no "Theme" eyebrow); collapsed state labels → 24px letter circles; error → 26px circle; speed pills narrowed (`padding:4px 4px;min-width:21px`) and the slowest (1/10) shows a `bi-hourglass-split` icon instead of the fraction. The theme↔speed `.fp-div` divider was removed. **Responsive collapse is JS-measurement-driven (`reflowToolbar`), NOT `@media` breakpoints** — it sheds in priority order (`fp-c-tight` → `counter` → `eyebrows` → `err` → `state` → `title`) until the header fits one line, recomputed from scratch each resize so header height is MONOTONIC in width (widening never transiently grows it taller — the bug breakpoints caused). Wrapping only as a last resort.
- **Toolbar segment pills own their hover + focus ring on the LABEL, not the hidden input.** Hidden radios/checkboxes (`opacity:0;pointer-events:none`) can't surface `:focus-visible`/hover, so `.fp-seg label:has(input:focus-visible)` and `.fp-check:has(input:focus-visible)` carry the system `--shadow-focus` ring, and hover uses a neutral fill (`--gov-color-neutral-100`) distinct from the primary/error-tinted checked state.
- **Theme is a 2-state TOGGLE:** clicking the already-active sun/moon flips to the other (a label `click` listener comparing `input.value === currentTheme` → `preventDefault()` + `toggleThemeUI()`); applies to both the toolbar and modal-bar theme segments.
- **Shortcut controls carry `data-tip-key`** (alternatives split by `|`, chord parts by space, e.g. `"D | 1"`, `"Ctrl Shift P"`) → `_tooltip.js` renders a second "SHORTCUT" line of keycap `<kbd>`s (styled in `_tooltip.css`, tinted off `currentColor` so it reads on both the light and inverted-dark tip). Plain `data-tip` (no key) is unchanged.
- **Transient action announcer** (`#fp-announce`, bottom-center pill) surfaces toolbar/keyboard actions ("Forced state: Hover", "Default state", "Theme: Dark", "Speed: …", "Error variant on"). Fired ONLY from the toolbar `change` listeners (so a keyboard shortcut, which dispatches a synthetic change, also announces) — NEVER from the boot `applyTheme`/`applyState` (those don't dispatch change), so no announce on load. The pill INVERTS with theme (bg=`--color-fg-default`), so the highlighted value uses `primary-200` in light but a pinned `#2f6bb0` in dark (the light-blue token is too faint on the pale dark-mode pill).
- **Active-section number pill never shifts content:** base `.fp-sect__num` matches the active pill's box (`inline-block` + `2px 7px` padding + radius, transparent bg) so `.is-active` only swaps colours, not geometry.
- **Section-details toggle is animated WITHOUT collapsing the header in-place** (squeezing it into a 0px column reflows the header text AND inflates short sections, because an opacity:0 header still reserves height). `applyDetails(hidden, animate)` sequences classes: `fp-details-fading` (header opacity/translate out, still in-flow at full 220px → no squish) → then `fp-no-details` (header `display:none` + column WIDTH animates `220px↔0px`, frame grows with the header already out of flow, so the row settles to the frame's own height). Show reverses with `fp-details-collapsing` holding the header out of flow through the grow. Reduced-motion (or `animate:false` at boot) → instant. **Grid children are pinned to their tracks** (`.fp-sect__h{grid-column:1}`, `.fp-sect__body{grid-column:2}`) — without this the `display:none` header lets the body auto-place into the shrinking track-1 and the frame collapses to 0 instead of expanding (see pitfalls).
- **01 Links is a REAL iframe (`comp-links.html`), like every other section** — converted from inline `.fp-prose` so it gets the standard `.is-active .fp-frame` ring (a div's inset shadow rendered differently from an iframe's) and click-anywhere active-section marking (the inject `pointerdown`/`focusin` handlers). Inline `<a>` forced-disabled rides a `[data-fp-disable]` opt-in (playground disabled pass gives it aria-disabled + tabindex -1, same as comboboxes); `.prose a` is in `STATE_STAMP_SELECTOR` + `FOCUS_TARGETS` for forced hover/pressed/focus. Speed is handled by the normal `FRAMES` loop now (the old `linksSection`/`setRateOn` same-document special-casing was removed).
- **Modal close fades the panel (opacity + transform), not transform-only** (transform-only snapped off at `visibility:hidden`). **Modal focus is trapped/region-navigable:** Tab wraps within the previewed component (last↔first), F6 hops focus between the iframe content and the titlebar close button (`e.target.ownerDocument` decides the region), and Esc closes an open LIVE combo dropdown FIRST (capture-phase pre-check stashes `doc.__fpEscOverlay` before the combo's target-phase handler clears `data-open`; matches only `.combo-list[data-open="true"]`, NOT the static `aria-expanded` docs demo) and only closes the modal when nothing is open. All modal-only (inline section frames don't trap).
- **Section navigation lands the section top on the toolbar bottom border** (`scrollToSection` offset = `toolbarEl.offsetHeight`, no `+14` gap) so the two 1px borders merge into one line.

- **Title is a section-nav disclosure** (`#fp-nav-trigger` → `.fp-nav` popover): searchable list of all 29 sections (num + name), type-to-filter, ↑/↓ + Enter, click → `window.scrollTo({behavior:'smooth'})` offset by live toolbar height. NEVER `scrollIntoView` (house rule) — internal list scroll uses a manual `ensureNavVisible()` too.
- **Compact header:** state segment ships full label + single-letter abbreviation (D/H/P/F/X = the shortcut letters); the JS compaction (`body.fp-c-state`) swaps to letters (data-tip carries the name). Width control is a button (`#fp-width-btn` "Full"/"NNNpx", `.is-capped` when capped) opening a non-modal `.fp-widthpop` with the slider+reset. Speed pill already compact.
- **Keyboard contract (handler attached to playground doc + every iframe doc + the modal doc):** state 1-5 / D H P F X, speed 6-0, error E, theme `\`, details `/` or `[`, cheat sheet `?`, Tweaks `` ` ``(backtick) or `T`, open/close active-frame preview `]`, prev/next section `,`/`.` or Ctrl+PgUp/PgDn. **Positional keys read `e.code`** (Digit/Numpad/Bracket/Slash/Backslash/Backquote) so a Czech (or any) layout hits the SAME physical key; letter mnemonics read `e.key`. **Re-pressing the active state/speed toggles back** to default/1× (`pickState`/`pickSpeed`).
- **Tweaks `T`/backtick are routed through `window.__fpTweaksToggle`** (the panel registers it via `registerToggle`; playground passes `disableKeyToggle` to suppress the panel's own `T` handler) — so the toggle fires even while focus is inside an iframe/the modal. Opening via keyboard moves focus INTO the panel. **The toggle check sits BEFORE the `isTypingTarget` bail in `handleShortcut`** (guarded by `isTextEntry`, which excludes radio/range/checkbox/button) so the SAME key closes the panel again even once focus has moved into it (the panel counts as a typing target); still suppressed in a real text field / while the cheat sheet is up.
- **Active section** = the section whose iframe is focused or last pointer/focus-touched (tracked via document `focusin` + per-iframe `pointerdown`/`focusin`). Styled: primary inset ring on `.fp-frame`, inverted number pill + primary title in the left column. Changing the width cap re-anchors the active section's scroll position (sections above can change height a lot).
- **Source-link / `]` open the modal as a FAST full-file preview loaded with `?fp=1`** — the source suppresses its own Tweaks panel + floating theme toggle and applies saved CSS; the playground then drives theme/state/motion/width AND broadcasts the combined Tweaks into it (`FP_TWEAKS.applyAll` also targets `#fp-modal-frame` when open). Modal bar has a global theme segment (synced both ways with the toolbar via `applyTheme`) left of the close button, and its own width slider (caps+centers the frame; `.demo-bar` per-file sliders hidden via `MODAL_INJECT_CSS`, which also paints a theme-aware dark canvas + hides `#__theme-toggle`). All shortcuts work while the modal is open; `,`/`.`/Ctrl+PgUp/Dn step the previewed file; `]` closes it.
- **Modal opens with the iframe `visibility:hidden` until `injectModal` applies the theme** (revealed on a double-rAF) so a freshly-loaded source never flashes its white canvas before dark theme lands. **`closeSourceModal` must reset `modalFrame.style.visibility=''`** — the inline `visibility:visible` set on reveal OVERRIDES the parent modal's `visibility:hidden` on close, leaving a blank solid iframe hanging over the page.
- **The whole playground scroll is LOCKED while the modal is open** — `fp-modal-open` on `<html>`+`<body>` (`overflow:hidden !important`), added in `openSourceModal`, removed in `closeSourceModal`. Blocks keyboard AND mouse-wheel scrolling of the page behind the modal regardless of where focus sits (the previewed file's own iframe still scrolls). This replaced an earlier keyboard-only guard that only worked when focus was on the modal chrome.

## Combined Tweaks panel (playground-tweaks.jsx)

- **Defaults: Type chip `filled` · Table density `compact` · Rows `zebra`** (`TWEAK_DEFAULTS`). Changed Jun 2026 from outlined/comfortable/lines.
- **"General" section** (top) owns Hyphenation + Type chip, applied SYSTEM-WIDE, not per-component: hyphens hits all title surfaces (`.cv__title`,`.cv__title-mini`,`.dc .heading`,`.lib-card__title`,`.tbl .col-title-cell .title-text`); chip treatment hits `:is(.dc,.lib-card,.filter-item,.filter-group,.related,.metabox) .dc-chip` + a table-density copy `.tbl .dc-chip`. Reusable `fpBuildChipCSS(style,scope,fs)`: letter/dot uses the real `.dc-chip__dot` where present AND a `currentColor` `::before` fallback for chips that ship none (lib-card, filter-group, metabox) — so "Dot" works everywhere without DOM mutation. **Adding a NEW chip-bearing component to the global Type-chip control = add its root selector to fpBuildGeneralCSS's `:is(...)` scope** (this is how the metabox chip, section 23, was wired Jun 2026 — it was previously absent so the chip ignored the tweak).
- **Table tweaks also drive the Metabox (section 23):** density → `.metabox__row` padding + `dt`/`dd` size (FP_METABOX_DENSITY tiers); rows zebra/lines/none → `.metabox__row` even-tint / border-top. (The chip treatment is in the GENERAL builder, not the table one — see above.)
- **A component only obeys the button-style tweak (3D border / fill / shadow) if it consumes the CANONICAL `_buttons.css`** — the broadcast sets `--btn-primary-*`/`--btn-danger-*` TOKENS in `@layer tweaks`, so only filled primary/danger buttons that link `_buttons.css` change. A component with a LOCAL re-implemented `.btn` (its own unlayered rules) silently ignores it (root cause of the "header login button doesn't respond" report, section 16). Fix = delete the local `.btn` visual rules, link `_buttons.css`, and use `.btn btn--primary` (`btn--sm` matches a 6px 12px/13px CTA). Secondary/ghost are NEVER affected by the tweak (no `--btn-secondary-*` in the broadcast) — so a button that MUST respond has to be primary/danger.

## Tweaks panel shell (tweaks-panel.jsx) — additions

- **`<kbd>` inside the panel is styled BY the panel (`.twk-panel kbd` in `__TWEAKS_STYLE`): fixed light chip, never the page's theme-aware `kbd` tokens.** The bare element rule in `_card.css` rides theme tokens, so in dark mode its remap leaked into the fixed-styling glass panel and rendered unreadable caps (the Playback `Space` cap bug). Same fix applied to `.fp-anim-hint kbd` (_inspector-controls.jsx) — any panel-internal rule must set color + background + border explicitly, not rely on the page rule.
- **Comp-page demo buttons live on their OWN row (`.ex-actions`) BELOW the `.label-sm` caption, never in the same flex row** — a long caption pushes buttons out of view at modal width (the comp-quick-tile bug). Captions stay SHORT; the long explainer goes in a `.help` box at the card bottom (helper-strip aware). Applied: comp-quick-tile, comp-filter-group, comp-num (`.nx-actions` now a row). Playback hint shortened to “Pause (Space or titlebar) to scrub frame-by-frame.”

- **The panel is ALWAYS a light glass surface — its controls own a fixed LIGHT focus ring** (`.twk-panel :is(button,select,input,[tabindex]):focus-visible`), kept tight (3.5px) + `position:relative;z-index:2` so it paints above neighbours. Don't let it inherit the host's theme-aware `--shadow-focus` (reads wrong on the pale panel in dark mode).
- **Collapsible `TweakSection` bodies must reveal `overflow:visible` once OPEN, or they clip focus rings.** `.twk-sect-collapse>.twk-sect-body` keeps `overflow:hidden` (needed for the `0fr↔1fr` grid-rows collapse animation) — which clipped the rings of the first/last controls + preset pills in each section (the Jun 2026 report). Fix: `[data-open="1"]>.twk-sect-body{overflow:visible;transition:overflow 0s .26s}` reveals overflow only AFTER the open animation ends; the base (collapsing) rule has no delay so it clips immediately and the height animation stays clean.
- **Props `registerToggle(fn)` + `disableKeyToggle`** let a parent own the open/close keyboard contract (playground). `keyToggle` opens locally WITHOUT re-posting `__edit_mode_available` (reopen-blink pitfall) and sets `focusOnOpenRef` so a keyboard-open moves focus into the panel.


## CSS architecture (layers + tokens + state)

- **Full target architecture is `notes/architecture.md` — read it before refactoring any component.** One-line summary of the binding rules below.
- **Cascade layer order is `@layer reset, tokens, theme, base, components, app, tweaks;`**, registered once at the TOP of `colors_and_type.css`. Later layers win regardless of specificity; UNLAYERED rules beat all layered rules — so the statement alone is inert and components migrate INTO layers one at a time.
- **State lives in the component; theme lives in token values — never both.** A migrated component is written ONCE, theme-agnostic, every colour/shadow via a token. Dark mode RE-VALUES tokens only; never write `[data-theme="dark"] .component-selector` again (exception: surfaces that must not flip). For flip-sensitive colours reference the gov/semantic scale token DIRECTLY in the component rule (so it resolves on the element) — do NOT pre-store `var(--gov-*)` in a `:root` token, it FREEZES to the light value (see pitfalls "Custom-property freezing"). Literals that differ by theme get a `tokens` default + a `theme` literal override.
- **One file per migrated component** carries its own `@layer tokens` (light), `@layer theme` (dark remap), `@layer components` (rules). NO `_dark.css` entry. `_buttons.css` is the reference.
- **Unified state vocabulary — ONE approach everywhere.** A single `data-state` attribute (space-separated, matched with `~=`) bridges each forced state to its live pseudo in the SAME selector group: `:hover/[data-state~="hover"]`, `:active/[~="pressed"]`, `:focus-visible/[~="focus"]`, `:disabled/[disabled]/[~="disabled"]`, selection via `[aria-pressed="true"]/[aria-selected="true"]/[~="selected"]`. Disabled prefers the native pseudo; the token form is only for non-form elements. RETIRE bespoke `.btn--focus`/`.field--focus`/`.is-focus`/`.btn--disabled` classes as each component migrates. Composed/organism components use the SAME vocabulary (own the state on the element it belongs to; `:has()` to reflect a child) — never a new ad-hoc class.
- **`--color-bg-raised` is the shared "raised control/panel surface" token** (white in `@layer tokens`, `--color-bg-subtle` in `@layer theme`). Use it for any preview control/panel that must read as a DISTINCT block resting on the page/card and sink to a well in dark: `.filter-pill`, `.view-switch` (container), `.pag button` (NOT `.pag-compact`, which already flips via the `neutral-0` token), `.related a`, `.quick-tile`, `.lib-card`, `.filter-group`, `.notice` (base; variant tints override). Deliberately SEPARATE from `--field-bg` despite identical current values — intents differ (raised panel vs. input well), so they must be independently themeable. Convert EVERY consumer (preview card + prototype `ui-kit.css`) when adopting it, then the `_dark.css` dark-surface blanket entry can be deleted. checkbox/radio pressed reuses `--field-pressed-bg` (it IS a form control) — its bespoke dark `:active` override is gone.

- **Migration status:** buttons ✅, cover-card ✅, doc-card ✅, table ✅ (`@layer components`-wrapped Jun 2026), metabox ✅, cta-panel ✅, `_chips.css` ✅, **fields** ✅ (`.field` fully `@layer components`-wrapped; `.input`/`.tb-select` token-migrated, unlayered), **switches** ✅ (`.sw` fully layered; `.tsw`/`.tsw3` token-migrated across comp-switch + ui-kit + brand-header), **global focus ring** ✅ (`@layer base`), **core dark token remap** ✅ (`@layer theme`), **`.bi` glyph reset** ✅ (`@layer reset`), **raised controls/panels** ✅ (shared `--color-bg-raised` token), checkbox/radio ✅. Buttons disabled `!important` DROPPED. Pending: only the `_dark.css` per-card rescues for not-yet-migrated brand/type/colour cards. doc-card is now `@layer components`-wrapped too (Jun 2026), so every component that overrides `_chips.css` is layered.

- **`.input` / `.tb-select` reuse the field tokens; `.tsw`/`.tsw3` have their own `--tsw-*` contract.** `.input` (prototype) and `.tb-select` (prototype + comp-select) are field "wells" — they ride `--field-bg` (rest) and `--field-pressed-bg` (toolbar-select pressed); no new tokens. The theme switch needs `--tsw-bg`/`--tsw-bd` (track) + `--tsw-puck-light`/`--tsw-puck-dark` (the flipping puck+icon colours) + `--tsw-thumb-shadow`, light in `colors_and_type.css` `@layer tokens`, dark in `_dark.css` `@layer theme`. ALL `.tsw` consumers were converted in lockstep (comp-switch, ui-kit general `.tsw`, the always-dark `.bar .tsw` thumb override, brand-header) before deleting the blanket — the always-dark bar thumbs MUST use `--tsw-puck-dark` (not raw `neutral-900`, which inverts to near-white in dark and goes invisible on the white puck). When migrating any shared component, convert EVERY consumer (incl. the prototype's ui-kit.css copies + always-dark `.bar` overrides) in the same pass, or removing the blanket regresses the un-converted ones.

- **The core dark token remap lives in `@layer theme`** (`_dark.css`): the `body[data-theme="dark"], html[data-theme="dark"] body { --tokens }` block only. The SELECTOR-property dark rules below it (header/footer/chips/tooltip/pagination/swatches/per-card rescues) STAY UNLAYERED — they override unlayered per-card `<style>` rules, and layering them would make them lose. Safe to layer the token block because token VALUES resolve by inheritance proximity (body's dark value beats html's light default), independent of layer.

- **The global focus ring lives in `@layer base`** (`colors_and_type.css`): `:where(button,[role=button],input,select,textarea,[tabindex]):focus-visible` + `a:focus-visible`. Was unlayered; moving it to base is what UNBLOCKED layering the field family AND fixed buttons to show their own tokenised ring (danger focus is now RED, not the generic blue). Rule going forward: a component that owns a focus ring puts it in `@layer components` — it beats this base default cleanly; not-yet-migrated UNLAYERED controls keep winning over base, so they're untouched.

- **Fields (`.field` input-box) are FULLY `@layer components`-wrapped on a SHARED token contract.** The `.field` box is duplicated per card (comp-inputs/search/select/textarea each redefine it — there is NO shared `_field.css`), but the theme-flipping surfaces are centralised as tokens: light defaults in `colors_and_type.css` `@layer tokens` (`--field-bg` white→well, `--field-disabled-bg` neutral-50→bg-page, `--field-disabled-bd-style` solid→dashed, `--field-pressed-bg` primary-100→translucent-white), dark re-value in `_dark.css` `@layer theme`. Each card wraps its `.field*` rules in `@layer components`; the inner `.field input:focus{box-shadow:none}` suppression now beats the `@layer base` ring on the native control, and the wrapper ring (`.field:focus-within`/`--focus`) wins too. The entire `body[data-theme="dark"] .field*` blanket was deleted from `_dark.css`. UNLAYERED-and-deferred: per-card scaffold, the search `.clear`/`.btn`/`.suggest`, and `.input`/`.tb-select`/`.combo-*` (prototype-shared, still on the `_dark.css` blanket). comp-select's `.field` is a `[tabindex]` trigger — its plain `:focus-visible` ring comes from `@layer base`, its open/forced ring from `@layer components`.

- **Boolean switch `.sw` is fully self-contained + layered** (comp-switch.html `@layer tokens`/`theme`/`components`), the reference for a clean field-family wrap. Tokens: `--sw-track-off`/`-off-hover`/`-disabled` (one neutral step lighter in dark), `--sw-thumb` (white→#ebeef3) + `--sw-thumb-shadow`. The `on` track + `on-hover` ride `primary-500`/`-600` directly (flip on their own). No `.sw` entry in `_dark.css`. The theme switch `.tsw`/`.tsw3` is DEFERRED (shared with the prototype's ui-kit.css which depends on the `_dark.css` `.tsw` blanket — migrate both in lockstep, same as `.tb-select`).
- **comp-table.html is now `@layer components`-wrapped (Jun 2026).** Unblocked by moving the global `.bi` reset into `@layer reset`: the table's `.tbl thead th .h-icon { display:none }` (`.h-icon` is a `.bi`) now wins from `@layer components` over `@layer reset` (previously, while `.bi` was unlayered, wrapping made `.h-icon` lose → header icons leaked at wide width → reverted). The full wrap is also safe because `_chips.css` is itself `@layer components`, so the table's chip overrides (`.tbl tbody tr:hover .vchip/.achip`, `.tbl .dc-chip` font-size, the ≤640/≤460 container-query chip tweaks) beat the layered chip base by SPECIFICITY within the shared layer. Scaffold `.card`/`.demo-bar` overrides stay UNLAYERED (they override `_card.css`'s unlayered base). dc-chip dark stays in `_dark.css` (box-shadow-only table rules coexist regardless of layer).
- **A component that OVERRIDES a shared atom can only be `@layer`-wrapped once that atom is ALSO layered** — doc-card and the table restyle `_chips.css` `.vchip/.achip/.cchip` on hover. While `_chips.css` was unlayered, wrapping them would have made those overrides lose to the unlayered chip base. Both kept the token/theme/state wins (those don't need layering) and stayed unlayered UNTIL `_chips.css` moved into `@layer components` — then both were fully wrapped (Jun 2026): within the shared layer the override wins by SPECIFICITY (`.dc:hover .vchip.v-valid` / `.tbl tbody tr:hover .vchip.v-valid` (0,4,0)+ beat `.v-valid` (0,1,0)). Cover-card had no such dependency, so it was layered fully from the start.
- **Organism migration pattern (cover-card):** migrate IN PLACE — restructure the card's own `<style>` into `@layer tokens`/`theme`/`components` and delete its `_dark.css` block. Layer ONLY the component's own rules; keep shared/scaffold selectors it overrides (`.card`, `.demo-bar`, `.annot`) UNLAYERED, or they LOSE to `_card.css`'s unlayered base (unlayered beats layered). Theme-specific surfaces become co-located token revalues (`--cv-tint` 4%→13%, `--cv-frost-*` light→dark); anything that's "white" becomes `var(--gov-color-neutral-0)` so it flips on the element.
- **Focus playground bridges both channels during migration** — `applyStateTo('focus')` stamps `data-state="focus"` AND adds the legacy `FOCUS_TARGETS` classes, so migrated (buttons) and un-migrated components both light up. Drop a component from `FOCUS_TARGETS` once it reads `[data-state~="focus"]`.

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

- **The prototype references the canonical design-system files DIRECTLY — no mirrors.** `index.html` links `../../colors_and_type.css` (tokens), `../../preview/_chips.css` (chips), `../../preview/_tooltip.css` (the `.tip` visuals), `../../preview/_dark.css` (dark remap + shared-component dark rules), `../../preview/_tooltip.js` (controller) and `../../preview/tweaks-panel.jsx` (Tweaks shell). The old per-folder copies (`colors_and_type.css`, `tooltip.js`, `tweaks-panel.jsx`) were DELETED. Font `url()`s resolve relative to the stylesheet's own location (root), so referencing the root token file loads `/fonts/*` correctly. `data-preview.html` references the same shared CSS.
- **`.tip` visuals were extracted to `preview/_tooltip.css`** and `@import`ed at the top of `_card.css` (so preview cards keep it) — that's the single source the prototype links too. Don't re-inline `.tip` into `_card.css` or the prototype.
- **Two CSS layers only:** `ui-kit.css` = prototype-specific component CSS (header/footer/pages/ported controls — `.pag`, `.seg`, `.filter-pill`, `.filter-item`, `.tsw`, `.tb-select`/`.combo-list`, region-nav, `.rstat`); `dark-mode.css` = THIN app-specific dark layer (page canvas, `.card`, `.hero`, `.doc-card`, cover-card, timeline dots, toast) — every shared component's dark rule comes from `_dark.css`. When adding a prototype surface with a raw `white`/literal, add its dark rule to `dark-mode.css`, NOT `_dark.css`.
- **Interactive controls that bind app state are React PORTS, not shared files** (`controls.jsx`: `ThemeSwitch`, `LocaleCombo`, `Segmented`, `Pagination`+`mountPagination`, `Combobox`). They reproduce the canonical CSS (in `ui-kit.css`) + keyboard/WAA behaviour faithfully. Vanilla auto-wire modules can't bind React state, so this split is deliberate: shared = tokens/chips/tooltip/dark/tweaks-panel; ported = stateful controls.
- **`Pagination` owns its `<nav>` imperatively.** React renders an empty `<nav className="pag" key={total} ref>`; `mountPagination` fills + drives it and calls `onChange(next)` as the 660ms thumb glides (masks the data swap). Keyed on `total` so it re-mounts when the result count changes; React never reconciles its vanilla-added children. Catalogue owns `page`; reset to 1 via a `useEffect` on the filter/query/sort deps.
- **F6 region nav is `region-nav.js`** — a delegated, idempotent module that queries `[data-region]` LIVE on each keypress (re-render-safe). Mark real landmarks with `id` + `data-region="<name>"` + `tabindex={-1}` + `aria-label`; app.jsx renders the skip-links (`.skip-link[data-skip]`), the `#region-announcer` pill and `#region-live` sr-only status once.
- **Theme is the header `.tsw` switch (2-state), in the gov.cz bar**, NOT a Tweaks toggle. It shares the `cos-preview-theme` localStorage key with the preview cards' floating toggle, and sets `data-theme` on BOTH `<body>` and `<html>`. `cosApplyTheme(cosGetTheme())` runs at the top of app.jsx before render. The bar also carries the `.locale` combo (CS/EN). Header/footer markup follows the new `brand-header.html` / `brand-footer.html` (theme switch + locale live in `.bar__right`).
- **Combined Tweaks panel** (`tweaks.jsx` → `PrototypeTweaks`): English labels, `collapsibleSections persistKey="cos-prototype"`, a **Data** section switching dataset (real ↔ mock) via `window.COS_setDataset`, plus Table / Cover / Buttons matrices. NO dark control.
- **Dataset selection is `data-source.js`** (loaded after `data.js` + `library-data.js`): precedence `?data=` → localStorage → default `real`. Publishes the active set as `window.DOCS` / `window.COS_REQUESTS` / `window.COS_LIBRARY` + a `window.COS_DATA` descriptor; `window.COS_setDataset(mode)` persists + syncs the URL param + reloads (so edits to `library-data.js` are picked up immediately — no build step). Mock mode synthesises requests/library covering all five statuses. `TYPE_DESC` (only in `data.js`) is guaranteed via a fallback.
- **Guard optional fields** — real docs have no `pages`, drafts no `updated`. `DocCard` / detail metabox render those rows conditionally.

## Buttons (_buttons.css)

- **`.on-active` — active-tinted-surface treatment (Jun 2026).** A primary-50 "active" container (hovered catalogue card, selected row, highlighted panel). Secondary / ghost buttons there CAMOUFLAGE: their normal hover fill IS primary-50 = the surface colour, so the feedback vanishes and the transparent ghost reads weakly. Fix in `_buttons.css` `@layer components`: `.on-active` paints `primary-50` + radius; inside it the secondary REST pops to a solid `--color-bg-surface` fill, and every hover/pressed DEEPENS a step (`primary-100` → `-200`) with a firmer edge so motion stays legible against the blue. All gov refs resolve ON the `.btn` (never `:root` tokens) → flip in dark. Favourite mirror lives in `_favourite.css` (solid rest, warning hover/favourited re-declared at higher specificity so the rest fill can't clobber hover). `_buttons.css`/`_favourite.css` stay GENERIC (no `.dc` knowledge); **comp-doc-card.html re-points its own inner `.btn--secondary`/`--ghost` on `.dc:is(:hover,[data-state~="hover"])`** (same precedent as the chip hover-activate). Showcased as `.row.on-active` (“↳ active”) lines in comp-buttons.html, mirroring the Chips preview active rows.

- **The prototype (ui_kits/cos-library) now CONSUMES `_buttons.css`** (Jun 2026) — the bespoke `.btn`/`--primary/--secondary/--ghost/--danger/--sm/--lg`/disabled block in `ui-kit.css` was deleted; index.html links `../../preview/_buttons.css` after `_chips.css`. Only prototype-local rule left: `.btn { text-decoration:none }` (for `<a class="btn">`). The button Tweaks (`tweaks.jsx` `buildButtonCSS`) are now TOKEN-DRIVEN — `@layer tweaks { body:not([data-theme=dark]){--btn-* fills+shadows} body[data-theme=dark]{--btn-* shadows} }` with a `BTN_SHADOW_DARK` table + `bs()`, identical to comp-buttons.html / playground-tweaks.jsx. The old unlayered `.btn--primary{background;box-shadow}` form is GONE (it clobbered the canonical focus ring/disabled/dark — see pitfalls).

## Field responsive icon-hiding (inputs / textarea / select / search)
- **Narrow `.field` hides its LEADING inset icon below 150px** — frees space for the value when cramped. Implemented with a CONTAINER query, not `@media`, so each field reacts to its OWN width independently (rows of cols shrink unevenly). The `.field` itself is the named query container: `container: field-q / inline-size;`. The `@container field-q (max-width:150px)` rule is UNLAYERED so its `display:none` beats the `@layer reset` `.bi { display:inline-flex }`.
- **Only the leading affordance hides — never the trailing one.** Target the leading glyph precisely: inputs/textarea `.field > i`; search `.field > i.bi-search` (clear `×` button survives); select `.field > i.bi:not(.chev)` (chevron / open affordance survives). Lives in comp-inputs.html, comp-textarea.html, comp-select.html, and `_field.css` (search — propagates to the prototype).
- **`container-type: inline-size` is safe on `.field`** — it implies `contain: layout style inline-size` (NOT `paint`), so the focus-ring `box-shadow` still paints outside the box, and the combo dropdown (anchored to `.combo-wrap`, not `.field`) is unaffected.

## Search field (_field.css) — shared component
- **`preview/_field.css` is the single source for the SEARCH field** (`.search`/`.search--lg`/`.search-wrap` layout, the `.field` well + hover/focus/error/disabled, the `.clear` button, `.suggest` list, dark rescues), extracted from comp-search.html. comp-search.html links it (its `<style>` is now just `.row`/`.col`/`label` demo scaffolding); the prototype links it too and renders it via the `SearchField` React component (components.jsx). The `.field` CORE is `@layer components`; `.clear`/`.suggest`/dark blocks stay UNLAYERED (own rings beat base; dark overrides must win). NB: this is the SEARCH field only — plain input/select/textarea `.field` boxes stay duplicated per preview card (no shared field for those).
- **`SearchField` (components.jsx)** — controlled (`value`/`onChange`), React owns the `data-empty` flag the CSS reads (no vanilla wiring); props `large`, `button`, `buttonLabel`, `className`. Used by the catalogue hero (`pages.jsx`, `className="hero__search" large`) and My Library (`pages2.jsx`, `button={false}`, filters the grid). Replaced the prototype-local `.input--lg` search; `.input` stays for form fields/selects.

- **comp-empty-state, comp-lib-card, comp-search now CONSUME shared `_buttons.css`** (Jun 2026) — joining cta-panel/doc-card/cover-card. Each had a bespoke local `.btn`/`--primary`/etc. copy with no real focus/pressed/disabled/dark; the lib-card/empty primary had NO focus ring and comp-search's dark hover rode `--gov-color-primary-600` which LIGHTENS in dark (wrong direction). All three local copies were deleted and `_buttons.css` linked after the other sheets; markup already used `.btn .btn--primary/--secondary/--ghost` (lib-card buttons given `btn--sm` to keep their compact size; search keeps its `.search--lg .btn` padding override). **Rule reaffirmed: never ship a bespoke `.btn` — reach for `_buttons.css`.**
- **`playground-tweaks.jsx`'s button generator is token-driven, identical to comp-buttons.html's.** It writes `@layer tweaks { body:not([data-theme="dark"]) { --btn-* fills+shadows } body[data-theme="dark"] { --btn-* shadows } }` into each iframe's body-end `<style id="__fp-tweaks-css">` (last in source order → wins within `@layer tweaks` over comp-buttons' own override). It carries its own light + `FP_BTN_SHADOW_DARK` tables + `fpBs()`. NEVER revert to unlayered `.btn--primary{box-shadow}` (see pitfalls — it broke focus/disabled/dark for primary+danger).

- **The button component lives in shared `preview/_buttons.css` — and is now SELF-CONTAINED (see "CSS architecture" + `notes/architecture.md`).** That one file carries the button's light tokens (`@layer tokens`), dark remap (`@layer theme`), and rules (`@layer components`). Dark mode is a token re-value there — NOT a per-variant mirror in `_dark.css` (the old `body[data-theme="dark"] .btn--*` block was deleted; do not re-add it). Component rules are theme-agnostic and route every colour/shadow through `--btn-*` tokens. State uses the unified `data-state` vocabulary (`:hover/[data-state~="hover"]`, `:active/[~="pressed"]`, `:focus-visible/[~="focus"]`, `:disabled/[disabled]/[~="disabled"]`) — the bespoke `.btn--focus`/`.btn--disabled` classes are GONE. Focus is ONE tokenized rule for all variants + both themes. comp-buttons.html still layers its Tweaks-generated `!important` CSS on top (unlayered → wins). Link `_buttons.css` AFTER `_card.css`/`_dark.css`.
- **comp-buttons.html links `_buttons.css`** and keeps only its demo-scaffold rules (`.row`/`.lbl`) + the Tweaks/playground JS inline. Don't re-inline the base there.
- **Doc-card (`comp-doc-card.html`) CONSUMES shared `.btn` + `.dc-chip` — no bespoke CTA/chip.** CTAs are `btn btn--primary btn--sm` (Číst) / `btn btn--secondary btn--sm` (Požádat); doc-type chips are filled `.dc-chip .dc-chip--cos`/`--stanag` with the `.dc-chip__dot`/`__full`/`__short` markup (mirrors comp-table). The old `.btn-mini`/`.btn-mini--out` + `.chip`/`.chip--cos` rules are DELETED (had no dark/hover/press/focus). Links `_buttons.css` after `_chips.css`. Only LOCAL CSS left: `.heading .dc-chip{margin-right:8px; vertical-align:-2px}` (heading layout) and the `@container` narrow collapse (`.dc-chip__short` swap-in at ≤460, `.btn__label` hide + tighter `.cta-wrap .btn` padding at ≤340). Those local rules sit in doc-card's `@layer components` and win over `_chips.css`/`_buttons.css` by later source order at equal specificity.
- **Cover-card CTAs ARE the real button** (`comp-cover-card.html`): `Číst`→`btn btn--primary btn--sm`, `Požádat`→`btn btn--secondary btn--sm`, `Detail`→`btn btn--secondary btn--sm`. `.cv__cta` is now a layout-only modifier (`width:100%; justify-content:center; margin-top:2px`) — all colour/state/dark styling comes from `.btn`. The old `.cv__cta--out`/`--muted` classes + their `_dark.css` overrides were deleted. When a card needs a button, do this — reach for `.btn`, never re-style a bespoke CTA.
- **Focus rings + dark hover/focus shadows are baked statically into the shared files** (`_buttons.css` light `:focus-visible` rings per variant; `_dark.css` dark hover/danger shadows + dark `:focus-visible` rings). They USED to exist only inside comp-buttons.html's runtime CSS generator, so any card using `.btn` without that generator had no focus ring and no dark hover shadow. The static defaults mirror the generator's `SHADOW_DARK.ground` + halo values; comp-buttons.html's generated `!important` rules still override them when tweaked. Keep the static defaults in sync if the generator's default preset changes.
- **comp-buttons.html's generator is TOKEN-DRIVEN — no `!important`, no per-state duplication** (Jun 2026; the long-noted cleanup, now done). `generateCSS` writes only `--btn-*` token assignments into a `@layer tweaks` block in `#btn-tweaks-override`: a LIGHT scope (`body:not([data-theme="dark"])`) sets primary/danger `bg`/`bg-hover`/`shadow`/`shadow-hover` (gradient + 3D edge + light shadow), and a DARK scope (`body[data-theme="dark"]`) sets ONLY the `shadow`/`shadow-hover` (dark shadow recipe + 3D edge). Everything else — pressed, focus rings, secondary/ghost, dark FILLS — flows from the canonical `_buttons.css` tokens/rules with ZERO duplication. Pressed/dark-pressed canonical tokens (`--btn-{secondary,ghost}-{bg,fg,bd,shadow}-pressed`, dark `--btn-{primary,danger}-{bg,shadow}-pressed`) are the single source. Because the LIGHT tweak is theme-scoped, DARK falls through to the canonical saturated dark fills (so comp-buttons === cta-panel in dark). **Consequence (intended):** a non-solid gradient preset shows in LIGHT only — dark uses the canonical flat dark fill (the system has no dark-gradient tokens). `bs()` returns `0 0 #0000` (never bare `none`) so a shadow token is safe inside the canonical focus ring's comma list. Demo state buttons use `data-state="hover|pressed|focus"` (the `.btn--focus` class is retired).
- **Dark button FILLS use the saturated light palette, NOT a lifted/brighter blue** (per design review, Jun 2026). The old `_dark.css` had lifted dark fills (primary rest `#2c70b8`, hover `#4187ce`-lighter; danger `#d64556`) — these read too bright and the hover LIGHTENED (wrong direction). Canonical dark tokens are now: primary rest `#2362a2` → hover `#1e5086` → pressed `#0e3a6e`; danger rest `#c52a3a` → hover `#a51e2d` → pressed `#7a1020` (same hues as light, hover/pressed DARKEN monotonically; only the box-shadows differ from light — black-ink dark shadows). Secondary/ghost still flip via the subtle-surface + gov-scale tokens. Change these in `_buttons.css` `@layer theme` only — they propagate to every consumer (comp-buttons via the generator's `var()` refs, cta-panel, cover-card CTAs, doc-card).

## Cover card (comp-cover-card.html)

- **The responsivity-sandbox grid uses the FILL model, not fixed columns:** `.cv-stage .cv-grid { grid-template-columns: repeat(auto-fill, minmax(min(var(--tw-card-max,184px), 100%), 1fr)) }` with NO per-card `max-width`. Cards fill their track (no between-card gaps), reflow as either slider moves, and resize fluidly within each column-count band so the container slider visibly rescales cards + their container-query text. The "Card width" slider sets the per-card target/floor width (it's a floor, not a hard max — see `notes/pitfalls.md` "CSS grid"). Default 200px.
- **Per-card inline `style="color:…"` on `.cv__title-mini` is FORBIDDEN** — it hard-codes the light `--color-fg-default` (#0c1838) and goes invisible in dark. Let the title ride `var(--color-fg-default)` so it flips. (Removed from the cancelled-doc card.)

## Tweaks panel — collapsible sections

- **`TweakSection` collapse is ANIMATED (Jun 2026) — `grid-template-rows: 0fr↔1fr` transition, NOT `{open && …}` conditional render.** In collapsible mode each section renders `.twk-sect-grp` (a flex child wrapper) → `.twk-sect-btn` + `.twk-sect-collapse[data-open]` → `.twk-sect-body` (`overflow:hidden; min-height:0`). The grid-rows transition (.24s) animates height with no JS measuring; children stay mounted so it can animate both ways. The group wrapper keeps `.twk-body`'s 10px gap BETWEEN sections only (no double-gap around a collapsed body); `.twk-body>.twk-sect-grp:first-child .twk-sect-btn{padding-top:0}` restores the first-section flush top. The global reduced-motion policy zeroes the transition automatically where the host doc imports `colors_and_type.css`. Non-collapsible mode is unchanged (flat `.twk-sect` heading + `.twk-sect-body`).
- **`preview/tweaks-panel.jsx` `TweakSection` supports opt-in collapsing.** `<TweaksPanel collapsibleSections persistKey="...">` provides a context; each `TweakSection` then renders a clickable header + chevron and persists its open state in `localStorage['twk-collapse:<ns>:<label>']` (default open). Without the flag (every existing preview card) sections render as the original static heading — backward compatible. The `T`-key toggle and host protocol are unchanged.

## Library dataset (library-data.js)

- **Real catalogue data lives in `ui_kits/cos-library/library-data.js`, SEPARATE from the 12-row mock `data.js` — never merge them.** `data.js` stays the hand-tuned demo set; `library-data.js` is the bulk real dataset for the UI-kit preview. Both publish the SAME display-metadata maps (`AVAIL_META`/`VALIDITY_META`/`TYPE_TOOLTIP`/`CLASS_TOOLTIP`) guarded with `window.X = window.X || {…}` so loading both is safe.
- **Source = oos-data.army.cz register pages** (ČOS `WebPrehlCOS_*`, STANAG `WebPrehlSTANAG_*`, STANREC `WebPrehlSTANREC`, AP `WebPrehlAP_*`). Two table shapes: ČOS (code/ed/změna · název · charakteristika · zavádí · zdroj · účinnost · zpracovatel) and NATO (code/wg · stupeň utajení · přejímané dokumenty · EN+CZ název · edice+datum · přist./zaved. · ČOS).
- **Unified schema** via builders `c(...)` (ČOS) and `n(type,...)` (NATO) → `window.LIBRARY_DOCS`. Keep the legacy `data.js` fields a strict subset so existing components consume either array unchanged.
- **Normalization rules (binding):** ČOS Czech titles authored sentence-case (source is ALL-CAPS); NATO English titles auto-converted ALL-CAPS→Title Case by `titleCaseEN()` (curated `ACRONYMS` set + `FIXUPS` map for `GHz/mm/Mk/XIIA/VDC/…`); NATO Czech titles kept verbatim (already sentence-cased at source).
- **Status mapping (binding):** ČOS `ZRUŠEN`→`invalid`; `Nahrazeno ČOS …`→`replaced` (+`replacedBy`); `(návrh)`→`draft`; else `valid`. NATO: edition present→`valid`, blank edition→`draft` (in ratification). Availability: ČOS valid→`public`/draft→`login`/else→`metadata`; NATO valid→`controlled`/draft→`metadata`. **Color is never the only carrier — every chip pairs icon+text+tint (see Color decision).**
- **Coverage is COMPLETE** (June 2026): entire OOS register parsed from raw HTML → **2,597 records** (ČOS 425 · STANAG 1027 · STANREC 93 · AP 1052). `window.LIBRARY_COVERAGE.pending` is all-empty; banner shows "Kompletní katalog."
- **The dataset is now GENERATED, not hand-authored.** User runs `tools/fetch_oos.py`/`.ps1` → uploads raw register HTML to `uploads/oos_pages/` → a `run_script` DOMParser parser extracts every row and bakes records into `library-data.js` as a compact JSON array (`var DOCS = [ {…}, … ]`). The old `c()/cc()/n()` builder calls are GONE from the file (helpers were generation-time only). To regenerate after a fresh fetch, re-run the parser (full source in chat history ~m0089).
- **Parser rules (binding):** ČOS table `#cosTable`, 7 cols (code+PDF-link / title / **Charakteristika** / Zavádí / Zdroj / date / owner); PDF link on code ⇒ `public`, `ZRUŠEN`⇒invalid, `Nahrazeno ČOS …`⇒replaced, `řízené distribuce` ⇒ controlled. NATO: STANAG 10 cols / STANREC·AP 9 cols; col0 = code+WG (stacked divs), col2 = Přejímané (STANAG/STANREC→the AP code) or covering doc (AP→STANAG/STANREC number, resolve type by membership in parsed STANREC code-set), col3 = EN div + bold-CZ div, col4 = edition + date, STANAG col8 = `P n`/`Z n`. **The adopted-doc cell is often plain text with NO `<div>` — read `td.textContent`.**
- **Real source text as descriptions:** ČOS `description` = verbatim "Charakteristika". NATO descriptions were generated boilerplate → **dropped** to save ~325 KB (modal shows adopts/related instead). Compact serialization omits null/""/empty-array fields (preview guards every optional field). File ~1.6 MB — DS-compiler size note expected/accepted (standalone data file under `ui_kits/`, not a token source).
- **Czech ALL-CAPS → sentence case** via `sentenceCaseCZ()`. Keep-set `CZ_KEEP` holds acronyms/designators (AČR, NATO, CBRN, DAS, HARD/KILL/SOFT, RUN-FLAT…); `CZ_FIX` lowercases units (MM→mm). **Never put Roman numerals (V, I…) or single letters in `CZ_KEEP`** — `V` collides with the Czech preposition "v". Capitalizes first word + any word after sentence-ending `.`/`:`.
- **`window.REQUESTS` + `window.MY_LIBRARY`** are sample workflow data referencing real controlled docs by id; request statuses `submitted→in-review→more-info→approved→rejected` (`REQUEST_STATUS_META`). Keep `MY_LIBRARY.docId` referentially valid against `LIBRARY_DOCS`.
- **`data-preview.html`** is the dataset's own catalogue preview, REBUILT Jun 2026 to render entirely through the SHARED components (no `ui-kit.css`, no bespoke `.tile`/`.fbtn`/local table): quick-tile category entry-points (per-type figures + click-to-filter), segmented view switch, `.search .field`, sidebar `.filter-group`s, `.filter-pill` active-filter strip, `_table.css` catalogue table, `.pag` pagination, and the `.rstat` + `.timeline` request status. It links the canonical `preview/_*.css` directly + their `_*.js` controllers, then wires the freshly-built DOM via `window.initFilterGroups/initSegmented/initStatus/initPagination`. Verification surface for the data, not a product screen.
  - **Bridge patterns (reuse for the prototype rebuild):** (1) the canonical `_pagination.js` has NO onChange callback — bridge it with a `MutationObserver` on the `<nav>`'s `data-current` attr → re-render the page slice; rebuild the `<nav>` (fresh `initPagination`) only when the result TOTAL changes. (2) A programmatic filter change (pill `×`, quick-tile, clear-all) sets `checkbox.checked` then dispatches a bubbling `new Event('change')` so BOTH `_filter-group.js`'s row/chip sync AND the app's own debounced re-apply run. (3) View switching mirrors the segmented control's `.is-active` button to the visible `<section>` via a `MutationObserver` on the seg's class (so click AND arrow-key nav both switch). (4) Row/pill/section entrance animations are WAA `element.animate` (so the global reduced-motion CSS reset doesn't touch them — gate on a `reduce()` helper) and TRANSFORM-ONLY (never opacity-from-0, per pitfalls — stays visible under a frozen capture clock).
  - **v2 additions (Jun 2026):** (5) **NATO-classification filter** — a 4th `.filter-group` (`data-fg="classif"`, `Stupeň utajení`) of `.cchip`s (NEOZN./NU/NR/NC/NS), counts via a `clsOf(d)=d.classification||"NEOZN."` tally; only LEVELS PRESENT in the active dataset are shown (no empty filters). (6) **Mock-data toggle as a segmented TAB** (`Mock dokumenty`, after `Katalog dokumentů`) — load `data.js` BEFORE `library-data.js` so `window.DOCS` stays the 12-row mock while `window.LIBRARY_DOCS` is real; an in-page `setDataset('real'|'mock')` (NOT `data-source.js`'s reload path) rebuilds the coverage notice + quick-tiles + filter groups from `active()` and re-applies. `catalog`+`mock` tabs share `#view-catalog`; requests/library stay on the real workflow data. (7) **Metadata modal** on row click (`tr[tabindex=0]` → `openModal`) renders the shared `.metabox` and navigates the WHOLE filtered list across pages: ↑/↓/←/→ = prev/next doc, PgUp/PgDn = prev/next PAGE (`floor(idx/PER)±1`·PER), Home/End = first/last GLOBAL, Esc = close; `goToDoc` syncs `state.page` + remounts the pager so closing lands on the right page. Open with the forced-reflow pattern (`hidden=false; void offsetWidth; classList.add('is-open')`) — NOT rAF (rAF is frozen in the offscreen capture, so the modal looked invisible). (8) **Pagination UX** — the `.pag` lives in a STICKY floating pill footer (`position:sticky; bottom`, range eyebrow `Zobrazeno X–Y z Z`) so it's never scrolled out of view; page changes do NOT scroll (removed the scroll-to-top) and `_pagination.js` keeps focus on the active control → uninterrupted keyboard paging. No `data-responsive`/`data-docs` on this `<nav>` (the custom range eyebrow replaces the built-in one).

## Request status (comp-status.html)

- **Responsive collapse is container-query driven (Jun 2026), keeping "previous + active" so line logic is untouched.** Three regimes on the timeline's OWN width: full (≥430px, every step) · 2-step (250–429px, the focus step + the step immediately BEFORE it) · mini (<250px, focus only, no connectors). "Focus" = `.curr/.moreinfo/.rejected`, or the LAST step when the request is fully complete (`.timeline:not(:has(.curr,.moreinfo,.rejected)) .step:last-child`). Hidden steps are `display:none`; the grid's 1fr columns redistribute. **Chose PREVIOUS (DOM-adjacent) over FIRST for the 2-step view** so the surviving connector is `prev → focus` — its existing per-segment colour/gradient (done→active green→warning, done→rejected green→red, done→done green) stays EXACT, and `.conn`'s percentage geometry self-corrects to the wider gap. "First → focus" would mis-colour the line whenever intermediate done steps exist. **The focus step's OWN outgoing `.conn` is hidden in 2-step** (it points at a now-hidden next step — otherwise a grey stub pokes out to the right of the active dot); only the previous step's connector (prev→focus) survives. Selectors qualify on `.timeline` (e.g. the all-done `:not(:has())`), so the timeline must be a **descendant** of the size container, not the container itself — `_status.js` `initStatus` injects a `.timeline-wrap` (`container-type:inline-size`, in `_status.css`) around each timeline (idempotent), needing zero consumer markup changes. Used only by comp-status.html; the prototype app (`ui_kits/cos-library`) has its OWN `.timeline__*` stepper and is untouched.
- **Collapse-aware opening animation.** `playTimeline` filters to VISIBLE steps (`getComputedStyle(s).display !== 'none'`) and drives centres/connectors off that array — so the 2-step view plays the SAME staggered slide+draw "without changes", just across two dots (the pulled-in connector belongs to the previous visible step, which is DOM-adjacent → correct gradient). **1 visible → MINI branch**: no path/connector; the dot grows IN PLACE (`.dot__bg` scale 0.6→1, glyph fades — never translated) and the callout slides in (`translateY(18px) scale(.96)→0`), or the labels fade up for an all-complete step. Beacon still starts on the active callout's `onfinish` in every mode. `reduced()` snaps to `.played` (frozen rings) as before.
- **Four example flows; ONLY examples 1, 3, 4 carry `data-playground`** (in-review, more-info, rejected) — the "approved/all-done" example #2 is omitted from the playground (no active state to inspect). Status-note captions (`.cap`) were REMOVED — not needed in the gallery.
- **Variable-length timeline (3–4 steps), `grid-auto-flow:column`.** Step states: done ✓ (`bi-check-lg`) · curr (`bi-stopwatch`, NOT hourglass) · moreinfo (`bi-question-lg`) · rejected (`bi-x-lg`) · pending (number). Glyph icons are 25px (bigger); stopwatch is 22px + `translateY(-0.5px)` (its stem rides high → optical-centre nudge); numbers stay 17px.
- **Dots are border-box 44px; the CIRCLE is a child `.dot__bg`, the glyph/number stay in `.dot`'s flow.** This DECOUPLES the opening scale from the glyph: the entrance scales `.dot__bg` only (a solid element — its teardown re-rasterisation is invisible), so the icon/number never sub-pixel-snap (the "icon jumps up/down when the dot stops animating" bug). State fills (done green / rejected red / curr+moreinfo amber) live on `.dot__bg`; the glyph colour (`#fff`) stays on `.dot`. `.dot__bg` is `z-index:-1` (normal-flow glyph paints above it). Dark pending-dot well moved to `.step.pending .dot__bg`.
- **Connector thickness: grey 2px@`top:21`, completed/gradient 4px@`top:20` — BOTH even heights at INTEGER tops** (was 3px@20.5, an odd height at a half-pixel top → blurry, and visibly varying between examples). Even+integer keeps each segment crisp; residual variance is only the card's fractional page-offset.
- **Extracted to `_status.css` + `_status.js` (single source of truth)** so the prototype's request-detail page reuses the exact stepper. `_status.js` exposes `window.playTimeline(el)` + `window.initStatus(root)`; comp-status.html keeps only the demo-card layout (`.col`/`.ex-head`/`.play`) inline. Edit the SHARED files, not the page.
- **Connector is a REAL `.conn` element**, coloured by the SEGMENT it represents: done→done = green; **done→active (curr/moreinfo) = `linear-gradient` green→warning**; done→rejected = gradient green→red; **active→future = GREY** (we are WAITING at the active step — NOT green; corrected Jun 2026); future→future = grey. Only the green/gradient (done-origin) segments are 3px+`top:20.5px`; grey ones (incl. active→future) are 2px/`top:21px`. Connector spans this dot's centre → the **next dot's LEFT EDGE** (`right: calc(-50% + 22px)`), so the drawn tip lands on the next dot's edge instead of poking into the empty centre while that dot fades in.
- **Beacon starts when its OWN step lands, not after the whole timeline.** Gated on `.step.curr.beaconing` / `.step.moreinfo.beaconing` (was `.timeline.played`). `initStatus` adds `.beaconing` to active steps for the static default load; `playTimeline` removes it at the start, then re-adds it on the active step's callout `onfinish` (+ a `setTimeout` fallback for the frozen offscreen case).
- **Beacon/radar pulse** on the active non-final step: a `.beacon` wrapper (`z:1`, BEHIND the dot `z:2` + callout `z:2`, ABOVE the connector `z:0`) holds 3 hollow `.wave` rings. They expand `scale .7→2.0` + fade, ease-out, staggered 0/0.5/1.0s over a 2.8s cycle, then pause.
- **Reduced-motion AND "instant" → a FROZEN two-ring beacon** (so the active step keeps its special look without motion). `html.motion-frozen` (set by JS) styles ring1 `scale 1.7 / opacity .16` + ring2 `scale 1.18 / opacity .42` (BOTH >1 so they peek beyond the 44px dot — earlier `.86` sat hidden under it) + ring3 hidden, `animation:none`. JS sets `.motion-frozen` when `reduced()` (`?rm`/matchMedia) OR the playground's **instant** speed is active — detected by observing the playground's injected `#__fp-motion` style for `1ms` (MutationObserver on `<head>`). `@media (prefers-reduced-motion: reduce)` mirrors the frozen rings as a no-JS fallback.
- **Callout spike = a rotated square** (`::before`, `rotate(45deg)`, `border-left`+`border-top` 1px, `background: var(--cobg)`). `--cobg`/`--cobd`/`--cofg` drive body + spike + text together. **DARK uses OPAQUE pre-blended fills** (`--cobg: #34301c` amber / `#341f22` red) — the gov `*-50` tokens are TRANSLUCENT in dark (`rgba …,0.14`), which let the spike's hidden geometry + the body's top border show through (the "semi-transparent callout" bug). Same opaque-fill rule as the dark chip fills.
- **Opening animation (`playTimeline`, the request-detail entrance) — play button per example.** Staggered WAA (speed-scaled; instant fast-forwards it). Each step's dot slides in from the previous dot along the path (`translateX(-span)→0`, ease-out) + fades — **opacity/translateX ONLY, NO `scale`**: a scale transform composites the dot and re-rasterises the glyph a sub-pixel off on teardown, so the icon/number visibly jumped (up/down, direction per glyph) the moment each dot's animation finished while the rest of the timeline kept going. The connector INTO each dot draws in **LOCKSTEP via an animated MASK** (`--conn-lead`/`--conn-a`, see below) — **NOT `scaleX`** (re-rasterised the line thinner mid-animation) and **NOT `clip-path`** (couldn't fade the leading edge). Details fade/slide up — EXCEPT the active `.callout`, which slides in from FURTHER (`translateY(40px) scale .92`)+fades. `fill:'backwards'` + per-step delay holds each hidden until its turn; last `onfinish` (+ setTimeout fallback) adds `.played` → beacon begins. DEFAULT on load = `.played` (static, beacon running); the entrance fires only on the play button (don't auto-play: WAA is frozen offscreen, would blank the preview). `reduced()` → snap to `.played`.
  - **Connector reveal = animated `@property` MASK — leading edge tracks the next dot's edge AND its opacity (Jun 2026 rewrite).** The `.conn` `mask-image` is `linear-gradient(to right, transparent 0, transparent 20px, #000 20px, rgba(0,0,0,var(--conn-a)) var(--conn-lead), transparent var(--conn-lead))`: TRANSPARENT up to 20px (just inside this dot's 22px edge — the under-dot stretch is NEVER painted, so it can't be exposed while the dot is still fading in; painting it `#000` was the "line starts from the dot CENTRE / all lines visible at the start frame" bug), OPAQUE from 20px ramping to alpha `--conn-a` at the leading edge `--conn-lead`, then a hard cliff to transparent. `_status.js` animates BOTH (registered `@property`: `--conn-lead <length>`, `--conn-a <number>`) with the SAME eased timing as the dot: `--conn-lead` `-(DOT_R·DOT_S0)=−18.92px` (behind the prev dot → line invisible at frame 0) → `span−DOT_R`, which EQUALS the dot-edge path `−18.92 + (span−3.08)·p` because both animations share the same easing/duration/delay — so the cliff coincides with the next dot's CURRENT scaled outer edge at EVERY frame (no overlap needed for tracking). `CONN_OVL` (1.5px) is added to BOTH keyframes as a CONSTANT tuck under the dot edge — just enough to swallow the 1px AA hairline; adding it to only the END keyframe was the bug where the gap interpolated as `OVL·p` (too little early, too much late). The cliff thus sits on the next dot's scaled outer edge every frame (accounts for the 0.86→1 scale AND the 2px border — `DOT_R` is border-box); `--conn-a` `0→1` tracks the next dot's animated opacity, so the right TIP fades in step with the dot while the LEFT stays opaque (the 2px under-dot overlap, 20 vs 22, hides the left seam against the already-solid prev dot). Colour stays the per-segment background gradient (prev→next; whole-grey when next is pending) — the mask only governs reveal/alpha. `fill:'both'` (hidden during delay, solid after); rest = default vars (`--conn-lead:9999px; --conn-a:1`) → fully solid. Supersedes the old uniform-opacity `clip-path` reveal AND the earlier reverted `mask-position`-over-oversized-gradient swept fade (that one interpolated wrong in motion; THIS one animates the gradient STOPS via registered `@property`, which is correct in real browsers). Can't be frame-verified offscreen (WAA + `@property` interpolation frozen — `getComputedStyle` reads the from-value, e.g. `--conn-lead:-18.92px`) — tune with the user watching.
  - **`playTimeline` is RESTART-SAFE (general rule — applies to ALL animations).** On (re)start it bumps a run token (`tl.__run`) and `tl.getAnimations({subtree:true}).forEach(a=>a.cancel())` so a replay can't stack onto a half-finished run, (the mask is driven by the cancel-reverting `@property` vars, so no inline-mask cleanup is needed). Every deferred callback (`onfinish` for beacon-start and the `go()` commit) is guarded by `if (tl.__run === run)` so a stale run's callbacks become no-ops. Replaying mid-flight (easy at slow playground speeds) is then clean. Reuse this pattern (cancel-in-flight + run-token-guarded callbacks) for any re-triggerable animation.
  - **ALL sequencing is WAA-driven — NO `setTimeout` (fixed Jun 2026).** The beacon-start and the cleanup/`.played` commit used `setTimeout`s, which fire at WALL-CLOCK time while the playground speed-scales WAA via a rAF `playbackRate` loop — so at 0.1×/0.25×/instant the timers fired out of sync and the dots/connectors/beacon visibly misaligned (and the misalignment changed with the speed pill). Now: beacon starts on the active step's callout `onfinish` (WAA, scaled); the cleanup `go()` fires on the last animation's `onfinish` PLUS a no-op WAA "clock" (`tl.animate([{opacity:1},{opacity:1}],{duration:total})`) as a scaled fallback. Project rule confirmed: a hand-rolled `setTimeout`/rAF timeline is NOT speed-scaled — drive timing off WAA (onfinish / a no-op clock).
  - **Connector mask uses `fill:'both'`** so a finished connector holds its end state (`--conn-lead:span−DOT_R, --conn-a:1` = solid to the dot edge); during the start delay the backwards fill holds the hidden from-state. Cancel on replay reverts the vars to the solid default, so a fresh run's backwards fill re-hides cleanly with no flash.
  - **Transform-compositing snap is the recurring lesson:** any WAA `transform` (scale especially) on an element holding crisp text/icons OR a thin line re-rasterises it a sub-pixel off on teardown. Put the scaled visual on a SEPARATE solid element (e.g. `.dot__bg`), keep glyphs/numbers unscaled, and reveal lines with a `mask`/`clip` rather than `scaleX`.

## Breadcrumbs (comp-breadcrumbs.html)

- **Overflow `…` pill EXPANDS the collapsed middle (Jun 2026).** The hidden segments live in `.hidden-path` INSIDE one `.crumb-slot` flex item (next to the `.more` pill) so the surrounding separators keep a constant gap whichever child shows — no leftover-gap snap. Reveal is a CSS `max-width` + `opacity` transition (so the playground speed scale + global reduced-motion both apply); JS sets `hidden.style.maxWidth = hidden.scrollWidth + 'px'` on expand so it lands EXACTLY on content (no max-width "overshoot wait"); collapse clears it → 0 via CSS. `.hidden-path > * { flex:none }` keeps `scrollWidth` stable. `.more` collapses away (`max-width:0; opacity:0; padding:0`) when `.crumbs.is-expanded`.
- **`…` tooltip + aria-label = the hidden path** (replaces "Zobrazit skrytou cestu"): JS joins the non-`.sep` `.hidden-path` children → "Katalog › Obranné standardy", sets `data-tip` + `aria-label` + toggles `aria-expanded`. Loads `_tooltip.js`.
- **"Sbalit" reset (`.crumb-reset`)** re-collapses for testing — appears only when `.is-expanded` (its own max-width/opacity transition), mirrors the filter-pills "Obnovit" affordance. Keyboard: `…` expands on Enter/Space and focus moves to the first revealed crumb; collapse returns focus to `…`.
- **Tab-order + focus-ring correctness (Jun 2026 fix).** Clipped-to-0 segments are still in the DOM, so an `apply(expanded)` toggles `inert` on `.hidden-path` + `.crumb-reset` while COLLAPSED (else you could Tab into a 0-width link and trip its tooltip) and on the `…` pill while EXPANDED (`tabIndex` mirrors). AND: `.hidden-path` keeps `overflow:hidden` only DURING the reveal — a ~340ms timer flips it to `overflow:visible` once expanded so the segments' `box-shadow` focus rings aren't clipped at the container edge; collapse re-clips (`overflow:''`) before animating back to 0. `first.focus({preventScroll:true})` avoids a clip-scroll jump.

## Brand header (brand-header.html)

- **Login CTA collapses to icon-only at ≤620px (was ≤720), both brand-header.html AND the prototype ui-kit.css** — below the tab-wrap point the top row is only lockup + CTA, which coexist down to ~620px, so the label survives longer (user-directed, Jun 2026).
- **gov.cz strip underline rides the label spans, never the anchor** — see "Links — underline never under the icon" above.
- **The login CTA uses the CANONICAL `_buttons.css`, not a local `.btn` (Jun 2026).** Was a hand-rolled outline `.btn` (white bg + primary border/text) that ignored the combined Tweaks panel's button-style control (section 16 "doesn't respond" report) AND duplicated the button. Now `<link>`s `_buttons.css` and the auth button is `.btn btn--primary btn--sm` (filled — matches the gov.cz login CTA; `btn--sm` = the old 6px 12px/13px size). Only `.btn__lbl{white-space:nowrap}` + the `@container ≤720px` icon-collapse padding override stay local (unlayered, so they still beat the layered `.btn--sm`). Lesson: a consumer with a chip/button MUST route through the shared component to get system tweaks — never re-implement `.btn` locally.
- **The theme switch + language switcher are the SHARED components, not bespoke copies (single source of truth, Jun 2026).** Theme switch = `.tsw.tsw--on-dark` from `_switch.css`/`_switch.js`; language switcher = the shared combobox (`.combo-wrap[data-combo]` + `.tb-select.tb-select--on-dark` trigger + `.combo-list`) from `_select.css`/`_select.js`. The old hand-rolled `.tsw` (40×22, click-only) and `.locale`/`.locale__btn`/`.locale__menu` dropdown (no ARIA/keyboard) are GONE. brand-header now gets the elastic drag/spring + the full ARIA 1.2 keyboard + type-ahead for free, and integrates with the playground's forced-state machinery (`.tsw`/`.tb-select`/`[role=combobox]` are already in its stamp/disable lists). The header keeps ONLY its own chrome (bar, lockup, nav ink, login btn, container queries). The theme switch is a pure UI demo (flips `aria-checked`, NOT the page theme — the floating toggle owns that).
- **`--on-dark` modifiers carry the dark-strip look, owned by the shared files** (`.tsw--on-dark` in `_switch.css`, `.tb-select--on-dark` in `_select.css`) — white-alpha border/fill + white focus ring on black; the combobox dropdown stays the standard light surface. Don't restyle `.tsw`/`.tb-select` locally in a consumer; add/extend an `--on-dark`-style modifier in the shared file so every consumer shares it.
- **gov.cz strip line + institution sub-line shorten at ≤380px** (`@container`): "gov.cz — oficiální portál státu"→"gov.cz", "Úř OSK SOJ — Odbor obranné standardizace"→"Úř OSK SOJ — OOS", via `.bar__full/__short` + `.inst__full/__short` spans. GOTCHA: the institution base hide is `.lockup .inst__short` (0,2,0), so the container-query reveal MUST also be `.lockup .inst__*` or the base out-specifies it and the short form never shows (the bar's `.bar__short` is only 0,1,0 so a bare selector works there — they're intentionally asymmetric).
- **Active tab is `font-weight:600`, but the box reserves the bold width so it does NOT nudge siblings** — a hidden bold ghost (`.nav__lbl::after { content: attr(data-text); font-weight:600; display:block; height:0; overflow:hidden; visibility:hidden }`) sizes the box; visible text rides inside. Each `.nav__lbl` carries `data-text`. Tabs are `user-select:none` (whole tab is a click target).
- **Tab indicator is a SLIDING `.nav__ink` bar** (replaces the per-tab `border-bottom-color`), glides old→new like the switch thumb and STRETCHES across the gap at the midpoint then settles (WAA keyframe: `translateX`+`width`, mid-offset spans `min(left)…max(right)`). `.nav` is `position:relative`; JS adds `.nav--ink` (→ hides the static `border-bottom-color`, kept as the no-JS fallback). WAA so the playground speed scale applies; `reduced()` snaps.
  - **Interrupt-safe + always-from-current-position:** a new `moveInk` first reads the ink's LIVE rect (`getBoundingClientRect` reflects the running WAA transform), cancels the in-flight `inkAnim`, `setInk(live)`, then glides from there — so a mid-glide tab change resumes from the real position (no jump/reset). `fin` commits only if `a0 === inkAnim` (not superseded). `inkCur` is set ONLY in `setInk` (never optimistically to the target).
  - **Stays in sync with the tabs via a `ResizeObserver` on `.nav`** (`if(!inkAnim) moveInk(false)`) — covers responsive width changes, the ≤500 icon-collapse, and sign-in reflow. The old single `w.input` listener was insufficient because `.hdr-wrap`'s `max-width` transition means the tabs reach final width AFTER the input event.
- **VERIFY width-responsive behaviour with the wrap transition disabled** — `.hdr-wrap` has `transition: max-width 0.08s`, so setting `maxWidth` in the offscreen iframe freezes at the START width (the container never narrows during eval, container queries don't fire). Inject `wrap.style.transition='none'` before measuring.

## Region navigation — F6 + skip links (comp-region-nav.html)

- **F6 cycles forward through major landmark regions; Shift+F6 backward; both wrap.** This is the cross-app standard (browsers, Office, editors) for hopping between panels/panes — chosen because Tab/Shift+Tab move *within*, Ctrl+Tab is browser tabs, Alt+Tab is the OS. Do NOT invent double-tap-Tab or Esc-to-hop gestures (timing-fragile / collides with dismiss semantics).
- **F6 moves focus to the REGION CONTAINER (`tabindex="-1"`), not its first control.** Matches the Firefox model: land on the region, then normal Tab / arrows work within it. Each region is a `<section data-region="…" tabindex="-1" aria-label="…">`.
- **Current-region detection = `region === activeElement || region.contains(activeElement)`.** First F6 starts from `cur = -1` so it lands on region 1. **Bail on Ctrl/Alt/Meta+F6** (those belong to browser/OS) — only handle bare/Shift F6.
- **Skip links are the first Tab stops on the page** — visually hidden (`top:-200px`), slide in on `:focus-visible`. Activating one calls the SAME `gotoRegion()` as F6 (focus + highlight + announce), not just an anchor scroll.
- **Esc is deliberately NOT bound for group/region hopping — reserved for dismiss/cancel** (popovers, dialogs, combobox listbox) and the grid `exit-edit` case only. House rule across the system.
- **"You are here" is driven two ways:** `gotoRegion()` sets `.is-current` (region rings via `--shadow-focus` + numbered badge goes solid primary + the `F6` keycap reveals), AND a delegated `focusin` on the app re-marks the current region as the user Tabs around inside it. A transient top-right `.announcer` pill + an `aria-live="polite"` `.sr-only` status announce the jump (name + "region N / 5").
- **Region order is DOM order, decoupled from grid placement** — CSS `grid-template-areas` positions the five regions (toolbar / filters / results / pagination / details) while DOM order defines the logical F6 cycle. Keep DOM order = intended cycle order.

## Keyboard navigation (comp-pagination.html)

- **All-tabbable + arrows-move-focus model (matches comp-segmented / comp-filter-pills — NOT roving tabindex).** Buttons are native `<button>`, already tab stops; `wireKeys(nav)` (on every `[data-total]` nav, standard + compact) adds, when the control has focus:
  - **←/→ and ↑/↓ — move focus to the previous / next enabled button** in DOM order (page numbers AND prev/next chevrons; the `…` gaps are `<i>` not buttons, so they're skipped). No wrap at the ends.
  - **Home / End — commit a jump to first / last page** (acts like clicking it; hint `'page'`, so restoreFocus lands on the active page button).
  - **PgUp / PgDn — previous / next page** (hint `'prev'`/`'next'`). PgUp = previous (lower number), PgDn = next.
  - Page-changing keys route through `changeTo()`, so the slide/sweep animations + `restoreFocus` fire exactly as for a click. **Bail on any modifier key**; `wireKeys` calls `e.preventDefault()` UP FRONT for any owned NAV_KEY (after the modifier bail) so the page never scrolls — INCLUDING the edge no-ops (already first/last page, focus already at an end button). Previously those early-returned WITHOUT preventDefault, so PgUp/PgDn (and Arrow/Home/End) leaked to the document and scrolled the view at the boundaries (Jun 2026 report).
- **`restoreFocus` has a compact fallback** — compact has no `.pg.active` page button, so after a boundary page change it falls through to the first enabled nav button (`button:not([disabled])`) instead of dropping focus to `<body>`.
- **Keyboard-hint `.kbd-hint` strip at the card bottom** documents ←/→/↑/↓ · Home/End · PgUp/PgDn, per the keyboard-help convention.

## Compact symmetric width (comp-pagination.html)

- **`equalizeCompact(nav)` runs after every compact (re)render — two jobs:**
  - **Reserve the counter's widest width so a single→double digit change in the current page ("9 / 12" → "10 / 12") never reflows the middle segment.** Widest current has the same digit count as `total` (current ≤ total), so `"{total} / {total}"` is a safe sizer; measured via a `position:absolute;visibility:hidden` span appended to `.where__num`, its `offsetWidth` → `min-width`. `.where__num` is `justify-content: center` so the value centres in the reserved box. Tabular figures (`font-variant-numeric: tabular-nums` on `.where`) pin same-digit values; the sizer pins the digit-count jump.
  - **Equalise the two nav buttons to a common width** (`Math.max` of both `offsetWidth` → `min-width` on each) so the control reads left–right symmetric; the wider label ("Předchozí") wins, "Další" stretches to match. `.pag-compact button` is `justify-content: center` + `box-sizing: border-box` for this.
- **Inline `min-width`s are reset (`= ''`) before each measure** so they don't compound; re-equalised on `document.fonts.ready` (icon webfont widens buttons after first paint — same trap as comp-segmented).

## Responsive driver (comp-pagination.html)

- **`data-responsive` opt-in (the Standard example + the Compact example).** A `ResizeObserver` on the nav's CONTAINER picks a `{ mode, siblings, eyebrow, compactLabels }` profile by inline-size and re-renders ONLY when the profile key changes (`applyResponsive`). Two profile pickers, chosen by the attribute VALUE:
  - **bare `data-responsive`** → `pickProfile` full cascade. Width brackets: ≥500 standard/siblings 2/full · ≥380 standard/siblings 1/page · ≥300 standard/siblings 0/mini · ≥232 compact+labels/mini · <232 compact ICON-ONLY/mini. So as width shrinks: fewer page buttons → shorter eyebrow → collapse to compact prev/next+counter → drop the "Předchozí/Další" labels.
  - **`data-responsive="compact"`** → `pickCompactProfile`: STAYS compact at every width (never expands to page buttons), only toggles `compactLabels` (labels ≥232px, chevron-only below). Used by the dedicated Compact example.
  - **`renderDefault` reads `nav.__siblings`** (default 2) into `items(current,total,sib)`, and `nav.__eyebrow` selects the `.count` text via `countText(eb,docs,current,total)`: `full` = "142 dokumentů · stránka 1 z 12", `page` = "Stránka 1 z 12", `mini` = "1 / 12" (none = omit). Non-responsive navs keep `full` when `data-docs` is present, else none.
  - **Mode switch toggles the element's own class** `pag` ↔ `pag-compact` (+ removes `pag-compact--icons` on the way back to standard) and re-renders through the existing `render()` dispatch; `clearThumb()` first so a mid-flight slide can't strand. `renderCompact` reads `nav.__compactLabels` (default true) — false drops the labels to chevron-only (the `aria-label`s stay, so it's still accessible) and adds `pag-compact--icons`.
  - **Eyebrow stacks below ONLY when it really wraps (`checkStacked`, runs after every standard render + on every width change).** WRAP DETECTION GOTCHA: a short eyebrow is VERTICALLY CENTERED on the 36px button row, so inline it still sits ~8px down — comparing `count.offsetTop > first.offsetTop + 2` false-positived at full width. Correct test: `count.offsetTop > first.offsetTop + first.offsetHeight * 0.5` (only a real new line drops it ≥18px). When stacked, `.pag--stacked` sets `justify-content:center` on the nav + `.count { flex-basis:100%; margin-left:0; text-align:center }` → the whole control reads as a centered stack.
  - **Width-guarded re-entry** — `applyResponsive` bails if `host.clientWidth === nav.__lastW`, so a stack-induced HEIGHT change (the observed container's height grows when the eyebrow drops to a new line) can't re-trigger the observer.
  - **VERIFY in a REAL TAB / via a direct call — ResizeObserver does NOT fire in the offscreen preview iframe** (same freeze as rAF; confirmed `__lastW` never updated on forced width changes). During development a temp `window.__pagTest(nav,w)` hook (sets host width + nulls `__lastW` + calls `applyResponsive`) drove the cascade for verification; REMOVED after. The cascade + stacking were verified at 210/280/360/480/520/560/620/660/720px this way.

## Pagination logic (comp-pagination.html)

- **Pronounced directional prev/next chevron on hover + focus (Jun 2026).** Mirrors the Related-row arrow at control scale: the nav glyph `scale(1.35)` + `-webkit-text-stroke-width:0.6px` + a 2px nudge toward the row edge, on `:hover`/`[data-state~="hover"]`/`:focus-visible`/`.is-focus`, gated `:not([disabled])` (boundary chevrons stay inert). **`transform-origin` is the OUTER edge (left for prev, right for next)** so growth pushes AWAY from the adjacent label in the compact text variant; `transform` never reflows, so “Předchozí”/“Další” text is NOT pushed. In `_pagination.css`, shared by `.pag` + `.pag-compact`; reduced-motion collapses it via the global policy.

- **Page-change slide animation (standard + small).** On a page change a `.pag__thumb` (primary-500 fill, carrying the destination page number) GLIDES from the old active button to the new one via the Web Animations API (660ms expo-out — deliberately slow, masks the background data load on page change; the de-activation `fadeButton` matches at 660ms), then hands off to the real active fill on `anim.finished` — same transient-thumb + snap technique as comp-segmented. KEY DIFFERENCE from segmented: the pagination thumb sits **above** the buttons (`z-index: 5`), not behind them, because the active fill is a DARK primary-500 with white text — it can't read through the opaque white button faces the way the segmented control's light primary-50 tint does. While the thumb is in flight `.pag.is-sliding button.active` makes the rendered active button read inactive (white face) so the thumb is the only blue on screen; on landing the thumb is removed and the real fill snaps in (button backgrounds carry no transition, so it's instant; `.pag--snap` is belt-and-suspenders). The thumb is created/destroyed per slide (render's innerHTML wipes it) and geometry of the OLD active button is captured BEFORE render(). Interrupt-guarded with a `nav.__pagAnim` token + `cancel()` (`clearThumb()` tears it all down). `changeTo()` skips the whole thing under `prefers-reduced-motion` (JS guard — `matchMedia` checked once) and just re-renders.
  - **Two distinct cases, decided by comparing old vs new active x.** MOVED (positions differ, e.g. near-edge next or a page jump): glide the thumb + crossfade the de-activating old-page button (the slot now showing the OLD number) active→inactive via `fadeButton(btn,'out')`. SAME-SLOT (mid-range next/prev — the constant-length window scrolls UNDER a fixed active position, so the active stays centered and only the numbers change; a glide would be invisible): no thumb, and the active page button is left UNANIMATED — ONLY the old-page button plays the slight `'out'` de-activation fade (the shared `fadeButton(oldBtn,'out')`, identical to the moved case's trailing fade). `clearThumb()` runs on this path too, or a stranded `is-sliding` would keep the new active looking inactive. PREVIOUS approach (revertible, left as a comment in `changeTo`): also crossfade the new active `'in'` — dropped per design review; `fadeButton` still supports `'in'`.
  - **`fadeButton` reads its active/inactive palette from tokens via `getComputedStyle(document.body)`, NOT `documentElement`** — `<html>` doesn't inherit the `body[data-theme="dark"]` token scope (see pitfalls "Dark mode / tokens"), so reading from `<html>` would animate to LIGHT colors in dark mode. Token-driven (not the measured node's computed style) so a stray `:hover` on the slot can't contaminate the endpoint.
  - **The in-flight suppression must be re-pinned in `_dark.css`.** Light `.pag.is-sliding button.active` is (0,3,1); the dark active rule `body[data-theme="dark"] .pag button.active` is (0,3,2) and wins, so the destination flashed blue immediately in dark. Fixed with `body[data-theme="dark"] .pag.is-sliding button.active` at (0,4,2) → reads `--color-bg-surface` / border-default / fg-default (the inactive dark button look).
  - **The thumb must NOT set `font-variant-numeric`.** `.pag button` uses proportional figures; a `tabular-nums` thumb renders Czechia Sans's slab-footed tabular `1` (and other figures), so the gliding number visibly changed shape vs the buttons ("serif version"). The thumb is a single centered number — no column to align — so plain proportional figures (matching the buttons) are correct.

- **All four examples are LIVE** — each container is driven by `data-current` / `data-total` (+ `data-docs` for the counter) and re-rendered on click via the inline `<script>`. Clicking enabled page/prev/next buttons moves state, recomputes the truncation window, the active page, the prev/next disabled state, and the counter. Lets the truncation logic be tested.
- **Truncation rule (constant-length window):** boundary 1 + current ±2 siblings, rendered so the slot COUNT is identical at every page (near an edge the ellipsis on that side is swapped for real pages to refill the row; a single gap shows the missing number, not `…`). `total=12` → always 9 slots (`1 2 3 4 5 6 7 … 12` / `1 … 4 5 6 7 8 … 12` / `1 … 6 7 8 9 10 11 12`). Lives in `items(current,total,siblings)` (MUI-style usePagination math).
- **`.dots` is the same footprint as a page button** (`width:36px`/`28px` sm, flex-centered) so the numeric block keeps a constant pixel width — combined with the constant-length window this keeps prev/next from sliding under the cursor as the active page moves.
- **`.pag button` is `inline-flex` centered** so chevron glyphs sit on the vertical centre (they read high in the small variant otherwise).
- **Hover well, not just a border** — `.pag button:not(.active):not([disabled]):hover` gets a primary-50 fill + primary-700 glyph (mirrors compact). The active page (`cursor:default`, non-clickable) and boundary-disabled chevrons are excluded.
- **Compact prev/next disable at the ends** — `renderCompact` adds `disabled` at page 1 / last; `.pag-compact button[disabled]` supplies the muted visual (opacity + not-allowed) and its hover is scoped `:not([disabled])`.
- **Render re-uses existing CSS classes** (`.active` / `[disabled]` / `.dots` / `.count` / `.where`) — number buttons get an extra `.pg` + `data-page`, prev/next get `.nav-prev`/`.nav-next` + `aria-label`. Dark mode + focus rings untouched. Don't rename these hooks without updating the script.
- **Inline Links section (`.fp-prose a`) is live to the toolbar** — it lives in the playground document itself (not an iframe), so it keys off the `data-fp-force-state` attribute the toolbar sets on the playground `<body>`, paired with the real pseudo-class (`:hover`/`:active`/`:focus-visible`). Hover→primary-700, pressed→primary-800 ink + primary-50 well, focus→animated bloom ring (`--shadow-focus`), disabled→tertiary + not-allowed. One state forced at a time so the four rules never collide. **Dark pressed needs its own override** — `--gov-color-primary-800` is NOT remapped in `_dark.css` (only 50/100/200/400/500/600/700 are), so it stays dark navy and the pressed ink vanishes on the dark page; re-pinned to primary-700 ink + primary-100 well under `body[data-theme="dark"]`.
- **Interactivity is LIVE everywhere, including under `?fp`** (the focus playground). Pagination rebuilds its `<nav>` via `innerHTML` on click, which drops the forced-state classes the playground stamps — so the playground re-applies the current force-state after each re-render via a per-iframe `MutationObserver` (`watchReRenders` in `comp-focus-playground.html`, observes `childList`/`subtree` only; re-apply touches only classes/attrs so it never self-triggers; guarded to do nothing in default+no-error state). Do NOT re-add the old `?fp` `wire()` suppression — clicking a page now correctly moves state in the playground.
- **Keyboard flow survives re-render** — `restoreFocus()` refocuses the activated control if still enabled, else the active page button, so repeated Enter on prev/next pages through.

## Compact pagination (comp-pagination.html)

- **Page-change sweep animation.** On a change a soft primary-50 wash (`.where__sweep`, absolute `inset:0`, `overflow:hidden` on `.where`) SWEEPS through the counter in the travel direction (left→right Next / right→left Prev, 1200ms expo-out — slow & deliberate per design review) while the number (`.where__num`, wrapped) slides in from the incoming side (800ms). Both are WAA animations fired by `sweepCompact(nav, dir)`; `changeTo()` skips them under `prefers-reduced-motion`. `renderCompact` emits `<span class="where"><span class="where__sweep">…</span><span class="where__num">…</span></span>` — the `.where strong` counter styling lives inside `.where__num` now. Direction `dir = next > prev ? 1 : -1`.

- **`.pag-compact` is pinned to content width, not the column** — it's `inline-flex` but the `.col` flex parent stretches it to 100%; `align-self: flex-start` keeps it the width of its contents. Mirrors `.seg`/`.seg-pill` `align-self: flex-start`.
- **Container is `align-items: stretch` so inner dividers span full height** — the `.where` counter (`1 / 12`) is shorter than the padded buttons, so its `border-right` separator stopped short. Stretch equalises child height; `.where` becomes `inline-flex; align-items: center` to keep its text vertically centered.
- **Dividers flanking the focused button go `border-right-color: transparent`** — same recipe as `.seg`. Clears the focused button's own right divider plus the right divider of the element immediately to its left (`.where:has(+ button:focus-visible/.is-focus)`, or an adjacent button) so the focus ring reads as one clean shape. `transparent` keeps the 1px box (layout unchanged).
- **`.pag-compact button` carries `.is-focus` alongside `:focus-visible`** — was `:focus-visible`-only, so the focus playground's forced state showed no ring. Two-tier z-index: forced `.is-focus` → `z-index:1`, real `:focus-visible` → `z-index:2`.

## Checkbox & radio (comp-checkbox-radio.html)

- **`.checkbox` / `.radio` labels are `user-select: none`** — the whole label is a click target, so rapid successive clicks would otherwise double-click-select the word under the cursor. Suppress text selection on the control.

## Related documents (comp-related.html)

- **Reference parsing (Jun 2026 rework): `parseRefsDetailed()` in `_related.js` is the single engine.** Depth-aware tokenizer (NOT regex — strings nest parens), returns `{ type, code, edition, qualifier, full, display }`: type ∈ ČOS/STANAG/STANREC/AP, `code` prefix-stripped ("3733" / "AEP-3733" / "051101"), `edition` normalised ("Ed. 3" / "Ed. A" / "1. vyd."), `full` = lookup code ("STANAG 3733" / "AEP-3733"), `display` = "AP AEP-3733 (Ed. A)". Rules: suffix paren letter = AP edition ("ALCCP-01(B)" → Ed. B); digits paren = amendment (dropped); other paren content = nested ref(s) emitted AFTER their primary; top-level commas + " a " split; bare numbers inherit context (previous type, or alpha prefix for "AQAP 110, 119"); bare 6-digit = ČOS, bare 4-digit = STANAG; edition-only segments ("1. vydání", "Ed.1", "Oprava 1") attach to the previous ref; de-dupe by type+code merges missing editions; leading lowercase prose ("nahrazuje …") stripped. Legacy `parseRefs()` = the `.full` projection — keep it.
- **data-preview.html resolves parsed refs TYPE-AWARE (`findByRef`)** — exact `type`+`code` match first ("existing STANAG with code 3733"), `findByDisplayCode` fallback. `relationRefs` items carry `{ parsed, doc, title, rel }`; the related row's edition pill shows the edition the SOURCE string CITES (may differ from the loaded doc's current edition), resolved doc's edition only as fallback.
- **Tests: `preview/test-related-parse.html`** — 21 fixtures (incl. real docs cos-051101 / cos-051627 / cos-051659 / cos-130033) + a full-catalogue compilation table (every unique adopts/replacedBy/implementedByCos string → parsed refs → resolution, with search + unresolved/empty filters). Run it after ANY parser change; comp-related.html has a live 5-sample parser demo.
- **Static HTML, NO local Tweaks panel (Jun 2026 rework).** Earlier draft had a local React `chipStyle` `TweakRadio` (CHIP_MOD class modifiers) — REMOVED: it duplicated the combined panel's chip mechanism and wasn't `?fp`-gated (a stray panel showed inside the playground). Now matches the doc-card / filter-group / lib-card convention: NO per-component chip panel — the COMBINED panel (`playground-tweaks.jsx`) governs the doc-type chip treatment app-wide. `.related` was ADDED to its general chip scope: `fpBuildChipCSS(t.chipStyle, ":is(.dc, .lib-card, .filter-item, .filter-group, .related)", null)`. So in the playground the Type-chip control restyles related's chips; standalone they're default filled. Zero duplication.
- **TABLE BEHAVIOUR (Jun 2026): the list is a GRID, every row subgrids it.** `.related` defines 5 shared tracks — `max-content fit-content(min(var(--rel-code-max,160px), 26%)) minmax(0,1fr) max-content max-content` (type | code | title | validity | arrow); each `a` is `grid-column: 1/-1; display:grid; grid-template-columns: subgrid` — so all rows' columns are EQUAL like a table. `.main` is `display: contents` (markup grouping only). Column spacing is MARGINS on the cells (`column-gap: 0`) so a column hidden at a breakpoint takes its spacing with it (no double gap) — required because size @container queries can't restyle `.related` itself, so the template must survive every breakpoint unchanged.
- **Code column grows AND shrinks between limits; pills wrap.** Floor = `--rel-code-min` (64px, `min-width` on `.rel-code`); ceiling = `min(--rel-code-max (92px), 26%)` — the % term shrinks the ceiling in narrow containers so wide codes can't starve the title. The 92px ceiling sits just above the longest real code (AECTP-400 ≈ 84px) ON PURPOSE: a code+pill pair always wraps (user-tuned down from 160px; short pairs like `4370 Ed.6` that fit stay inline). `.code` is `nowrap`; past the ceiling the `.rel-pill` edition/amendment tags (mirror comp-doc-card's `.pill`; `--am` = warning tint) WRAP to extra lines inside the cell (`inline-flex; flex-wrap: wrap`). The cell is vertically CENTRED over the row (`grid-row: 1/span 2; align-self: center`), like the chips/arrow; title keeps `align-self: baseline`.
- **Cell placement:** `.id-cell` (groups type chip + code; `display: contents` at wide widths) → type-cell col 1, rel-code col 2; title col 3 row 1, `.rel-sub` col 3 row 2 (relation tag and/or descriptor — optional, rows without it stay single-line), chip-cell col 4 (right-aligned in its track), arrow col 5; side cells span rows 1–2 (empty row 2 collapses, anchor row-gap 0; sub-line spacing = its own margin-top). The old `.rel-head` wrapper and per-row `.main` inner grid are RETIRED. Named `.rel-code` (not `.code-cell`) to avoid colliding with the ui-kit table's `.code-cell` in data-preview.html.
- **`data-preview.html`'s `relatedHTML()` emits the same structure** — `.rel-code` + `editionPills(rd.edition, "rel-pill")` when the reference resolves (unresolved refs get code only). Keep the builder in lockstep with comp-related.html's markup.
- **Each row's doc-type chip carries the canonical `.dc-chip__dot/__full/__short` spans** (so the combined panel's filled/outlined/dot treatment AND the responsive short form work without markup changes). The type PREFIX is dropped from the code (chip carries it): chip STANAG + code `4370`. Validity chip KEPT (useful status).
- **Active-row chip state REUSES the canonical `--active` modifiers (do NOT duplicate their colours).** A small inline script toggles `vchip--active` / `dc-chip--active` (+ achip/cchip) on each row's chips when the row is `:hover` / `:focus-visible` / `.is-focus` / `[data-state~="hover"]`. That gives: validity outline + tonal fill in BOTH themes (`.vchip--active`, _chips.css), and doc-type inset border in DARK only (`.dc-chip--active`, _dark.css, guarded against outlined/letter). A `MutationObserver` (class / data-state) catches the playground's forced-state stamping in addition to real pointer/keyboard events. Verified: light vchip border → success-500, dark dc-chip box-shadow → inset 1px #cdd4dc.
- **Arrow emphasis on hover AND keyboard focus (animated).** `.related .arrow` rests muted/`scale(1)`/0 stroke; on `a:hover`/`[data-state="hover"]`/`:focus-visible`/`.is-focus` it animates to `translateX(3px) scale(1.4)`, `primary-600`, `-webkit-text-stroke-width: 0.6px`. `transform-origin: right center`. System reduced-motion collapses it. VERIFY end-state with transitions disabled (offscreen iframe freezes them at the start value).
- **Responsive collapse via container queries** (`.related` is `container: rel / inline-size`): ≤680 validity → icon-only (it collapses EARLY since it's least critical); ≤540 doc-type chip → short form (STANAG→NAG); ≤480 type + code MERGE into one stacked cell — `.id-cell` stops being `display:contents` and becomes a real left-aligned flex column (chip above code) spanning tracks 1–2, centred over the row, capped at `max-width: 96px` so edition pills wrap UNDER the code and the title keeps maximum width (user-tuned down from 140px) (must re-set `align-self: flex-start` on `.rel-code` there — the base grid `center` would horizontally centre short codes in the column flex context); ≤400 validity dropped entirely (its track + margin collapse to 0); ≤340 the anchor leaves the grid (`display:block`): all chips gone, `.id-cell`/`.main` go inline so code (+ pills) + title flow as one running line, `.rel-sub` keeps `display:flex` (own line), arrow = `position:absolute` right-centred. The code-column %-ceiling makes pills wrap progressively as the container narrows. Verified.
- **All chips carry `data-tip` (loads `_tooltip.js`)** — full type name (`TYPE_TITLE`, e.g. "STANAG — Standardizační dohoda NATO") and the validity label. Especially useful once collapsed (validity icon-only ≤680, doc-type short ≤540) but present at every width, matching comp-doc-card / comp-table.
- **`data-playground` on the FIRST anchor only** (focus-playground forced-state demo). The edge-case examples (long wrapping title · row with no relation sub-line · descriptor-only sub-line) live in a SECOND `.related` list under a `.label-sm` caption and are deliberately NOT whitelisted — standalone-preview only.

## Doc-type chips — treatments (_chips.css)

- **The three doc-type chip treatments are canonical in `preview/_chips.css` and carry application-wide** — `.dc-chip` alone = FILLED (default solid block), `.dc-chip--outlined` = hollow colored border + text, `.dc-chip--letter` = colored text + a small colored `.dc-chip__dot`. Opt in by adding the modifier class; never re-define per component. Showcased (all four types, full + collapsed) in `comp-chips.html`. Dark inks (brighter #98c0e8/#cdd4dc/#c8b9e0/#a8d2c4) live in `_dark.css` as compound `body[data-theme="dark"] .dc-chip--outlined.dc-chip--cos` rules (0,4,0) that override the filled tint back to transparent.
- **Filled chips have an `.dc-chip--active` (selected) state that is dark-mode-only.** In dark, an active filled chip gets a 1px inset border (`box-shadow: inset 0 0 0 1px …`) in the outlined-variant dark ink (#98c0e8/#cdd4dc/#c8b9e0/#a8d2c4) so it reads as selected against the muted dark tint; in light mode the active state is a no-op because the vivid light fills already carry enough emphasis. Inset box-shadow (not a real border) keeps the box size identical across states; rule is scoped `:not(.dc-chip--outlined):not(.dc-chip--letter)` so it only affects the filled treatment. Lives in `_dark.css`; showcased as the "↳ active" row in `comp-chips.html` (lead labels given a fixed-width cell sized per-group via `--lead-w` on `.chip-group` — generic `.chip-group .row > .sub-label:first-child` / `.on-hover > .collapsed-label:first-child`, the active cell 8px narrower to offset `.on-hover` padding — so every group's active chips share a left edge with the resting chips above; applies to all four groups: type/validity/availability/classification). `comp-doc-card.html` adopts the active treatment on card hover: `body[data-theme="dark"] .dc:hover .dc-chip--{type}` mirrors the inset-border inks (dark-only, alongside the existing vchip/achip/cchip hover block). `comp-table.html` does the same on ROW hover: `body[data-theme="dark"] .tbl tbody tr:hover .dc-chip--{type}:not(.dc-chip--outlined):not(.dc-chip--letter)` — the `:not()` guards are REQUIRED there (unlike doc-card, which is always filled) because the table's `chipStyle` tweak can render outlined/letter chips, which must not pick up the inset border.
- **Outlined fill is `transparent` (not white) so it adapts to any surface** — card, zebra row, hover tint. This replaced the old table-local `--tbl-chip-out-bg` token (white light / transparent dark), now deleted.
- **`comp-table.html` CONSUMES these classes, it does not own them.** Its `chipStyle` tweak maps filled/outlined/letter → the shared modifier via `CHIP_MOD` and renders `<DocTypeChip chipStyle=…>`; the only table-local chip rule left is `.tbl .dc-chip { font-size: var(--chip-fs) }` (density scaling). The `data-chipstyle` table attribute + all `.tbl[data-chipstyle=…]` CSS were removed. The letter dot element is `.dc-chip__dot` (renamed from the old generic `.dot`).

## Preview helper styling (_help.css)
- **Shared annotation styles live in `preview/_help.css`** — `.label-sm` (section sub-labels), `.help` (explanation text), `.kbd-hint` (keyboard-hint strip), and the chip-showcase helpers `.sub-label` (mono inline row label), `.row-divider` (vertical separator), `.collapsed-label` (the “↳ collapsed” / “↳ active” marker). Linked after `_card.css`/`_dark.css` in every preview card. Never re-declare these in a per-card `<style>`; add only file-specific *extensions* (e.g. comp-switch keeps `.help { line-height:1.4; margin:0 }` + `.help--inline`; comp-chips keeps the contextual `.on-hover .row-divider` tint).
- **Card titles (`.label`) are English.** No Czech or mixed titles. Document-type CODES (ČOS, STANAG, STANREC, AP) and proper nouns (Lev, JVS) are kept as-is inside an otherwise-English title.
- **“↳” inline markers are lowercase and share `.collapsed-label`** — “↳ collapsed” / “↳ active”. Don't style one uppercase (`.sub-label`) and the other lowercase; they must match.
- **One keyboard-hint format: the `.kbd-hint` strip.** A compact, wrapping inline row of `<kbd>` keycaps with `<span class="sep">·</span>` dividers. Blue-ish tint (mono glyph on `--gov-color-neutral-50`, 1px border w/ 2px bottom) — token-driven so it flips in dark. The old titled `.notes`→"Keyboard"→`<ul>` block (was in comp-segmented) and the white `.kbd` span are retired for keyboard help. (`.notes` still exists in comp-list-toolbar for its changelog — that's a different component, leave it.)
- **Keyboard hints always go LAST**, at the bottom of the card, set off by the strip's dashed top divider. Never at the top.
- **English for all annotation text.** Titles (`.label`/`.label-sm`), explanation (`.help`), keyboard hints, demo scaffolding (the width-slider `.demo-bar` labels), annotation blocks (`.annot`), and all code comments are English. Czech is kept ONLY for genuine example copy *inside* the demoed control (field values, option text, pill data, consent-checkbox microcopy, validation messages, doc data) and for real UI `aria-label`/`title` chrome (project is CZ-first — see "Copy & content").

## Filter group (comp-filter-group.html)

- **←/→ cross-group landing is GEOMETRY-AWARE (Jun 2026):** side-by-side groups (rects overlap vertically) keep the same item index (horizontal hop); STACKED groups (responsive wrap/one column) land on the FIRST item going → (group below) and the LAST item going ← (group above) — a same-index landing felt random there. Decided per jump from live `getBoundingClientRect`, so mixed grid layouts pick the right model pair-by-pair (`jumpGroup` in `_filter-group.js`).

- **`.filter-item` rows are `user-select:none` (Jun 2026).** The whole `<label>` row is a click-to-toggle hit target — rapid double-clicks (or a small drag while pressing) would select the chip + count text. Suppress selection on the label. General rule: any UI element clicked quickly or draggable should be `user-select:none`.

- **Per-group clear control is labelled "vše"** (lowercase), NOT "vyčistit" — clearing a filter == selecting all, so the affordance reads as "show all". Distinct from comp-filter-pills' global "Zrušit vše". Carries `aria-label="Zrušit výběr — zobrazit vše"`.
- **"vše" reveals only while ≥1 row in its group is selected** (`btn.hidden = !boxes.some(checked)`), recomputed on every checkbox `change` and after a clear. Lives right-aligned in the `.filter-group__title` (flex space-between). Clicking it unchecks every checked box in that group and re-syncs row/chip active state.
- **"vše" appear/disappear is ANIMATED (Jun 2026)** — fade + scale (`opacity` + `transform: scale(0.82) translateY(-1px)`, `transform-origin: right center`). Still hidden via `visibility` (NOT `display`) so its flex box stays reserved and the title row doesn't jump ~1px as it toggles. On HIDE, `visibility` flips to hidden only AFTER the fade via a delayed `transition: visibility 0s linear 190ms`; on SHOW it returns instantly. The reset is a FLEX ITEM (title is `display:flex`) so it's blockified and `transform` applies despite `all:unset`→`display:inline`. System reduced-motion policy collapses it automatically. **Its focus ring blooms too** — `box-shadow` is in the base `.reset` transition (220ms expo-out), so `:focus-visible`/`.is-focus` animates in like every other focusable.
- **Keyboard (Jun 2026, full set on each checkbox `keydown`; bail on any modifier):**
  - **↑/↓** move (and wrap first↔last) WITHIN the focused filter's items (`focusBox`).
  - **←/→** jump (and wrap across groups) to the prev/next FILTER, KEEPING the vertical row index (`jumpGroup`, clamped to target length). Previously ←/→ duplicated ↑/↓.
  - **Enter** toggles like Space (a checkbox ignores Enter natively → explicit `setChecked(cb, !cb.checked)`).
  - **Insert** = CHECK (not toggle) the focused item + advance focus to the next item; **Delete** = uncheck + advance. "Next" follows a FLAT `allBoxes` track across all groups in DOM order, wrapping at the very end (skips "vše"). `advanceItem(cb)`.
  - **Backspace** = clear the whole group (same as "vše"), handled BOTH on an item's checkbox AND on the focused "vše" button. `e.preventDefault()` so it can't trigger browser-back.
  - Shared helpers hoisted to module level: `updateClear(g)`, `setChecked(cb,val)` (sets + `syncRow`, no native `change`), `clearGroup(g)`. The "vše" button is NOT in any focus loop. Checkboxes are native tab stops (all-tabbable model, no roving tabindex).
- **Focused row needs `z-index: 1`** — rows are `position: relative` siblings, so a later sibling's `.is-active` primary-50 fill paints over the bottom of the focus ring. `z-index:1` lifts the focused face above auto/0 siblings; tooltips (body-appended, far higher z) still clear it. Applied on `.filter-item:has(input:focus-visible), .is-focus`.

## Filter pills (comp-filter-pills.html)

- **× glyph centering: do NOT override the global `.bi` display.** The `.x i.bi` used to carry `display:block` ("kill baseline drift"), which UNLAYERED-beats the `@layer reset .bi { display:inline-flex; align/justify:center }` and reinstates the inline line-box strut — the `<i>` measured 15×16.458 (vs the 15×15 glyph) so the × rode high/off-centre in the 18×18 dot. Removed it (Jun 2026): with the global rule the `<i>` collapses to glyph size and the flex-centred `.x` button lands it dead-centre (verified `vCenterDelta 0.00`). Per the Iconography pitfall — never re-add per-component glyph `display`/`top`/`translateY` nudges.
- **Active token has its OWN hover (added Jun 2026).** The rest active state AND the generic `.filter-pill:hover` are both blue-50, so an applied pill had zero hover feedback. `.filter-pill.is-active:hover` (+ `[data-state="hover"]` mirror) steps to blue-100 fill / blue-600 border / **blue-700 text** (`.key` → blue-600). Higher specificity than `.filter-pill:hover`, so order-independent. NOTE: text is blue-700, NOT blue-800 — blue-800 has no dark remap (stays #1d3c5d navy → invisible on dark, killing the value AND the `×` glyph which is `currentColor`); blue-700 flips to a light blue (#b9d2ec) in dark. The `.x` dot keeps its own deeper hover on top.
- **`×` dot must out-step the active-pill hover bg.** Since hovering the pill turns its bg blue-100 AND the dot's own hover was also blue-100, the dot vanished. Dot is now blue-200 hover / blue-300 pressed+focus (was 100/200). Dark: blue-200 is a remapped translucent tint (reads over the pill's blue-100), but blue-300 is NOT remapped (opaque mid-blue → solid chip), so a `body[data-theme="dark"]` rule pins the pressed/focused dot to `rgba(82,142,210,0.46)`.
- **Enter/leave is animated by IN-FLOW WIDTH COLLAPSE (reworked Jun 2026), NOT FLIP/absolute-pin.** A leaving pill (single remove, each pill on "Zrušit vše", clear-all itself, restored pills, and the "Přidat" demo adds) animates its own `width` 0↔natural (WAA) plus opacity, while a negative `margin-right` (−`GAP`, 8px) eats the flex gap; neighbours reflow into the space naturally. NOTHING goes `position:absolute`. This was a full rewrite after the FLIP/pin version produced: the ghost "Zrušit vše" overlaying pills (absolute siblings paint over each other), "Zrušit vše" jumping/wrapping (FLIP across the whole row), content shifting up (the `scale()` + absolute), and a sudden end-pop. The `fp-clip` class (overflow:hidden + children `flex:0 0 auto`) makes the collapse clean — children keep full size and clip instead of the flex algorithm squishing the text as the box narrows.
  - **The collapse animates the FULL horizontal box (width + paddingL/R + borderL/R-width + marginRight + opacity), NOT just width.** A flex item's `width:0` only zeros its CONTENT box — flex-basis is content-box and `box-sizing:border-box` does NOT change that for the flex layout — so padding(16)+border(2) leave a ~17px residual that SNAPS on add/remove (the persistent "jump"). Animating every horizontal box prop to 0 (`BOXP` list, content-box width so leave box-sizing default) gives a TRUE 0 footprint; verified collapsedWidth 0.00. `fp-clip` (overflow:hidden + children `flex:0 0 auto`) keeps children full-size and clips them (no text squish); opacity fades the edge.
  - **Active row uses MARGIN-based horizontal spacing, not flex `column-gap`.** `.row[aria-label="Aktivní filtry"] { column-gap:0 }` + `> * { margin-right:8px }` (last-child 0); row-gap 8px stays for wrapped lines. Flex `gap` is NOT animatable and a negative-margin trick to "eat" it is browser-dependent, so the un-animated gap snapped in/out. With margin-based spacing each item's spacing IS its `margin-right`, animated to 0 with the rest of the box → footprint fully closes, no snap.
  - **WHY WAA: the playground speed control scales motion by setting `playbackRate` on every `doc.getAnimations()` entry in a rAF loop** — `element.animate()` results ARE in that list, so they follow the speed scale (the old per-pill timing partly didn't). `width:auto` isn't transition-animatable anyway; WAA takes explicit px keyframes. Force `display:inline-flex` inline so width/padding apply to the otherwise-`inline` ghost `.clear-all`.
    - **expandIn (ENTER) = box-width GROW (0 → natural), the established + preferred entrance.** A clip-path WIPE was tried (fp6) to kill the "fit-then-rewrap" quirk but rejected — it read as worse and left `.clear-all` looking un-animated; reverted to the grow (fp7-grow-restored). KNOWN tradeoff that comes back with the grow: a pill destined for a NEW line starts at width 0 on the current line and HOPS down once it widens past the line edge — inherent to a real box-grow (the footprint IS the width). Eliminating it without a wipe needs a transform-scale entrance (which squishes the text) — deferred. `fill:'both'` back-fills the ZERO start frame before first paint (no full-size flash / neighbour jolt); onDone `a.cancel()`s then removes `fp-clip`. **collapseOut (LEAVE) box-collapses** (width+padding+border+marginRight+opacity → 0) so siblings/row close up (FULL-box-collapse bullet above).
  - **Demo controls (`.clear-all`, `.reset-row`, `.add-row`) need explicit `flex:none; white-space:nowrap`.** `.clear-all { all: unset }` resets white-space to normal and leaves flex-shrink:1, so a tight row (or mid-animation frame) let the flex algorithm squish it and "Zrušit vše" wrapped to two lines.
  - **No mask.** The opacity fade hides the clip edge, and with everything in-flow nothing slides underneath anything (the mask's original purpose). A static right-feather mask was rejected — it dims the `×` from frame 0 (applied for the whole collapse, so at full width the right half is already faded).
  - **`onceDone(anim, fn, ms=DONE_FALLBACK)` removes/hides on `anim.onfinish` OR a `setTimeout`.** Fallback is 3000ms — must EXCEED the slowest playground speed (0.1× × 240ms = 2400ms) so a slowed real-tab animation isn't cut short (onfinish always wins first at any speed); it's also the ONLY path that fires in the frozen offscreen preview (rAF/compositor + WAA onfinish paused there; JS timers still run — so verification waits ~3s for the DOM removal).
  - **"Přidat" demo button** (`.add-row`) inserts a pill from a cycling `SAMPLE_PILLS` pool before `clearBtn`, calls `syncClearAll()` (reveals "Zrušit vše" if hidden), `expandIn`s it (neighbours make room), focuses its `×`. `restoreDemo()` resets `addIdx` to 0.
  - **Focus moves SYNCHRONOUSLY on remove, before the collapse** (a11y). The leaving pill gets `.is-leaving` and EVERY count/focus selector excludes it (`.filter-pill.is-active:not(.is-leaving)`).
  - **The deferred clear-all hide MUST be cancellable, else "Obnovit" can't bring it back.** `showClearAll` resets state (`remove('is-leaving','fp-clip')`, `cssText=''`, `hidden=false`) then `expandIn`, which `cancel()`s any stale/orphaned collapse OFF THE ELEMENT (`getAnimations().forEach(cancel)`) — a completed hide leaves a `fill:forwards` WAA collapse holding width/opacity 0 that a by-reference cancel would miss (was the "restored but empty space" bug). The hide's `onceDone` re-checks `is-leaving` and aborts if a show cleared it.
- **Active row: every control is a real Tab stop AND ←/→/Home/End move focus** (matches comp-segmented, which is all-tabbable + arrows — NOT roving). `role="group"`; do not use `role="toolbar"` (that implies a single tab stop). For pills the arrows only MOVE focus — every pill is already active, there's no single selection (unlike segments, where arrows also change the active one). `items()` = `.is-active > .x` + visible `.clear-all` + `.reset-row`, in DOM order; no `tabindex` juggling. No wrap. Removal focuses the shifted neighbour (then previous, then first suggested).
- **On remove, compute the next-focus target from active-pill `.x`'s ONLY — NOT `items()`.** `items()` includes `.clear-all` + `.reset-row`, so `after[idx]` after removing the LAST pill landed on "Zrušit vše" instead of falling back to the previous pill. Use a dedicated `activeXs()` (`.is-active > .x` only) for the `after[idx] || after[idx-1] || firstSuggested()` math.
- **Suggested pills: Enter must paint `:active` like held Space does.** Native `<button>` Enter fires click immediately without the `:active` press visual; Space shows it while held. Fix = transient `.is-pressing` class added on Enter keydown / cleared on keyup (+ capture `blur` guard), and CSS pairs `button.filter-pill.is-pressing` with `button.filter-pill:active`.
- **`.reset-row` ("Obnovit") is a demo-only restore.** Snapshots the initial active pills' `outerHTML` + suggested `aria-pressed` at load; restores them (and resets toggles) without a page reload so the remove/clear flows can be re-tested. Muted tertiary ghost, lives at the end of the active row (wraps to its own line on narrow cards — fine). Handlers are delegated on `root`/`activeRow`, so re-inserted pills need no re-wiring.
- **× glyph stays centered in its box (no transform); text/key/leading-icon nudged `translateY(1px)`.** Caps-heavy pill text reads ~1.5px high in a `line-height:1` flex line (no descender mass); nudging the TEXT down 1px lands the caps on the true centre and makes them line up with the box-centered × ink (both end ~0.5px high → mutually aligned). Verified by injecting a red center-line per pill and screenshotting (text renders in captures; the × icon-font glyph does NOT — see below). Don't nudge the × glyph to "fix the dot" in isolation — it desyncs from the text (cost a round trip).
- **save_screenshot / html-to-image does NOT render the bootstrap-icon `::before` glyph** — the × is invisible in captures and an isolated icon-only test page renders blank. To inspect glyph centering, measure ink on a `<canvas>` via `eval_js` in the live page (font is loaded there); for TEXT alignment, screenshots work.
- **Two semantic forms.** Active token = `<span class="filter-pill is-active">` (label `.key` + value `.val` + a real `<button class="x">` remove control); the × is the focusable control, Space/Enter/Backspace/Delete remove. Suggested = `<button class="filter-pill" aria-pressed>` — the whole pill is the toggle. Never nest a button in a button; the active form is a span so the × can be the only button.
- **Focus ring blooms on the WHOLE pill, not the ×.** Rule selector is `.filter-pill:focus-visible, .filter-pill:has(.x:focus-visible), .filter-pill.is-focus` with the system 220ms expo-out bloom (base pill transition carries the 140ms `box-shadow` exit). The × button's own `:focus-visible` ring is suppressed (`box-shadow:none`) and it shows a tint **dot** (`--gov-color-primary-100/200` bg) instead, so keyboard users see both the pill ring and which control is targeted. Playground stamps `.is-focus` on both `.filter-pill` and `.filter-pill button.x` — the × dot also keys off `.x.is-focus`.
- **× hover dot uses primary-100/200 tokens (not rgba black)** so it flips in dark with no `_dark.css` override. Glyph is flex-centered + `i.bi { display:block; line-height:1; font-size:15px }` (no transform).
- **Asymmetric inner spacing — NOT flex `gap`.** Wide label→value (`.key { margin-right: 8px }`), tight value→× (`.x { margin-left: 2px }`). Value is semibold (`.is-active .val { 600 }`), key is medium tertiary. Pills are `white-space: nowrap; flex: none` (button form wraps its text otherwise).
- **Padding: base `4px 12px` (symmetric — suggested pills get real right padding); `.is-active` overrides `padding-right: 4px`** because the × supplies the optical right gap.
- **Copy: "Zrušit vše"** (not "Vyčistit vše") for clear-all — more idiomatic CZ. `clear-all` hides via `[hidden]` when no active pills remain.
- **JS is scoped to the live rows** (`.row[aria-label="Aktivní filtry"]` / `.row[aria-label="Navržené filtry"]`). Static Hover/Pressed/Focus demo rows use `data-demo="hover|pressed"` / `.is-focus` and must stay out of the interactive queries — scope handlers to `activeRow`, don't query `.card` globally, or demo pills get counted/removed.

## Focus / state playground

- **comp-list-toolbar uses the REAL components now** (Jun 2026): the sort is the custom combobox ported verbatim from comp-select.html (`.combo-wrap--inline` + `.tb-select` trigger + `.combo-list` dropdown + the ARIA-1.2 combo JS), NOT a native `<select>`; the view switch is the animated segmented control ported from comp-segmented.html (`.seg` solid active fill + transient WAA sliding thumb + arrow-key nav). The container-query demotion ladder is preserved (sort label→icon ≤720, `.seg__label` hidden ≤600, count→pill ≤420). The view-switch's own click handler is gone (the `[data-seg]` JS owns it).
- **`.dc` (doc-card) and `.cv` (cover-card) are in `STATE_STAMP_SELECTOR`** so forced hover drives their existing mirrors (doc-card: card bg + `.dc:is(:hover,[data-state~="hover"]) .vchip/.achip/.cchip/.dc-chip` → active chip borders; cover-card: `.cv__img` translateY(-2px) + `.cv__title` underline).

- **Links + any focusable get the forced-focus ring from the shared base rule.** `colors_and_type.css` `@layer base` now matches BOTH `a:focus-visible, a[data-state~="focus"]` (with the bloom transition added to the `a` ring) AND `:where(button,[role=button],input,select,textarea,[tabindex]):focus-visible, …[data-state~="focus"]`. So the playground's forced `data-state="focus"` paints an animated ring on ANY focusable it stamps — footer/header/toast/breadcrumb links no longer need a per-file focus rule (they only own hover/pressed mirrors). A control that RESETS its box-shadow (`all: unset`, unlayered local `box-shadow`) still must re-assert the ring locally (it beats the layered base — see pitfalls).
- **Preview chrome is hidden systemically in the playground, not per-file.** `FORCE_STATE_CSS` unconditionally `display:none`s `.card > .label` (title), `.card > .col > .label-sm` / `.card > .label-sm` / `.annot` (section annotations) and `.demo-bar` (per-file width slider — the toolbar's Width control replaces it). This fixes the "title shows" reports across every no-marker file at once, and catches React-rendered files (comp-table) whose `data-playground` markers don't exist yet when the one-shot filter runs. `.help` is deliberately NOT hidden (can be microcopy inside a whitelisted control).
- **Organism interactive leaves added to `STATE_STAMP_SELECTOR`** (Jun 2026): `.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`. Each source mirrors its `:hover/:active/:focus` with `[data-state~=…]`. comp-list-toolbar's view-switch was also made interactive (click changes the active segment) and its `overflow:hidden` dropped (it clipped the focus ring) with first/last-child corner rounding instead.
- **comp-breadcrumbs whitelists only the 4-level + overflow examples** (`data-playground` on those two `.crumbs` navs) — drops the 2/3-level rows, the title, and the "Use when…" note.
- **Forced disabled handles custom comboboxes.** The disabled branch sets `aria-disabled`+`tabindex="-1"` on `[role="combobox"]` and `tb-select--disabled` on `.tb-select`; comp-select's combo JS bails on `aria-disabled==="true"` at the top of click+keydown. See pitfalls.

- **RENAMED `comp-focus-playground.html` → `preview/playground.html` (Jun 2026).** It's now a broader component gallery, not just focus/state inspection: ALL 13 form-control sections PLUS 16 organism sections (breadcrumbs, list-toolbar, header, footer, chips, status, notices, toast, empty-state, metabox, cta-panel, quick-tile, doc-card, lib-card, cover-card, table). Title/h1 are "Component playground". Older notes still say `comp-focus-playground.html` — same file. The `?fp=1` whitelist + force-state architecture is unchanged.
- **Combined Tweaks panel lives in `preview/playground-tweaks.jsx` (`PlaygroundTweaks`), mounted into `#tweaks-root`** — mirrors the prototype's `tweaks.jsx` (Table / Cover / Buttons matrices) but MINUS the theme + data-source controls (the toolbar owns theme; there's no live dataset here). It uses `<TweaksPanel collapsibleSections persistKey="cos-playground">`. Loads React+Babel+`tweaks-panel.jsx`+`playground-tweaks.jsx` at the end of `<body>`.
- **The combined panel broadcasts CSS into every iframe via `window.FP_TWEAKS`** — `FP_TWEAKS.applyToFrame(iframe)` appends/updates ONE `<style id="__fp-tweaks-css">` at the END of each iframe's `<body>` (table `.tbl …` rules + button `.btn--primary/.btn--danger` rules + cover `:root{ --tw-* !important }` vars). Body-END placement is REQUIRED: comp-buttons.html fills its own `#btn-tweaks-override` `<style>` in its body, so a `<head>`-injected sheet would lose the source-order tie. The cover vars MUST be `!important` because comp-cover-card.html sets the same `--tw-*` inline on `documentElement` from its own React effect — `!important` in a sheet beats a non-important inline custom property.
- **`inject()` calls `window.FP_TWEAKS?.applyToFrame(iframe)` per frame; the panel's mount-time `applyAll()` catches up.** `playground-tweaks.jsx` is a Babel script that transpiles AFTER the inline IIFE runs, so on the very first inject `FP_TWEAKS` may not exist yet (guarded). The panel's `useEffect` runs `applyAll()` on mount + every tweak change, re-applying to all already-loaded frames. Reloads (reduced-motion speed) re-run inject per frame.
- **Button tweaks apply EVERYWHERE buttons appear** (buttons, cta-panel, lib-card, doc-card, cover-card sections), not just the buttons section — the injected `.btn--*` rules are broadcast into every frame, which is the intended system-wide consistency.
- **New organism sections: only the NOISY source files got `data-playground` markers** (comp-doc-card `.doc`, comp-cover-card `#cv-stage`, comp-list-toolbar `#tbarWrap`, brand-header `#hdrWrap`, brand-footer `#ftrWrap`, comp-table `.tbl-wrap`) — to hide their width-slider demo-bars / notes. The clean display components (chips, status, notices, toast, empty-state, metabox, cta-panel, quick-tile, lib-card, breadcrumbs) have NO demo-bar, so they intentionally fall through to the full-preview fallback (showing all their variants — desirable in a gallery).
- **Toolbar height is measured live into `--fp-toolbar-h`, never hardcoded** — the `position:fixed` toolbar WRAPS to 2–3 lines as the viewport narrows; `body` `padding-top` and the sticky `.fp-sect__h` `top` both read the var (ResizeObserver on `.fp-toolbar` + `resize` + `fonts.ready`). The old `padding-top:56px` left section 01 hidden under a wrapped toolbar — don't reintroduce a fixed reserve.
- **Toolbar collapses progressively, controls keep a `data-tip` when text drops.** Breakpoints: ≤1280px hide the "Tab to walk through" counter; ≤1120px drop the uppercase eyebrow labels (STATE/THEME/SPEED/WIDTH) + the group divider + the Error-variant text (→ icon-only, `aria-label`+tip carry it); ≤720px hide the title text (icon-only brand). `preview/_tooltip.js` is now loaded so the icon-only controls tip. Add a `data-tip` to any new control whose text label you let collapse.
- **Speed pill has TWO kinds of control — synthetic speed vs. the real reduced-motion test.** `0.1`/`0.25`/`instant` are a SYNTHETIC viewing aid: `playbackRate` on every `getAnimations()` entry (relative scaling of CSS transitions, CSS anims AND scripted WAA) in a rAF loop that only runs for these modes; `instant` also injects a 1ms-duration CSS shim (1ms NOT 0/none so `transitionend`/`animationend` still fire). They never reload.
- **`reduced` is the REAL prefers-reduced-motion test — it RELOADS the frames with `?rm=1`, never fakes motion.** So each component sees the preference BEFORE its scripts run and runs its OWN code path end-to-end: (a) JS — `comp-pagination` honours `?rm` at boot (`FP_RM = URLSearchParams(location.search).has('rm')`, OR'd into its live `REDUCE.matches` read) so its genuine "skip the slide" branch fires; (b) CSS — a page can't make `@media (prefers-reduced-motion: reduce)` *match* from JS, so on (re)load `promoteReducedCSS()` lifts each component's own such rules verbatim out of the `@media` wrapper into a `#__fp-rm-promote` late stylesheet (same-origin sheets only; CDN sheets throw on `.cssRules` and are skipped). Components with NO reduced-motion handling (e.g. comp-segmented) correctly keep animating — the tool shows the truth, doesn't paper over the gap. Crossing the reduced boundary (either direction) is the only thing that reloads; leaving restores normal. The `?rm` convention parallels `?fp` — extend it (not a matchMedia monkey-patch, which can't retro-flip components that captured the MQL at load) when a new component needs a JS reduced-motion test hook.
- **Width control caps each iframe's `max-width` (range slider, 320–1440, max=Full).** Sets `iframe.style.maxWidth`; the per-frame body `ResizeObserver` (autosize) re-measures height as the inner content reflows. Reset button is `hidden` until a cap is set. The inline Links section (not an iframe) intentionally stays full-width.

- **Hover & pressed are forced by STAMPING `data-state="hover"` / `"pressed"`, NOT by a CSS mirror.** `comp-focus-playground.html`'s `stampState()` sets `data-state` on a whitelist of leaf interactive elements (`STATE_STAMP_SELECTOR`); each source file mirrors its `:hover` rules with `[data-state="hover"]` and its `:active` rules with `[data-state="pressed"]` (added as extra comma-selectors on the SAME rule → identical specificity + values). This fires the component's REAL styling — icon glyphs, sub-buttons (clear ×), dark-mode shadow/glow, error variants all included. **Never reintroduce the old `body[data-fp-force-state="hover"] .x { … !important }` mirror** — it only restyled the parent, drifted out of sync, and silently missed every sub-element + dark + error case (that was the root cause of the May 2026 state-playground bug report).
  - **When adding a new interactive component to the playground, add BOTH:** (1) `[data-state="hover"]`/`[data-state="pressed"]` comma-selectors next to its `:hover`/`:active` rules (light AND dark — `_dark.css` rules need the mirror too, e.g. `.field`/`.tb-select`/`.filter-pill` dark hover), and (2) its selector to `STATE_STAMP_SELECTOR`. Stamp on the element the `:hover`/`:active` is actually keyed on (sub-buttons like `.clear`/`.x` get their own entry; field icons are driven from the parent's `[data-state]`).
  - **`clearForcedClassesIn()` restores stamped `data-state`** via the `data-fp-orig-state` / `data-fp-no-state` markers `stampState()` writes; `applyStateTo()` always clears before re-stamping, so hover→pressed→default round-trips cleanly. The inline Links section is the ONE exception — it lives in the playground doc (not an iframe) so it keys off `body[data-fp-force-state]` set on the playground's own `<body>`, untouched by this change.
- **Every FORCE_* component needs the focus modifier the playground stamps (`FOCUS_TARGETS`).** Forced focus = a class (`.is-focus`, `.field--focus`, `.btn--focus`), NOT `:focus-visible`. If a source styles focus only via `:focus-visible` / `:focus-within` / `[aria-expanded]`, ADD a class mirror or the playground shows no ring. Fixed gaps: search `.field--focus` + `.clear.is-focus` + `.btn--primary.btn--focus`; select `.tb-select.is-focus`; filter-pills `.reset-row.is-focus`; switch `.sw.is-focus .sw__track` / `.tsw.is-focus` / `.tsw3.is-focus` (the previously-documented "switch forced-focus gap" is now closed).
- **Disabled is forced by the native `disabled` attribute + wrapper classes (`.field--disabled` etc.).** So a component's disabled visual MUST respond to `:disabled` (or the stamped wrapper class), not only a bespoke `.x--disabled` demo class. `comp-buttons` needed a real `.btn:disabled` rule with `!important` at `(0,2,0)` to beat the generated Tweaks CSS `.btn--primary { background … !important }` `(0,1,0)` — without it Primary stayed bright blue when disabled. `comp-search` was missing `.field--disabled` entirely (added). Scope hover/press `:not(.field--disabled)` / `:not(:disabled)` so disabled wins.
- **`?fp=1` flag — source files skip the Tweaks PANEL UI, not the CSS generation.**
 The playground appends `?fp=1` to every iframe src. Source files SHOULD check `new URLSearchParams(location.search).has('fp')` and, when set, call `generateCSS(TWEAK_DEFAULTS)` once and write to the override `<style>` tag — but skip `ReactDOM.createRoot(...).render(<App />)`. The panel UI is draggable and distracting inside the playground; the generated CSS is what gives the component its current saved look and must still apply. Canonical example: bottom of `comp-buttons.html`.
- **`T` key toggles the Tweaks panel.** Wired in `preview/tweaks-panel.jsx` — global keydown listener posts the same `__activate_edit_mode` / `__edit_mode_dismissed` messages the host toolbar uses, so panel state stays consistent across sources of toggle. Ignored when typing in inputs / textareas / contenteditable, and when any modifier key is held. Works in standalone "Present > New tab" views where the host toolbar isn't visible.
- **Source links open via the Claude Design URL, not the preview-serve URL.** Inside the playground, source-file labels build `https://claude.ai/design/p/<projectId>?file=preview%2F<name>&present=1` (same shape as Claude Design's own "Present > New tab"). The preview-serve URL works inside the iframe (carries session) but fails on top-level navigation with "preview token required". Project ID is parsed from `location.pathname` (`/v1/design/projects/<id>/serve/...`); fall-back to the iframe's resolved URL only if no project ID can be found.
- **Source links are `tabindex="-1"`.** The playground's tab-walk is for inspecting focus on components, not on the section chrome.
- **`data-playground` is a whitelist marker, not a blacklist.** Add the attribute (any value) to the element(s) in a source preview file that should appear in `comp-focus-playground.html`. Everything else under `<body>` is hidden by the playground at load time. Source files with no marker fall back to rendering full (legacy behavior).
- **Mark one canonical instance of each variant, not the kitchen sink.** The toolbar drives state (default / hover / pressed / focus / disabled) and theme — explicit "Hover / Pressed / Focus" demo buttons are redundant inside the playground. See `comp-buttons.html` `.row--live` for the pattern.
- **Layout-wrapper backgrounds that are ANCESTORS of a keeper can't be hidden by the whitelist filter** — the filter only hides siblings/non-path nodes, never on-path ancestors. comp-switch's `.tsw`/`.tsw3` keepers live inside a `.demo-cluster` panel (neutral-50 fill + padding) that therefore always renders as a pale "blueish" frame around the switch in the playground. Fix is a targeted `body[data-fp-filtered] .demo-cluster { background:transparent; padding:0; border-radius:0 }` rule in FORCE_STATE_CSS — flatten the framing panel only in the filtered view. Repeat this pattern for any future preview whose canonical control is wrapped in a decorative demo panel.
- **Iframes inside the playground get a small `color-scheme + min-height` patch** injected as `__fp-force-state` style. Don't remove it — it's what stops the dark-mode bottom-band leak when the autosizer adds a few extra px below body.
- **Toolbar is `position: fixed` on purpose.** `_card.css` puts `overflow: auto` on both html and body, which breaks `position: sticky` for the toolbar (the scroll context flips to body but body has no constrained height). Body has `padding-top: 56px` to reserve the toolbar's height. Don't switch this back to `sticky` without re-testing scroll behaviour in dark mode + at narrow widths.

## Source-of-truth files

- **`README.md` + `colors_and_type.css` are canonical.** When `SKILL.md` (or any derived doc) disagrees with them, trust the canonical pair and fix the derived doc in the same turn.
- **`colors_and_type.css` is the token contract.** Import it everywhere; never redefine colors, type scale, radii, shadows, or focus tokens locally. Domain extensions belong in this file, not scattered across components.
- **`ui_kits/cos-library/components.jsx` + `pages.jsx` is the canonical pattern library.** Copy from there when building new mocks; don't re-derive from screenshots.

## Brand & visual identity

- **No new brand.** Adapt Design systém gov.cz (DSGCZ) + Jednotný vizuální styl státní správy (JVS). Never invent a separate identity for the library.
- **No decorative military symbolism** — no camouflage, crosshairs, dramatic contrast, military hardware imagery. The library is administrative, not operational.
- **No marketing rhetoric.** Tone is closer to a court document than a SaaS app. State, trustworthy, technically precise, calm.
- **Imagery, when used, is cool / neutral / desaturated** — documents, hands on paperwork, architectural details of state buildings. Never warm tones, never flag waving.
- **No gradients in UI chrome. No grain. No textures. No glass / blur surfaces.** Transparency is only for image scrims and the disabled state (60% opacity).

## Typography

- **Czechia Sans is the project typeface** (Tomáš Brousil / Suitcase Type Foundry — official JVS typeface). Self-hosted from `/fonts` in five weights × upright/italic.
- **Czechia Sans is NOT freely available.** Access is granted on request by JVS brand owners / Suitcase Type Foundry. Any production deployment must verify its licence; previews and contexts without the font must rely on the fallback cascade below. This is why the cascade exists.
- **Sans cascade** (`--font-sans` / `--font-display`): `'Czechia Sans'` → `'Roboto'` (Google Fonts) → `'Aptos'` (modern Windows / Office system font) → `'Arial'` → `'Helvetica'` → `system-ui, -apple-system, sans-serif`. Project web fonts come first, then a Google web-font tier, then progressively safer system fonts. Roboto is imported in `colors_and_type.css` so the second tier always resolves.
- **Mono cascade** (`--font-mono`): `'Roboto Mono'` → `'JetBrains Mono'` (both Google Fonts) → `'Consolas'` → `ui-monospace, monospace`. Used for document codes and ID fields only — never as a terminal-aesthetic display face.
- **Use the variables, not literal family names.** Always reference `var(--font-sans)` / `var(--font-mono)` / `var(--font-display)` in component CSS — never hand-pick `'Czechia Sans'` directly. That's how the cascade stays consistent across the system.
- **Weight 600 is lost when the cascade falls past Roboto.** Roboto has no Semibold; the browser substitutes 700. Acceptable, but Semibold-vs-Bold contrast disappears in fallback rendering — review at the fallback tier before shipping if Czechia Sans access is uncertain.
- **Two stylistic-set variants of Czechia Sans** (ignored by fallbacks, which don't ship these features):
  - *Text variant* — default for body, UI, captions, metadata. `font-feature-settings: var(--otf-text)`.
  - *Logotype variant* — institutional names, brand wordmarks, large display headers. Applied via `.czechia-logotype`, `.text-display`, `h1`, or `font-feature-settings: var(--otf-logotype)`.
- **Weight tiers by size — see `notes/pitfalls.md` "Type rendering" for the rendering reason.** Bold (700) only at ≥ 24 px (Display, H1, H2). Semibold (600) at ≤ 22 px including H3, H4, brand wordmark, table headers, filter titles, metabox labels.
- **Tabular numerals for codes.** Use `.doc-code` (or `font-variant-numeric: tabular-nums`) so columns of standards align.
- **Line-height generous.** 1.5 for body, 1.6 for long-form metadata blocks.

## Color

- **One blue.** `--gov-color-primary-500` (`#2362a2`) carries every interactive element — buttons, links, focus, active states. The full DSGCZ blue scale lives in `colors_and_type.css`.
- **JVS Modrá (`#00469B`) is reserved for the logo block** — the Lev symbol tile and the product wordmark. Never used for UI chrome inside the page body.
- **Page background is `--gov-color-neutral-50` (`#f3f7fc`)**, a slightly cool off-white. Surfaces are pure white.
- **Color is never the only carrier of meaning.** Every status chip = text + icon + tint. Every availability label = text + icon + tint.
- **Document-type chips (ČOS / STANAG / STANREC / AP) use muted tonal swatches** — state-blue, graphite, plum, teal — each on its own pale tinted background. Distinct but quiet.

## Iconography

- **Bootstrap Icons via CDN** (`bootstrap-icons@1.11.3`). Outline variant by default; filled only for indicators that need solidity (active filter chip, selected radio). 16–24 px, `color: currentColor`.
- **No emoji. Ever.** This is a state register.
- **Icon glyphs are normalized to centre themselves** — `colors_and_type.css` carries `.bi { display:inline-flex; align-items:center; justify-content:center }` (both root + prototype copies). This makes every `<i class="bi">` shrink-wrap to its glyph and centre it, so icons sit dead-centre in any flex-centered control with no per-component vertical nudge. See `notes/pitfalls.md` "Iconography / glyph alignment" for the root cause. When adding a control with a centered icon, just flex-centre the control; never patch icon position with `top`/`margin-top`.
- **No unicode glyphs as icons.** Not `→`, not `▼`, not `·`. Use `bi bi-arrow-right`, `bi bi-caret-down-fill`, `bi bi-three-dots`.
- **Don't redraw the Lev or DIA/OOS/Úř OSK SOJ lockups.** Use `assets/logos/lev-jvs.svg` for the Lion in `currentColor`; full lockups must be supplied by the institution per JVS rules. The Lev tile sits on a square JVS-Modrá fill, radius 5 px.

## Dark mode architecture

- **Activated by `data-theme="dark"` on `<body>`.** `preview/_dark.css` remaps the neutral scale + semantic surface tokens; anything routed through `var(--color-bg-surface)` / `var(--color-fg-default)` / etc. flips automatically.
- **Theme toggle is injected by `preview/_theme-toggle.js`** — a sun/moon fixed button in the top-right of every preview card. State persists in `localStorage` under `cos-preview-theme`.
- **`ui_kits/cos-library/dark-mode.css` mirrors the same strategy** for the full prototype. Keep the two in sync when adding new tokens.
- **When adding a new preview card with hard-coded literal colors,** add a `body[data-theme="dark"] .your-selector { ... }` rule to `_dark.css` in the same commit. Otherwise the card breaks on toggle.

## Copy & content

- **Language rule (DS-wide, user-decided Jun 2026): ENGLISH ONLY for all design-system chrome** — playground UI, comp-page captions/labels/hints, Inspector & Tweaks controls and section labels, demo buttons (Narrow/Broaden/Replay), notes, code comments. **Czech is reserved for EXAMPLE content**: mock data (library-data.js), the cos-library prototype's product copy, specimen text inside components (doc-type chips, validity chips "Platný", "Nalezeno N dokumentů", aria-labels on example components mimicking the product). When in doubt: would it ship in the real product? → Czech; is it FOR the designer/developer using the DS? → English.
- **Czech-first (the PRODUCT).** The prototype/example interface copy is CZ; data fields are bilingual where the source data is (e.g. STANAG titles). EN locale planned but secondary. (This governs example content only — see the language rule above for DS chrome.)
- **Formal "vy" / "vás" / "vám".** Never "ty". Never address the user by name.
- **Sentence case** for headings and buttons (Czech convention). UPPERCASE only for eyebrow labels and the document-type chips (`ČOS`, `STANAG`, `STANREC`, `AP`).
- **Non-breaking space between abbreviation and number** — `ČOS&nbsp;100001`, `STANAG&nbsp;4370`.
- **Czech date format `1. 1. 2026` for human display**; ISO `2026-01-01` only in tooltips / export.
- **Distinguish "dokument neexistuje" from "dokument není veřejně dostupný"** in 404 / access-denied flows. Critical to never collapse these.
- **Empty state pattern** = "Zatím zde nic není." + one specific next-step link.

## Tooltip

- **Danger variant (`data-tip-variant="danger"`, Jun 2026).** For destructive / error tips (delete / clear / irreversible) or surfacing an error on hover/focus. Set `data-tip-variant="danger"` on the trigger; `_tooltip.js`'s `place()` mirrors it onto the shared `.tip` node (cleared on every show so it never leaks to the next plain tip). Solid `--gov-color-error-600` fill + white ink in BOTH themes (a semantic alarm, not the theme-inverted default chip), red-cast shadow. Implemented via a new `--tip-bg` var on `.tip` that drives BOTH the fill AND the caret border-colour (so a variant re-points ONE var; the `::after` no longer hard-codes `--color-fg-default`). Showcased in comp-tooltip.html (static specimen + a live `.icon-btn--danger` trash trigger).

- **comp-tooltip.html preview (Jun 2026) — playground section 02 (right after Links; all later sections renumbered +1).** Showcases the three variants STATICALLY (so all are visible without hover) via a `.tip--demo` presenter: `.tip` forced `position:relative` (NOT static — the `::after` caret must still anchor to the tip box), `visibility/opacity` on, no animation, floated above a representative anchor control with the down-caret pointing at it. Variants: **simple** (one line), **multiline** (wraps at 240px), **shortcut** (`data-tip-key` — single key / chord `Ctrl K` / alternatives `F | S` / action-sep `K / J`), plus a live hover/focus row of real triggers. **Playground whitelist:** only the Simple / Multiline / Chord `.tip--demo` boxes carry `data-playground` (anchor buttons + captions + live row are NOT keepers → the filter shows just those three tooltips, no controls). Bootstrap-icon glyphs read empty in html-to-image captures (expected) — verify via `document.fonts.check` + `::before`, not the capture.

- **comp-tooltip.html layout (Jun 2026 refinement).** ALL six specimens live in ONE `.specs` flex-wrap container; the grammar eyebrow is a full-width `flex-basis:100%` line break (`.eyebrow--break`) that groups the core 3 above the sub-forms. When that eyebrow is hidden — the playground whitelist filter, OR the modal "hide helper text" toggle — the break vanishes so the remaining tips flow as one compact row (fixes the "tips don't flow / waste space" report). Every caption (`.spec__cap`) + the live `.hint` carry the system `.help` class so the modal's helper-hide (`MODAL_HELPERS_CSS` hides `.help`/`.eyebrow`) strips all explanatory text, leaving only the tips + live controls.

- **The `redundant` guard compares `innerText` (VISIBLE text), not `textContent`.** A label hidden via `display:none` still appears in `textContent`, so the old guard wrongly suppressed tips on controls that collapse their label to icon-only at narrow widths (comp-table chips never tipped, full OR collapsed). `innerText` excludes display:none text, so the tip stays hidden while the label shows and appears the instant it collapses. Mirror in `ui_kits/cos-library/tooltip.js` carries the same fix.
- **Never use native `title=` for visible tooltips** — converted to `data-tip` + `_tooltip.js` in comp-chips, comp-table, comp-list-toolbar (view switch), comp-switch (theme toggles). Add `aria-label` alongside `data-tip` on any control that collapses to icon-only.
- **Shared module — do NOT inline per component.** CSS lives in `preview/_card.css` (`.tip` + caret + reduced-motion) with the dark drop-shadow in `preview/_dark.css`; the controller is `preview/_tooltip.js`. The prototype mirrors all three: `ui_kits/cos-library/ui-kit.css`, `dark-mode.css`, `tooltip.js`. Keep the mirror in sync. To add tooltips to a preview card: just `<script src="_tooltip.js"></script>` (the `.tip` CSS is already global via `_card.css`); in the prototype it's already loaded in `index.html`.
- **Usage:** put `data-tip="text"` on any element. For icon-only controls also add `aria-label` (the controller is presentational only — it does NOT supply an accessible name).
- **`data-tip-hover` opts a control out of keyboard-focus show (hover-only)** — added Jun 2026. Default behaviour stays hover OR `:focus-visible`; add this attribute when the tip is incidental and the control's state is changed repeatedly via keyboard, so a tip re-firing on every focus/toggle would be noise. Applied to the 2-state theme switch (`comp-switch.html`: live `#tsw1` + the matrix-generated `.tsw` cells). Mirror in `ui_kits/cos-library/tooltip.js` if used there.
- **Never use native `title=` for visible tooltips** — can't be styled/animated and double-renders next to the custom chip. **But `title=` is NOT always a tooltip:** `<TweaksPanel title>`, `<FilterGroup title>` (component props) and `<iframe title>` (a11y name) are NOT tooltips — leave them. Only convert `title=` that renders a hover label.
- **Equal-text guard makes `data-tip` safe on label+icon controls.** The controller skips showing when the tip text equals the trigger's visible `textContent`. So a nav link / chip that shows its label at wide width and collapses to icon-only at narrow can carry a static `data-tip` (+`aria-label`): the tip stays hidden while the label is visible and appears only once the label goes `display:none`. No conditional markup.
- **Theme-awareness is token-driven, not a per-theme color list.** Surface = `var(--color-fg-default)` (near-black navy in light, near-white in dark); text = `var(--color-bg-surface)`; caret reads the same `--color-fg-default`. Inverted chip that flips automatically. Only the drop-shadow is re-pinned in `body[data-theme="dark"] .tip` (navy ink invisible on dark → black ambient + hairline).
- **Animation is parking-proof.** Opacity + visibility are **rule-driven** (`.tip` hidden → `.tip[data-show="1"]` visible) and never transitioned, so opacity can't park at 0 in throttled/capture/verifier contexts. The entrance motion is a **transform-only** `@keyframes tipIn` (translateY 4px + scale .98 → none); if its clock is frozen the worst case is a ~4px offset, never invisible. Real browsers play the rise. Hover 120 ms open delay; keyboard focus shows immediately. Honors `prefers-reduced-motion` (animation removed). **Do not reintroduce an opacity transition off a 0 base** — it parks (cost us a long debugging detour; the symptom is computed `opacity:0` while `data-show="1"`).
- **Placement — `data-tip-pos` requests a side: `auto` (default) · `top` · `bottom` · `left` · `right`.** `auto` keeps the legacy prefer-above / flip-below behaviour. A requested side that would clip the viewport flips to its OPPOSITE (left↔right, top↔bottom); the tip then clamps to the viewport. Caret re-aims on the perpendicular axis: `--tip-arrow-x` for top/bottom, `--tip-arrow-y` for left/right (JS sets whichever applies; the unused one is removed). Entrance slide direction also follows placement via `--tip-in-x`/`--tip-in-y` (transform-only, still parking-proof). **The floating cluster (`_inspector.js mkBtn`) sets `data-tip-pos="left"`** — it sits top-right and its sub-buttons expand DOWNWARD, so a top/bottom tip on the resting dark-mode button would cover the buttons it reveals; left clears the column. Hide on `Esc` and on scroll (capture). Documented live in comp-tooltip.html (a Placement row of four arrow triggers).
- **Placement ANCHORS the trigger-adjacent edge — so the arrow is size-independent (the robust fix for web-font reflow).** `layout()` pins the edge the arrow sits on with `right`/`bottom` for left/top placements (and `left`/`top` for right/bottom): left→`right`, top→`bottom`, right→`left`, bottom→`top`. The tip then grows AWAY from the trigger when it reflows (Czechia Sans / mono keycaps loading after the first measurement), keeping the anchored edge + arrow locked — no re-measure needed on the main axis, correct on first paint. Verified: a tip forced from 168→250px wide with rAF+RO frozen kept its right edge locked and grew leftward. Only the PERPENDICULAR centering axis still uses the measured size (+ clamp); its arrow re-aims via the follow loop / `ResizeObserver` on the tip if the size changes. `place()` is split into `layout(el)` (no `data-show`) + the rAF `follow()` (keyed on trigger rect + tip size) for when the TRIGGER moves (cluster sub-buttons sliding as it expands). Retired the earlier hacky timed `setTimeout`/`fonts.ready` re-aims — edge-anchoring + RO + follow supersede them.
- **Measure with `offsetWidth/offsetHeight`, not `getBoundingClientRect()`** — the chip's `scale()` transform skews the rect; offset sizes ignore transforms and give true layout size for positioning math.
- **Dynamic labels must keep `data-tip` in sync.** When JS rewrites a control's label (e.g. `brand-header` auth button on sign-in), set `data-tip` + `aria-label` in the same handler — don't leave a stale tip. `components.jsx` does this for free because `data-tip={authLabel}` re-renders.
- **Controller is idempotent** (`window.__tipInit` guard) — safe if a page includes it twice.

## Shared component files (preview/_*.css + _*.js)

- **Every interactive + standalone component now lives in a shared `preview/_x.css` (+ `_x.js` where it has a controller), and its `comp-*.html` card LINKS those instead of inlining (single source of truth, Jun 2026).** Done set: `_buttons` · `_chips` · `_field` (search) · `_tooltip` · `_switch` · `_select` · `_segmented` · `_pagination` · `_breadcrumbs` · `_filter-pills` · `_filter-group` · `_region-nav` · `_status` · `_table` · `_checkbox-radio` · `_toast` · `_notices` · `_empty-state` · `_metabox` · `_quick-tile` · `_lib-card` · `_related` · `_cta-panel` (+ `_search.js` for the search field whose CSS is in `_field.css`). NOT extracted: the organisms **doc-card / cover-card** (kept self-contained per-file by architecture decision).
- **`_table.css` extracted from comp-table.html (Jun 2026) so the catalogue table is shared (single source for the card + `data-preview.html` + the coming prototype rebuild).** Holds the `--tbl-zebra-even` token (+ dark @layer theme revalue) and the whole `@layer components { .tbl-wrap / .tbl / .code-cell / .date-col / chip row-hover / container-query ladder }` block. comp-table.html now LINKS it and keeps only the scaffold (`.card` padding + `.demo-bar` width slider) inline. NOTE: `ui-kit.css` still carries an OLD duplicate `.tbl` (and `.quick-tile`/`.filter-group`/`.timeline`) — reconcile those to the canonical `_*.css` during the prototype rebuild; until then the prototype keeps its copy and `data-preview.html` does NOT link `ui-kit.css` (it would shadow the canonical filter-group/quick-tile with the old higher-specificity `.filter-list .filter-item` rules).
- **`.rstat` request-status PILL lives in `_status.css`** (same request-status component family as the `.timeline`). `cls` keys mirror `window.REQUEST_STATUS_META` (submitted/in-review/more-info/approved/rejected); gov `*-50` fills are translucent in dark so it tints correctly with no extra theme rule. `ui-kit.css`'s `.rstat` copy is now the duplicate to drop in the prototype rebuild. Each `comp-*.html` keeps ONLY its demo scaffolding (`.row`/`.col`/`.grid`/`.demo-bar`/width slider/`.see-also`) + a comment pointing at the shared file.
- **Controller convention — every `_x.js` is: (1) globally idempotent (`if (window.__xInit) return; window.__xInit = true;`), (2) per-element guarded (`if (el.dataset.xWired==='1') return; el.dataset.xWired='1';`), (3) exposes `window.initX(root)` so the prototype can wire dynamically-rendered DOM, and (4) auto-runs `initX()` on DOMContentLoaded (or immediately if already loaded).** Use delegated/`querySelectorAll`-on-init listeners so React-rendered DOM works. `_select.js` also fires a `combo:change` CustomEvent; `_status.js` exposes `window.playTimeline(el)`; `_switch.js` `window.initSwitches`, etc.
- **`--on-dark` placement modifiers live in the shared file, not the consumer** (`.tsw--on-dark` in `_switch.css`, `.tb-select--on-dark` in `_select.css`). A consumer on a dark surface adds the modifier; it never re-skins the base component locally.
- **list-toolbar + brand-header are now pure CONSUMERS** — they link `_select.*`/`_segmented.*` (toolbar) and `_switch.*`/`_select.*` (header) and keep only their own chrome + a few documented deltas (e.g. toolbar's wider right-anchored `.combo-list`, centered `.seg`). When a card composes shared components, override via a small delta block AFTER the links (unlayered, same specificity, wins by source order) — don't fork the component.
- **region-nav was generalised off the demo's hard-coded IDs** to a `[data-region-nav]` container (reads its `[data-region]` sections + optional `.announcer`/`.sr-only`); skip-links anywhere in the doc targeting its region ids are wired; when focus is outside all regions, only the FIRST `[data-region-nav]` claims F6.
- **filter-pills / filter-group controllers are scoped to a root** (`initFilterPills`/`initFilterGroups(root)`); cross-group keyboard nav + the Ins/Del linear track operate within the groups found under that root (one sidebar), so multiple independent sidebars don't bleed into each other.

## Refactor playbook — putting a component onto the system

Generic checklist when adding/auditing states on any interactive component (used for the segmented-control + tooltip refactor; follow it for the next one):

- **Read three reference points first:** `notes/pitfalls.md` + `notes/decisions.md`, the canonical sibling component (e.g. `comp-buttons.html` for button-family states), and `comp-focus-playground.html` to see how each state is *forced* (it drives the live demo and CI-style review).
- **Match the playground's force hooks exactly** or the state won't show in the playground: focus = `.is-focus` class (alongside `:focus-visible`), pressed = the playground's `body[data-fp-force-state="pressed"]` visual (mirror its colors in your real `:active`), disabled = native `disabled` attr (so scope hover/press `:not(:disabled)`). Add `data-state="hover|pressed"` hooks for static doc rows, and omit `data-seg`/interactive wiring on those rows so they don't reshuffle.
- **Use the system tokens + recipes, never new values:** focus = `--shadow-focus` + the two-tier bloom transition (140 ms base / 220 ms expo-out on the focus selector); disabled = muted-neutral (`--color-fg-tertiary`), not an opacity wash; pressed = a tint well + `inset` shadow.
- **Dark mode in the same edit.** Any new hard-coded state color needs a `body[data-theme="dark"]` override (in the per-card `<style>` if the file already keeps its dark rules there, else `_dark.css`). primary-100/700 tints invert oddly — prefer `rgba(255,255,255,x)` wells for dark pressed (see `comp-buttons` dark `:active`).
- **Extract anything reused into a shared file** (`_card.css` / `_dark.css` / a `_x.js` module) and delete the inline copy; mirror into `ui_kits/cos-library/` if the prototype needs it. Plain-JS modules use delegated `document` listeners so they work with React-rendered DOM.
- **Document + sync the index.** Add the rule here (newest at top), add pitfalls if a gotcha bit, and update the `CLAUDE.md` notes index if you add/rename an H2.

## Segmented control

- **Full state set lives in `preview/comp-segmented.html`** — hover / pressed / focus / disabled for both `.seg` and `.seg-pill`, in light and dark.
- **`.seg` active tint slides via a TRANSIENT thumb, not a persistent one.** Rest state = the active segment's own solid `.is-active` primary-50 fill (robust, pixel-aligned, dividers intact, and `:hover` still tints it since `:hover` out-specifies `.is-active`). The injected `.seg__thumb` is `opacity:0` at rest and only revealed via `.seg.is-sliding` for the glide between segments; `.seg.is-sliding button.is-active` goes `background:transparent` so the moving thumb paints the tint. Motion is driven by the **Web Animations API** (`thumb.animate(transform+width)`), NOT a CSS transition, and the hand-off keys off `anim.finished` so it stays in sync under DevTools animation throttling (a wall-clock timer fired early and cut the slide short). On finish: add `.seg--snap` (`transition:none !important`, one frame) so the destination's solid fill SNAPS in instead of fading up after the thumb vanished, remove `.is-sliding`, `anim.cancel()`, then clear snap via double-rAF + `setTimeout` fallback. `go(toBtn)` early-returns on `toBtn === fromBtn`; interrupts guarded by an `anim !== mine` token.
  - Thumb corner radius is set per-position (`3px 0 0 3px` first / `0 3px 3px 0` last / `0` middle) and metrics are measured fresh each slide (no fonts.ready re-place needed since the thumb only exists mid-motion).
  - **Dividers are hidden for FOCUS only** (`:focus-visible`/`.is-focus` + their `:has(+ …)` left neighbour), so the focus ring reads clean. Active segments keep their dividers. Focus radius relies on `:first/last-of-type` (NOT `:first-child` — the injected thumb is the real first child). See pitfalls "Segmented control / sliding thumb".
- **Every hover/press rule is scoped `:not(:disabled)`** so the `:disabled` rule wins without a fight; disabled = muted `--color-fg-tertiary` + `not-allowed`, the muted-neutral language shared with comp-buttons (not an opacity wash).
- **Pressed = `primary-100` well + `inset 0 1px 2px` press shadow**, matching the forced "pressed" rule in `comp-focus-playground` so real `:active` and the playground demo read identically. Dark remaps to `rgba(255,255,255,0.07)` + inset (primary-100 inverts to near-black).
- **Focus rings carry the system bloom AND `.is-focus`.** `:focus-visible, .is-focus` both get `--shadow-focus` + the 220 ms expo-out entry / 140 ms exit two-tier transition. `.is-focus` is mandatory — it's the class `comp-focus-playground` toggles to force the state; a `:focus-visible`-only rule shows nothing in the playground.
- **`data-state="hover|pressed"` hooks exist for static state demos** (documentation rows that must not reshuffle) — those demo containers omit `data-seg` so the activate handler leaves them alone.

## Switch

- **Component CSS + JS live in `_switch.css` + `_switch.js` (single source of truth, extracted Jun 2026).** `comp-switch.html` links them and keeps only its demo-layout helpers + the doc-only state-matrix builder + the focus demo; `brand-header.html` links them too (theme switch). `_switch.js` is idempotent (`window.__switchInit`), auto-wires every `.sw`/`.tsw`/`.tsw3` on DOMContentLoaded, SKIPS `aria-hidden="true"` cells (the matrix) and already-wired elements (`data-sw-wired`), exposes `window.initSwitches(root)` for dynamically-built switches. Variants: `.tsw` (52×28) · `.tsw--sm` (44×24) · `.tsw--on-dark` (40×22, dark-strip). Travel per variant in `wireTsw` (on-dark 18 / sm 20 / default 22). When adding a switch anywhere, use the shared classes — do NOT re-derive the CSS or controller.
- **Switches are drag-toggleable + elastic (added Jun 2026, `comp-switch.html`).** A single controller (`enhanceSwitch(host, cfg)`) owns the thumb's position AND its squash/stretch for `.sw` (boolean) and `.tsw` (2-state theme). Pointer-drag the thumb/track, tap, or keyboard — all share one path. `.tsw3` is unchanged (segmented, click-only).
  - **Thumb is driven by INLINE CSS vars, not a fixed transform** — `--sw-x/--sw-sx/--sw-sy` on `.sw__track` (read by `::before`); `--tsw-x/--tsw-sx/--tsw-sy/--tsw-p` on the `.tsw` button (read by `.tsw__thumb` + the `<i>` glyphs). The old `:checked`/`[aria-checked]` rules now set the resting var instead of `transform:translateX()` — the no-JS / non-enhanced fallback, and what positions the JS-built state-matrix cells. `.sw--js`/`.tsw--js` (added on enhance) drop the CSS transform transition so JS writes aren't double-tweened; box-shadow stays transitioned so the drag shadow fades.
    - **CRITICAL: the resting-var rule must target the SAME element the JS writes inline on, else the thumb JUMPS instead of animating** (fixed Jun 2026). JS writes inline vars on the `.tsw` BUTTON, so the resting rules are `.tsw[aria-checked="true"] { --tsw-x; --tsw-p }` (on the button), NOT `.tsw[aria-checked="true"] .tsw__thumb`. A var specified DIRECTLY on the thumb would beat the inline value INHERITED from the parent (specified > inherited), pinning `--tsw-x` to the end-stop the instant `aria-checked` flipped — so the thumb teleported and only `--sx/--sy` animated ("jump then squash at destination"). With the rule on the button, inline (written every frame) out-cascades it and the thumb interpolates. `--tsw-p`/`--tsw-x` reach the thumb + glyphs via `inherits:true` on the `@property`. Colour/background stays on `.tsw__thumb` (those aren't inherited the same way). `.sw` never had this bug — its inline vars AND its `input:checked + .sw__track` rule are on the SAME element (`.sw__track`), where inline always wins.
  - **Stretch is a function of POSITION, never velocity** (`stretchFor(p)`): `s = sin(π·f)·0.16 − |overshoot|·1.4`. Net positive = stretch (wide+flat) mid-travel; net negative = squash (narrow+tall) when poking past an end or during the anticipation dip → the "flatten → stretch → opposite at destination". **Dragging writes `stretchFor(dragPos)` synchronously per `pointermove`** (scrubs the same timeline; frame-independent, so it works even where rAF is throttled).
  - **Toggles play a spring-AUTHORED path through a no-op WAA "clock".** `springPath(from,to)` (K=0.12, D=0.66, backward anticipation kick) shapes the timeline; a throwaway `host.animate([{opacity:1},{opacity:1}], dur)` is created ONLY as a clock — its `currentTime` advances at whatever `playbackRate` the playground sets, so the speed control scales it. A rAF loop samples `clock.currentTime/dur`, interpolates the path, and writes the inline vars (which render reliably — unlike interpolated WAA/CSS-animation values, which this engine does NOT reflect; see pitfalls). Reduced motion (`?rm` flag OR `matchMedia`) snaps with no clock. This is why point-5 "respect playground speed + reduced motion" holds.
  - **Drag shadow is drag-ONLY and fades** — `.is-drag` (added on pointerdown, removed on pointerup) lifts a deeper thumb shadow via the box-shadow transition. NOT applied on click/keyboard toggles (it was distracting there).
  - **Theme-switch glyph swap is part of the thumb timeline** — sun/moon `opacity`+`rotate` are `calc()`-driven off `--tsw-p` (the same 0..1 progress `write()` sets every frame), so they cross-fade/rotate in lockstep with the thumb, not on a separate transition. Glyphs are centred with `position:absolute; inset:0; display:grid; place-items:center` so rotation pivots on the true centre (fixes a ~1px vertical drift the old per-glyph transform caused).
  - **grab/grabbing cursors only OFF Windows** — `html:not(.is-win)` gates them; the `is-win` class is set from JS (`navigator.userAgentData.platform` / UA regex). Windows lacks native grab cursors (browsers substitute blurry bitmaps), so it keeps the plain pointer. Drag still works on every platform; only the cursor differs.
  - **`@property --sw-x/--sw-sx/--sw-sy/--tsw-*` are registered** (typed + initial values) so the non-enhanced fallback + matrix cells have correct defaults; interpolation is NOT relied on (this engine can't interpolate custom props via WAA *or* CSS animations).
  - State flips at the **midpoint** (`pos>=0.5`) during a drag so the track colour / glyph cross over together with the thumb.

- **Hover styling exists on all three switch types (added May 2026).** Boolean `.sw`: track steps one notch — off `neutral-300 → neutral-400`, on `primary-500 → primary-600`. Theme `.tsw` / `.tsw3`: quiet surface lift — `background: neutral-200` + `border-color: border-strong` (the mode track is always neutral, so hover is a surface change, never a colour change). Every hover rule is scoped `:not(.sw--disabled)` / `:not(:disabled):not(.tsw--disabled)` and mirrored with `[data-state="hover"]` so the focus playground + the static state-matrix can force it. Dark direction is re-pinned in `_dark.css` (light hover out-specifies the dark base remap): `.sw` off → `neutral-500`, on → `primary-600`; `.tsw`/`.tsw3` → `neutral-200` + `border-strong`.
- **Theme switches now carry disabled + the focus-bloom transition.** `.tsw--disabled, .tsw:disabled` / `.tsw3--disabled, .tsw3:disabled` = `opacity:0.55` + `not-allowed` (matches `.sw--disabled`). `.tsw`/`.tsw3` got the system two-tier focus transition (base `box-shadow 140ms ease-out`; focus selector overrides to `220ms cubic-bezier(0.16,1,0.3,1)`).
- **`.sw` / `.tsw` / `.tsw3` are in the playground `STATE_STAMP_SELECTOR`** (scoped `:not(.sw--disabled)` / `:not(:disabled):not(.tsw--disabled)` etc.) so forced "hover" works there. They were already in `FOCUS_TARGETS` for forced focus.
- **State matrix (`#smx2` / `#smx3` in comp-switch.html) is doc-only and JS-built.** A compact grid — positions down the rows (light/dark for 2-state; light/auto/dark for 3-state), states across the columns (default / hover / focus / disabled). Cells carry `aria-hidden="true"` (the live switches above are the real controls for AT) + `tabindex="-1"` (out of the tab walk) and no `data-playground` (so the playground's whitelist hides them). State is forced per-column: hover→`data-state`, focus→`.is-focus`, disabled→native `disabled` attr + `--disabled` class.
- **Keyboard map mirrors the segmented pills** (`preview/comp-switch.html`). Boolean `.sw` (native checkbox): Space toggles natively; **Enter also toggles**; **← sets off, → sets on** (binary — no stepping, no wrap, no Home/End). 2-state theme switch `.tsw` (`<button role="switch">`): Enter/Space toggle via native click; **← off, → on**. 3-state theme switch `.tsw3` is a segmented pill — it keeps the full ←/→ + Home/End stepping.
- **Don't intercept Space on the native checkbox** — the browser already toggles it; a manual Space handler double-toggles. Only add Enter + arrows.
- **Keyboard-hint strip lives at the card bottom** (`.kbd-hint`), per the keyboard-help convention.

## Combobox / Select

- **Toned-down “pronounced chevron” on hover AND focus (Jun 2026).** The `.field`/`.tb-select` chevron previously reacted to hover only (colour + 1px down-nudge) and did NOTHING on focus. Now it also GROWS `scale(1.18)` + `-webkit-text-stroke-width:0.4px` on hover AND keyboard focus (`.field--focus`/`:focus-visible`, `.tb-select.is-focus`), and on OPEN it grows while it rotates (`rotate(180deg) scale(1.12)`). Transform list kept to a consistent 3-function order (`translateY() rotate() scale()`) across every state so transitions interpolate. `.field--error` is EXCLUDED from the focus grow (keeps its red, no scale). **`.tb-select--on-dark` (gov-strip language switcher) SUPPRESSES the grow** — its `.chev` rules (incl. a new `.is-focus` entry) pin `scale(1)` + currentColor white so the minimal strip control is untouched. `transform` never reflows, so the trigger value text is unmoved.

- **Component CSS + JS live in `_select.css` + `_select.js` (single source of truth, extracted Jun 2026).** `comp-select.html` links them and keeps only its demo layout; `brand-header.html`'s language switcher links them too. `_select.js` is idempotent (`window.__selectInit`), wires every `[data-combo]`, exposes `window.initCombos(root)`, and fires a `combo:change` CustomEvent (`detail.value`) on the wrapper when a value commits (consumers hook it to e.g. switch UI language). Triggers: `.field` (form size) · `.tb-select` (compact) · `.tb-select--on-dark` (dark strip). Helpers: `.combo-wrap--inline` (anchor menu to trigger) · `.combo-wrap--end` (right-anchored menu) · `.combo-item .lang` (trailing right-aligned meta label). When adding any combobox, use these shared classes + `data-combo` — the controller wires it.
- **Canonical model is demonstrated in `preview/comp-select.html`** (CSS/JS now in `_select.css`/`_select.js`). Re-use that pattern when adding any new combobox — don't re-derive the keyboard map, ARIA wiring, or animation choices.
- **ARIA 1.2 combobox-with-listbox.** DOM focus stays on the trigger; option highlight moves via `data-active="true"` on the option + `aria-activedescendant` on the trigger. Selection state is separate (`aria-selected="true"`) and persists across closes. Trigger carries `role="combobox"`, `aria-haspopup="listbox"`, `aria-expanded`, `aria-controls`.
- **Keyboard map (focused trigger):** closed: `↓`/`↑`/`Alt+↓`/`Enter`/`Space` open + highlight selected-or-first; printable keys open + type-ahead jump. Open: `↓`/`↑` move (no wrap to first/last — wrap is fine), `Home`/`End` jump, `Enter`/`Space` commit + close, `Esc` close + revert + restore focus, `Tab` close and let focus move on, printable keys type-ahead.
- **Type-ahead folds Czech diacritics.** `s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase()` — so typing "c" matches "ČOS". 500 ms buffer; starts-with match preferred, includes-match fallback. CZ-first project rule.
- **Pointer-hover and keyboard share the active item.** `mouseenter` on an option calls the same `setActive` as arrow keys. Active-item style adds a 2 px inset rail (`box-shadow: inset 2px 0 0 primary-500`) on top of the hover tint so the keyboard target is distinguishable when the mouse is also over the list.
- **Dropdown open + close both animate via a single `transition` (opacity + translateY).** `.combo-list:not([data-static])` carries the hidden state (`opacity:0, visibility:hidden, transform:translateY(-4px), pointer-events:none, transition: opacity 100ms ease-in, transform 100ms ease-in, visibility 0s linear 100ms`); `[data-open="true"]` adds the open state (`opacity:1, visibility:visible, transform:translateY(0), pointer-events:auto, transition: opacity 140ms cubic-bezier(0.16, 1, 0.3, 1), transform 140ms, visibility 0s linear 0s`). The `visibility 0s linear 100ms` delay on close keeps the element visible during the fade-out then hides it once fully transparent. Element stays `display:block` always — the close transition needs a target to interpolate to. **Do NOT use `@keyframes` paired with `display: none ↔ block`** for this — it works on open but skips on close (`display:none` removes the element before the animation can run).
- **Chevron specificity trap — `.field > i.bi` beats `.field .chev`.** Both target the chevron, but `.field > i.bi` = (0,2,1) (one class + one type + one class) wins over `.field .chev` = (0,2,0), so its `transition: color 100ms linear` declaration replaces the chev's `transition: color 120ms, transform 180ms ...` — the rotate never animates. **Scope every `.field > i.bi` rule (and its hover/focus/disabled/error variants) to `:not(.chev)`** so the funnel rules only apply to the funnel and the chev is governed exclusively by `.field .chev` + `.field[aria-expanded="true"] .chev`. The `.tb-select` chev doesn't have this trap because there's no `.tb-select > i.bi` rule. If you add a leading `<i class="bi …">` to `.tb-select` in the future, repeat the `:not(.chev)` pattern.
- **Chevron is a single icon + CSS rotation.** Don't swap `bi-chevron-down`/`bi-chevron-up` in markup. Rotate 180° via `transform: rotate()` keyed off `[aria-expanded="true"]`. Compose hover-nudge + open-rotate as a single `translateY(...) rotate(...)` so transitions interpolate cleanly between any pair of states.
- **`.field` / `.tb-select` use the focus-ring bloom recipe.** Base `transition: background-color 100ms, border-color 100ms, box-shadow 140ms ease-out` for hover snap; override under `.field--focus, .field[aria-expanded="true"]` to `box-shadow 220ms cubic-bezier(0.16, 1, 0.3, 1)` (expo-out) for the ring bloom-in. Same pattern as comp-inputs / comp-buttons.
- **Don't use `Element.scrollIntoView`** even on contained overflow lists — project rule. Do the delta math by hand against `getBoundingClientRect()` and adjust `list.scrollTop`.
- **Static-mode docs example** (`data-static="true"` on the list) is for spec documentation only: flows inline, no animation, no JS wiring. Don't route real interactions through it.



- **Focus-ring bloom pattern is system-wide.** Components that carry their own focus ring use a two-tier transition: base `box-shadow … 140ms ease-out` for hover snap, then on the focus selector (`:focus-visible` / `:focus-within` / `:has(input:focus-visible)` / `.is-focus` / `.field--focus` / `[aria-expanded="true"]` — whatever the component uses) the transition is overridden to `box-shadow 220ms cubic-bezier(0.16, 1, 0.3, 1)` (expo-out). The 220ms only fires on rest→focus (the bloom in); 140ms covers focus→rest. Border-color is bumped to 140ms ease-out under the focus selector so the inner edge fills in at the same pace as the outer halo. Native `<button>` / `<input>` / `<select>` / `<textarea>` / `[tabindex]` inherit the focus-state transition from the global `:where(...):focus-visible` rule in `colors_and_type.css` — no work needed unless a component redeclares its own transition under `:focus-visible`. Wrapper components that own the ring (and where the underlying input doesn't): `.btn` (comp-buttons), `.field` (comp-inputs / comp-textarea / comp-search / comp-select), `.tb-select` (comp-select / comp-list-toolbar), `.checkbox` + `.radio` (comp-checkbox-radio), `.filter-item` (comp-filter-group), `.sw__track` (comp-switch, sibling-target via `.sw input:focus-visible + .sw__track`), `.related a` (comp-related — its base transition was missing `box-shadow`, so the ring appeared with no bloom; fixed). Match this when adding any new wrapper that owns a focus ring.
- **Focus z-index uses two tiers — forced sibling vs. real focus.** Any component that owns a focus ring AND lives in a sibling group (where a later sibling's solid fill, or the playground forcing `.is-focus` on every sibling at once, would paint over the ring) lifts the focused face: forced `.is-focus` → `z-index: 1`; the element that genuinely 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. Live in `.pag button` + `.pag-compact button` (comp-pagination), `.seg button` + `.seg-pill button` (comp-segmented), `.filter-item` (comp-filter-group), `.filter-pill` (comp-filter-pills), `.related a` (comp-related). z-index works on flex items without `position`; keep `position: relative` for explicitness. Never exceed the tooltip/popover z (body-appended, far higher). See `notes/pitfalls.md` "CSS specificity" for the overflow-clipping + missing-`.is-focus` gotchas.
- **WCAG 2.1 AA is the floor.** Not a stretch goal.
- **3 px focus ring is mandatory** — `--shadow-focus`, `rgba(35, 98, 162, 0.35)`. Applied uniformly via `colors_and_type.css`. Required by DSGCZ rules.
- **`prefers-reduced-motion: reduce` is respected globally — ONE policy, ONE place.** The system reset lives in `colors_and_type.css` (mirrored in `ui_kits/cos-library/colors_and_type.css`): a single `@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition-duration:1ms; transition-delay:0; animation-duration:1ms; animation-delay:0; animation-iteration-count:1; scroll-behavior:auto } }`, all `!important`. So EVERY state change — hover, the focus-ring bloom, press, open/close, slide, sweep, chevron, sliding thumb — snaps uniformly. **Components must NOT ship their own `@media (prefers-reduced-motion)` block** — that scattered approach is what made the focus ring snap on filter-pills but keep blooming on buttons/inputs. Removed local blocks from: comp-filter-pills, comp-search, comp-select, brand-header, `_card.css`, `ui-kit.css`.
  - **Why 1ms, not 0/none:** keeps `transitionend`/`animationend` firing for JS hand-offs, and reads identically to the playground's synthetic `instant` shim — so `reduced` and `instant` now match (the user's reference comparison).
  - **JS-driven (Web Animations API) motion is invisible to the CSS reset** — the rectangular segmented thumb (comp-segmented) and the pagination slide/sweep glide via `element.animate()`. Each script reads the SAME preference (`?rm` flag OR `matchMedia('(prefers-reduced-motion: reduce)')`) and SKIPS the glide, snapping to the destination. comp-segmented got this guard added (it was missing; pagination already had it). **Any new JS-driven motion must add the same guard.**
  - No bounce, no spring, no celebration animations regardless of setting.
- **Hover / press / focus never relies on color alone.** Pair with shape, position, or icon change.
- **Affordance buttons inside fields are real `<button type="button">`s** — clear (`×`), reveal-password, voice, etc. Never `<i>`/`<span>` with a click handler. They must: be tab-reachable, render the DSGCZ focus ring on `:focus-visible`, and carry hover / `:active` / `:disabled` styles + dark-mode overrides (per-card hard-coded neutral hovers re-pin in `_dark.css` or via `rgba(255,255,255,…)` in the source). Visibility-driven affordances (clear when empty) toggle `data-empty` on the field wrapper from a small JS handler and hide via `visibility: hidden` — not `display: none` — so the field height doesn't shimmer on first/last keystroke. Canonical example: `.search .field .clear` in `preview/comp-search.html`.
- **Tables use horizontal rules only** — no vertical lines, no checkered look. Zebra at 1.5 % primary tint, hover row at 4 % tint.
