diff --git a/apps/web/docs/BUDGETS.md b/apps/web/docs/BUDGETS.md
new file mode 100644
index 0000000..bb0cce8
--- /dev/null
+++ b/apps/web/docs/BUDGETS.md
@@ -0,0 +1,64 @@
+# Bundle Budgets — MBD Web App
+
+Source of truth for ceilings lives in [`apps/web/src/build/bundleConfig.ts`](../src/build/bundleConfig.ts). This file documents the *why* behind each lift and the policy.
+
+## Current ceilings
+
+| Chunk class | Raw | Gzip |
+| --- | --- | --- |
+| Main-thread chunk | 304 KB | 81 KB |
+| Worker chunk | 443 KB | 143 KB |
+| Chart vendor (lazy `vendor-charts`) | 430 KB | 120 KB |
+
+The main-thread chunk caps have not moved since launch — `MAIN_THREAD_CHUNK_BUDGET_BYTES` / `MAIN_THREAD_CHUNK_GZIP_BUDGET_BYTES` are deliberately frozen so any new app code that lands on the main thread surfaces as a regression in `bundleBudget.test.ts`.
+
+## Lift policy
+
+1. **Smallest safe lift.** A slice ships with the minimum cap move that restores headroom. No round-number gifting.
+2. **Raw and gzip move independently.** A copy-heavy story slice usually only needs gzip; a wire-only worker slice usually only needs raw.
+3. **Chunk routing first, ceiling lift second.** Before lifting a cap, check whether routing the new module into a different chunk (e.g. moving narrative prose into `game-engine-story` or `game-engine-capstone`) keeps `game-engine-core` lean.
+4. **CI vs. local terser drift.** Local builds typically emit `game-engine-core` ~250–500 bytes smaller than CI. Lifts must include headroom for that drift; otherwise the budget test goes red only in CI.
+5. **Document the rationale in the PR.** This file gets the *why*; the journal below records milestones.
+
+## Worker chunk lift timeline
+
+| Cap (raw / gzip) | Slice | Cause |
+| --- | --- | --- |
+| 406 / 124 KB | (pre-arbitration baseline) | — |
+| 408 / 125 KB | Arbitration broadcast | Arbitration press-conference templates + moment descriptions. Smallest safe gzip lift after copy trim. |
+| 410 / 126 KB | Holdout briefings + trade-deadline press | `generateHoldoutResolutionBriefing` and trade-deadline press copy. Raw-only lift first, gzip moved a tick later when trade-deadline copy crossed the cap by 471 bytes. |
+| 411 / 126 KB | Team-moment store (v22) | `appendTeamMoments` + `teamMoments` map on `FullGameState` + identityMoments seam through `buildTradeBroadcastCoverage`. Wire-only. |
+| 412 / 126 KB | Chase Watch query | `getChaseWatch()` assembling milestone alerts + pace chases. Pure aggregation. |
+| 414 / 126 KB | Pennant-race-heat query | `getPennantRaces` with division race + wildcard bubble. |
+| 417 / 127 KB | Team-moment types (v23) | `detectSeasonIdentityMoments` + `championship_run` / `contention_collapse` enum + description templates. |
+| 420 / 127 KB | Career Retrospective query | `buildCareerRetrospective` unifying titles, beats, story arcs, awards, top rivalry. |
+| 423 / 127 KB | Season Story Reel query | `buildSeasonStoryReel` per-season deep-dive. |
+| 425 / 127 KB | Pennant Race Detail query | `getPennantRaceDetail` all-six-divisions + top-5 wildcard. |
+| 425 / 128 KB | Team-identity wave 2 (v25) | `rebuild_begun` + `breakout_season` + `contention_window_opens` detectors. Gzip-only lift. |
+| 428 / 131 KB | Narrative depth wave 3 (v26) | Additive detector/template expansion. Sprint hard cap. |
+| 428 / 132 KB | CI terser drift recovery | One KB gzip lift to absorb a +534-byte CI vs. local minifier drift on an unchanged commit. |
+| 432 / 134 KB | Narrative depth wave 4 (v27) | Persisted-state fidelity + playoff comeback tracking. |
+| 433 / 134 KB | Career Retrospective season-history | `seasonHistory` derivation. Raw-only. |
+| 434 / 135 KB | Codex narrative-prose reservation | Headroom reserved for parallel `codex/narrative-prose-expansion` branch (deterministic prose variant pools). |
+| 434 / 137 KB | Codex narrative-prose landing | Lift confirmed after rivalry-wave1 (PR #50) stacked with prose pools in `game-engine-core`. |
+| 436 / 137 KB | This Week in History query | `getThisWeekInHistory` over persisted moments maps. Raw-only. |
+| 438 / 137 KB | Player Arcs of the Season query | `getPlayerArcsOfSeason` filtering redemption_arc / late_career_peak / rookie_breakout. Raw-only. |
+| 439 / 139 KB | Narrative depth wave 6 | Dynasty-marker worker wiring + new prose pools. |
+| 439 / 141 KB | Narrative depth wave 7 | Position-group moment detectors (`dominant_rotation` / `bullpen_collapse` / `lineup_of_era`). Gzip-only. |
+| 440 / 143 KB | Narrative depth wave 8 | Player micro-arc worker source plumbing. |
+| 442 / 143 KB | Narrative depth wave 9 | Weekly cadence detectors split into `game-engine-weekly` to protect `game-engine-core`. |
+| 443 / 143 KB | Narrative depth wave 10 (current) | Capstone prose split into `game-engine-capstone`. |
+
+## Notes on routing
+
+Routing changes that have paid off:
+
+- **`game-engine-onboarding`** isolates the Day-1 flow so it never lands in `game-engine-core`.
+- **`game-engine-story`** absorbs holdout coverage and worker-only narrative payload, leaving the deterministic core lean.
+- **`game-engine-capstone`** (Wave 10) splits award / HOF / retirement / stable prose variant pools from the core.
+- **`game-engine-weekly`** (Wave 9) splits weekly cadence detectors + prose.
+- **`game-engine-shell`** holds only the Comlink entry point and the sim-core root barrel, preventing circular chunk edges between `game-engine-core` and `game-engine-onboarding`.
+
+Routing changes that did *not* pay off:
+
+- Routing `tradeDeadlinePressConferences.ts` into `game-engine-story` pushed story over its own cap (421,762 raw / 125,530 gzip). The smaller ceiling lift on core was the safer fix.
diff --git a/apps/web/src/build/bundleConfig.ts b/apps/web/src/build/bundleConfig.ts
index 6fb3e97..ac04f9c 100644
--- a/apps/web/src/build/bundleConfig.ts
+++ b/apps/web/src/build/bundleConfig.ts
@@ -1,170 +1,7 @@
export const MAIN_THREAD_CHUNK_BUDGET_BYTES = 304 * 1024;
export const MAIN_THREAD_CHUNK_GZIP_BUDGET_BYTES = 81 * 1024;
-// WORKER raw: bumped 406 -> 408 KB to cover the arbitration payload carried across
-// the arbitration schema/broadcast slices.
-// WORKER raw: bumped 408 -> 410 KB for the holdout resolution news slice. Adds
-// generateHoldoutResolutionBriefing + worker wire-in alongside the existing
-// opening-beat briefings in game-engine-story. Copy was trimmed aggressively but
-// the function signature + constants still landed ~1.3 KB over the prior raw
-// ceiling. Gzip stayed at ~125 KB (well under budget), so the actual wire cost
-// is unchanged — this is a raw-ceiling-only concession for the new helper.
-// WORKER gzip: bumped 124 -> 125 KB for the arbitration broadcast slice. The
-// arbitration press-conference templates + moment descriptions compressed less
-// than projected (actual ~+0.4 KB over prior ceiling), so lift the gzip roof by
-// one KB to restore headroom. Holdout briefings already live in the story chunk.
-// WORKER gzip: bumped 125 -> 126 KB for the trade deadline broadcast slice after
-// the new trade press-conference copy left game-engine-core 471 bytes over the
-// prior gzip cap. Attempted routing tradeDeadlinePressConferences.ts into
-// game-engine-story first, but that pushed story far higher overall (421,762
-// raw / 125,530 gzip), so the smallest ceiling lift was the safer fix. Raw
-// ceiling already at 410 KB from the holdout briefing slice accommodates the
-// trade deadline worker growth (+613 bytes over the 408 baseline).
-// WORKER raw: bumped 410 -> 411 KB for the team-moment store slice (v22). Adds
-// appendTeamMoments, the teamMoments Map on FullGameState, the identityMoments
-// seam through buildTradeBroadcastCoverage, and snapshot serialization of
-// teamMoments. Story chunk landed ~206 bytes over the 410 KB ceiling after the
-// wiring; gzip held well under cap (~125 KB actual vs 126 KB ceiling) so only
-// raw moves. No new template copy — just wire-in code.
-// WORKER raw: bumped 411 -> 412 KB for the Chase Watch query slice. Adds
-// getChaseWatch() to sim.worker.queries.ts — a league-wide view assembling
-// career milestone alerts (via existing buildMilestoneAlertsForPlayers) and
-// pace chases (via existing projectSeasonStats + findNotableProjections) into
-// a single dashboard payload. Pure wiring of existing helpers, no new sim
-// logic. Story chunk landed 547 bytes over the 411 KB ceiling; gzip held
-// under cap (~125 KB actual vs 126 KB ceiling) so only raw moves.
-// WORKER raw: bumped 412 -> 414 KB for the pennant-race-heat query slice.
-// Adds getPennantRaces with division race + wildcard bubble computations
-// plus inline DivisionRace/WildcardRace types. Story chunk landed ~1 KB
-// over the 413 KB stack when combined with Chase Watch growth; gzip held
-// well under cap (~126 KB actual) — wire cost unchanged, raw ceiling only.
-// No new sim-core changes.
-// WORKER raw: award race boards slice — adds getAwardRaceBoards
-// (league-split + sample-size-filtered enrichment around the existing
-// calculateAwardRaces scorer) into the story chunk. Absorbed within the
-// current 414 KB ceiling from the pennant-race stack; gzip held well under
-// cap (~126 KB actual vs 126 KB ceiling) — no new sim-core changes.
-// WORKER raw: bumped 414 -> 417 KB for the team-moment types slice (v23).
-// Adds detectSeasonIdentityMoments + championship_run / contention_collapse
-// enum values + MOMENT_TYPE_ORDER and description template entries. When
-// stacked on top of the dashboard query slices (chase/pennant/award), the
-// story chunk landed ~1.6 KB over the 414 KB ceiling — raw lift with a
-// small headroom buffer. Gzip also bumped 126 -> 127 KB; copy compressed
-// ~296 bytes over the prior cap.
-// WORKER raw: bumped 417 -> 420 KB for the Career Retrospective query slice.
-// Adds buildCareerRetrospective + getCareerRetrospective to sim.worker.queries
-// — unifies titles (WS/pennants/division/playoffs derived from seasonArchive +
-// archivedSeasons), team-moment beats, completed story arcs, awards-shelf
-// counts, and top rivalry into one dashboard payload. Story chunk landed
-// ~2.5 KB over the 417 KB ceiling; gzip held under cap (~125 KB actual vs
-// 127 KB ceiling).
-// WORKER raw: bumped 420 -> 423 KB for the Season Story Reel query slice.
-// Adds buildSeasonStoryReel + getSeasonStoryReel — per-season deep-dive
-// assembling record/rank, playoff path, storylines, timeline events, signature
-// beats filtered by season, key transactions, awards, and stat-leader
-// highlights from seasonArchive + archivedSeasons. Story chunk landed ~2.2 KB
-// over the 420 KB ceiling; gzip held under cap (~125.8 KB actual vs 127 KB
-// ceiling). Raw-only lift.
-// WORKER raw: bumped 423 -> 425 KB for the Pennant Race Detail query slice.
-// Adds getPennantRaceDetail — all-six-divisions full standings + top-5
-// wildcard picture with projectedWins via winning-percentage pace. Story
-// chunk landed ~1.5 KB over the 423 KB ceiling; gzip held under cap
-// (~126.4 KB actual vs 127 KB ceiling). Raw-only lift.
-// WORKER gzip: bumped 127 -> 128 KB for team-identity expansion wave 2 (v25).
-// Adds rebuild_begun + breakout_season + contention_window_opens detectors with
-// their MOMENT_TYPE_ORDER entries and description templates. game-engine-core
-// landed at 130,174 gzip bytes vs the prior 130,048 cap (+126 bytes), so the
-// smallest safe lift is one KB. Raw ceiling already at 425 KB from PR #44's
-// Pennant Race detail query absorbs the wave-2 detector code with headroom.
-// WORKER raw/gzip: bumped 425 -> 428 KB raw and 128 -> 131 KB gzip for
-// narrative depth wave 3 (v26). The additive detector/template expansion kept
-// game-engine-core raw under the existing ceiling (~399 KB) but pushed gzip to
-// 133,864 bytes; game-engine-story stayed under gzip but landed at 437,490 raw
-// bytes. This is the maximum sprint-allowed lift (+3 KB raw / +3 KB gzip),
-// preserving the hard stop at 430 KB raw / 131 KB gzip.
-// WORKER gzip: bumped 131 -> 132 KB for CI/local terser-output drift. Local
-// verify showed game-engine-core at 133,864 gzip bytes; CI minifier emitted
-// 134,398 bytes for the identical commit (+534 byte environmental drift),
-// which tripped the 131 KB cap (134,144) by 254 bytes. One KB lift restores
-// headroom for the already-landed wave-3 payload without touching any code.
-// WORKER raw/gzip: bumped 428 -> 432 KB raw and 132 -> 134 KB gzip for
-// narrative depth wave 4 (v27). The persisted-state fidelity sprint added
-// worker-side snapshot/state plumbing plus playoff comeback tracking, pushing
-// the measured worker chunks to 415,354 raw / 136,054 gzip for
-// game-engine-core and 442,235 raw / 131,714 gzip for game-engine-story.
-// This lands exactly at the sprint hard cap and restores bundleBudget headroom
-// without exceeding the approved +4 KB raw / +2 KB gzip ceiling.
-// WORKER raw: bumped 432 -> 433 KB for the Career Retrospective season-history
-// slice. Extends buildCareerRetrospective with seasonHistory derivation (user
-// team per-season win% from seasonArchive + archivedSeasons, deduped by
-// season). Story chunk landed 442,727 raw bytes (+359 over the 432 KB cap);
-// gzip held at 131,941 (well under the 134 KB ceiling) so only raw moves.
-// Pure aggregation of existing persisted state — no sim-core changes.
-// WORKER raw/gzip: bumped 433 -> 434 KB raw and 134 -> 135 KB gzip to reserve
-// headroom for the parallel codex/narrative-prose-expansion branch landing
-// alongside this branch. That branch adds deterministic prose variant pools
-// to holdoutCoverage / consequences / narrativeState / storyArcs with no
-// threshold/ID/schema changes (+4 sim-core tests, determinism sweep clean).
-// Measured combined story chunk (Claude season-history + Codex prose) at
-// 443,379 raw / 131,595 gzip local; CI-buffered gzip measurement via zlib
-// gzipSync lands at 137,334 (+118 over the prior 134 KB cap, matches the
-// known +250-500 byte game-engine-core terser drift pattern in patterns.md).
-// Smallest safe simultaneous lift: +1 KB raw / +1 KB gzip, giving ~1 KB
-// headroom on both ceilings for either-or-both-branch merge paths. If the
-// Codex branch is not ultimately merged, this reservation becomes slack but
-// is bounded to one KB on each axis.
-// WORKER gzip: bumped 135 -> 137 KB for the codex/narrative-prose-expansion
-// landing after codex/rivalry-narrative-wave1 already merged its rivalry
-// prose pools into game-engine-core (PR #50, commit 2806d37). The original
-// 135 reservation was measured against rivalry-wave1 NOT being on main; once
-// that landed, rivalry prose + narrative-prose-expansion's owner/consequences/
-// storyArcs pools stacked in the same chunk. Post-rebase measurement on this
-// branch: game-engine-core gzip 138,815 (+575 over 135 KB cap). +2 KB lift
-// covers the stack plus the established +250-500 byte CI terser drift. Raw
-// stayed comfortably under: game-engine-core raw 423,076 vs 444,416 cap.
-// WORKER raw: bumped 434 -> 436 KB for the This Week in History query slice.
-// Adds getThisWeekInHistory to sim.worker.queries — computes yearsAgo and
-// strict-prior-season ±dayWindow filtering over persisted playerMoments /
-// teamMoments Maps for the dashboard tile. Pure read wiring, no sim-core
-// touch. Story chunk landed 445,537 raw bytes (+1,121 over the 434 KB cap);
-// gzip held well under cap (132,609 actual vs 140,288 ceiling). +2 KB raw
-// lift gives ~900 bytes headroom against the established +250-500 byte
-// CI terser drift. Raw-only; no gzip ceiling move needed.
-// WORKER raw: bumped 436 -> 438 KB for the Player Arcs of the Season query
-// slice. Adds getPlayerArcsOfSeason to sim.worker.queries — filters persisted
-// playerMoments for redemption_arc / late_career_peak / rookie_breakout and
-// surfaces the most recent season's arcs for the dashboard tile. Pure read
-// wiring of existing player-arc moment state (PR #55), no sim-core touch.
-// Story chunk landed 446,889 raw bytes (+425 over the 436 KB cap); gzip held
-// well under cap (132,865 actual vs 140,288 ceiling). +2 KB raw lift gives
-// ~1,599 bytes headroom for CI terser drift. Raw-only; no gzip move needed.
-// WORKER raw/gzip: bumped 438 -> 439 KB raw and 137 -> 139 KB gzip for
-// Narrative Depth Wave 6. The dynasty-marker worker wiring moved enough
-// season-summary/detector orchestration into game-engine-story to leave the
-// chunk 66 bytes over the prior raw ceiling (448,578 vs 448,512), while the
-// new prose pools and detector exports expand the worker-compressed footprint.
-// Smallest safe lift: +1 KB raw / +2 KB gzip, preserving sub-1 KB local
-// headroom on story while leaving the broader worker budget effectively flat.
-// WORKER gzip: bumped 139 -> 141 KB for Narrative Depth Wave 7. Position-group
-// moment detectors add team aggregate helpers plus deterministic prose pools
-// for dominant_rotation / bullpen_collapse / lineup_of_era. Local budget test
-// measured game-engine-core at 142,927 gzip bytes, 591 bytes over the 139 KB
-// cap. Raw stayed under the existing 439 KB ceiling (440,242 vs 449,536), so
-// only the pre-authorized +2 KB gzip lift is applied.
-// WORKER raw/gzip: bumped 439 -> 440 KB raw and 141 -> 143 KB gzip for
-// Narrative Depth Wave 8. Player micro-arc worker source plumbing was trimmed
-// before lifting the cap; local build lands game-engine-story just under the
-// 440 KB raw ceiling and game-engine-core at 145,118 gzip bytes, covered by
-// the pre-authorized +2 KB gzip headroom.
-// WORKER raw: bumped 440 -> 442 KB for Narrative Depth Wave 9. Weekly cadence
-// detector/prose modules are split into game-engine-weekly to protect
-// game-engine-core; the remaining worker checkpoint wiring leaves
-// game-engine-story ~0.6 KB over the Wave 8 raw cap. +2 KB raw stays within
-// the pre-authorized Wave 9 lift and leaves modest minifier-drift headroom.
-// WORKER raw: bumped 442 -> 443 KB for Narrative Depth Wave 10. The capstone
-// prose variant pools are split into game-engine-capstone to keep
-// game-engine-core under budget; the remaining award/HOF/retirement worker
-// context wiring leaves game-engine-story at 452,887 raw bytes (+279 over the
-// Wave 9 cap). Gzip stayed under the existing 143 KB ceiling (134,439 bytes).
+
+// Worker ceilings: see apps/web/docs/BUDGETS.md for the lift policy and per-slice rationale.
export const WORKER_CHUNK_BUDGET_BYTES = 443 * 1024;
export const WORKER_CHUNK_GZIP_BUDGET_BYTES = 143 * 1024;
diff --git a/apps/web/src/features/feedback/FeedbackButton.tsx b/apps/web/src/features/feedback/FeedbackButton.tsx
deleted file mode 100644
index 044ee44..0000000
--- a/apps/web/src/features/feedback/FeedbackButton.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { useState } from 'react';
-import { MessageSquare } from 'lucide-react';
-import { FeedbackForm } from './FeedbackForm';
-
-export function FeedbackButton() {
- const [open, setOpen] = useState(false);
-
- return (
- <>
-
- {open ? setOpen(false)} /> : null}
- >
- );
-}
diff --git a/apps/web/src/features/feedback/FeedbackForm.tsx b/apps/web/src/features/feedback/FeedbackForm.tsx
deleted file mode 100644
index 7672970..0000000
--- a/apps/web/src/features/feedback/FeedbackForm.tsx
+++ /dev/null
@@ -1,250 +0,0 @@
-import { useEffect, useMemo, useState } from 'react';
-import { Send, X } from 'lucide-react';
-import { useFocusTrap } from '@/shared/hooks/useFocusTrap';
-import {
- FEEDBACK_BODY_MAX_LENGTH,
- FEEDBACK_BODY_MIN_LENGTH,
- formatFeedbackType,
- submitFeedbackWithFallback,
- type FeedbackPayload,
- type FeedbackSubmitter,
- type FeedbackType,
-} from './feedbackSubmit';
-
-const FEEDBACK_TYPES: FeedbackType[] = ['bug', 'suggestion', 'question'];
-
-interface FeedbackFormProps {
- onClose: () => void;
- submitFeedback?: FeedbackSubmitter;
- autoDismissMs?: number;
-}
-
-interface FeedbackErrors {
- type?: string;
- body?: string;
- submit?: string;
-}
-
-function validateFeedback(type: string, body: string): FeedbackErrors {
- const errors: FeedbackErrors = {};
- const trimmedBody = body.trim();
-
- if (!FEEDBACK_TYPES.includes(type as FeedbackType)) {
- errors.type = 'Choose a feedback type.';
- }
-
- if (
- trimmedBody.length < FEEDBACK_BODY_MIN_LENGTH
- || trimmedBody.length > FEEDBACK_BODY_MAX_LENGTH
- ) {
- errors.body = 'Write 200-500 characters so the report is actionable.';
- }
-
- return errors;
-}
-
-export function FeedbackForm({
- onClose,
- submitFeedback = submitFeedbackWithFallback,
- autoDismissMs = 3000,
-}: FeedbackFormProps) {
- const [type, setType] = useState('');
- const [body, setBody] = useState('');
- const [contact, setContact] = useState('');
- const [errors, setErrors] = useState({});
- const [status, setStatus] = useState<'idle' | 'submitting' | 'sent'>('idle');
- const trapRef = useFocusTrap(true);
-
- const bodyLength = body.trim().length;
- const canSubmit = status !== 'submitting' && status !== 'sent';
-
- const payload = useMemo(() => {
- if (!FEEDBACK_TYPES.includes(type as FeedbackType)) {
- return null;
- }
-
- return {
- type: type as FeedbackType,
- body: body.trim(),
- contact: contact.trim() || undefined,
- };
- }, [body, contact, type]);
-
- useEffect(() => {
- if (status !== 'sent') {
- return;
- }
-
- const timeout = window.setTimeout(onClose, autoDismissMs);
- return () => window.clearTimeout(timeout);
- }, [autoDismissMs, onClose, status]);
-
- useEffect(() => {
- const handleKeyDown = (event: KeyboardEvent) => {
- if (event.key === 'Escape' && canSubmit) {
- event.preventDefault();
- onClose();
- }
- };
-
- document.addEventListener('keydown', handleKeyDown);
- return () => document.removeEventListener('keydown', handleKeyDown);
- }, [canSubmit, onClose]);
-
- async function handleSubmit() {
- if (!canSubmit) {
- return;
- }
-
- const nextErrors = validateFeedback(type, body);
- if (Object.keys(nextErrors).length > 0 || !payload) {
- setErrors(nextErrors);
- return;
- }
-
- setStatus('submitting');
- setErrors({});
- try {
- await submitFeedback(payload);
- setStatus('sent');
- } catch {
- setStatus('idle');
- setErrors({ submit: 'Feedback could not be sent. Try again or use your mail client directly.' });
- }
- }
-
- return (
-
-
-
-
-
- Player Feedback
-
-
- Send feedback
-
-
- Report bugs, questions, or friction from the exact thing you are seeing. Only the fields you type are sent.
-