# SVS MSP CALC — Beta Build Checkpoint

**Date:** 2026-03-15
**Status:** Phases 1–8 + Stage 8 complete. Beta + a11y/perf audit + code quality passes I & II + test expansion + print enhancements done.
**Tests:** 254/254 passing
**Build Prompt:** .claude/plans/STAGE2-BUILD-PROMPT.md
**Previous Stage Prompt:** docs/STAGE3-SESSION-PROMPT.md
**Previous Stage Prompt:** docs/STAGE5-SESSION-PROMPT.md
**Previous Stage Prompt:** docs/STAGE6-SESSION-PROMPT.md
**Previous Stage Prompt:** docs/STAGE7-SESSION-PROMPT.md
**Previous Stage Prompt:** docs/STAGE8-SESSION-PROMPT.md

---

## Completed

### Phase 1: Bug Fixes (6/6)

| # | Issue | File | Change |
|---|-------|------|--------|
| 1.1 | ADDON_INKY default $5 → $8 | quote-pricing.js:12 | `ADDON_INKY: 5` → `8` |
| 1.2 | Onboarding fee loses manual override on term switch | SVS-MSP-Calculator.js:41-70 | Store manual value in `data-manual-value` before 24mo clears it; restore on switch back to m2m/12mo |
| 1.3 | VoIP fax CSV comment misleading | package-prices.csv:18 | "Flat/mo" → "Per seat/mo" |
| 1.4 | Print forces HST on regardless of user toggle | quote-export.js:12 | Removed `state.hstEnabled = true;` — print now respects user's HST toggle |
| 1.5 | JSON export missing schema version | quote-export.js:229 | Added `version: '1.0'` as first field in payload |
| 1.6 | ZT admin supplement triggers with no warning | quote-render.js:494-499 | New amber nudge when `ztActive` warns about $250 admin supplement |

Test expectations updated in test-quote-engine.js for INKY $8 (4 values changed).

### Phase 2: Visual Polish (Sections I–III)

| # | Issue | File | Change |
|---|-------|------|--------|
| 2.1a | Hardcoded `#e06070` danger icon | components.css:41 | → `var(--text-danger)` — adapts per theme |
| 2.1b | Hardcoded `#86efac` pill-savings on checked state | components.css:442 | → `var(--text-pill-savings-active)` — new token |
| — | Token added to all 4 themes | tokens.css, light.css, glass.css, 70retro.css | Dark: `#86efac`, Light: `#d4f5e0`, Glass: `#a8f0c8`, Retro: `#e0f0d0` |
| 2.1c | QUICK-REF.md outdated | docs/QUICK-REF.md | Updated INKY $8, export desc, theme list, test count |

**Audit findings (no action needed):**
- All 4 themes fully token-covered for Sections I–III
- Glass theme uses `!important` selector overrides (valid for glassmorphism effects)
- Sidebar focus-toggle white rgba values sit on colored header — correct everywhere
- No remaining hardcoded colors in Sections I–III component CSS
- Sidebar renders correctly across all 4 themes at all breakpoints

### Phase 3: UX Hardening (Sections I–III)

#### 3.1 Interaction Refinements

| # | Change | Files | Details |
|---|--------|-------|---------|
| 3.1a | Smooth theme-switch transition | tokens.css, theme-manager.js | `body.theme-transitioning` class enables 0.25s color/bg/border fade; applied for 300ms during `toggleTheme()` |
| 3.1b | Nudge crossfade on rotation/nav | components.css, quote-render.js | `.nudge-fading` class fades opacity to 0; `cycleNudge()` does fade-out → swap → fade-in (180ms); auto-rotation now uses `cycleNudge(1)` for consistency |
| 3.1c | Summary badge fade-in on collapse | components.css | `@keyframes badgeFadeIn` — 0.25s opacity + translateY animation on `.sec-summary-badge` |
| 3.1d | Addon toggle micro-feedback | components.css | `@keyframes addonPulse` — 0.2s scale(1.015) pulse on `.addon-row.selected` |

#### 3.2 Responsive Edge Cases

| # | Change | Files | Details |
|---|--------|-------|---------|
| 3.2a | Touch targets ≥44px on mobile | responsive.css | `.mobile-panel-close-btn` 36→44px, `.nudge-nav-btn` 34→44px at ≤1100px; `.collapsible-header` and `.section-toggle` min-height 44px at ≤600px |
| 3.2b | Container query fallback verified | — | `@container (max-width: 760px)` for addon rows has adequate fallback via ≤600px media query; no change needed |

