name: back-nine-design
description: Project design law for The Back Nine's UI — the rules no general design skill covers. Color-blind-safe encoding (the user is color-blind; color is NEVER the only signal), honest confidence-band/projection-fan rendering, calm intake-form UX, financial-input discipline, and PWA/offline behavior under the strict CSP (style-src 'self', the landmine). Use whenever building, modifying, or reviewing ANY user-facing surface in this project — React components, the U5 intake flow, the U6 confidence band, the U7 confidence statement, charts, forms, colors, icons, error states, animations, or service-worker/update behavior — even for a "quick" UI tweak, and even if the user doesn't mention design. Load alongside compound-engineering:frontend-design (direction), emil-design-eng (motion craft), and web-design-guidelines (post-build review).
The Back Nine — Project Design Law
This skill carries the project-specific design rules that the general design skills do not cover. It is law, not taste: every rule here traces to a hard project requirement (the user is color-blind; "calm-but-wrong is the sin"; the strict CSP; the locked R-requirements) or to a verified primary source. On conflict it outranks the other design skills (see CLAUDE.md "UI design skills" for the loadout and precedence).
Division of labor: compound-engineering:frontend-design owns aesthetic direction and lifecycle. emil-design-eng owns motion timing/easing/perf craft. web-design-guidelines is the post-build review checklist. THIS skill owns: color + redundancy law, chart honesty, intake-form behavior, financial-input handling, and CSP/PWA constraints on UI work.
Tone law (the one-paragraph version)
This is a calm, plain-language financial co-pilot a spouse with no finance background can read — never a dashboard, never a casino. Confidence is graded and honest ("7 of 10"), precision is humane (~$X), and nothing animates to excite. When a general design skill pushes "bold / striking / unforgettable," this project's answer is: distinctive through restraint and typography, not intensity. The N=1 cold-read judges tone; the rules below are correctness.
Data visualization + color-blind-safe rules (U6/U7)
Why this section is load-bearing. The N=1 reviewer (Briggsy) is color-blind, and the P2 centerpiece is a confidence-band / projection-fan that friends bet real retirement money on. Every rule here is a correctness rule, not a style preference. Claude owns all color decisions; the reviewer never adjudicates hue. The single source for these primitives is
src/viz/palette.ts(phase-2 cross-cutting #3) — the luminance ramp, shape/marker set, line-style set, and the "separate by luminance, not hue" law. Two consumers map them to two axes: verdict-state (src/ui/verdictSignal.tsx) and series-identity (U6 two-series). "Parity" = both draw from the same primitive set, never identical rendering.
1. Palette law
Categorical data → Okabe–Ito subset. Where discrete categories must be distinguished by color at all, draw only from the Okabe–Ito 8-color color-universal set (hex verified against clauswilke.com/dataviz + the canonical jfly.uni-koeln.de page, 2026-06-11):
Token (project name) Okabe–Ito name Hex --inkblack #000000--accent-blueblue #0072B2--accent-vermilionvermilion #D55E00--accent-bluegreenbluish green #009E73--accent-skysky blue #56B4E9--accent-orangeorange #E69F00--accent-purplereddish purple #CC79A7--accent-yellowyellow #F0E442Define the project's picks as named semantic tokens, never raw hex at call sites. For The Back Nine the categorical surface is deliberately tiny (this is not a dashboard): pick at most two chromatic accents for the two-series encoding and reserve the rest. Recommended series picks: blue
#0072B2(series 1) and vermilion#D55E00(series 2) — the Okabe–Ito pair with the widest luminance separation, so they survive grayscale and CVD. (This pairing is an engineering recommendation, not a pinned value — confirm against the actual oklab probe output whenpalette.tsis built.) Never use a red/green-adjacent pairing (vermilion vs bluish-green) as the sole differentiator of good-vs-bad — that is the classic deuter/protan collapse.#F0E442yellow is banned for thin lines, small markers, and any text — it has1.32:1 contrast on white (1.24:1 on the cream paper), measured (culori); nowhere near the 4.5:1 text floor. Use it only for large filled areas, if at all.- Derived shades (the mislabeling trap). Raw Okabe–Ito values often can't meet a text-contrast requirement (4.5:1). Lightness/alpha-adjusted derivatives of an OI hue are PERMITTED for those utility roles — same hue (within ~3° in oklch), varied only in L/alpha, and gated by the same CVD probe as every other color. Name a derived token as a derivative of its base (
--accent-blue-ink, with a comment naming the base and the contrast reason) — never label an off-hue or heavily-shifted value "Okabe–Ito": a reader auditing the palette must be able to trust the token names, and a mislabeled "OI" value silently defeats that audit. Data/series/band colors stay raw OI; derivatives are for text-on-tint and contrast utilities only.
Ordered / graded data (the confidence band) → single-hue LIGHTNESS ramp, never a hue gradient. A percentile fan is ordered data; a rainbow/hue sweep is the wrong tool and reads dishonestly under CVD (
projects/burned/docs/insights/010-art-directed-palettes-fail-apca-radix-guarantees.md: red and green accents must separate by luminance, not hue;projects/burned/docs/insights/051-prose-cvd-recommendations-are-wrong-direction.md: hue intuition does not survive the deuter/protan/tritan transforms). Build the band fill as a monotonic lightness ramp of one hue (a single Okabe–Ito blue base, varied only in oklch L/alpha). The outer (worse-case) percentiles are the lighter/lower-emphasis end; the median region is the darkest/highest-emphasis — so density of ink tracks likelihood and the ordering is legible in pure grayscale. If a true multi-hue sequential scale is ever needed, Paul Tol's YlOrBr or iridescent schemes (Tol's 2021 technical note; corroborated via SRON mirrors — the primary site was unreachable at authoring time) are the named references — but for a single distribution, a one-hue L-ramp is correct and calmer.Constants discipline. The Okabe–Ito picks, the band-ramp L stops, the CVD floor (
0.10), and the oklab metric live in one canonical token source per the project's one-canonical-table rule — never re-typed in a test or inline at a call site (a shape test greps for inlined hex, mirroringsrc/engine/constants/discipline).
2. Never-color-alone (every encoding carries a redundant channel)
WCAG 1.4.1 Use of Color is Level A (verified, w3.org): color may never be the only visual means of conveying information. This is the floor; the project bar is higher (the reader is color-blind, and the signal must live in the accessibility tree — not merely be grayscale-visible).
- Confidence band edges → direct percentile TEXT labels, not a color legend. Annotate the band's outer edges and median in-place with text ("median", "10th–90th"); never "blue = median, light = tail" in a legend keyed by color. The non-color tell of the indeterminate placeholder is the absence of the median line + a wide low-emphasis hatch/dashed boundary — never hue (phase-2 U6 contract).
- Good/bad outcome states → icon + word, never red/green alone. The six engine states (
on-track / borderline / off-track / indeterminate / over-funded / already-failing) render verdict word + dollar magnitude + a distinct shape/icon as the lead; color is redundant only. The six icon shapes arepalette.tsprimitives and must be pairwise-distinct on the non-hue channel (silhouette + luminance) — run all C(6,2)=15 pairs through the same oklab/luminance probe (phase-2 U6). Each icon carriesaria-label= the state name (routed throughcopy.ts, never an inline attribute literal — the U5 no-inline-copy lint coversaria-label). - Two-series identity → solid/dashed line-style + distinct marker shape + direct end-of-line label + luminance. Four redundant channels; color is the fifth and least-trusted. Direct end-labels beat a legend (no eye round-trip, no color dependency). Apply
vector-effect='non-scaling-stroke'so line weight and dash geometry stay constant in screen pixels across viewports — this protects the colorblind-substitute encoding on the narrowest phone (phase-2 U6 responsive-SVG contract). - The X-of-10 display. Renders as the literal text "X of 10" (the pinned natural-frequency frame; denominator pinned at 10), with the verdict word and shape adjacent — the count is never a bare colored bar or gauge. Top-of-scale renders "more than 9 of 10" (the over-funded near-ceiling clamp; "10 of 10" can never appear). Position/length encodings (a quiet progress thread, a fill proportion) are encoded by fill proportion, never hue, with an SR-announced position.
- Programmatic availability. Because the reader is color blind, not blind, every signal must be reachable as text in the a11y tree: verdict word as text, icon
aria-label= state name, band/series exposed via a text alternative (role="img"+ accessible name, oraria-hiddengraphic + adjacent SR text). Grayscale-visible is necessary but not sufficient.
3. Honest-chart rules for the confidence fan
- No truncated / exaggerating axes. y-axis = LINEAR portfolio value in today's (real) dollars, anchored at $0 — reject a log scale explicitly: log's domain is strictly positive so it cannot draw the depletion-to-$0 (ruin) case, the single most important honest signal for off-track / already-failing (phase-2 U6). The ruin case must draw the band touching $0 at the correct household-clock year. Never start the y-axis above 0 to make a shallow decline look like a cliff, or vice-versa.
- No spurious precision. The headline is a coarse natural frequency ("X of 10"), not "7.3 of 10" or "73.4%". This ties to the engine's cross-engine robustness rule:
confidence.tsquantizes the headline statistic to a coarse grid before the band-edge decision (the screenshot-reproduction guard in CLAUDE.md). The viz must not re-introduce false precision the engine deliberately removed — band edges are percentile regions, dollar figures wear~and round to a humane magnitude through typed copy slots (~$X,~N years). - Band quantization honesty. The drawn band reflects the engine's emitted percentiles; the UI never recomputes or interpolates a grade from raw paths (drill-down grade == crowning grade; a planted independent recompute must fail — phase-2 D2 §3c). The indeterminate state renders a deliberately wide, low-emphasis placeholder with no median line — forbid any thin/precise band or single flat line in that mode (it would read as a confident answer when there is none).
- Calm rendering — no dashboard theatrics. The headline dollar figure and the X-of-10 render static: no count-up / odometer — an animated financial figure reads as a gambling/dashboard tell, and the static number is the screenshot artifact the reload-determinism contract must reproduce byte-identically. The band draws once on its first on-demand reveal, then morphs (interruptible transition/spring, never an
@keyframesdraw-from-zero replay) on every recompute; the morph rides the fixed scenario seed so the shape change is pure signal, never RNG jitter. State→state verdict changes crossfade the word+shape+magnitude, never a hue-swap or a pop. The band is never on the verdict's first frame — it is the on-demand "range" (R2/R4 forbid a dashboard on the primary surface). Peremil-design-eng's banking-app principle, motion should feel like a calm financial tool settling into its answer — not a casino reveal. All of it honorsprefers-reduced-motion(drop movement, keep a short comprehension fade); the final rendered state is identical with motion on or off — no information lives only in the animation.
4. The CVD self-test (port burned's planted-fail probe)
Keep the pinned contract: oklab Euclidean distance, 0.10 STRICT floor, across deuteranopia / protanopia / tritanopia, via culori's filterDeficiencyDeuter/Prot/Trit + differenceEuclidean('oklab') (the pipeline in projects/burned/docs/insights/051-prose-cvd-recommendations-are-wrong-direction.md). Do not switch to differenceCiede2000 — the 0.10/oklab pair is the pinned metric. Two refinements for this repo:
- Probe the effective composited color. The band uses alpha fills, so the on-screen color of band-over-bg, line-over-band, and label-over-band ≠ the token's nominal hex. Compute the composite first, then run the CVD distance. The probe must cover every color either consumer renders — including
verdictSignal's redundant colors and the verdict-icon swatches — so no UI-layer color escapes validation. - Plant a deliberate too-close pair so the probe can fail (
projects/burned/docs/insights/070-bc-missing-silent-noop-verification.md— a verification that can silently no-op manufactures false confidence). Add a fixture of a hue-only / near-identical pair (e.g. a borderline-vs-off-track icon pair, or a red/green-only series pair) that the probe MUST flag at the 0.10 floor. The test asserts both arms: real tokens pass and the planted pair fails loud. Mirror this for the grayscale/desaturation check (the two series stay distinguishable with color stripped) and the pairwise-icon check. - Repo conventions. Keep the oklab probe a dev gate, not a CI unit test (phase-2 U6); co-locate as
src/viz/__tests__/colorblind.test.tsx.*.pbt.test.tsviafast-checkfor any property over the lerp math; commit fixtures, don't generate into gitignored dirs (AJS 008). Assertscale.tslerp boundaries as a real unit test alongside.
5. Tabular numerals for all financial + scanning figures
- Apply
font-variant-numeric: tabular-nums(orfont-feature-settings: "tnum") to every dollar figure, the "X of 10" count, ages, years, percentile labels, and any column or vertical list of numbers. Proportional digits make a morphing/recomputing headline jitter horizontally and make a vertically-scanned list misalign — both read as instability on a tool whose entire job is calm trust. Tabular figures keep each glyph the same advance width so a number that updates in place changes value without shifting position. - This is a token-level decision in the shared type system built before components, so the verdict line, U6 axis/edge labels, and the Phase-3 control readouts inherit it uniformly — never bolted onto individual components.
- The self-hosted display + body faces (the CSP forbids a font CDN) must be chosen with a real tabular-figures feature; verify the feature actually renders — a font silently lacking
tnumis a false-green of this rule.
Intake forms (U5 / D1)
The intake is the product's front door: a calm, one-question-at-a-time, ~5-minute account-level setup (R35) whose confidence answer surfaces and sharpens during entry, never behind a final "calculate." The cardinal sin is calm-but-wrong (R25); a form that quietly drops a field, runs the engine on a mistyped figure, or hides an error behind color alone is a correctness failure, not a polish gap.
Labels and field anatomy
- Always-visible labels above the field. Never placeholder-as-label. A placeholder vanishes on focus and on input, leaving the user mid-answer with no reminder of the question, and it is invisible to screen readers as a label. Use a real
<label for>(or wrapping<label>). The placeholder, if any, holds only a format example ("e.g. 7,500"), never the field's name. - Bind the label programmatically. Every input has a
<label for>/idpair or is wrapped. The intake copy lint (cross-cutting #4) routes label text throughcopy.ts— no inline JSXText. - One question per screen, finger-sized full-width fields (mobile must shine, not survive). Interactive targets are at least 24×24 CSS px (WCAG 2.2 SC 2.5.8 Target Size, Level AA — the hard minimum); treat 44px as the practical floor for a primary field on a phone (established practice, not a WCAG number).
- Help/hint text (the "what counts as a recent marketplace quote" pre-flight note, the OOP-vs-spend contrast copy) sits adjacent and is wired via
aria-describedby, not crammed into the label.
Validation timing (R5 advisor-style, R11 calm-never-traded-for-engagement)
- Validate on blur and on attempt-to-advance. Never per keystroke. A rule whose inputs are not all present yet does not fire. A value that is invalid-as-prefix but valid-when-complete ("6" on the way to "62") is never flagged mid-entry.
- Errors clear the instant the field is re-edited (on input), not on the next blur — the calm path is: silent while typing, checked on leave, forgiven on return.
- Block only true impossibilities (retirement age < current age for a *still-working* person; SS claim outside 62–70; survivor-spending ratio > 100%; a contribution above the C1 legal ceiling for that account/age). Coherent-but-dire input ($0 portfolio + positive spend) is not an error — it flows to an honest "0 of 10." Do not invent plausibility gates the requirements don't name.
- Status-conditional age rule (D1 supersession): for an already-retired person,
retirementAge ≤ currentAgeis the legitimate entry (the stop age) and must never be R19-rejected. The contradiction to catch is status-vs-age disagreement, not the inequality itself. - Directional re-validation on back-nav: when an upstream answer a downstream rule depends on is edited via Back, re-fire the downstream rule. An upstream edit keeps downstream answers (never silently wipes them) but marks dependents for re-check; if the edit makes a downstream answer impossible, the same calm inline message fires at the point of edit. No stale-but-impossible combination reaches the engine.
Error presentation (ties directly to the color-blind law)
- Color is never the only signal. Every error carries three redundant channels: a shape/icon, plain-language text adjacent to the field, and color only as reinforcement (WCAG 2.2 SC 1.4.1, Level A). Claude owns all color choices — do not ask the user to pick.
- Programmatic association: the error node carries
role="alert", the field getsaria-invalid="true"andaria-describedbypointing at the error id; the error clears (andaria-invaliddrops) on correction. The reader is color-blind, not blind — the signal must be reachable as text in the accessibility tree. - Tone, not alarm. No scary red flash, no shake, no all-caps. Plain advisor voice: "A retirement age can't be earlier than your current age — did you mean to mark yourself already retired?" Route every error string through
copy.ts; it passes copyGuard (no imperative-advice verbs, no false-certainty). - Text contrast: error text and all body copy meet WCAG 2.2 SC 1.4.3 — at least 4.5:1 against its background (3:1 for large text). Do not rely on a faint tint to read as "error."
Multi-step flow rules
- Focus-to-heading on every step change — the established project pattern. On step advance, move focus to the new step's
<h*>heading withtabindex="-1", NOT to the input. Auto-focusing the input pops the mobile keyboard before the user can read the question; the heading-as-focus-target IS the announcement (no double-announce). This sharedannounce()/focus-move helper is reused by U7 (focus to the verdict) and U8 (the Save screens). - One visually-hidden polite live region carries only transient status, with clear-after-announce discipline (
projects/burned/docs/insights/045-aria-live-region-staleness-mistaken-for-visible-toast-persistence.md) so stale prior-step text never lingers in the a11y tree. - Focus-safe motion (load-bearing): any step that receives programmatic focus on mount is faded with opacity only — never a
visibility/autoAlphatoggle — and the outgoing step is unmounted, not hidden, or.focus()becomes a silent no-op (projects/ai-journey-stats/docs/insights/006-gsap-autoalpha-breaks-focus.md). Step direction encodes meaning (forward enters from the right, Back reverses), interruptible transition/spring, andprefers-reduced-motiondrops the slide, keeping a fast opacity fade. - Progress indication: a quiet, unlabeled progress thread encoded by fill proportion, never hue, with an SR-announced position. No "Step N of M" chore-counter — the flow is variable-length (N accounts), so a fixed denominator would be inaccurate and reads as a form.
- Back-navigation preserves all entered data. Every step reads/writes through the single
memoryModel(no per-step local copy to lose), so Back re-renders the prior step pre-filled and advancing forward preserves edits. Mechanism is implementation's choice. - No-persistence-until-Save is a HARD rule. Nothing reaches IndexedDB during intake — not on completion, not on an error branch, not on back-nav, not on a mid-intake abandon. Park-and-resume is honestly in-session only: a closed tab loses the partial entry; cross-session park requires an explicit Save (U4). This is why the pre-flight "have your statements and a marketplace quote handy" note is the primary affordance. The no-write assertion is tested against U4's real store (fake-indexeddb / e2e), never a stub (a stub is vacuously green).
- Paired two-person screens: render "you / your spouse" as one paired screen wrapped in a
fieldset/legend(orrole="group"+aria-labelledby), never two near-identical sequential screens.
Numeric and financial inputs (mobile must shine)
inputmode="decimal"on open-ended dollar/percent fields summons the fractional numeric keypad; useinputmode="numeric"for integer-only fields (ages, years, whole-dollar).inputmodeonly hints the keyboard — it enforces no validation (MDN), so the R19 sanity rules remain the real guard.- Use a
type="text"field withinputmode, NOT<input type="number">, for currency. The native number spinner rejects grouped digits (thousands separators), shows steppers, and is hostile to exact financial entry; a slider can't hit an exact figure (violates R6's "set any assumption precisely"). Reserve sliders for bounded ranges only (ages, percentages). autocomplete="off"on financial-figure fields. Balances, contributions, basis, premiums, and SS/income figures are per-scenario values the browser must not prefill, and they must not be offered up to the autofill store for a personal-finance tool. Caveat (MDN / WCAG 2.2 SC 1.3.5):autocomplete="off"disables an assistive feature, so use it only on the financial figures — for genuinely identity-shaped fields where a meaningful token exists, supply the correct token instead (which satisfies SC 1.3.5 rather than opting out).- Add
enterkeyhint("next" mid-flow, "done"/"go" on the last field of a step) so the mobile enter key labels the advance action (MDN). - The model stores a raw number; the field displays a formatted string. Reformat (thousands separators) on blur, not during typing — formatting per keystroke jumps the caret.
font-variant-numeric: tabular-numson all displayed money/figures (see the dataviz §5 — token-level, not per-component).- Period disambiguation at entry (R19): every per-period money field carries an explicit
$/monthvs$/yearsegmented control (active segment shown by shape/weight, never hue). The model stores one canonical period. For a value coherent as both monthly and annual, intake forces an explicit per-period confirmation rather than silently computing on the default — the engine can never run on 1/12× or 12× the real figure. The implausible-magnitude check is the second line of defense, not the first.
Plain-language microcopy (calm, R19/R14 voice)
- Questions a spouse with no finance background understands. "How much do you spend in a typical month, all in?" not "Annual decumulation requirement." No jargon (MAGI, SLCSP, IRMAA, sequence-of-returns) on the question face; if a term is unavoidable (a marketplace SLCSP figure), define it inline in the help text in plain words.
- The still-working user is never asked to guess a retirement date — the date IS the product's answer. D1 constructs a placeholder
retirementAgeinternally (never user-visible). Asking the question would undercut the magic moment. - All copy through
copy.ts, gated by copyGuard: no imperative advice verbs ("you should," "we recommend"), no false-certainty ("guaranteed," "locks in"). Quantitative content appears only through typed slots (~$X,~N years, the "X of 10" frame). A field's required-vs-optional framing is itself copy (the OOP-medical question must state "out-of-pocket costs should already be inside your spending figure — we use this only to size your HSA's tax-free draw," or the dedicated question silently invites double-exclusion).
The 5-minute budget — progressive disclosure
- Collect only what the engine needs to compute; defer everything optional. The required-fact set is ~12–14 facts plus the pre-65 ACA quote (the single biggest honesty lever) when any member retires pre-65. Methodology assumptions (real return + σ, inflation, longevity tables, survivor ratios) are pre-defaulted and surfaced, never a "skip with a reasonable default" UI button.
- No in-range default fallback for a required user-fact (
projects/burned/docs/insights/062-default-fallback-coincides-with-valid-measurement.md). A required fact not yet collected is an explicitunknownsentinel (an out-of-range value), and the engine maps it to indeterminate — never a silent 0 or?? 0.04that overlaps a plausible real value and renders a falsely-rosy answer. - The answer renders provisionally during entry, anchored to
validateParamsacceptance — not a UI-side counter. Below acceptance: a calm input-incomplete placeholder that names the specific missing input and why the tool won't synthesize it (especially the un-fetchable ACA quote — R36 forbids a live lookup). Between acceptance and account-set-complete, all outcome classes render in provisional form with a distinct, copyGuard-cataloged incompleteness copy class — never the final off-track / no-date copy. A mid-entry reading that improves as accounts land is a labeled provisional update, never an alarm and never a bargaining tick. - Advanced precision is an opt-in expander, never a default question (the per-age ACA escalator override, the exact-% ticker classifier, per-person retirement-year asymmetry) — depth on demand (R6/R7), keeping the guided path at ~5 minutes.
PWA / offline UX
For a personal, single-device, local-first tool whose data lives encrypted in IndexedDB and whose CSP is connect-src 'self' (no runtime fetch, R36), offline is a first-class state, never a broken one. The app's entire job runs client-side; there is no server round-trip to lose.
- Offline works, silently. The engine, the intake, the saved vault, and a recompute all function with no network. Do NOT show a "you're offline" error, a blocking banner, or a retry-to-reconnect prompt — there is nothing to reconnect to. At most, a quiet, dismissible, non-modal note that data is stored locally on this device, never an interruption.
- Update flow must never silently reload and eat un-saved intake state. Intake holds the in-memory
memoryModelthat is deliberately not persisted until Save. A service-workerskipWaiting+auto-reload mid-intake would discard the whole session. Use a prompt-mode update: when a new version is waiting, show a calm, dismissible "A new version is ready — refresh when you're done" toast; the user chooses when to apply it. (U0 shipsinjectRegister:false/ prompt mode precisely so no inline registration script is needed underscript-src 'self'.) - Defer the update across a Save in flight. During the U8 first-save ceremony, an accepted update must defer
skipWaiting+reload until the store's no-write-in-flight signal clears and the atomic IndexedDB commit lands — never a false "saved" before commit, never a torn vault (U8 owns this integration test). - Install prompt etiquette: never interrupt the intake flow. Capture
beforeinstallprompt, suppress the default mini-infobar, and surface "add to home screen" only at a calm boundary (after the first answer is saved, or from a settings affordance) — never mid-question, never as a modal over the magic moment.
CSP constraints on UI work (do not rediscover at runtime)
The shipped policy (verified in vercel.json) is style-src 'self' with no 'unsafe-inline' and no nonce — and inline style attributes are governed by style-src just like <style> blocks (MDN). Consequences for every UI surface:
- Prefer CSS custom properties set via stylesheet-driven classes over per-element inline
style={{…}}for anything dynamic. - motion@12 layout-animation features inject a
<style>element. When animation lands using those features, either avoid them or supply a per-response nonce to bothstyle-srcand<MotionConfig nonce>(CLAUDE.md "CSP forward landmines"). - Self-host all fonts —
connect-src 'self'/style-src 'self'forbid a font CDN — and count them against the 300 KiB entry-JS byte budget (pnpm verify:bundle).