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. -

-
- -
- - {status === 'sent' ? ( -
- Thanks — sent. -
- ) : ( -
- - -