Skip to content

Frontend modernization: polish pass (PR 8) + unified type system (PR A) - #29

Merged
tdody merged 16 commits into
mainfrom
design/modernization
Apr 20, 2026
Merged

Frontend modernization: polish pass (PR 8) + unified type system (PR A)#29
tdody merged 16 commits into
mainfrom
design/modernization

Conversation

@tdody

@tdody tdody commented Apr 20, 2026

Copy link
Copy Markdown
Owner

Summary

Closes out two PRs from design_handoff_modernization/PROGRESS.md:

  • Phase 2 PR 8 — the polish pass: global focus rings, skeleton + empty-state primitives, keyboard navigation for tablists and dialogs, DEV-only axe-core a11y reporter + fixes, and a test infrastructure (vitest + RTL + jest-axe) with coverage for every primitive this PR introduces.
  • Phase 3 PR A — the unified type system: one PageHeader primitive + four token-based type utilities (.h1 / .h2 / .hero / .caps-eyebrow). Every top-level page plus PropertyDetail now emits the same eyebrow / H1 / subtitle pattern. Every inline text-[NNpx] on a heading is gone.

15 feature/fix commits + 4 bookkeeping commits on design/modernization, stacked on top of main (last SHA: 8886ff9).

What shipped

PR 8 — Polish pass (6 tiers)

Tier Commit Change
1 b925ee3 Global :focus-visible outline in index.css using var(--accent); .field opts out via its own bottom-rule focus. Dropped legacy focus:ring-indigo-500 from SnapshotButton.
2 5fabe8d New Skeleton / SkeletonLine + EmptyState primitives. Loading text placeholders replaced with structured skeletons on Dashboard, PropertyPage, PropertyDetail (Financing / Revenue tab loaders), ComparisonView. All skeleton containers carry role="status" + aria-label; shapes are aria-hidden. Supports prefers-reduced-motion.
3 2e9a622 New useFocusTrap and useEscapeKey hooks. Segmented and the PropertyDetail tab bar use roving tabIndex + Arrow / Home / End nav. ConfirmDialog, HistoryDrawer, and SnapshotButton's dialog gain role="dialog" / aria-modal / aria-labelledby + focus trap + Escape to close.
4a 606e974 + e09fd6f axe-core installed as a dev dep; DEV-only runtime reporter in main.tsx patches history.pushState / replaceState and fires on popstate. Prod bundle unaffected. window.axe exposed for ad-hoc console audits.
4b f4f286c Field / SliderField / CurrencyInput / PercentInput wire htmlFor via useId. Raw <select> in PropertyInfoTab gets htmlFor. Settings file input gets aria-label. FormSection <h3><h2>, PropertyCard <h3><h2>, "New property" dialog <h3><h2>, aside <h4>s (Import status / Shortcuts / Live preview) → <h3> so hierarchy is h1 → h2 → h3 with no skips. Clears every axe violation except color-contrast.
5 aec4fbf Vitest + @testing-library/react + @testing-library/jest-dom + @testing-library/user-event + jsdom + jest-axe. vitest.config.ts (jsdom, excludes macOS * 2.tsx dupes), src/test/setup.ts (jest-dom matchers + toHaveNoViolations + cleanup). Tests for Skeleton (2), EmptyState (3, axe clean), Segmented (6 incl. keyboard nav), useEscapeKey (4), useFocusTrap (4), ConfirmDialog (4).

Deferred from PR 8 → THI-58: systemic color-contrast failures trace to --ink-3 at oklch(0.62) vs --paper at oklch(0.995) computing to ~3.8:1. Requires a design decision on token values — not a mechanical fix — so filed with root cause, 4 remediation options, and acceptance criteria.

PR A — Unified type system (3 tiers)