#### 3.3 Mobile Experience Completeness

| # | Change | Files | Details |
|---|--------|-------|---------|
| 3.3a | Focus trap in mobile panel | mobile-sync.js | `trapFocus()` function keeps Tab cycling within open panel; focus moves to close button on open, returns to pill on close |
| 3.3b | Safe-area insets for notch phones | responsive.css | `padding-bottom: env(safe-area-inset-bottom)` on `.mobile-panel-sheet`; `right: max(14px, env(safe-area-inset-right))` on `.mobile-quote-pill` |

**GATE: 88/88 tests pass. All JS syntax-checked. CSS brace balance verified.**

---

### Phase 4: Documentation & QA

| # | Task | Status |
|---|------|--------|
| 4.1 | Update all docs (README, code-verification, quote-rules, phase-roadmap, QUICK-REF, MASTER-SESSION-PROMPT, ai-session-brief) | COMPLETE |
| 4.2 | Full regression checklist walkthrough | COMPLETE — 88/88 automated, 15/15 manual items verified in code |
| 4.3 | Beta definition of done verification | COMPLETE — all 13 criteria pass |

**Docs updated:**
- README.md — phase status, 88 tests, 4 themes, export description, file map (70retro.css added)
- code-verification.md — date, test count, all Phase 1-3 changes as known-good baseline
- quote-rules.md — onboarding manual override persistence, HST print behavior, JSON export rules, admin nudge
- phase-roadmap.md — Phases 1-4 status, 88 tests
- QUICK-REF.md — test count in "Remind User", 70retro.css in CSS file map
- MASTER-SESSION-PROMPT.md — 88 tests (3 occurrences), 4 themes (6 occurrences), 70retro.css in tree, 4 theme override layers
- ai-session-brief.md — test count updated

**Regression checklist results:**
- Automated: 88/88 pass
- Manual: All 15 items verified via source code review (admin waive displays, term/onboarding logic, manual override persistence, sidebar sync, mobile panel sync, persistence round-trip, reset behavior, print HST, JSON export, section headers, theme transitions, nudge crossfade, focus trap, safe-area insets, touch targets)

**Beta Definition of Done: ALL 13 CRITERIA PASS**

**GATE: PASSED — Beta build for Sections I–III is complete.**

---

### Phase 5: Performance & Accessibility Audit

| # | Fix | File(s) | Details |
|---|-----|---------|---------|
| A2 | `aria-expanded` on section & collapsible toggles | HTML, SVS-MSP-Calculator.js | Added `aria-expanded="false"` to 12 toggle elements; JS updates dynamically on toggle |
| A3 | Focus trap on reset confirm modal | quote-persistence.js | `trapFocusInModal()` — Tab cycles within modal when open |
| A4 | `aria-label` on stepper buttons | HTML | All 12 step-btn elements have descriptive labels (e.g. "Decrease users") |
| P1 | Glass theme scroll jank on mobile | glass.css | `background-attachment: scroll` at ≤1100px — avoids fixed-bg repaint on iOS |
| P2 | Skip mobile sync on desktop | mobile-sync.js | Guard skips 35+ element sync when panel closed on desktop; forces full sync on `openMobilePanel()` |
| M1 | `sidebarFocusClientName` not in sync map | mobile-sync.js | Added to html sync list — client name now updates in mobile panel |
| M2 | `sl-discount-detail` + `sl-value-onboarding-label` not in sync map | mobile-sync.js | Added to html sync list — contract term label and onboarding label now sync |

**Not flagged (clean):** Token coverage, `:focus-visible`, mobile focus trap, escape handling, touch targets, `will-change` usage, print CSS isolation, no unused JS.

**GATE: 88/88 tests pass. All fixes verified.**

### Font Awesome Icon Fix

| # | Fix | File | Details |
|---|-----|------|---------|
| FA1 | Icons invisible on `file://` protocol | components.css:44-79 | All 36 FA Sharp Solid SVG file references converted to inline `data:image/svg+xml` URIs — eliminates CORS/`file://` restriction on `mask-image: url()` |

**Root cause:** CSS `mask-image: url("fontawesomekit/svgs/...")` is blocked by browser security on the `file://` protocol. Inline data URIs bypass this completely.

**GATE: 88/88 tests pass. Icons render on local file open.**

