---
name: dnd-sheet
description: Work on Alex's self-contained D&D 5e character sheet web app (the MPMB replacement) -- a single ~13MB index.html with all CSS/JS/sourcebook data baked in. Use when Alex says "the D&D sheet", "character sheet app", "the MPMB replacement", "the dnd-character-sheet repo", mentions a bug or feature on that sheet, asks to add a class/feat/spell/item mechanic, the Universal (formerly D&D Beyond) or Classic theme, the rest system, magic items, the coverage oracle, or wants to ship/deploy a change to it. NOT for solo-dm, ttrpg-player, or TTRPG-campaign-manager (those are play/GM skills). Read this before touching index.html so you don't retread known gotchas.
---

# D&D Character Sheet

Alex's from-scratch, single-file D&D 5e (2014 rules) character sheet -- a clean-room MPMB replacement. The whole app is ONE self-contained `index.html` (~16MB): inline CSS + JS, no frameworks, full 5etools sourcebook baked into a `#source-data` `<script>` (~5MB), fully offline-capable.

> **This doc was distilled 2026-08-03 (at 2.159.5).** The full pre-distillation version -- with complete version-by-version incident narratives -- is preserved in harness git history at commit `817568d` (`git show 817568d:.claude/skills/dnd-sheet/SKILL.md`). If a compressed lesson here seems to be missing context, read it there. The deeper archive is `…/memory/project_dnd_sheet.md`.

- **Local repo:** `~/Documents/dnd-character-sheet` · **GitHub:** `BaesTheorem/dnd-character-sheet` (`main`, gh auth = BaesTheorem)
- **Deploy = push to `main`** (GitHub Pages: `baestheorem.github.io/dnd-character-sheet`). Alex runs the Pages build; "I don't see my change" usually = reload the Pages tab.
- **NEVER edit Alex's local/iCloud character-sheet files unless he explicitly asks (standing rule 2026-07-02).** Ship to the repo only; he downloads. Reading his file to reproduce a bug is fine; writing is not. The transplant/rescue procedure (Distribution model) is only for when he ASKS.
- **2014 rules ONLY.** Never add or unblock 2024 ("One D&D") content -- standing policy, not per-task.

## First moves every session

1. **`grep` on `index.html` WILL hit the baked data blob.** ~5MB of WotC JSON is ONE physical line (plus base64 at the `PAGES` line); a bare grep for any term that appears in the data (`edition`, `name`, spell names, common words) dumps multi-MB output. Habits: (a) anchor on code-only tokens (`grep -n "function isBook2024"`, `grep -n 'id="loaded-books"'`); (b) otherwise print line numbers only or `| cut -c1-160`, then Read the range.
2. **Read `VERSION` (current SemVer) + the changelog top before bumping anything.** Alex commits in parallel; a stale assumption regresses the version (has happened). Never trust the last value you set.
3. **For ANY D&D rules value, read the source data -- never memory.** Absolute rule; recall of stat blocks/dice/DCs/HP formulas has repeatedly been wrong and silently corrupted the sheet. Data: `~/My Drive/5etools/data` (override `FIVETOOLS_DATA`); a server may run at `:5050` but is often down. Key files: `class/class-<class>.json`, `optionalfeatures.json`, `bestiary/bestiary-<src>.json`, `spells/`, `races.json`, `feats.json`, `items.json`, `magicvariants.json`. Strip `{@tag}` markup. Verify finished work with a programmatic diff against source when feasible.
4. End of every change set: auto-commit + push, then update THIS skill (next section).

## After every change set (don't ask first)

1. **Auto-commit + push the sheet repo.** `git add -A && git commit && git push -q origin main` -- but if Alex may have parallel uncommitted work, `git status` first and add specific files (a blind `-A` once swept his in-progress work into my commit). Real commit messages, never "auto-commit". NO AI-attribution trailers (Alex's standing rule overrides the harness default). `--no-verify` for commits that don't touch shipped `index.html`.
2. **Update THIS skill** with new gotchas/seams/corrected assumptions; revise existing sections rather than appending; keep durable lessons, drop stale specifics. Then commit + push the harness (privacy-check first). Keep the doc LEAN -- history goes to git/project memory, not here.

## Two version numbers (do not conflate)

- **`APP_VERSION` = `vN`**: build id, auto-bumped by `.githooks/pre-commit` every commit (drives update detection + `sw.js` cache-bust). Never hand-edit.
- **`APP_SEMVER`**: read from the `VERSION` file, hand-curated. **Bump `VERSION` on EVERY commit that changes shipped `index.html`** -- MINOR for features, PATCH for fixes AND internal-only changes ("no visible change" is NOT a reason to hold it flat; Alex corrected this explicitly). Report SemVer to Alex, never vN. To add a release note: top `{v:"Unreleased", items:[...]}` changelog entry; the hook stamps it.
- Repo sets `core.hooksPath .githooks`; fresh clones must set it. `--no-verify` for non-`index.html` commits so offline players don't get a no-op 13MB update.

## Testing protocol

- **`node --check` on extracted `<script>` is UNRELIABLE** on this file (a `</script>` literal in a string truncates the extraction; brace-count deltas are noise). **Always verify edits by loading the page in Playwright**: `page.on("pageerror")` + `typeof compute === 'function'`. If an Edit looks balanced but the page breaks, `git diff` for an accidentally duplicated/dropped line.
- **Playwright = the PYTHON package** (`/opt/homebrew/bin/playwright`), not Node. Click `#intro-skip` to dismiss the welcome overlay. Wait ~3s after load for `#source-data` to parse into `DATA` before probing spell renders.
- **Never `--headless=old`** (renders `<style>` as body text; `--dump-dom` can deadlock for an hour). macOS has no `timeout` binary (use `gtimeout` or a background poll). Prefer the browser-render skill or Playwright `evaluate()` + screenshots. Hung render: kill the Chrome chain on `user-data-dir=/tmp/render-e` + rm the lock dir.
- **Print-media FULL-PAGE screenshots paint blank/gray bands where content exists** (headless artifact on this file). Take ELEMENT screenshots or probe layout before diagnosing "blank page in print".
- **THE PRINT INVARIANT (three separate bugs, one law): nothing may mutate the Core/combat layout while `matchMedia('print').matches`.** Chrome's interactive print preview fires `focus` AND `resize` mid-print; any handler (`_ucsAfterPrint`, `fitDefenses`, `_ucsSync`→`activate()`) that reacts by relocating/resizing Core content blanks the first printed sheet. The structural guard now lives on the mutation itself: `activate()` early-returns while print media matches; `fitDefenses` clears its inline heights + skips scheduling while printing; `_ucsAfterPrint` early-returns while still printing. **Audit any new resize/focus/ResizeObserver-driven layout code for a print guard.** Repro seam (deterministic ~40s): `emulate_media('print')` → dispatch `beforeprint` → dispatch `focus`+`resize` ~5× → assert `body.ucs-layout` stays false and `#page-core` holds 0 `template.ucs-ph` / >1 `.card`. `page.pdf()` does NOT reproduce these (no interactive preview) but IS the discriminator for pagination bugs (extra blank first sheet).
- **PDF baseline for a blank sheet: 8 pages (1300px viewport).** Don't chase "missing pages" against remembered numbers -- render the pre-change commit and the current file in the same run and diff. GOTCHA: never call `emulate_media('screen')` before `page.pdf()` (it overrides pdf's print emulation and silently renders the dashboard -- looks like a regression, is a test artifact).
- **Theme preview:** copy index.html and swap `const DEFAULT_THEME = "ucs"` → target id; `let darkMode = prefBool(DARK_KEY)` → `true` for dark.
- **Headless seams:** hand-pushed `asiChoices` are wiped by `compute()→syncAsiSlots()` without a class -- set `#classlevel='Fighter 4'` first. Class-resource Limited rows come from `fillLimitedFeatures` at generation, not `refreshLimitedFeatures` -- seed them. Write-in spell rows hold the name in an INPUT VALUE (`input.sp-name`). A headless wizard sheet needs `spellMaxByInst[0]=9` + `spellEntriesOf(0)[L]` rows. `#armorsel` is populated from EQUIPPED INVENTORY -- setting `.value='Chain Mail'` on a blank sheet silently no-ops (append a real `<option>` or drive the add+equip path; read `armorByName(sel.value)` BEFORE compute). `speedBase` is null on a blank sheet → speed assertions are vacuous; set it. `DATA` is a plain script-scoped `let`, NOT a Proxy and NOT on `window` -- probe via functions or `typeof DATA`, never `window.DATA`.
- **Any viewport <820px flips `html.mobile`, which changes the ENTIRE print path** and prevents UCS activation -- print-CSS edits then "don't take" in probes but work in real prints. ALWAYS probe print at a wide viewport (1300). Also `html.mobile` is ON during every real print (paper width < 820) -- write mobile print rules accordingly.
- **For any animation/motion feature, also test a `reduced_motion="reduce"` context** -- Alex's Mac has macOS Reduce Motion ON and headless doesn't inherit it (this shipped two broken dice defaults).
- **Verify visual styles by COMPUTED value or rendered pixels, never the inline style you just set** (a CSS `!important` rule can override it; three "passing" portrait fixes shipped broken this way). Verify centering with screenshots, not rects (glyph ink can be off-center inside a centered box).
- **`position:fixed` elements have `offsetParent === null` ALWAYS** -- never use offsetParent for visibility/liveness (bit the dice-rail collider AND the prep-stat chip). Gate on computed `display` or a stable never-detached reference.

## Keep app CODE copyright-clean (load-bearing)

Alex may one day sell/distribute the sheet. **The baked `#source-data` blob is the ONLY place copyrighted content may live.** Null it → what's left must be a clean app (verified: ~13.3MB → ~2.85MB with a graceful "load a sourcebook" prompt).