Tier Commit Change
A1 b0e37d3 Type-scale CSS variables (--h1: 48, --h2: 22, --hero: 56, --caps-eyebrow: 10). New utility classes (.h1, .h2, .hero, .caps-eyebrow), all Instrument Serif at weight 400 — no font-semibold. New PageHeader { eyebrow, title, subtitle, hero, actions } primitive in shared/. Migrated Dashboard, Settings, Glossary, Comparison top-level headers. Includes 5 render tests + axe assertion.
A2a 43c7856 PropertyDetail's persistent h1 + address + rental-badge + Export-PDF header row is gone. Replaced with a compact meta row (rental badge left, Export PDF right) + a PageHeader that switches by activeTab. Info tab's h1 is the property name; other tabs use the property name as the eyebrow so context never disappears. Metric strip and tab bar untouched.
A3 ff81759 Stripped every remaining inline text-[NNpx] + font-serif from heading elements. Swap to .h2 utility across Results (7 sites), Sensitivity (2), Revenue (1), FormSection, PropertyCard, Dashboard, Glossary entry terms. Demoted three <h3 className="caps"> sites (Import status, Shortcuts, Scenarios rail) to <p> — they're labels, not sub-sections. Exceptions documented: Glossary 48px decorative letter h2s; dialog h3s in ConfirmDialog/HistoryDrawer/SnapshotButton (spec ambiguous for modal chrome).

Bookkeeping

  • 27dbe7d — PROGRESS.md corrected to reflect that Phase 2 PRs 1–7 had pre-dated the tracker. No code change.
  • 996a850 — PROGRESS.md clarified PR 2's actually-shipped primitive set (README inventory, not the original one-liner list) so PageHeader was explicitly deferred into PR A.
  • 1759792 — PROGRESS.md recorded PR 8 tier status + color-contrast deferral to THI-58.
  • 179565c + aae6f06 — closing bookkeeping for PR 8 and PR A respectively.

Out of scope for this PR

  • Color-contrast token decisionTHI-58.
  • CI integration of npm test.github/ is off-limits per CLAUDE.md. Test suite runs locally; wire it into CI in a separate PR when ready.
  • Test backfill for pre-PR-8 primitives (Field, FormSection, MetricCell, etc.) — a standalone effort; this PR only tests the primitives it introduced.
  • Legacy QuickTest.tsx — pre-modernization; rewrite is separate.
  • Phase 3 PRs B / C / D / E — expense breakdown, chart primitives, brand, final audit. Queued.

Test plan

Local verification performed on design/modernization before push:

  • npm run lint — clean
  • npm run build — clean (463.93 kB JS, 48.36 kB CSS, 123 modules)
  • npm test — 28/28 tests pass across 7 files
  • axe-core dev reporter exercised on Dashboard / Property Info / Financing / Revenue / Results / Sensitivity / Compare / Glossary / Settings — non-contrast violations are zero; contrast is tracked in THI-58
  • Dev-server spot-checks confirmed per tier (focus rings, skeleton layouts, dialog Escape + focus trap, roving tabindex on Segmented, PageHeader on every page + per tab)

Please also check in your review:

  • Dashboard, Settings, Glossary, Compare, PropertyDetail headers match the type spec visually — 10px eyebrow, 48px Instrument Serif h1, 15px subtitle
  • PropertyDetail tabs — property name visible on every tab (h1 on Info, eyebrow elsewhere)
  • Keyboard: Tab into a segmented control lands on the active option; arrows move selection; Escape closes any dialog
  • Loading states — cut the backend and reload; skeletons should appear instead of "Loading…" text

🤖 Generated with Claude Code

tdody and others added 16 commits April 19, 2026 20:06
Phase 2 PRs 1–7 all shipped under THI-45 through THI-54 before this
tracker was committed. Audit on 2026-04-19 confirmed `frontend/src/index.css`
tokens match `README.md` spec exactly (no drift, light + dark). Phase 2
PR 8 (polish pass) is in-progress with #26/#27/#28 landed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Record that PR 2 (THI-46) shipped the README's primitive inventory —
Field, FormSection, MetricCell, OccupancyGauge, PropertyThumb, RentalBadge,
Segmented, SliderField, Toggle — and that Card/Button/Tabs were deliberately
not extracted. PageHeader is deferred to Phase 3 PR A (unified type system).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… classes

Add a global :focus-visible rule in index.css (2px var(--accent) outline,
2px offset), matching the README spec. Exclude .field so its bottom-border
focus treatment wins on form fields. Strip the lingering focus:ring-indigo-500
classes on the snapshot-name input so it inherits the new rule.