---

### Phase 6: Code Quality Pass (Stage 3)

| # | Fix | File(s) | Details |
|---|-----|---------|---------|
| CQ1 | New `--sky` color token | tokens.css, light.css, glass.css, 70retro.css | Per-theme sky/info accent: Dark `#38bdf8`, Light `#0e7490`, Glass `#7dd3fc`, Retro `#a34a14` |
| CQ2 | New `--transition-fast` token | tokens.css | `0.15s` — replaces hardcoded timing in layout.css button transitions |
| CQ3 | Consolidated duplicate button CSS | layout.css:25-47 | `.btn-reset-quote` and `.btn-import-quote` shared 10 identical properties → merged into grouped selector |
| CQ4 | Hardcoded amber hover → token-derived | layout.css:43-46 | `rgba(232,146,15,…)` → `color-mix(in srgb, var(--amber) …%, transparent)` |
| CQ5 | Hardcoded sky blue hover → token-derived | layout.css:47-50, light.css, glass.css, 70retro.css | All `rgba(56,189,248,…)` / `#38bdf8` / `#7dd3fc` / `#a34a14` → `var(--sky)` + `color-mix()` |
| CQ6 | Dead null-check removed | quote-render.js:533 | `nudgeIndex == null ||` removed — `nudgeIndex` is always initialized to `0` |

**Audit findings (no action taken — documented for future):**
- `fmt()` duplicated in quote-render.js and quote-export.js (both inside IIFEs — intentional isolation, one-liner)
- Spacing magic numbers (14px/16px/20px) used 95+ times — too many touchpoints for surgical migration
- `console.warn()` statements in pricing/persistence/import are intentional error reporting
- No dead functions, no unreachable code, no unused exports across all 8 JS modules

**GATE: 88/88 tests pass. All 4 themes verified tokenized.**

---

### Phase 7: Test Coverage Expansion (Stage 4)

| # | Test Group | Count | Details |
|---|-----------|-------|---------|
| T1 | Pricing DEFAULTS integrity | 34 | All required keys exist, types correct, values match spec, frozen, ordering invariants |
| T2 | Engine edge cases & boundaries | 55 | Admin fee thresholds, large counts (100u/100ep), string coercion, invalid inputs (NaN/null/empty), servers-only, VoIP-only, VoIP edge cases, ZT without user addon, admin waived, all addons combined, BYOL term independence, discount rounding |
| T3 | Export JSON schema validation | 18 | Payload structure, field types, version field, contract term labels, licensing labels, pricing sub-object, voip tier null handling |
| T4 | Persistence state shape | 6 | JSON round-trip for strings/numbers/booleans, engine compatibility, zero-state |
| T5 | Import payload mapping | 12 | Contract term reverse-map, full export→import→engine round-trip (MRR, effectiveMrr, mrrWithHst, userTotal, endpointTotal, voipTotal, adminFeeNet, effectiveAnnual) |
| T6 | Quote output invariants | 24 | 6 configs × 4 invariants (effectiveMrr, effectiveAnnual, mrrWithHst, non-negative values) |

**Total: 88 → 250 tests (162 new). All passing.**

**GATE: 250/250 tests pass.**

---

### Phase 8: Enhanced Print/PDF (Stage 4)

| # | Enhancement | File(s) | Details |
|---|------------|---------|---------|
| P1 | Quote notes field | HTML:920, components.css, quote-persistence.js, quote-export.js, quote-import.js | `<textarea id="quoteNotes">` in sidebar, persisted in localStorage, included in JSON export/import, rendered on print invoice |
| P2 | Explicit validity date | quote-export.js | Computes 30-day expiry: "Valid until [date]" in print footer instead of generic "30 days" |
| P3 | Page break control | quote-export.js (inline CSS) | `page-break-inside:avoid` on table rows + `.tots-wrap`; `break-inside:avoid` on notes section |
| P4 | Rep name field | HTML:100, layout.css, quote-persistence.js, quote-export.js, quote-import.js | `<input id="repName">` below client name, persisted, in JSON export/import, shown in print header + footer |
| P5 | CYA "Not Included" section | quote-export.js | Print splits config into "Your Service Configuration" (active) + "Services Not Included in This Quote" (excluded, muted, smaller) |

**Additional changes:**
- JSON export schema version bumped to `1.1` (new `repName`, `quoteNotes` fields)
- JSON import handles new fields gracefully (backward-compatible with `1.0` exports)
- Print CSS hides notes + rep inputs on `@media print` (main page path)
- 4 new tests added (repName/quoteNotes in export schema + persistence)

**GATE: 254/254 tests pass.**

---

## Key Files to Read on Resume

1. `docs/MASTER-SESSION-PROMPT.md` — full architecture and constraints
2. `docs/QUICK-REF.md` — compact file map, IDs, pricing
3. `docs/regression-checklist.md` — test procedures
4. `.claude/plans/STAGE2-BUILD-PROMPT.md` — the build prompt driving this work
5. This file — checkpoint status

### Stage 5 / Phase 9: Visual QA + Retro Theme Overhaul

**Visual QA:** 3 breakpoints (mobile ~375px, desktop ~1100-1400px, wide ~1800px+) × 4 themes.

| Theme | Mobile | Desktop | Wide | Result |
|-------|--------|---------|------|--------|
| Dark | Clean | Clean | Clean | PASS |
| Light | Clean | Clean | Clean | PASS |
| Glass | Clean | Clean | Clean | PASS |
| Retro | Overhauled | — | — | REWORKED |

**Retro theme overhaul:**
- **Problem:** Original 70s wood-panel brown palette had low contrast, muddy colors, invisible logo (black SVG on brown header)
- **Solution:** Warm paper base + neon-warm cyberpunk accents
- Accent: hot rose `#e11d48` (warm neon, harmonizes with cream)
- Green/Sky: warm teal `#0d9488`
- Header: warm charcoal `#1c1317` with rose neon border
- Logo: `.top-bar-logo path { fill: #f0e4d0 }` — overrides hardcoded `#0c0c0c` SVG fills
- Progress bar: rose → teal gradient
- Paper texture: warm brown scanlines (unchanged from original)
- **Status:** Functional, user notes full design pass deferred to later

**Remaining QA not yet done:** Retro theme at all viewport widths, landscape orientation.

**GATE: 254/254 tests pass. No visual bugs found on Dark/Light/Glass.**

---

### Stage 6 / Phase 10: Elastic Responsive Foundation

**Problem:** 5 fixed breakpoints (1350, 1100, 900, 600, 780px landscape) with hardcoded px overrides at each step. Max width capped at 1800px — wasted space on 1440p+ monitors.

**Solution:** Fluid `clamp()` tokens replace discrete breakpoint steps. Only structural breakpoints remain.

| # | Change | File(s) | Details |
|---|--------|---------|---------|
| E1 | Fluid layout tokens | tokens.css | `--page-max-width: clamp(1200px, 92vw, 2400px)`, `--page-gutter-x: clamp(16px, 3vw, 80px)`, `--layout-column-gap: clamp(24px, 3vw, 56px)`, sidebar min 400→360px |
| E2 | Fluid section tokens | tokens.css | `--section-offset: clamp(52px, 7vw, 104px)`, `--section-num-width/size` fluid, `--section-padding-*` fluid |
| E3 | Eliminated 1350px breakpoint | responsive.css | Removed — fluid tokens handle narrow desktop scaling |
| E4 | Eliminated 900px breakpoint | responsive.css | Removed — fluid tokens handle tablet spacing/numerals |
| E5 | Fluid logo margin | base.css | `margin-left: clamp(26px, 5.2vw, 78px)` replaces hardcoded 78px + breakpoint overrides |
| E6 | Fluid main-col gap | layout.css | `gap: clamp(16px, 1.5vw, 24px)` replaces hardcoded 24px + breakpoint override |
| E7 | Fluid client-bar padding | layout.css | `clamp()` on vertical padding, `var(--section-offset)` for left |

**Breakpoint reduction:** 5 → 3 (1100px structural, 600px phone layout, 780px landscape orientation)

**Width scaling:**
- 1080p (1920px): content fills ~1766px (92vw)
- 1440p (2560px): content fills ~2355px (92vw)
- 4K (3840px): content caps at 2400px max

**GATE: 254/254 tests pass.**

---

### Stage 7 / Phase 11: Feature Work (Option A)

#### 11.1 Keyboard Shortcuts