- Never hardcode WotC TEXT into app JS. **STANDING RULE (2026-07-17): ALL rules text is VERBATIM from `#source-data`; only logic in app code.** Fully enforced since 2.135.0 (`CONDITIONS`/`TCE_RULES`/`SPELL_CHANGES` hold mechanics only; baked maps `conditionText` + `tceText` with readers `condText`/`tceText` beside `awText`, all falling back per-key to the baked blob via `bakedTextMap()`). App-BEHAVIOR text (UI hints) stays in code. "Spell Versatility" was UA-only, never in TCE -- don't re-add that toggle.
- **Names + mechanics + formulas are NOT copyrightable** -- hand-coded registries (`FEAT_SPELLS`, `FEAT_SKILLS`, `FEAT_SENSES`, `FEAT_INIT`, `FEAT_SPEED`, `FEATURE_ATTACKS`, `LIMITED_RESOURCES`, `ELSEWHERE_ITEMS`, `SPELL_CHANGES`, `TCE_RULES`, `ATTUNE_ALL_NAMES`, `AW_PROPS`, `DEF_ITEM_SPECIAL`, `EXTRA_SPELL_DC_ITEMS`, `REPEATABLE_FEATS`…) live in app JS and survive a rebake.
- New content type → route its TEXT through `#source-data` + a loader.
- WotC trade dress (Classic art) is fine -- Alex's call, sole judge of what ships; do NOT moralize about committing WotC data.

## Architecture / where things live

- **Unified source model (`DATA.sources`).** All three source paths (5etools batch, "Import files", homebrew) unify into `DATA.sources = [{id, name, kind:"5etools"|"file"|"homebrew", enabled, priority, data}]`. Helpers near `rebuildFromRaw`: `SRC_KINDS`, `srcId`, `hbBagsToData`, `migrateToSources` (folds legacy `_meta.raw` + homebrew, runs at load), `rebuildFromSources()` (the live merge, replaced `rebuildFromRaw()` calls), `tagSourcesFromRaw`. **Coexist+tag:** `mergeBook(into,add,name,opts)` stamps `_src`/`_srcName`; a `_coex` entity (imported/homebrew) keys on name+src so same-name entries from different sources COEXIST; plain official entities key on name (reprints dedup). `resolveEnt(list,name,src)` resolves a pick to its exact entity. `optLabel(x)` = "Name · Source" for background/feat/spell pickers. **The RACE list shows BARE NAMES** (`renderRaceList`, 2.158.2) with the source in `renderRaceDetail`'s `.wiz-cd-head`; it falls back to `optLabel` only when two loaded sources define the same name (nothing collides in the shipped 130). `#wiz-race` is a HIDDEN value-holder select -- its option text never displays, don't "fix" it. RACE is wired end-to-end by (name,src): hidden `#race-src`, `data-src` on list buttons, `wizRace()`/`applyRace` resolve by src. **Bake/persist STRIP `DATA.sources`** (rebuilt at load) -- never bake it. **ITEM source-choice is ONE gate, not a map re-key:** every item resolver reads homebrew through `hbActiveItems()`/`hbEnabledItem()` only; `#inv-prefer-official` (data-save) holds lc names the player chose official for (`preferOfficialSet`/`setPreferOfficial`); `hbPickerItems()` + `availableItemOptions()` show both entries for a colliding name; a chosen homebrew item may OVERRIDE official AC/DC on collision. `_meta.raw` + `SOURCES_RAW_KEY` still exist during the transition.
- **Storage = IndexedDB** (`dnd-sheet` db: chars, portraits, thumbnails) fronted by sync in-memory cache `_cs`. `SOURCES_KEY` + settings stay in localStorage. A file carrying baked characters partitions into `dnd-sheet-file-<hash>` (see BASE_KEY trap under Distribution).
- **Three data representations that DIVERGED -- not duplication to collapse:** (1) `build-data.py` = offline global baker (keep it; do NOT run a full rebuild to ship data -- it lags hand-patched fields and regresses; new data is spliced SURGICALLY into `#source-data` via Python string-insert); (2) the in-browser loader `etBook`/`mergeBook`/`bakeSourceData()`; (3) the shipped baked blob. Watch for drift BOTH ways: the JS loader can lag the baker, and the baked blob can carry stale PRE-RENDERED display strings from an older normalizer (e.g. class `prof.tools` are baked as display strings, not raw -- the tool picker `resolveToolChoices`/`TOOLPICK_RE` only cleans strings shaped exactly like live `etNormProf` output). **Baked-string fixes need a PAIR: correct the blob AND add a load-time migration for characters whose saved fields froze the bad text** (`migrateStaleToolProse` is the template -- and a stale-prose regex must match the text AS IT FROZE post-picker, pulled from real user data, not the pristine form). Note the blob carries the flattened `classes` array TWICE -- patch with replace-all.
- **Byte-safe blob edit technique (ANY surgical `#source-data` change):** `json.loads` the blob, mutate, re-dump with `json.dumps(d, ensure_ascii=False, separators=(',',':'))` -- round-trips byte-identical so only changed values diff. To reproduce baked text formatting, import `build-data.py` and call its `entries_to_text`. The Edit tool fights this 13MB file (and SOH `\x01` separators can't be matched) -- use verified Python replaces for blob/version edits.
- **Flat-feat-bonus idiom:** `FEAT_INIT`/`featInitBonus()` = name→number map + a summer reading ONLY `asiChoices` (never Features prose -- false-positives). `FEAT_SPEED`/`featSpeedBonus()` copies it (Mobile is the only speed feat in the catalogue, unconditional). Copy that pair for any new flat feat bonus.
- **Class-level helpers:** `classLevelNamed(name)` resolves from built `charClasses` first, falls back to parsing `#classlevel` (hand-typed sheets get features too) -- use it, don't re-inline regex parsing. `classLevelMatching(regex)` is the regex form (renamed from a second `classLevelOf` that SHADOWED the first and froze `FEATURE_ATTACKS` scaling -- in this file the LAST same-named declaration wins across the whole scope; a 2.159.4 audit sweep for duplicate declarations is worth re-running after big merges).
- **Optional Features engine = FIVE picker types** (Invocations / Metamagic / Maneuvers / Arcane Shot [Arcane Archer, XGE] / Runes [Rune Knight]). Adding one = an `OPTFEAT_TYPES` entry `{type, cls, known:[21 ints], gate, sub?, label, verb, dc?}` + name rows in `OPTIONAL_FEATURES`; `dc:` renders a live Save DC in the header. Text resolves from baked `DATA.optionalFeatures` via `optfeatText` (ALL featureTypes are baked -- parse-check before assuming a splice is needed). **UI = browsable expand-in-place pick-list** (`optfeatPickList`): `.of-take` toggle + `.of-up-toggle` chevron revealing `.of-up-text` IN PLACE (never re-render on a pure toggle -- scroll jump); take/drop re-renders, `_ofUpOpen` Set preserves open state. **Over-cap is BLOCKED** (enforced in BOTH the button attr and the click handler re-check; taken rows stay enabled so you can drop; over-limit legacy data keeps its picks; guard `known > 0`). CSS is SHARED with the infusion pick-list (`.inf-up, .of-up{…}` twins -- restyle one, both move). Picks persist as flat `data.optFeats=[{type,name}]`. `setupOptFeats` handlers call `compute()` after `save()` -- keep it.
- **Subclass limited-use resources live in `LIMITED_OVERRIDES`, not `LIMITED_RESOURCES`.** `LIMITED_RESOURCES`/`fillLimitedFeatures` is base-class + generation-time; `LIMITED_OVERRIDES[featureBlockNameLc]` is the live retroactive seam consumed by `refreshLimitedFeatures` per lim-sig change. **An override value can be a FUNCTION** re-deriving rows from live state, with a stale-variant sweep (label bases + optional `.stale` array) so "Superiority Dice (d8)"→"(d10)" swaps cleanly. Entries: `combat superiority` (fn), `arcane shot` (2/SR -- prose says "two" in words so the digit heuristic misses), `giant's might`, `rune carver` (fn), `pact boon: pact of the talisman` (PB/LR). `master of runes` is in `LIMITED_EXCLUDE`. The lim-sig includes serialized `optFeats`.
- **Picked-option passives** (rune adv badges / senses / defenses) are read from `optFeats` INSIDE the three live engines (`featureAdvEntries`, `senseChips`, `defenseChips`) -- filter by type in the engine; recompute-per-pass makes removal automatic. Fire Rune is deliberately text-only.
- **Pact Boon:** `PACT_BOON_NAMES` (names only) drives `pactChoicesFor`; Talisman filtered unless `tashaEnabled()`; feature text via `optfeatText`.
- **Artificer Infuse Item** (`#infusions-inner`, Features page): `infusions = [{known, replicate, resist, target, infused, attuned}]`; registry `ARTIFICER_INFUSIONS` (16, no text field -- `infText` reads baked text + app-hint `note`); caps `infCaps()` from `INFUSIONS_KNOWN`/`INFUSED_ITEMS`. UI = `infPickList()` (AW-style expandable, `_infUpOpen`, cap-guarded, Replicate repeatable ×N) + `infManageRow` per known (Infused? checkbox, On-item `<select>` of eligible inventory via `INF_SLOT`/`eligibleInfTargets`, sub-choice, attune). Auto-apply: `INF_WEAPON_BONUS` → `infusionWeaponBonus` overlaid in `weaponByName`; Enhanced Defense/Arcane Focus feed AC/spell-attack in `compute()`. New infusion mechanic = `INF_SLOT` entry + either an `INF_WEAPON_BONUS` fn or a `compute()` read of `activeInfusionNames()`.
- **Features & Traits = editable CARD list; `#features` is a HIDDEN backing `<textarea data-save>`** -- still THE single source every scanner reads (`featureAdvEntries`/`scanDefenses`/`attacksPerAction`/action-economy/`hasFeatNamed`/…). Visible UI = `#feat-cards` via `renderFeatureCards` (feat-sig block in compute); blocks split on blank lines (`featBlocksRaw`/`setFeatBlocks`/`_featCardParse`); `featCardsAction` (delegated once) handles add/edit/delete → rewrite blocks + compute + save. Don't reintroduce a visible textarea. **Sticky hand-edits:** `featOverrides = {titleLc:true}` (persisted) -- editing a GENERATED card records an override ONLY when the block actually changed (the guard is load-bearing: a no-op edit must not register or the feature vanishes on rebuild); `createCharacter` calls `dedupFeatOverrides()` after computing `genFeatures` to drop the regenerated duplicate; deleting a hand-edited card clears its override. Test via a throwaway copy exposing `window.__T` hooks; run each edge in an isolated page.
- **Armorer "Arcane Armor"** (`#arcane-armor-card`, top of Features page, shown when `isArmorer()`): persisted `arcaneArmor` = designated armor name; the ONE wired mechanic is nulling the heavy-armor Str requirement (`arcaneArmorActive()` in the speed-penalty ternary, like mithral). Armor Models (Guardian/Infiltrator) NOT built -- add if asked.
- **Spell sheets ride the `CASTERS` map**, keyed by class or `"Class (Subclass)"`. Entries: slot/known caster (`ability`, `prepares`, `fullList`, `ritual`) or `fixed` caster (`{fixed:{0:[…]}, noSlots:true}` = exactly-these spells, no slots). **A `fixed` list may be a FUNCTION of class level**, read via `casterFixed(info, cls)` (never `info.fixed` directly) -- e.g. Totem Warrior's ritual spells scale at Barb 10; unknown level (0) shows the base list. `#spellclass` options are STATIC HTML -- a new caster key needs an `<option>` or it's unreachable. `spellPlan` returns null for fixed casters, so the build's auto-anchor falls back to the first fixed CASTERS key (`primaryCasterOf` fallback).
- **Unprepared-spell collapse** (`renderSpellTable`): prepared casters' unprepared leveled spells hide behind a per-level "Show N not prepared" toggle (`unprepOpen`). **A Wizard spellbook RITUAL stays visible when unprepared** (`canRitualCast` + `d.ritual` in the write-in `hide` expr) -- Wizard-scoped by construction; full-list prepared casters (Cleric/Druid/Artificer) intentionally unchanged (their rituals require preparation).
- **"Spells missing properties" -- THREE causes, diagnose FIRST** (`location.protocol/href` + baked spell count `JSON.parse($('#source-data').textContent).spells.length`; healthy ≈ 505-510):
  1. **Starved baked data in a `file://` copy** (bad "Reload all sources" baked forward). Self-heals now: `additiveMergeSources` folds the fresh baseline in on update; `checkForMissingBooks()` shows a MANUAL banner (via `unresolvedCharSpells()`: open character has ≥4 named spells, ≥half unresolved -- metadata detection is a DEAD END, don't retry it) with a "Load the missing book" button (`loadMissingBook`, downloads only on click). First check for any "spells blank" report: **is the needed sourcebook just not loaded/enabled?**
  2. **Stale service worker on hosted** -- `DATA` null. SW is self-healing since 2.80.0 (network-first doc); manual escape = console unregister+caches.delete snippet. NEVER tell a user to use DevTools "Clear site data" (nukes IndexedDB).
  3. **Mobile**: property columns are hidden by design; they fold into `.sp-mmeta` stacked lines under the name. Not a bug.
  Wizard `fullList:false` means `classSpellList('Wizard')` returning null is CORRECT.
  **Key architectural law from this saga: a fix that runs in the BROKEN build's code can't rescue that build -- repairs must ride the DOWNLOADED build and run on OPEN.** Immediate rescue for a gutted file: `cp` a good build over it (characters live in origin IndexedDB, not the HTML) -- but see the BASE_KEY transplant caveat under Distribution.
- **Homebrew Studio (`#hb-studio`, lazily built by `buildHbStudio`) supports ALL content types with MULTIPLE named sources.** Store = `homebrewSources = [{name, enabled, <HB_BAGS>}]` where `HB_BAGS` = spells/items/feats/races/backgrounds/subclasses/classes/monsters (an unlisted bag is silently DROPPED on reload by `hbNormSources` -- always add new bags there). `loadHomebrew` migrates legacy shapes -- normalize via `hbNormSources`, never assume. Cached in `HOMEBREW_KEY` localStorage, mirrored to `DATA.homebrew`, bakes into `#source-data` (the old `#embedded-homebrew`/`#aw-data` blocks are gone). **Homebrew is a CARRIED KEY, never merged through the book system.** Resolution precedence for spells: `customSpells` → `hbEnabledSpell` → `spellIndex()`. **Treat every numeric field from stored/imported homebrew as untrusted -- `parseInt`-coerce** (string spell levels once filed everything under Lv 1; `relocateMisleveledSpells` still repairs mis-filed rows in-memory per render, homebrew/custom only). Studio: tabs per type (`_hbType`), spells/items/creatures have bespoke forms; feats/races/backgrounds/subclasses share the generic `HB_GEN_SPECS` pane; classes are import-only. The ✎ on a write-in spell row opens the Studio anchored to the owning source. Entry: ☰ `#btn-hb-studio-top`.
- **Homebrew item schema** `{name,type,rarity,attune,acBonus,spellAtk,spellDc,unarmedBonus,grantSpells,limited,senses,senseMods,actions,text,flavor,img}` -- items overlay onto the centralized lookup fns via `hbActiveItems()` (the pattern for lookup types: inject at read points, never merge into DATA). Field→mechanic wiring: `unarmedBonus` → `unarmedItemBonus()` folded into `unarmedStrike()` (attack AND damage); `grantSpells` → rows in the Granted Spells table (`grantedSpellRows`), attune-gated; `limited` → exact Limited Features rows in `refreshLimitedFeatures`'s equipped-item loop (skips the text heuristic for that item; `limitedFromText` knows dawn/dusk/LR/SR); `senses`/`senseMods` → computed chips (below); `actions` → precisely-labelled Action Economy entries (items without `.actions` fall back to a prose scan via `_actionCost`). **Attunement is in the lim-sig** (`_lastLimSig` includes the attune slots) so attune-after-equip refreshes rows live; gate any new equipped-item render on the lim-sig, not the feat-sig. **Template for a new "item buffs stat X" field:** schema+form (`normHbItem`/`hbOpenItemForm`/`hbSaveItemForm`), then inject at that mechanic's resolver with the equipped+attuned idiom; if attune-gated, ensure its driver is in the relevant sig.
- **Homebrew injection strategies -- pick by how the sheet reads the type:** lookup-overlay (spells, items, monsters) vs merge-into-DATA (feats, races, backgrounds, subclasses, classes -- `hbApplyToData()` tags `_hb`, `hbStripData`/`hbStripClone` strip on bake/persist, idempotent, re-run on refresh; a homebrew name colliding with a real sourcebook entry is NOT clobbered; homebrew subclasses need the parent class loaded). Import (`hbImportIntoSource` + `HB_IMPORT_TYPES` registry) covers all 8 types from one `{_meta:{homebrew:true}, …}` envelope into the currently-selected source; per-source Export bundles the AW catalog when AW items are present. A full sourcebook-schema file is rejected with a pointer to Settings → Import files (separate path -- don't conflate). New type recipe: `HB_BAGS` key + `HB_GEN_SPECS` entry (or overlay fns) + `hbApplyToData`/strip branches + one `HB_IMPORT_TYPES` line.
- **The runtime homebrew CACHE shadows the baked copy, and fresh-storage probes LIE.** `HOMEBREW_KEY` is per-origin (file:// vs Pages diverge); `loadHomebrew` prefers it over the file's bake. To see what the user sees: clone their real Chrome `Local Storage` dir into a Playwright `launch_persistent_context` profile. Self-heals: `loadHomebrew` add-only unions (`hbUnionBakedInto`) the file's `_bakedHomebrew` (captured from `#source-data` at init -- NOT `DATA.homebrew`, which can predate the file) into the cache; re-grafts stripped `.aw` blobs; `hbSaveItemForm` carries `prev.aw` through a save. **Every bake path must be ADD-ONLY for homebrew** (`bakeAllInto` unions the fresh shell's baked homebrew over the user's cache) -- baked-only content that never reached the cache is otherwise dropped by the first cache-based re-bake. To ship shared homebrew: bake into `#source-data` `DATA.homebrew` + bump; it rides the update. A user-deleted baked item re-appears next open (shipped content; disable the source to suppress).
- **Images:** `awResizeImage` is the ONE image-from-file loader (portrait conventions: `accept="image/*,.heic,.heif"`, validate + `flashStatus` on reject, downscale ≤480px, PNG for transparency sources, undecodable → raw dataURL capped 2MB). `item.img` is the ONE canonical image slot including AWs (`aw.art` is read-only legacy fallback). **User images RESIZE to fit, never crop** -- `object-fit:contain` everywhere.
- **Preview card:** `previewCardLookup(name)` (homebrew any type → DATA spell/feat/race/background/item → `bakedItemInfo` fallback covering EVERY item with any data), `previewCardHtml`, `setupPreviewCards()` wires hover/focus/tap on `[data-card]` + `PC_INPUT_SEL` fields. **Spell hovers = the preview card ONLY -- the native `title` tooltip is RETIRED on every spell surface, don't reintroduce it.** Wrap `data-card` around ONLY the name text in table cells (buttons live in the cell). `.pc-body` caps at `min(60vh,560px)` hidden -- the pop is pointer-events:none so scroll is impossible; raise the cap if clipped.
- **Ancestral Weapons (SHIPPED, feature complete).** Third-party framework (Matt Vaughan / Dungeon Rollers PDF). Catalog: `AW_UPGRADES` (134 upgrades, mechanics + `fx` tags, in app JS) regenerated by committed `ancestral-weapons/extract.py` (never hand-edit; two source quirks are fixed in the parser); TEXT lives in baked `DATA.awText` (150 keys = 134 upgrades + 16 `AW_PROPS` properties), read via `awText(name)` with a per-key fallback to the baked blob (`awBakedText`). Engine: `awRarity`/`awSpent`/`awCanAdd`/`awDerive`. Builder `#aw-builder` emits a homebrew WEAPON with an `aw:{baseWeapon, points, upgrades[], …}` blob; `hbAwMagicWeapon(lc)` overlays `weaponByName`; AC/spellAtk/attune ride the existing item overlays. **Everything gates on ATTUNEMENT** (unattuned = mundane base). Tab `#page-aw` is INVENTORY-SCOPED (`hasAw = awEnabled && awInvItems().length`) and fully hides when none carried; `applyLayout` must be called from EVERY inventory mutator (`addInvItem`, `deleteInvRow`, onChange) -- hooking only the input event missed ✕-delete and programmatic add twice. **Per-character tuning lives in `awOverrides`** (persisted in collect/applyAll; overlay-clone `awCharItem(it)` applied at the `hbActiveItems`/`hbEnabledItem` gates; NEVER mutate the source item) -- points+upgrades are character-local, base weapon/extras/lore/art are source-authored. `aw.unarmed` redirects magicBonus+riders onto Unarmed Strike (`awUnarmedEffects` + `unarmedStrike`; `hbAwMagicWeapon` returns null so no phantom weapon row). Character-wide effects (`awEquippedEffects`, attuned-gated): speed + fly auto-apply; darkvision/slots/resist/prof are deliberately surfaced-not-injected (stale-line risk in editable fields). `AW_PROPS` autofill fires on `input` (datalist picks don't reliably emit `change` in Safari), fills only empty fields. `#aw-base` datalist filters through `hiddenItemNames()`. Import rides "Import files…" via the `_meta.awExport` branch (`importAwContent`); the old `exportAwContent` is deleted -- export via the Studio's per-source Export. `awEnabled` is PER-CHARACTER (localStorage value = default for record-less characters). Launch force + up-table landings on the dice… (see Dice). Name-collision lesson: many "homebrew" names are REAL WotC items (e.g. Snicker-Snack) -- check `ATTUNE_ALL_NAMES`/`DATA.magicWeapons` before assuming a gating bug. **awText self-heal chain** (bake bases on the existing blob → `mergeUpdatedSources` always unions fresh awText → `maybeHealStarvedAwText()` re-fetches on open when any known key is missing): generalizes to any baked TEXT map a download could strip; never stringify a possibly-empty runtime map forward.

### The DATA_VERSION gotcha ("Sourcebooks out of date" forever)

Bumping the `DATA_VERSION` const REQUIRES re-stamping the baked `#source-data` `_meta.dataVersion` (the FIRST `"dataVersion":N` occurrence; per-book raw blocks carry their own copies -- verify counts before/after). A const-only bump makes the baked data self-stale. Use a verified Python byte-level replace, not the Edit tool.

## Themes

**Two themes: Universal Character Sheet (`ucs`, default, landscape dashboard) and Classic (`classic`, WotC PDF overlay).** "D&D Beyond" is fully scrubbed (trademark) -- one migration literal remains in `compute()` (`universal`/`dndbeyond`/`material` → `ucs`; keep it). A "Material Design" theme was built then REMOVED at Alex's call -- don't assume it exists, don't rebuild without him asking. Never blind-replace `ddb` (base64 blobs contain it coincidentally; gate on a non-alphanumeric preceding char).

- **New-theme recipe:** `{id,name}` in `THEMES` (drives `#set-theme` via `renderThemeOptions`, which only fires on Settings-open -- preview via `document.body.dataset.theme` directly) + two CSS var blocks (`body[data-theme="<id>"]` light + `.dark`) placed after the ucs palette. Default = canonical portrait layout; for a UCS-style dashboard, broaden `isUcs()` (flows through everything), define `--ucs-gold` (the only consumed `--ucs-*` var), and make `_ucsAfterPrint` use the live `theme` var not a hardcoded 'ucs'.
- **Flat/sharp block** (`*{border-radius:0 !important; box-shadow:none !important}` + white-field rules) is Alex's house style for THIS app -- new themes ride WITH it (recolor via higher-specificity theme-scoped rules); never edit the block itself; a theme CAN opt out with theme-scoped `!important`. Dark-mode composition rules out-specify plain theme rules -- reinforce tonal fills with `body[data-theme="<id>"].dark` twins. Fonts must fall back to system (no bundled assets).
- **UCS layout engine** = the bottom IIFE gated on `isUcs()`; `activate()` relocates Core cards OUT of `#page-core` into the `#ucs-*` scaffold. **LOAD-BEARING: under UCS, `#page-core` holds only `template.ucs-ph` placeholders and `.page[data-page]` finds NOTHING from Core** -- first suspect for "X can't see Core content" (broke Search and Mobile once; Search now scans `.sheet` + `_fieldPageKey`; UCS is gated NOT-mobile via `wantUcs()`, phones keep canonical pages; `window._ucsSync()` flips the scaffold live). **The UCS tab bar is its OWN static `TABS` list, not derived from `PAGES` -- adding ANY new page needs BOTH entries** or it's unreachable on desktop. `syncTabs()` mirrors hidden/muted from the canonical `.ptab` twins. Diagnostic trap: a hidden ancestor doesn't change a child's computed display -- probe `#ucs-tabbar .ucs-tab[data-tab=…]`, never the canonical tab's own display.
- **Classic** = pixel-faithful rebuild of the official 2014 fillable PDF (not a restyle -- Alex rejected a reskin). Engine `CLASSIC_PAGES` (`var PAGES=[…]` blob: light/dark base64 art + AcroForm-rect field map), gated `body.classic-layout`. Canon-only: pages core/bg/spells/mini, official 1-2-3 order via classic-scoped flex `order` (screen + print), dark mode disabled (`applyDarkSetting` reads `body.dataset.theme`, NOT the `theme` let -- TDZ at load), all other tabs hidden (fields still in DOM for automation). Print: `.cl-page` scales 0.86 via transform + explicit wrap height (**never CSS `zoom` -- print pagination uses pre-zoom height**); a classic-scoped force-hide beats the ID-level print rules. Art fixes = PIL-decode the base64, recolor, re-encode, string-replace the data URI. **Classic checkbox slots must push on BOTH `input` AND `change`** (the app's global input listener runs compute→syncClassic on `input` and overwrites the click before `change` fires); death saves stay manual (`updateDeathSaves` early-returns in classic). Portrait slot = a real `<img>` (a CSS background is overridden by `.cl-slot > *{background:transparent !important}`); `renderPortrait` is wrapped to re-sync (the MutationObserver misses the src change); `showPage` calls `syncClassicPg1()`. Verify checkbox slots with real `pg.mouse.click`, not `cb.click()` in evaluate.

## Engines on the Core page

- **Conditions & Exhaustion:** `CONDITIONS` (registry: key/name/paraphrased text/`eff`) + `EXHAUSTION_STAGES`; `buildConditionsPanel()` runs in init BEFORE `applyAll` (data-save inputs must exist to restore); panel lives in `#cond-modal`. `condEffects()` aggregates to `{disadv, adv, speedZero, speedHalf, hpHalf, incap}` with targets `attack`/`check`/`save`/`save:<k>`/`skill:<k>`. Auto-applied in compute: speed (`conditionSpeed`), Max HP halving at exhaustion ≥4 (`recalcMaxHp` reads `exhaustionLevel()` directly; `hpHalf`/`incap` flags are informational), passive Perception ±5 (reads `condEffects()` NOT `gatherAdvSources()` -- feature advantages are situational), and adv/disadv d20 markers via `renderConditionMarks()` (**must run LAST in compute** -- totals are rewritten each pass). **To wire a new feature adv/disadv: one `featureAdvEntries()` entry** `{kind, targets:[…], note}` -- flows through `gatherAdvSources`/`resolveAdv` (adv-vs-disadv cancels) to badges + dice rolls. Equipped-item skill advantages are SOURCE-DRIVEN via `itemSkillAdvEntries()` (scans `DATA.itemAdvText` for "advantage on … <Skill> checks", attune-gated) -- items need nothing added.
- **`scanDefenses(text)`** (prose scanner, separate from `featureAdvEntries`) pulls resist/immunity/(dis)adv lines from feature text; runs at creation/Edit. Guards: `condAdv` skips clauses governed by if/when/unless (Sneak Attack's "if you have advantage" is a condition, not a grant); the Warforged Integrated Protection trait is filtered (mode-conditional). A trait whose (dis)adv is gated behind a mode/option choice: exclude from the scrape, handle live.
- **Senses = COMPUTED CHIPS** (`#sense-chips`, rebuilt every compute by `renderSenseChips()`→`senseChips()`); `#senses` box = free-form notes only. Sources: race traits, custom-lineage darkvision, `FEAT_SENSES`, `FEATURE_SENSES`, equipped+attuned homebrew `.senses`. Dedup by sense TYPE (`_senseKey`), widest range wins, all sources credited in the title; removal is automatic (recompute). The render also STRIPS box lines a chip covers (legacy migration). `senseMods:[{sense,grant,bonus}]` = grant-if-none / +bonus-to-existing, applied phase-2 over the real base. New programmatic sense → add its source to `senseChips()`, never `addSense` into the box.
- **Defenses = COMPUTED CHIPS** (`#def-chips` above the free-form `#defnotes`), same architecture. `defenseChips()` gathers race resist (draconic ancestry wins), race/class/feature `scanDefenses` lines, equipped(+attuned) `DATA.itemAdvText`, `DEF_ITEM_SPECIAL` (hardcoded enemy-facing items like Cloak of Displacement), and manual `#defmanual` entries. `_defParse` classifies → badges (`advIconSvg` d20 / `defBadgeSvg` R/I shield); `_defNorm` = semantic dedup key (kind + rollType + content-words minus `_DEF_FILLER` -- collapses differently-worded copies); `_defCanon` normalizes free-form typing ("Immune to fire") at promotion time (`promoteDefNotes` on blur, recognized lines → `#defmanual`, manual chips get a ✕). Skill-check advs already badged on the skill line are SUPPRESSED from Defenses (`_defAdvSkills`/`_defSkillCovered`). **Box-strip filters must KEEP blank lines** or Enter is eaten while typing (applies to both Defenses and Senses boxes -- any per-compute textarea rewrite must preserve empties). **`fitDefenses()`** bottom-aligns `#defnotes` + `.cond-box` with the AC row (ResizeObserver + double-rAF settle + per-compute; nulls the CSS min-height inline on desktop; print-guarded per the print invariant).
- **Item-granted Limited rows are RECONCILED on unequip** via an ownership model in hidden `#gen-item-limited` (`_claimLim`: item-owned iff the item INTRODUCED the row or owned last pass -- the guard protects identically-named class rows). Two reversal patterns for equipped-item contributions: computed chips (stateless) or persisted-ownership reconciliation (stateful rows with counters).
- **AC formulas are ALL hardcoded by class/race NAME** (imported data has no machine-readable AC): Barbarian/Monk unarmored, `NATURAL_ARMOR` registry, Draconic/Dragon Hide, Mage Armor, mithral/adamantine/+N, `acItemsState().bonus`, Defense style, Enhanced Defense. Final = `acBase + bonus + warforged + fsDefense + infDef`. **Two Warforged systems coexist:** ERLW flat +1 via `warforgedFlatAcBonus()` (0 when a plating mode is active or the race is WGtE/UA; trusts the trait text otherwise; unresolved plain "Warforged" defaults +1) vs WGtE `INTEGRATED_PROTECTION_MODES` (Darkwood 11+Dex[+PB w/ light-armor prof], Composite 13+min(Dex,2)+PB, Heavy 16+PB+Stealth-disadv; detector fires on subrace name or `(ua)` or literal mode names). The WGtE Warforged is BAKED into `#source-data` (`Warforged (UA)` + Envoy/Juggernaut/Skirmisher). Mode detected → armor dropdown = 3 modes + Unarmored (kept on purpose: unarmored-defense calcs COMPETE, don't combine). Heavy Plating's Stealth disadv needs its own `featureAdvEntries` hook (`ipModeByName`). New mode-style race = registry + detector + dropdown override + AC branch (+ adv entry).
- **Shield detection is name-based** (`shieldBonus(name)`: `\bshield\b` minus competing slot nouns -- scroll/amulet/ring/…); verified against the whole catalog. New "shield"-named non-shield → add its slot noun to the exclusion, don't special-case.
- **Attunement recognition is global:** `attuneSet()` seeds from `ATTUNE_ALL_NAMES` (hardcoded const Set, ~685 names -- deliberately app-JS so a sources rebuild can't drop it); Equip checkbox + attune picker both route through it. Prose-only DC/attack items go in `EXTRA_SPELL_DC_ITEMS` (the sheet has no separate ki DC -- the Spell Save DC IS it for a Wis monk). Structured `bonusSpellAttack`/`bonusSpellSaveDc` flow via `etSpellBonusItems`→`spellItemBonus()`, attune-gated.
- **Attacks/Action (`#attacks-per`) = `attacksPerAction()`:** class+level parsing (Fighter 2/3/4 at 5/11/20; Barb/Paladin/Ranger/Monk 2 at 5; Bladesinger/Valor/Swords 2 at 6; Battle-Smith/Armorer 2 at 5) AND a feature literally named "Extra Attack" in Features. Both paths on purpose (manual sheets vs built). Extra Attack is excluded from Action Economy (redundant with the box). **Action bucketing = strong-vs-weak signal split** (`_actionCost`, shared for features and items): strong ("as an action") always buckets; weak ("take the X action") only when not already bonus/reaction (those phrases are triggers there -- Martial Arts, Cunning Action). Test regex changes against features that reference vs cost an action.
- **Long rest** drops exhaustion by 1 BEFORE the heal (so un-halving Max HP happens first); Long Rest dialog has "Reset Max HP changes to baseline" (default on).

## Sources, books, imports

- **Book loading is SOURCE-CODE-driven** (`fetch5etoolsBook(base, srcCode)` pulls from the 5etools CATEGORY files filtered by code; never the prose file) -- adventures load identically; the picker (`fetchBookCatalog`/`populateBookSelect`) lists Sourcebooks + "Adventures (player content)" optgroups. `loadAllBooks` stays sourcebooks-only ON PURPOSE. On the shipped file all 60 books are baked, so only the Adventures optgroup shows -- correct, not a bug. Needs the `:5050` server or a CORS mirror.
- **2024 blocklist = `isBook2024`**, in order: (1) `b.edition` set → "one"/"2024"; (2) uppercased id in `RULESET_2024` (XPHB, XDMG, XMM, XSCREEN, XSAC, ABH, FRAIF, FRHOF, NF, LFL, EFA); (3) `b.published >= "2024-09-01"` -- **the date rule does the real work** (`edition` is unset in Alex's data). Re-verify after any 5etools data update by partitioning the full catalog. **2024 TEXT can contaminate baked maps even with books blocked** (same-name 2014/XDMG reprints): the text maps are now cleaned + `build-data.py`'s `is_2024()` hardened (source code + publish date; look up the date map ORIGINAL-case -- 2024 codes are mixed-case). Removed-key corrections reach old users via the cumulative **`_meta.dropped2024` manifest** (additive merge deletes exactly those keys) -- APPEND to it for future removals, never rewrite. `itemText` has FULL 2014 descriptions for all addable items (hover cards), not just limited-use ones.
- **PER-CHARACTER source enablement:** WHICH sources a character uses is character data (`charSrcOff` → `data.disabledSources` + per-char `awEnabled`); what's LOADED stays browser-wide. Settings → Character Settings → "Enabled sources" (`#char-sources`, `renderCharSourceToggles`) holds the checkboxes; Settings → Advanced `#loaded-books` is names + ✕ only. `applyCharSources()` PROJECTS the record into the live/global stores (`DATA._meta.disabled`, `homebrewSources[].enabled`, `awEnabled`) so downstream readers are untouched; character switch = full reload. Legacy characters adopt the live state on first open. `srcOffRecord`/`srcOffForget` (`!reloading` gate on doLoadBook is load-bearing). **Serverless toggles:** disabling just sets the flag; enabling a book whose raw IS baked does an additive `mergeBook` + `resolveSubraces`; a FULL `rebuildFromSources()` only when `hasAllRaw()` (a rebuild with partial raw hollows out no-raw books -- the historical WGtE failure: imported sources got hollowed by "Reload all sources" and left a lying label in `_meta.books`). Test serverless paths with the `:5050` route blocked -- a live server takes the old path and fools tests.
- **Book-gated wiring principle:** a sourcebook toggle gates its WIRING, not just content -- `tashaEnabled()` (loaded && !disabled) gates every TCE rule + its Settings UI; write a `<book>Enabled()` for any similar case.
- **Import files** (`#file-input`): sheet-schema `source-data.json` files (NOT raw 5etools; `build-data.py` produces the schema), validated (`races`/`classes`/`spells` arrays), merged additively via `mergeImportedFile`→`mergeBook`. Imported races get real ability/size/speed + a `scanDefenses` scrape; other trait mechanics are text-only unless hand-wired.
- **`resolveSubraces`** folds parent race content into `subraceOf` subraces and must DEDUP (traits filtered to ones the subrace lacks; resist/spells Set-deduped). Two hard-won guards: the unconditional `r.resist` self-dedup sits BEFORE the early-returns (a subrace carrying every parent trait early-returns past anything later); `r.speed = p.speed` only when speed is null/30 (an unconditional copy reset Skirmisher/Air Genasi's legitimate 35). **Regression check: every runtime `DATA.races` speed vs its baked speed -- healthy is 0 divergence across all 130.** `migrateRaceSpeed()` (end of applyAll) repairs characters saved while the clobber was live -- its trailing `compute()` is load-bearing.
- **Reload guard:** `reloadAllBooks` reverts + warns if a reload would drop content below 60% of a ≥50-spell base.

## Row engines (Inventory / Extra Equipment / Attacks / Limited)

All four are continuous grow-as-filled lists, `MAIN=1`, **NO trailing blank rows anywhere (Alex's call -- don't reintroduce)**: add via the `#*-addrow` button or Enter (`*EnterNewRow`). Each has drag-reorder + ✕-delete + its own engine (PARALLEL engines, deliberately not a shared factory -- the weight/equip/AC-coupled inventory code must not regress). Data engines (`get*Data`/`set*Data`) preserve row linkage metadata (`dataset.weapon`/`unarmed`/`featatk` on attacks; `title` on limited). Deletion = splice + DOM shrink (persists across reload via `rebuild*(maxSaved+1)`). Auto-fill sites grow via `ensure*Slot`; derived rows (equipped weapon, class resources) converge back after deletion by compute -- by design.
- Inventory Enter must YIELD to the `#inv-ac` autocomplete popup (Enter picks the suggestion first). Containers: right-click make/unmake, `invEnterNewRow` inherits the container. Extra Equipment mirrors the full container+drag engine (`xeq*` twins), no weight/compute coupling.
- **Ammo bundles split on add** (`bundleSplit`: exact catalog `(N)` names only -- Arrows (20) → qty 20 @ /20 weight; free-typed "Torch (3)" is NOT expanded).
- **Table gotchas:** whenever a drag handle prepends a column, audit EVERY `nth-child` alignment/width rule for that table. Never draw row-spanning lines as per-cell `border-top` on `.invtable` (the `display:flex` `.invitem` cell breaks border-collapse → stepped line); use an absolute `::after` on the row. Never leave a "transparent" border on `.invtable td` (per-theme `table td{border-color}` rules recolor it visible). **NEVER `table-layout:fixed` on `.spelltable`** (colspan section rows break distribution); DO use it on `.attacks` in print. Drag smoothness: `.dragging` opacity fade added under rAF so the drag image captures first.
- **Overflow is PRINT-ONLY** (`buildPrintFlow`/`teardownPrintFlow` on beforeprint/afterprint): primary-page rows past the fold hide via `tr.pf-hide`; continuation `.pf-cont` sheets re-render rows freshly (never cloneNode -- dup ids) in the original bundled overflow-page format (Limited 8 + Inventory 24 per sheet; INV_PRINT1=20, LIM_PRINT1=8). `.pf-cont` is data-transient. Alex wants the bundled format kept -- don't revert to single-card sheets.
- **Tech-age gear filters** (renaissance/modern/futuristic, off = hidden): per-age seam = module flag + `hiddenItemNames()` gate + `et<Age>()` extractor + build-data parity + baked name-array splice + collect/applyAll persist + settings checkbox sync. `hiddenItemNames()` feeds every picker (inventory search, per-row autocomplete, `#aw-base`).

## Wizard, feats, ASI

- **Feat pickers grey out already-taken feats -- exactly TWO live pickers, keep in sync:** the ASI modal (`.asi-feat-radio`) and the Race step (`.rc-feat-radio`). Engine: `featsTakenElsewhere(skip, opts)` reads `asiChoices`; `.pick-item.taken` CSS serves both. The ASI modal re-renders per change; the Race step needs `refreshRcFeatTaken()` called from all three pick-change paths. **Repeatability is DATA: only Elemental Adept repeats in 2014** (verified across feats.json; "choose when you select" wording is NOT repeatability; 5etools `repeatable:true` exists only on blocked XPHB feats) -- hand-coded `REPEATABLE_FEATS` + `featRepeatable` honours a data flag if one appears. Never extend from recall.
- **Half-feat ability increases are structured data the prose OMITS** (all 51 increase-granting 2014 feats have zero mention in `entries`) -- `featAbilLine(f)` renders it; `featDescHtml` wraps it above the text at all three feat-text surfaces (ASI modal, Race step, preview card). Not a verbatim-rule violation (no source sentence exists; mechanics aren't copyrightable). Tavern Brawler is Str or CON (not Dex). Negative test cases: Alert, Elemental Adept.
- **Per-class choice pattern** (any "pick a skill/option at level N"): a `<class>ChoicesFor(w)` returning `{id, level, prompt, options, kind}`, registered in BOTH `pendingFor` (wizard select + decisions flag) AND the class-features apply site. One id per pick (`primalKnowledge3`/`primalKnowledge10`); later pickers filter earlier picks; featLine titles must not collide with existing block names. `BARBARIAN_SKILLS` is hardcoded (baked class prof has no skills list).

## Companions

Per-character `companions` array (saved via `save()`, not homebrew/DATA); "Pick a stat block…" datalist fed by `DATA.monsters` merged homebrew-first (`monsterByName`/`populateMonsterList` are the only two readers). `applyMonsterToCompanion` fills from the FLATTENED `etMonsters` shape, then overlays a `COMP_TEMPLATES` template. Portrait: dataURL `m.img` used directly (offline homebrew); else fetched from the 5etools server. Familiar/Chain templates render the full baked Find Familiar rules as a computed `.comp-ff` block (coexists with a picked stat block). Monster authoring = the Studio Creatures tab (`#hbc-*` bespoke pane → flattened etMonsters shape into the `monsters` bag); classes are the import-only exception. Companion stat blocks are display + picker only -- no combat engine (out of scope). Ground creature numbers in a real 5etools model, never memory.

## Dice rolling (`window.DiceRoller`)

Full 3D dice system; deep plan in `plans/3d-dice-rolling.md`.

- **Architecture:** three `<script>` blocks before `</body>` -- vendored three.js r147 UMD, cannon.js 0.6.2 UMD, then the DiceRoller IIFE (`window.DiceRoller` = {rollSpell, rollScroll, roll, d20, damage, prefs}; test seam `window.__diceTest` = {last, setRoller, instant, engineActive, shown, engine, log, parseDamageParts}). Cross-block calls are typeof-guarded both ways. The module reads main-script globals DIRECTLY by name (classic scripts share global lexical scope; `window.X` probes fail on top-level lets). The crypto `rollDie(sides)` is the single RNG (the old HP one is `rollHpDie`).
- **Wiring = ONE capture-phase document click listener**: ability/skill/save/init boxes (clone-and-strip `.advmark` before parsing -- its SVG text pollutes textContent), injected `.atk-roll` d20 buttons on filled attack Bonus cells (via a `window.compute` wrap → `renderAtkRollButtons`, idempotent), `.atk-fire` (capture reads the ammo badge before the app handler rebuilds). Guards: altKey, detail>1, `.sk-tri`, contentEditable, `_ovEditing`. To-hit only from the injected button (bonus cells are editable inputs); damage from the result card.
- **Cast flow:** `castSpellName`/`castInst`/`castRoll`; upcast via `openCastPop` dataset + `spellDamage(d, castLevel)`; `castBtnHTML(l, name)` emits roll-only Cast for damaging cantrips + fixed-list spells; attack spells (in `DATA.attackSpells`) roll to-hit from `sEl(inst,'spellatk')`, save spells roll damage subtitled with the DC, non-damage spells roll nothing.
- **Adv/disadv** rides `resolveAdv(gatherAdvSources(), keys)` (same keys as the badges) → 2d20 keep-high/low. Nat 20/1 flagged for attacks only; crit doubles dice COUNT not mods. `parseDamageParts` parses DISPLAY strings only -- never touch `_parseDice` (feeds upcast math).
- **3D engine: PRE-SIM + PLAYBACK.** `roll()` runs the whole physics synchronously and invisibly, records frames, remaps each landed face to the true value BEFORE the first rendered frame, then replays -- so `__diceTest.shown()` is populated synchronously and on-screen numbers can never change after settling. d100 = percentile d10 pair (label-set dice). Geometry is derived (primitives → outward-flipped normals → cluster faces by normal dot > .98 → groups/UVs/ConvexPolyhedron); **flip triangle normals OUTWARD before clustering** or faces merge across the die and values come up wrong. Digit placement: centroid anchor + INK metrics (`inkOf` via actualBoundingBox) + binary-search convex fit; d6 up = edge-midpoint (else 45° tilt). **The d4 is VERTEX-numbered and read at the apex** (`vertVals` + `upVertexIndex` + `d4Mats` repaint) -- no face points up on a tetrahedron; face-normal reads tie three ways. Digit rotation on a landed face is arbitrary -- deliberate, not a bug. Verify geometry changes with `faceCount(d)==d` for all six + a settle test.
- **Themes are PRE-MADE, not a color picker** (Alex: flat tints "look lame"): `DICE_THEMES` = 12 curated looks composing procedural painters; `Engine.themes` is a FUNCTION (live list incl. saved dice) -- invoke it. Material lessons (screenshot-iterated; re-render before trusting a tweak): GEMS = near-opaque saturated map + FrontSide + envMapIntensity .6; GLASS = the one DoubleSide+depthWrite:false case, dark ink; OPAL = opaque pearl, no sheen; METAL = brush grain, metalness .85/roughness .4. **The photoreal pipeline (commit 789149a) was built then REJECTED except shadows** -- PCFSoft shadowMap + ShadowMaterial floor + castShadow, key light at z-7 (camera up is -z; a +z light throws shadows up-screen and dice float). Don't re-add the rest without Alex asking.
- **Instant fallback** (`instant()`): 3D pref off, `body.no-animations`, vendors failed, no WebGL, >10 dice, non-standard die (d100 always instant). **`prefers-reduced-motion` deliberately does NOT gate the dice** (Alex: on by default; his Mac has OS Reduce Motion on -- this shipped broken twice because headless doesn't inherit it). **Changing a pref DEFAULT is never enough:** the resolved value persists in localStorage AND character data -- use the marker-key pattern (`d3on` + `_d3Forced`: force once when the marker is absent, `adoptCharPrefs` ignores the stale record that load, don't eagerly write the marker).
- **Prefs:** `theme`/`font`/`ink`/`threeD`/`rail`/`railPos` are PER-CHARACTER (`CHAR_PREF_KEYS` → `data.dice`, adopted in applyAll; localStorage = default for record-less characters). `railOpen` + `railXY` + saved dice (`dnd-sheet-dice-custom`) are browser-level on purpose. Roll log is per-character (`data.rollLog`; legacy browser key is read-only migration source). Font + ink are part of EVERY texture/material cache key. Switching theme clears the ink override (a stale pick otherwise masks every theme). Settings controls are keyed by `data-dice` ATTRIBUTE (two render sites -- Character Settings + the rail's Setup panel -- one delegated handler + one `syncDiceUI` serve both). New dice setting = one `diceSettingsHTML` entry + an `onDiceSettingChange` branch (+ `CHAR_PREF_KEYS` if per-character). `.dr-settings[hidden]{display:none}` is REQUIRED (hidden attr loses to display:flex).
- **Rail:** `#dice-rail` fixed, `data-pos` ∈ auto|top|right|bottom|left|float (`railPosEff`: auto → right desktop / bottom mobile). Horizontal docks span full width. Float drag lessons (any drag in this app): `touch-action:none` on the handle is LOAD-BEARING (declared in CSS up front; preventDefault in pointermove cannot win a stolen gesture); always handle `pointercancel`; drag+tap on one control needs an 8px threshold + capture-phase click swallow + NO preventDefault/`.dragging` on pointerdown; measure once at grab. No separate grip strip (tried, Alex rejected). `reclampFloat` via ResizeObserver (print-guarded, move-only so no loop). **Rail collider** (`syncRailCollider`): tall static CANNON.Box matching the rail rect (padded 7px), re-synced in onResize + every roll. **Gutter (`syncGutter`, 2.159.5):** while the rail is DOCKED + OPEN, a transient `<style id="dice-gutter-style">` (@media screen) pads the body on the docked edge by the rail's measured size so it never covers sheet content; cleared when collapsed/floating; driven from `applyRail` + the rail's ResizeObserver; print-guarded; never serializes.
- **Throws:** dice spawn centred on the SCREEN along the bottom edge (clamped into `playArea()` = table minus the ONE dock edge with smallest depth -- edge-independence collapsed the table once); launch force `-(19 + rand*11)` z / 12 x spread is DELIBERATE, dice landing up-table is intended (Alex reverted a "fix" -- don't re-fix). `THROWS` = 8 patterns walked IN ORDER (random repeats read as stuck). Spawn probes must record inside the sim (post-roll positions are the landed pose).
- **Outbound roll seam = ONE line in `finishRoll`**: document CustomEvent `dnd-sheet:roll` -- every "send rolls somewhere" feature listens there; never add a second hook at a call site.

## Self-heal & migrations (index -- check here BEFORE adding another; overlapping triggers can double-fire)

Recurring laws: (1) repairs must ride the DOWNLOADED build and run on OPEN; (2) bake/update paths base merges on the EXISTING baked blob, never a possibly-empty runtime map; (3) heals are idempotent + persist via trailing `save()`.

**A. Runtime repair:** `additiveMergeSources`/`mergeUpdatedSources` (every update: add missing, refresh errata by content-hash, heal starvation, delete `_meta.dropped2024` keys, preserve homebrew/adventures); `maybeHealStarvedAwText` (on open, empty OR partial); `checkForMissingBooks`+`loadMissingBook` (manual banner); `relocateMisleveledSpells`; `migrateStaleToolProse`; `loadHomebrew` baked-union + AW-blob graft; `dedupFeatOverrides`; `reloadAllBooks` 60% revert guard; SW self-heal; `bakeSourceData` awText/homebrew preservation.
**B. DATA mutations at load/update:** `resolveSubraces` (dedup + speed guard); `hbApplyToData` (`_hb` merge/strip); `hbNormSources` (coerce); `tagSourcesFromRaw`/`migrateToSources`; `_meta.dropped2024` deletion; bake/persist strip of derived stores.
**C. Saved-field migrations on load:** defenses/senses chip strip + `promoteDefNotes`; theme id migration (`universal`/`dndbeyond`/`material`→`ucs` in compute -- keep); Max-HP baseline adoption; `migrateRaceSpeed`.

**Riskiest to touch: the update/bake trio (`additiveMergeSources`, `mergeUpdatedSources`, `bakeSourceData`)** -- each has self-perpetuated a clobber before. Test changes against healthy AND deliberately-starved runtimes, on file:// AND hosted-over-http. `additiveMergeSources` facts: content-hash `stable(v)` strips `_`-prefixed runtime keys; `_hb` entries never overwritten; user-unique content + homebrew preserved verbatim; `_meta` unioned; only guard kept is user-version strictly newer. Unit-test via a throwaway copy exposing `window.__T_add`.

## NEVER version-bump the IndexedDB (incident 2.155.0→2.156.1)

Opening `indexedDB.open("dnd-sheet", N)` with a HIGHER version broke every older copy on the machine (`VersionError`; file:// shares ONE origin; no downgrade exists) AND an unhandled `onblocked` hung `await csInit()` forever, killing all of init silently. **Put new data in the EXISTING `chars` store under a key prefix** (version history lives at `dnd-ver::<slot>::<id>`, skipped by `_cs` hydration). The fixed `idb()`: open with NO version argument; create stores only when genuinely new; land new DBs on version 2 (`Math.max(2,…)`); guarantee settle (`onblocked`+`onerror`+4s timeout → null); `try{await csInit()}catch{}`. Any `await` in init is a chokepoint. Repro seams need the file:// page (about:blank denies IDB) and releasing the page's own connection first. Ship an `update-note.txt` critical flag for anything in this class.

## Version history + Settings panel

- Per-character snapshots in the `chars` store (`dnd-ver::` prefix). **The one hook: `try{ maybeSnapshot(json) }catch{}` at the end of `save()`** (history must never break saving). **Level-up pair:** `_verPrev` holds the PREVIOUS save's payload so `pre-levelup` is written from the true last state at the old level -- never re-implement as "mark the newest snapshot" (stale). Pruning touches `kind:"auto"` only (keep 40, 3-min gap); boundary/manual/restore kinds are permanent. Restore = snapshot current as `kind:"restore"`, `_suppressSave=true` (a pending queueSave would clobber), `csSet`, reload.
- Settings sub-sections are collapsed `<details>` wrapped AT RUNTIME by `groupSettings()` (idempotent via the `:scope > .set-group` check -- saved copies serialize the wrapped DOM). Content above the first header stays visible. Integrations renders its own `.set-group` markup. Headers = `.set-head`, not `.lbl`.
- **Removing a Settings block means removing its init listener too** -- grep init for the id first (an orphaned `$("id").addEventListener` null-derefs every load).
- Manual overrides were ALWAYS per-character (`data.overrides`); 2.155.0 only moved the control -- don't "fix" the behaviour.

## Integrations (Settings → Integrations, `window.Integrations`)

Module IIFE after the dice module; parsers exposed for Playwright unit tests (`foundryActor`, `ddbParse`, `mpmbParse`, `parseFormBytes`, `importToCharData`, `b20Formula`, …).

- **Beyond20:** listens for our dispatch of `Beyond20_SendMessage` (`[msg]` detail); `chat-message` posts OUR result verbatim (default -- the 3D dice already showed it), `custom` lets the VTT roll. The user MUST add the sheet's domain in Beyond20's custom domains or nothing arrives -- user step, not a bug.
- **Discord webhooks are CORS-open** (plain fetch works from file:// too); serial queue ~1.1s spacing, 429 requeue, `WEBHOOK_RE` validation. **The webhook URL is a SECRET and must not serialize:** browser-level `dnd-sheet-integrations` key only, set inputs via the `.value` PROPERTY never an attribute; regression test = clone documentElement, strip transient, assert the token appears nowhere.
- **Foundry export** = dnd5e actor JSON (import overwrites an actor). Skill keys from the system's config.mjs -- `itm`=Intimidation, `prc`=Perception, `prf`=Performance, `per`=Persuasion, `slt`=Sleight (re-verify after a dnd5e major). AC exports FLAT on purpose; `init.bonus` = sheet Initiative minus Dex mod.
- **OWLBEAR was built and REMOVED -- don't rebuild without solving storage first** (an embedded extension's origin has a separate character store; any revival needs a real character handoff as the first design decision).
- **D&D Beyond:** paste-first (their character service sends no CORS headers). Ability ids 1..6 = STR..CHA across stats/bonusStats/overrideStats (override wins); maxHP = override ?? base+bonus+conMod×level; current = max − removedHitPoints; spells live in both `spells.*` and `classSpells[]`. Schema ground truth = MrPrimate/ddb-importer types. **DDB's PDF export is UNREADABLE (zero AcroForm fields, values drawn as vector outlines) -- don't build a parser, don't re-litigate.**
- **In-browser form-PDF reader** (`parseFormBytes`, DecompressionStream): anchor stream detection on `>>\s*stream` (never bare `stream`); pair `/T` with `/V` scoped to ONE dictionary by walking `<<`/`>>` depth (proximity pairing makes blank fields borrow neighbours' values); inflate ONLY `/ObjStm` streams (correctness AND speed); `.catch()` the writer promises; `/V /Off` → `""`. Hierarchical (dotted) field names can't resolve without the object graph -- MPMB `.pdf` imports flat fields only; `.xfdf` gets everything; the official WotC sheet is flat and imports fully.
- **Two import profiles** (`detectProfile`): mpmb vs wotc. WotC skill boxes hold the BONUS; proficiency is INFERRED (`bonus - mod >= PB` → prof, `>= 2×PB` → expertise); field names carry REAL trailing spaces -- `trimKeys()` on ingest is load-bearing. MPMB real field names are documented in the parser; re-derive with pypdf `get_fields()` after an MPMB bump. Both importers normalize to ONE shape then share `importToCharData`+`landImport` (a third source = just a parser); imports land in a NEW slot, scores as `genMode:"manual"`, class features NOT replayed. **Fixture trap:** pypdf can't set checkbox or hierarchical fields -- a missing value in a test is usually the FIXTURE; read the filled file back first. Wait on the RESULT PANEL text, not `csKeys().length` (autosave races).

## Distribution model

- **Primary distribution = the offline HTML file**; web/Pages is the iPhone exception. In-app update banner polls `version.json` (Pages URL, raw.githubusercontent fallback -- a stale-but-200 Pages response never triggers the fallback, so **"the updater says an old version is latest" after a push = a HUNG Pages build**: diagnose raw-vs-Pages version.json + `gh api …/pages/builds/latest`; a build frozen at `status:building` → `gh api -X POST …/pages/builds` requeues; also `gh run rerun <id> --failed` for failed deploy runs -- a "fix that didn't reach the user" has been exactly this).
- **The service worker is self-healing -- do not regress to cache-first.** `networkFirstDoc` for the main document (cheap 304 revalidation; cache only when offline), stale-while-revalidate for the rest; registration uses `updateViaCache:"none"` + `reg.update()` on load/return/hourly + a `wasControlled`-guarded one-shot `controllerchange` reload. The pre-commit hook stamps `const CACHE = "dnd-sheet-vN"` in sw.js -- keep that line shape.
- **Updates refresh SOURCE DATA additively** via `additiveMergeSources` on BOTH surfaces (local-file `bakeAllInto`→`mergeUpdatedSources`; hosted `updateApp` parses the streamed fresh blob, merges, persists BEFORE reload). Characters are never involved.
- **Update progress:** `updProg` drives BOTH the Settings and banner widgets identically (verified byte-for-byte -- don't hunt an asymmetric path). Pages serves gzip, so Content-Length is compressed while the reader yields decompressed bytes: the hook writes the TRUE uncompressed `"size"` into version.json and `fetchLatestApp(onProgress, knownTotal)` prefers it (clamp overrun to 100, never flip to the indeterminate slider).
- **Changelog-after-update:** `renderChangelog` serves only the gap since the user's last-seen version, latest expanded, rest collapsed -- keep the `since`/`semverForBuild` gap logic.
- **Critical update channel:** root `update-note.txt` → `{critical, note}` in version.json → banner bypasses dismissal. Target by version: first line `<=X.Y.Z` → `criticalMax`, client `critApplies` gates (route ALL banner reads through it). **The flag is STICKY -- delete update-note.txt the moment the urgency passes** (a cosmetic release once shipped as urgent). Older clients ignore/mistreat the fields -- the fix-introducing update needs out-of-band notice.
- **Saved copies serialize the LIVE DOM -- runtime-built chrome must be `data-transient` or it ships as a dead corpse** (markup without listeners; an existence-guarded builder then skips the rebuild → dead modal). `stripTransientChrome(clone)` runs in both clone-download paths; guarded builders use the `_live` corpse-heal idiom (JS property doesn't serialize; exists-without-`_live` → remove + rebuild). Static-HTML modals are re-wired at init and safe -- do NOT strip them. Diagnostic signature: buttons dead in a SAVED file only, zero console errors, synthetic `.click()` also dead, close-fn works when called directly; grep the file for the element id in static HTML.
- **BASE_KEY partition trap (the "all my characters disappeared" report -- FIRST thing to check):** `BASE_KEY = "dnd-sheet-file-" + hash(baked roster)`, so every save that changes the roster changes the storage partition. `restoreRosterIfFresh()` now runs even with a `#slot=` hash (the old SLOT bail caused freshly-saved files opened by slot to show an empty library); slot ids regenerate at bake, so an unmatched `#slot=` clears the hash and reloads. Recovery on old builds: open the file at its plain URL, no hash. Nothing is ever deleted -- the roster just hadn't unpacked.
- **Rescue-by-`cp` caveat:** a raw `cp` over a file with baked characters switches its BASE_KEY partition and strands unbaked live edits. Safe in-place update = TRANSPLANT: splice the target's five data islands (`#source-data`, `#embedded-data`, `#embedded-roster`, `#embedded-prefs`, `#embedded-template-id`) verbatim into the fresh build. Verify pre-vs-post BASE_KEY equality IN THE SAME RUN (never a hardcoded historical key), and/or open the result in a CLEAN context and count `#charmgr .cm-card` (baked characters load from the roster regardless of partition -- the decision-relevant invariant). A user blob much smaller than the repo blob is NORMAL (missing never-loaded raw), not starvation.
- **Testing traps for slot/partition work:** hash-only `goto` does NOT reload (drive `gotoSlot()` itself); SLOT is hash not query param; sample restored counts AFTER async portrait/IDB writes settle; `launch()` = fresh partition every run -- use `launch_persistent_context` for "the browser has seen this file".
- **Link preview:** OG/Twitter tags at the very TOP of `<head>` (size-capped crawlers read only the first chunk of the ~16MB doc); `og:image` must be ABSOLUTE; scrapers cache hard (`?v=N` buster); the card regenerates from the favicon SVG via Playwright set_content+screenshot. Verify with a Range-limited curl. Don't move the app to a subpath (APP_SRC/version.json/updater/SW scope assume root).

## HP widget (Combat card)

`.hpbox`: Heal/Damage (`applyHpDelta`: damage → temp first then current floored 0; heal → current capped at max, never temp; delta clears after apply), big Current/Max + Temp, death saves replace `.hp-vals` at ≤0 (`updateDeathSaves`). Current clamps to Max (onChange + `recalcMaxHp`); Temp uncapped. Mobile stacks the box (readout on top; `.hp-big` widened to 4.3ch -- an `<input>` clips its own content independent of surrounding space; widen the field, not just the container).

**Max HP = baseline + modifier/override stack:** `#hpmax` readonly, tap opens `#hpmax-pop` (`#hpmax-mod` ± baseline, `#hpmax-override` wins); baseline in hidden `#hpmax-base` written by compute; `recalcMaxHp` computes effective into `#hpmax` (every consumer still reads it there -- **preserve the ids `#hpcur`/`#hpmax`/`#hptmp` in any rework**). Legacy values adopt as baseline; Long Rest optionally resets to baseline BEFORE healing; `makeSimulacrumData` halves the base.

**Input-styling gotchas (recurring):** global input rules + `body.dark input` + the flat/sharp `input{background:#fff !important}` + `body[data-theme="ucs"] input` all fight custom fields -- a borderless "plain value" input needs `border:none !important; background:transparent !important` at ≥(0,2,2) specificity PLUS a `body.dark` twin. Number-spinner reveal-on-hover: keep in layout with opacity, BUT a webkit spinner at opacity:0 still RESERVES ~9px width (centered values render visibly left) -- at rest the PSEUDO must be `-webkit-appearance:none` (opacity is the wrong tool), restored on hover/focus. One `:focus-visible` outline rule in the flat/sharp block serves keyboard focus -- keep it.

## UI conventions

- **Toolbar:** direct buttons = Create Character / Save / Search / Mobile; Print, Manage Characters, Homebrew Studio, Settings live in the ☰ `#app-menu` dropdown (relocated buttons KEEP their ids + listeners). New top-level actions go in the menu pop.
- **Header:** two-row nested grid (`.meta-row1` class/bg/race, `.meta-row2` alignment/player/campaign/XP-wide); column templates exist in FIVE scopes that must stay in sync (base, ≤560px, print, `html.mobile`, UCS `.ucs-ident`). Portrait stretches to header height on desktop; mobile/print/UCS pin explicit heights intentionally.
- **Page tabs:** `.ptab.muted` = exists-but-empty (Companions/Mini) -- muted tabs stay CLICKABLE (the discovery path); the AW tab instead fully HIDES when no AW is carried (reached by building one). `label.lbl` is 10px uppercase; nested `.wiz-hint` spans reset to sentence case -- use the `<label class="lbl">Title <span class="wiz-hint">…</span></label>` pattern freely.
- **Skill proficiency = one tri-state `.sk-tri` control on screen** (none→prof→expertise→none); the `#skp-*`/`#ske-*` checkboxes remain the SOURCE OF TRUTH (compute/ASI/race code read them) and the print representation (`#skills .marks` scope only -- saves also use `.marks` and must stay visible).
- **"Can Prepare" (`#prep-stat`) detaches ITSELF to the corner on scroll** (no clone -- Alex rejected a chip). Every per-frame check reads the STABLE `.spellhead` reference, never the box's own rect/offsetParent (a fixed element's rect reports the corner and its offsetParent is null → infinite flicker). During the prepare overlay it pins to the MODAL panel's top-right (inline right, cleared on the non-over path). Detach freezes width; top is measured from the sticky toolbar. Verify with REPEATED update calls, not one dispatch.
- **In-place vs re-render law:** a pure show/hide flips a class IN PLACE; only content changes re-render a long list (scroll jump otherwise). Open-state lives in module-level Sets so genuine re-renders preserve it.
- **UCS skills column is a fixed 268px** -- the width reclaim rules (smaller totals/gaps, `.rname` wrap) live after the `#ucs-skills` grid rules; verify overflow by measuring `scrollWidth === clientWidth`, not eyeballing.
- **Character Manager mobile:** 2-col grid; the renderer's inline `gridColumn: span N` on sim groups needs `grid-column:auto !important` + stacking in the mobile rule. Measure `.cm-group` rects; a full-page screenshot of a fixed modal also paints the sheet below it.
- **Overlaid controls CLIP the input they sit on** -- the symptom looks like occlusion but the fix is the CONTENT BOX (padding); the oracle is `input.scrollWidth > input.clientWidth`, not rect intersection. Whenever a noprint control reserves keep-clear padding on a field, audit the field's room at its widest realistic value, in screen AND print (the `.has-fire` ammo padding ate weapon names in print; the `.atk-roll` padding cut "+10" to "+1").
- **Mobile:** `html.mobile` forced ≤600px screens; ability rows carry Score/Mod sublabels via `::after`; identity row stays horizontal (don't re-stack); hot-path tap targets ≥44px. **iOS phantom sideways scroll** = the sticky `.pagetabs` negative-margin overscroll -- fixed with `overflow-x:clip` on html/body (**`clip`, not `hidden`** -- hidden breaks sticky + vertical scroll). Not reproducible headlessly, even in device-emulated WebKit.
- **Print flow specifics:** page-budget invariant -- Chromium funds the PDF page count from the NATURAL layout height; sheets past the budget are SILENTLY TRUNCATED, hence every printed page carries a ~261mm min-height floor; never "optimize" page heights below one sheet. `html.mobile` is ON during every print; `html.mobile #page-X{display:block !important}` beats `.excluded` -- extend BOTH the mobile list and its ID-qualified `.excluded` counter-rules when adding a page. Death saves print as an absolute strip anchored to the combat CARD (the reserve padding is the knob). Print min-heights that "don't apply": check the selector still matches current markup first.

## Design taste (this app only)

- **Flat / sharp:** square corners, no shadows, hairline borders, flat colors. D&D-sheet-ONLY -- don't generalize to Alex's other apps.
- **ANY light-tinted state class needs a `body.dark` twin** or dark mode goes white-on-white; measure contrast in BOTH modes. The dark accent is a LIGHT gold -- accent-filled controls need near-black ink in dark (white measures ~1.4:1). Use `--accent` for pressed/active states, not green (green = advantage/Heal here).
- **No emoji as icons** -- inline SVG with `currentColor`; icon source iconstash.io. The Cast button exists in TWO places (slot button + scroll render) that must stay in sync; icons go INSIDE the button (name readers use firstChild).
- **Logo/favicon = minimalist d20**, three surfaces generated FROM the favicon SVG (toolbar inline SVG, `<head>` data-URI favicon with a prefers-color-scheme swap so it survives dark tabs, PWA PNGs + a maskable variant refit to the safe circle). Regenerate via the Playwright rasterize path after any change; verify at 16/32/48px in BOTH color schemes (headless defaults light; Alex's Mac is dark).

## Roadmap

Lives in the SHEET repo: `ROADMAP.md` + `plans/`. Update those when work ships, not this skill. Standing constraints: **do NOT strip/null the real `#source-data`** until Alex signs off on the per-book pipeline end-to-end (test harness: gitignored `_pipeline-test/`); **save-in-place is SHELVED deliberately** -- don't build until he asks. Coverage oracle (MPMB parity) is RETIRED at 59/65 with the 6 gaps permanent architectural N/As (`allModes`, `fixedSpAttack`, `amendTo`, `page3notes`, `popupName`, `stopeval`) -- don't re-attempt without new justification; everything is retrievable at sheet commit `c239926`.

**Living details (current SemVer, open items) drift between sessions -- trust `VERSION`, the in-app changelog, and `ROADMAP.md` over any number written here.**