Part of PR 8 (polish pass), tier 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add shared/Skeleton.tsx (<Skeleton>, <SkeletonLine>) and shared/EmptyState.tsx
(extracted from the local copy in Dashboard). Add a token-based .skeleton
utility in index.css with a pulse animation and prefers-reduced-motion guard.

Replace plain-text loading placeholders with structured skeletons matching
each view's grid, so layout doesn't jump when data lands:
- Dashboard: hero, toolbar, 6 table rows
- PropertyPage: breadcrumb, title block, metric strip, tab bar
- PropertyDetail: Financing (280px rail + editor), Revenue & Expenses (3 form sections)
- ComparisonView: 8-row comparison table

Each skeleton container carries role="status" + aria-label; inner shapes
are aria-hidden.

Part of PR 8 (polish pass), tier 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add useFocusTrap and useEscapeKey hooks. Wire them into every dialog
and drawer in the app.

Tablists now use roving tabindex + arrow/Home/End navigation per ARIA
spec — Tab enters at the active option, arrows move between siblings.
Applied to Segmented (all consumers: Dashboard filters, Settings
preferences, Glossary category, etc.) and the PropertyDetail tab bar.

Dialogs gain role="dialog"/"alertdialog", aria-modal, aria-labelledby,
focus trap, and Escape to close:
- ConfirmDialog
- HistoryDrawer
- SnapshotButton's save dialog

Part of PR 8 (polish pass), tier 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Install axe-core and wire it into main.tsx behind import.meta.env.DEV.
Patches history.pushState / replaceState and listens for popstate so
client-side route changes trigger a re-run. Violations are grouped in
the devtools console with severity, help URL, and the offending selectors.

Prod bundle unaffected — the DEV branch is tree-shaken (bundle grew 20B).

Part of PR 8 (polish pass), tier 4a.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lets you run axe.run(document.body).then(...) directly from DevTools.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire htmlFor + id via useId() in Field, SliderField, CurrencyInput, and
PercentInput — associates every rendered label with its input so screen
readers announce the control. Also:

- Raw <select> on Property Info "Property Type" gets htmlFor/id.
- File input on Settings logo upload gets aria-label="Upload company logo".
- FormSection <h3> → <h2> (top-level sections under page h1).
- PropertyCard <h3> → <h2> (card titles on Dashboard).
- "New property" dialog <h3> → <h2>.
- Import status / Shortcuts / Live preview <h4> → <h3> so the hierarchy
  is h1 → h2 → h3 with no skips.

Addresses axe rules: label, select-name, heading-order. Color-contrast
violations persist across all views and trace back to the --ink-3 token
at oklch(0.62) vs --paper failing 4.5:1 — that's a design-spec call,
not a code fix.

Part of PR 8 (polish pass), tier 4b.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Update the PR 8 row with every tier's status (1 focus, 2 skeletons,
3 keyboard nav, 4a axe reporter, 4b a11y fixes). Color-contrast is
systemic across all views and traces to the --ink-3 token vs spec;
filed as THI-58 for the design conversation. Tier 5 (test infra)
not started.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Install vitest, @testing-library/react, @testing-library/jest-dom,
@testing-library/user-event, jsdom, jest-axe, @types/jest-axe. Add
vitest.config.ts (jsdom env, setup file, excludes macOS "* 2.tsx"
duplicates) and src/test/setup.ts (jest-dom matchers + cleanup +
toHaveNoViolations).

Tests cover the primitives introduced in PR 8:
- Skeleton / SkeletonLine — render, aria-hidden, classes
- EmptyState — title, body, actions slot, axe clean
- Segmented — tablist roles, roving tabindex, Arrow/Home/End/click
- useEscapeKey — fires on Escape, respects active flag, cleans up
- useFocusTrap — auto-focus, Tab/Shift+Tab wrapping
- ConfirmDialog — open/closed, role="alertdialog", labelled by,
  Escape → onCancel, Confirm → onConfirm

Drop the offsetParent !== null filter in useFocusTrap in favor of a
[hidden] attribute check — jsdom never computes offsetParent, which
was excluding every button in tests without changing real-browser
behavior meaningfully.

CI integration intentionally deferred (.github is out of scope per
CLAUDE.md). Run locally with `npm test`.