| Shortcut | Action | File | Details |
|----------|--------|------|---------|
| Ctrl+P | Print invoice | SVS-MSP-Calculator.js | `preventDefault()` blocks browser print dialog; calls `printInvoice()` |
| Ctrl+E | Export JSON | SVS-MSP-Calculator.js | Calls `exportQuoteJSON()` |
| Ctrl+R | Reset quote | SVS-MSP-Calculator.js | Opens confirm modal via `openResetConfirm()` — not a hard reset |
| Escape | Close overlays | mobile-sync.js (existing) | Already handled — closes sidebar focus + mobile panel |

All shortcuts are suppressed when focus is in an `<input>`, `<textarea>`, or `<select>` to avoid hijacking normal typing.

#### 11.2 New Contextual Nudges

| # | Nudge | Color | Trigger |
|---|-------|-------|---------|
| N1 | Users set but no endpoints | amber | `users > 0 && endpoints === 0` |
| N2 | VoIP seats ≠ user count | amber | `voipSeats > 0 && users > 0 && voipSeats !== users` |
| N3 | High admin-to-MRR ratio | amber | `adminFeeNet > MRR * 0.25` (and not waived) |
| N4 | Extended Hours upsell | green | `!addExtHours && users > 0` |

Added after existing nudges in `buildNudges()` in quote-render.js (lines 524–551).

**GATE: 254/254 tests pass.**

---

### Stage 8 / Phase 12: Code Quality Pass II

#### 8.1 `--transition-fast` / `--transition-medium` Token Adoption

| # | Change | File(s) | Details |
|---|--------|---------|---------|
| T1 | New `--transition-medium` token | tokens.css | `0.25s` — for chevron/collapsible/nudge transforms |
| T2 | 10× `0.15s` → `var(--transition-fast)` | components.css | pill-toggle, tier-seg, addon-preview-pill, addon checkbox, sidebar-focus-toggle, nudge-nav-btn, btn-toggle-all, quote-notes-input, btn-export |
| T3 | 3× `0.25s` → `var(--transition-medium)` | components.css | sec-chevron transform, collapsible-toggle transform, nudge-banner bg/border |
| T4 | 1× `0.15s` → `var(--transition-fast)` | base.css | theme-toggle-btn |
| T5 | 2× `0.15s` → `var(--transition-fast)` | responsive.css | mobile-quote-pill, mobile-panel-close-btn |

**Left as-is:** 0.12s (stepper/addon micro-interactions), 0.18s (term tile tuned), 0.2s (switch/section/overlay), 0.3s (progress bar/accordion), 0.34s (section-body tuned bezier). No `0.15s` hardcodes remain outside the token definition.

#### 8.2 CSS Selector Specificity Audit

| # | Change | File(s) | Details |
|---|--------|---------|---------|
| S1 | `.sec-open` → `.section.sec-open` | components.css | Removed 2× `!important` — specificity now beats `.section:hover` via class count |
| S2 | Documented intentional `!important` | components.css | Added comments to `.qs-discount-sub`, sidebar utility classes (`.sl-muted`, `.sl-discount-val`, `.sl-hst-val`), and VS value classes |

**Audit findings (no action — all legitimate):**
- components.css: 13 remaining `!important` — all utility `display: none` or color overrides that must beat compound parent selectors
- 70retro.css: 37 `!important` — theme override pattern (same as glass.css with 97)
- responsive.css: 8 `!important` — mobile sidebar embedding
- tokens.css: 1 `!important` — `body.theme-transitioning` (intentional, per spec)
- print.css: All `!important` — standard `@media print` override pattern
- No overly-qualified selectors found (element-qualified patterns are all necessary)

#### 8.3 Print CSS Hardening

| # | Change | File(s) | Details |
|---|--------|---------|---------|
| P1 | Hide 4 missing interactive elements | print.css | Added `display: none !important` for `.sidebar-focus-toggle`, `.sidebar-utility`, `.qs-switch`, `.confirm-modal` |
| P2 | Theme-independent callout borders | tokens.css, print.css | New `--print-callout-green-border` and `--print-callout-red-border` tokens replace theme-variable `var(--green)` and `var(--surface-danger-border)` in print context |

**Verification:**
- All `--print-*` tokens defined only in `:root` (tokens.css) — no theme overrides
- Page-break rules unaffected by fluid layout tokens (`.outer` forced to `display: block; max-width: 100%` in print)
- Print invoice (separate window) uses inline CSS — not affected by main page changes

#### 8.4 (Stretch) Spacing Token Consolidation — Deferred

**Assessment:** 150+ magic-number spacing values across components.css (10px: 36, 12px: 35, 14px: 36, 16px: 24, 20px: 19). Existing `--space-stack-*` tokens used only 4× out of 150+. Migration scope too broad for surgical approach. Deferred to a dedicated spacing-focused stage.

**GATE: 254/254 tests pass.**

---

### Stage 8 Feature Fixes

#### F1: Fullscreen Live Quote View — Print Only

| # | Change | File(s) | Details |
|---|--------|---------|---------|
| F1a | Hide Reset + Import in focus mode | components.css | `.export-wrap` and `.sidebar-utility` now `display: none` in sidebar-focus-open |
| F1b | Print button inside sidebar header | HTML, components.css | New `.sidebar-focus-print-btn` in `.sidebar-header-row` — hidden by default, `display: inline-flex` in focus mode |
| F1c | Print button hidden in print/mobile | print.css, components.css | `display: none !important` in `@media print` and `.mobile-panel-sheet` |

**Before:** Focus mode hid Print/Export JSON, showed Reset/Import
**After:** Focus mode shows a Print button in the header bar (next to collapse icon), hides all other action buttons

#### F2: Toggle Switch 2-State Theme Colors

| # | Change | File(s) | Details |
|---|--------|---------|---------|
| F2a | New `--surface-switch-off` / `--surface-switch-on` tokens | tokens.css | Dark: off `#4a4540`, on `var(--green)` |
| F2b | Light theme switch tokens | light.css | Off `#b5ad9f`, on `var(--green)` |
| F2c | Glass theme switch tokens | glass.css | Off `rgba(255,255,255,0.15)`, on `var(--green)` |
| F2d | Retro theme switch tokens | 70retro.css | Off `#c0b4a0`, on `var(--green)` |
| F2e | Component CSS uses tokens | components.css | `.qs-switch` bg → `var(--surface-switch-off)`, checked → `var(--surface-switch-on)` |
| F2f | Glass checked override | glass.css | Added `.qs-toggle-row input:checked ~ .qs-switch { background: var(--surface-switch-on) }` |

**Before:** Off = `--border` (barely visible), On = `--accent` (theme accent)
**After:** Off = distinct muted track per theme, On = `--green` (universally "enabled")

**GATE: 254/254 tests pass.**

#### F1 Fix: Print Button Visibility in Focus Mode

| # | Change | File(s) | Details |
|---|--------|---------|---------|
| F1d | Print button inside sidebar header | HTML:697 | New `.sidebar-focus-print-btn` button in `.sidebar-header-row`, between title and collapse icon |
| F1e | Focus-only visibility | components.css | `display: none` by default; `display: inline-flex` when `body.sidebar-focus-open` |
| F1f | Hidden in print + mobile | print.css, components.css | `display: none !important` in `@media print` and `.mobile-panel-sheet` |

**Root cause:** `.sidebar-utility` is a sibling of `.sidebar`, not inside it. When `.sidebar` becomes `position: fixed`, the utility div is left behind the backdrop.

#### F3: Pricing CSV → JSON Migration

| # | Change | File(s) | Details |
|---|--------|---------|---------|
| F3a | New JSON pricing file | package-prices.json | Structured by category with `{ key: { value, description } }` format — human-readable + machine-parseable |
| F3b | Script-loaded pricing | package-prices-data.js, HTML | `window.SVS_PRICING_DATA` set via `<script>` tag — works on `file://` protocol, no web server needed |
| F3c | Loader updated | quote-pricing.js | `loadPricing()` checks `SVS_PRICING_DATA` global first (script path), then `fetch()` fallback (web server), then built-in defaults |
| F3d | CSV retained | package-prices.csv | Original CSV kept for reference; no longer loaded at runtime |

**How to update pricing:** Edit `package-prices-data.js` — change the `value` field for any key. No web server needed. The file is loaded via `<script>` tag before the pricing engine initializes.

**JSON format example:**
```json
{
  "user_packages": {
    "RATE_M365": { "value": 130, "description": "Per-user/mo rate — M365 included" }
  }
}
```

**GATE: 254/254 tests pass.**

---

## Hard Constraints (reminder)

1. DOM IDs are a contract — no renaming
2. 254 tests must pass: `node svsmspcalc/tests/test-quote-engine.js`
3. localStorage keys unchanged
4. All 4 themes must work after every change
5. Mobile parity maintained
6. No frameworks, no npm — vanilla only
7. Surgical changes only
8. Sections IV–VI unchanged (deferred)