Part of PR 8 (polish pass), tier 5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tier 5 (vitest + RTL + jest-axe + tests for PR 8 primitives, 23 tests)
committed as aec4fbf. All tiers of PR 8 complete. Deferred items:
color-contrast token decision (THI-58), CI integration of npm test,
and test backfill for pre-PR-8 primitives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lock the type scale from "Font & Chart Proposals" section 01 into CSS
variables — --h1: 48px, --h2: 22px, --hero: 56px, --caps-eyebrow: 10px.
Add .h1 / .h2 / .hero utility classes (Instrument Serif, weight 400,
no font-semibold) and .caps-eyebrow (Inter 10px, 0.14em tracking, ink-3).

New shared/PageHeader.tsx — { eyebrow?, title, subtitle?, hero?, actions? }.
Renders the unified "caps eyebrow + serif H1 + optional subtitle" pattern
the spec calls for on every page.

Migrate the four top-level page titles to PageHeader:
- Dashboard — "Portfolio" eyebrow + dynamic property-count title
- Settings — "Workspace" / "Settings" / workspace blurb
- Glossary — "Glossary" / "Every term, defined." + search in actions slot
- Comparison — "Compare · N" / "Side by side"

Titles bump from text-[44px] to var(--h1) = 48px per spec. PropertyDetail
is deliberately untouched; per-tab header rework is Tier A2 pending an IA
decision.

Tier 5 tests cover the new primitive (5 tests; axe clean).

Part of Phase 3 PR A (unified type system), tier A1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the persistent property header (h1 + rental badge + address +
Export PDF) with two pieces: a compact meta row (rental badge left,
Export PDF right) and a PageHeader that switches by activeTab. Each
tab now owns its own h1 per the spec's "one H1 per page/tab" rule.

Eyebrow / title / subtitle mapping:
- Property Info — eyebrow "Property · Information", title = property.name,
  subtitle = full address. The property name is the h1 on the tab that's
  semantically about the property itself.
- Financing — eyebrow = property.name, title "Financing"
- Revenue & Expenses — eyebrow = property.name, title "Revenue & expenses"
- Results — eyebrow = property.name, title "Results"
- Sensitivity — eyebrow = property.name, title "Sensitivity"

Property name is visible on every tab (as h1 on Info, as eyebrow
elsewhere) so the user never loses context. Metric strip, tab bar,
breadcrumb, and tab content are untouched.

Part of Phase 3 PR A (unified type system), tier A2a.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace every remaining inline text-[NNpx] + font-serif on heading
elements with the .h2 utility class landed in A1. Strips redundant
leading-tight / leading-none / text-ink where the token class already
sets them. Cleans up:

- ResultsTab — 7 section sub-headings
- SensitivityTab — 2 section sub-headings (one promoted 20px → 22px
  for consistency with siblings)
- RevenueExpensesTab — Occupancy section title
- FormSection — 22px serif title
- PropertyCard — card title (promoted 20px → 22px)
- Dashboard — "New property" dialog heading
- GlossaryPage — entry term (stays <h3> semantically under letter <h2>,
  uses .h2 for visual 22px)

Demote three <h3 className="caps"> sites to <p className="caps"> —
they're small labels, not real sub-section headings, and the spec's
"no sans-serif headings anywhere" rule makes caps-styled <h[1-6]>
elements a conflict:
- PropertyInfoTab — "Import status" and "Shortcuts" aside labels
- FinancingTab — scenario rail "Scenarios" label

Explicit exceptions kept as-is:
- GlossaryPage's 48px decorative letter-section <h2> (A, B, C) —
  design call; the 4-token scale has no big-letter tier.
- Dialog <h3>s (ConfirmDialog, HistoryDrawer, SnapshotButton) — the
  "no sans headings" rule is ambiguous for modal chrome.
- Legacy QuickTest.tsx — pre-modernization; separate rewrite.

Part of Phase 3 PR A (unified type system), tier A3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
All three tiers of PR A (A1 infrastructure + top-page migration,
A2a PropertyDetail per-tab PageHeader, A3 inline heading cleanup)
committed. Exceptions documented. PR A is now in review state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@tdody
tdody merged commit d0a4732 into main Apr 20, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant