Skip to main content

Legacy UI removal — data and plan

Audit run against origin/staging @ a22abb5b16, 2026-09-18. The counts below are a snapshot of that commit; re-measure before acting on any one of them rather than trusting a number that is weeks old. Ground rule: components/modulus/** is the new system and is not touched by any of this. That includes the ten components built but not yet adopted (§3b) and components/modulus/foundations/blocks.tsx, which the reachability sweep flags and which is excluded on purpose. This project retires OLD components only. Scope rule, set 2026-09-22: UI only. This cleanup deletes UI components, pages, and the stories and tests that exist only for them. It does not delete logic — no hooks, lib/, app/api/, email templates, messaging senders, chat tools, or cookie/flag helpers, even when nothing imports them. A logic file orphaned by a UI deletion stays where it is. The original PR 7 (dead lib, hooks and API) is dropped for this reason. Progress: PRs 1–3 are done. Everything else in §4 is still proposed.

Re-audit, 2026-09-22 (against staging @ 304bd8792a, 136 commits later)

The plan was re-checked against the repo rather than re-read, with the blind spots of the first pass closed: .mdx files are now scanned, studio/ and the vitest configs are seeded as entry points, and every “nothing found” result is paired with a control that must find something. What it changed:
  • Two files the plan called dead are deliberate. MoveHoldingsDialog is the last unwired piece of a Storybook-first feature (b6782fea53: “before any data wiring lands”); its siblings from that commit have all since been wired. It stays, and so does its review harness. ReferralProgress was parked by Robert (6d284925af: “kept for future re-enablement”); Felipe decided on 2026-09-22 to delete it anyway, so the component remains scheduled for deletion in PR 5. Its data hook, use-referral-stats, is not deleted (scope rule).
  • A fresh sweep would delete a live experiment arm. components/landing-v5/ is Shain’s Landing V5, created 2026-09-21. On the re-audit commit it was not routed on staging yet; it has been since (4a5935e8c8), and it is live in production as test-b of the three-arm flag landing-page-conversion-v02. Even routed, 51 of its 103 files still read as unreferenced: the Framer-generated tree under landing-v5/framer/ imports itself with .js specifiers that resolve to .jsx/.ts files, which the reachability scan did not map (corrected 2026-09-23, see §5). Nothing under landing-v5/ is ever a deletion candidate.
  • Five of Shain’s open PRs edit files on the old delete list. With PR 7 dropped, only two UI files are still affected: footer.tsx (#2450, #1764, both months stale) and security-faq.tsx (#3562, the MX epic). Both are held in PR 6 until he confirms.
  • PR 8 conflicts with the PR 1 rule — see PR 8.
  • Independent confirmation of PR 3: tsconfig.json already excludes its seven landing files with the note “delete in a follow-up cleanup”; they use removed Clerk v6 APIs and no longer compile.
Recency alone is a noisy signal here: most “recent” touches on dead files come from repo-wide sweeps (#4119’s lint grandfathering stamped 216 files, the rebrand, the email-redaction fix). What matters is the last substantive commit, and whether its message says the file was parked on purpose.

1. The four facts that shape everything

The app shell is unconditionally Modulus. app/layout-with-sidebar-client.tsx:139 stamps data-theme-variant='modulus' with no flag and no condition. So inside app/(app)/** there is no control arm to protect, and legacy components there are dead weight rather than a live variant. But the root layout’s themeVariant is a different, still-live experiment. app/layout.tsx:137 writes control / dark-variant-b from resolveThemeVariant. That is the dark-theme test, not the Modulus one. Do not conflate them; removing legacy components must not touch app/themes.css, which those rules key on. Modulus is already fully independent of components/ui. Zero files under components/modulus/ import anything from components/ui/ (verified with a control search that does return @/components/chat/ ×3, so the empty result is real). The only mention is prose in components/modulus/CLAUDE.md. Nothing about deleting legacy UI can break Modulus itself. components/ui/** used to be declared read-only, and PR 1 narrowed that. CLAUDE.md now separates “Frozen. Do not edit” (themes.css, globals.css, the two tailwind.config.ts keys) from “Being retired. Deleting is allowed, editing still is not” (components/ui/** and the non-Modulus stories). The rule was rewritten rather than ignored, because a rule that is quietly worked around gets re-frozen by the next reader. Deleting is allowed; restyling or extending one of these components is still a stop-and-ask, and that half of the freeze is not a formality: 19 files under app/(public) still import components/ui, only 12 of its 64 modules have a Modulus counterpart, and the landing A/B is still running.

2. Which components are genuinely duplicated

Counts are production importers only (stories and tests excluded). The honest headline: only 12 of 64 components/ui modules have a Modulus counterpart at all. The other 52 are Radix primitives and product widgets that Modulus never set out to replace — label (59), skeleton (48), table (27), sensitive-text (23), dropdown-menu (19), tooltip (17), alert-dialog (17), popover (16), and so on. Those are not deletion targets; they are simply the rest of the design system. Two of the pairs are also not like-for-like, and should not be treated as a rename:
  • ModulusOptionList is a menu list, not a form <select> with a native value/validation contract.
  • ModulusModalPicker is a picker. dialog is the generic Radix modal behind 107 call sites. Modulus has no general-purpose dialog, so “delete ui/dialog” is blocked on designing one, not on migration effort.
ui/drawer looks nearly dead (1 importer) but is not independently removable: its only consumer is components/ui/responsive-dialog.tsx, which has 4 of its own.

3. Dead files across the codebase

Reachability run from 702 real entry points (Next.js route conventions, middleware, instrumentation, configs, .storybook/, scripts/, supabase/): 3376 files scanned, 2240 reachable. By directory: components 52, app 26, lib 17, hooks 13, rest 8. Clear clusters in the unreachable set:
  • Superseded landing pages — landing-page-client.tsx, -client-old, -original, -variant, -variant2, app/(home)/old-page.tsx, old2-page.tsx
  • Pre-revamp radar and vault — app/(app)/radar/components/{create-form, radar-header,radar-landing,radar-sidebar,schedule-card,schedule-list, template-gallery}, app/(app)/vault/components/{batch-upload-document, document-list,folder-list,upload-document}
  • Retired chat SQL tooling — app/api/chat/tools/{index,sql-query-tool, text-to-sql-tool,transaction-query-tool}, app/api/chat/utils/chat-logger
  • Dashboard cards and banners — net-worth-card, smart-alerts-card, portfolio-brief-card, office-hours-banner, pfs-announcement-banner, product-updates-banner, referral-popup, pwa-install-prompt
  • Orphaned hooks — 13 under hooks/mutations and hooks/queries whose only consumers were the components above

This list is NOT safe to bulk-delete, and I can prove it

My first pass reported 119 unreachable files. It was wrong about 14 of them, because the regex only matched from '…', import('…') and require('…') — and missed bare side-effect imports. lib/generative-ui/renderer.tsx:9 and lib/email/gen-ui-to-html.ts:40 both do:
That one line keeps every generative-UI component alive. Deleting on the first pass would have removed live chat and email rendering code. The regex is fixed, and two further known false positives remain in the 116 by construction: vitest.setup.ts and vitest.financial-literacy.config.ts (loaded by config, never imported) and types/global.d.ts (ambient, picked up by tsconfig, never imported). Two more need a human call rather than a tool: emails/scenario-complete-email.tsx sits in the outbound-messaging area, and hooks/use-experiment.ts + lib/experiments/registry.ts are test-only right now simply because no experiment is currently wired — docs/EXPERIMENTS.md mandates that hook for future tests, so unused here does not mean delete.

3a. The 35 “story-only” files split three ways — only 18 are deletable

“Held up only by a story” is not one category. Resolving which story holds each file up separates them cleanly.

DELETE — 18 files, superseded product code

Corrected 2026-09-22: move-holdings-dialog is not dead (unwired feature) and stays; use-referral-stats stays under the UI-only scope rule. See the re-audit at the top and PR 5. Each is held by exactly one story, and each has a live replacement in production:
The radar four are one tranche: radar-empty-state.stories.tsx alone holds low-data-warning-card, radar-info-card and silvia-scanning-animation, so that single story file is the whole cluster’s anchor. Only one of the 18 has a test: components/__tests__/onboarding-step-progress.test.tsx.

KEEP — 10 Modulus components (design-system inventory)

account-row, info-panel, logo-icon, logo-wordmark, mobile-app-bar, modal-showcase, nav-item, screen-shell, sidebar, thread. Eight of the ten are held by several stories, not one: logo-icon and nav-item by 6, sidebar, account-row and mobile-app-bar by 5, screen-shell by 4. They are the pieces that compose the full-screen design references — chat.stories, radar-page.stories, research.stories. Deleting them breaks the Storybook screens that this project treats as the source of truth.

KEEP — 7 story fixtures and harnesses

portfolio-story-fixtures (5 holders), silvia-feed.mocks (5), public-investments-{prod,story}-fixture, investment-accounts/stories/{fixtures, flow-harness,mock-securities}. These are story support files. Being story-only is their job, not a symptom, and most serve stories of components that are very much alive (portfolio-section, public-investments-detail-screen, insights-widget). One exception worth a look: investment-account-flow.stories.tsx imports only ./flow-harness and ./fixtures and no production component at all, so that story plus its harness is a closed loop documenting a mock rather than the product. Worth a decision, not an automatic delete.

3b. Duplication also runs the other way: 10 Modulus components are unadopted

These exist in components/modulus/ with stories, and are reached by nothing in production: account-row, info-panel, logo-icon, logo-wordmark, mobile-app-bar, modal-showcase, nav-item, screen-shell, sidebar, thread. The important one is ModulusSidebar: it is story-only, while the sidebar the app actually renders is components/sidebar.tsx. Same for ModulusThread and ModulusMobileAppBar against their shipped equivalents. So for these the duplicate pair is real but inverted — the legacy file is the live one and the Modulus file is the shelf-ware. Do not delete these as “dead code”. They are design-system inventory and the intended replacements. They belong in the Tranche 4 conversation (adopt them) rather than the Tranche 3 one (delete unreachable files). Flagging them because a naive reachability sweep puts them in the same bucket as old2-page.tsx, and they are not the same thing at all.

4. The plan, as PRs

Ground rule agreed 2026-09-18: anything under components/modulus/ is new and is not touched. That includes the 10 unadopted components and components/modulus/foundations/blocks.tsx, which shows up in the orphan sweep and is excluded on purpose. This work only retires OLD components. Ordering matters in two places: PR 1 unblocks everything, and the delete PRs come before the migration PRs because deletes remove call sites the migrations would otherwise have to do by hand.

PR 1 — Narrow the two read-only rules (docs only) — DONE

  • Split the one “Read-only. Do not edit these files” list into Frozen. Do not edit (themes.css, globals.css, the two tailwind.config.ts keys) and Being retired. Deleting is allowed, editing still is not (components/ui/**, non-Modulus *.stories.tsx).
  • Deletion rights only. Restyling or extending a legacy component is still a stop-and-ask, because 19 app/(public) files still import components/ui, 52 of its 64 modules have no Modulus counterpart, and the landing A/B is live. The freeze was protecting live surfaces, and most of them are still live.
  • Added this plan to the repo and to the AGENTS.md docs index, so the rule points at something real.
  • Why first: without it every later PR contradicts the repo’s own instructions.

PR 2 — Delete 6 unused demo components — DONE

  • components/ui/: aurora-background, credit-card-icon, dollar-icon, gooey-text-morphing, hero-highlight, lamp.
  • Verified on the commit it branched from: 0 absolute and 0 relative importers (controls: button 216 / ./button 15), 0 stories, 0 tests, no config or doc references.
  • The one thing that looked like a user: app/(app)/subscriptions/page.tsx contains <DollarIcon /> twice and <CreditCardIcon /> once. All three sit inside {/* <Card> … </Card> */} blocks (lines 245–386) — commented-out cards, so neither rendered nor type-checked. Whoever uncomments them will need an icon source; lucide-react has CreditCard and DollarSign.
  • tsc error set identical before and after; full vitest suite green.
  • Size: 6 files deleted, nothing edited. Risk: none.

PR 3 — Delete superseded landing generations — DONE

  • Deleted: components/landing-page-{client,client-old,original,variant,variant2}.tsx and app/(home)/{old-page,old2-page,net-worth-chart,total-cash}.tsx.
  • Stays (scope rule): hooks/use-new-landing-experiment.ts (reads a PostHog flag) and components/variant-persistence.tsx (renders nothing; writes the lp_experiment_variant cookie). Neither had an importer even before this PR, and neither imports anything that was deleted.
  • Live path untouched: app/(home)/page.tsx -> home-landing-router -> LandingPageV3 / LandingPageV4 / LandingPageV5 — the three arms of landing-page-conversion-v02, which is live. Nothing deleted here is on any of those paths, by the resolver and by plain text search.
  • Deliberately NOT changed, because the experiment is live: two comments in components/landing-page-v3.tsx (the control arm) and hooks/use-landing-engagement.ts still name landing-page-variant2.tsx as the deprecated predecessor. They are prose, not imports, and still read correctly as history. Editing the control arm mid-test for a comment is not worth it.
  • tsconfig.json: left for later. Its seven exclude entries now name files that no longer exist, which is a no-op. They were meant to go in this PR, but open PR #4057 (technoplato) edits exactly those lines — it appends to the same array after landing-page-variant2.tsx — so removing them now would give one of the two PRs a merge conflict. Remove them after #4057 lands.
  • docs/AB-TEST-VARIANT-TRACKING-FIX.md gains a status note: historical, the components it names are gone, and where the live experiment lives now.
  • Name trap still stands: use-landing-conversion-experiment is LIVE; use-new-landing-experiment is the dead one.

PR 4 — Delete pre-revamp radar and vault

  • Radar orphans: create-form, radar-header, radar-landing, radar-sidebar, schedule-card, schedule-list, template-gallery
  • Radar story-only + their stories: create-radar-dialog, delete-radar-dialog, radar-empty-state, low-data-warning-card, radar-info-card, silvia-scanning-animation
  • Vault orphans: batch-upload-document, document-list, folder-list, upload-document
  • All superseded by RadarDayZero (radar-page-client.tsx:43) and the current vault.
  • Bonus: removes one of the three segmented-tabs call sites, shrinking PR 8.
  • Size: ~23 files. Risk: low.

PR 5 — Delete superseded dialogs and misc story-only

  • add-investment-dialog, delete-investment-dialog, delete-property-dialog, disconnect-property-dialog. Live equivalents confirmed on 2026-09-22 — residential property delete lives in add-property-dialog.tsx (deleteResidentialProperty), commercial in commercial-property-dialog.tsx.
  • components/empty-state (-> ModulusEmptyState), components/feedback-modal (-> support-feedback-modal)
  • net-worth-trend-chart, onboarding-step-progress, stock-chart-example
  • referral-progress — deleted by decision (Felipe, 2026-09-22), overriding Robert’s parking in 6d284925af. Say so in the PR so it is not a surprise; the component is recoverable from that commit. hooks/queries/use-referral-stats stays (scope rule), as does its .storybook/main.ts mock alias.
  • Each takes its own .stories.tsx. One test goes too: components/__tests__/onboarding-step-progress.test.tsx.
  • REMOVED from this PR: investment-accounts/move-holdings-dialog and the investment-accounts/stories/ harness (flow-harness, fixtures, mock-securities, investment-account-flow.stories.tsx). Not dead — the unwired half of the holdings↔account feature.
  • Size: ~10 components + ~10 stories + 1 test. Risk: low.

PR 6 — Delete dead dashboard cards, banners and tables

  • ~33 files under components/: net-worth-card, smart-alerts-card, portfolio-brief-card, dashboard-investment-card, dashboard-silvia-feed, net-worth-breakdown-card, cash-accounts-{cards,table}, crypto-holdings-cards, loan-accounts-cards, public-investments-cards, office-hours-banner, pfs-announcement-banner, product-updates-banner, alert-banner, referral-popup, pwa-install-prompt, auth-dialog, app-header, footer, security-faq, silvia-showcase, testimonial-to-embed, sql-results, chart-renderer, section-skeleton, table-skeleton, sync-status-indicator, privacy-mode-toggle, investment-button, mobile-clickable-card, mobile-desktop-icon, transactions-sidebar-item, vault-sidebar-item, upload-thing-uploader, research/analysis-report, research/index
  • Chains: once these go, components/ui/dynamic-chart and components/ui/skeleton-card lose their last consumers and can go in the same PR.
  • Held until Shain confirms: footer.tsx (edited by his open #2450 and #1764, both untouched for months — likely abandoned, but his call) and security-faq.tsx (edited by #3562, the MX epic draft). Deleting either first hands him a modify/delete conflict.
  • Open: components/research/index.ts only re-exports its sibling analysis-report; default is to delete it with that component.
  • Hooks these components used (use-portfolio-brief, use-smart-alerts, use-pwa-prompt-status, …) stay under the scope rule.
  • Size: ~33 files. Mechanical but the largest delete. Risk: low individually, worth splitting in two if review is unwieldy.

PR 7 — DROPPED (2026-09-22)

Was: delete dead lib/, hooks/, app/api/ and email/messaging files. Out of scope under the UI-only rule — logic files are not deleted even when nothing imports them. Nothing from it is scheduled anywhere else. Two facts from its audit are still worth recording, because they are true whether or not anything is deleted:
  • AGENTS.md says the project uses “Neo4j for graph relationships”. In this repo lib/neo4j.ts has never been imported by anything, no file mentions Neo4j or Cypher, and neo4j-driver is an unused dependency. Either that line describes a different service or it is stale — worth confirming.
  • lib/blog/freshness.ts is documented in CLAUDE.md’s blog file map and in docs/blog/seo-infrastructure.md as live (“content staleness detection”), but nothing calls it.

Everything above deletes. Nothing above changes a rendered pixel. Below this line the work becomes migration, and each PR edits live UI.

PR 8 — segmented-tabs -> ModulusSegmented — blocked on a rule decision

  • 2 live call sites after PR 4: app/(app)/product-updates/page.tsx and detail-section.stories.tsx. Then delete the module.
  • Conflicts with PR 1’s rule. app/(app)/assets-liabilities/_components/ detail/detail-section.stories.tsx is outside components/modulus/, so it is still edit-frozen, and migrating it is an edit. Needs either a narrow exception in CLAUDE.md (“updating a story only to follow its component’s migration is allowed”) or a different approach.
  • Size: 2 sites. Risk: low. Modulus is already at 15 uses vs 2.

PR 9 — empty-state-grid -> ModulusEmptyState

  • components/assets-section.tsx, components/liabilities-section.tsx, plus components/__tests__/assets-section.test.tsx. Then delete the module.
  • Size: 3 files. Risk: low.

PR 10 — edit-icon-button / delete-icon-button -> ModulusIconButton

  • app/(app)/real-estate/page.tsx, components/precious-metals-table.tsx, components/physical-assets-table.tsx (relative import). Then delete both modules.
  • Size: 3 sites. Risk: low. Modulus already at 45 uses vs 7.

PR 11 — drawer + responsive-dialog (needs a design answer first)

  • ui/drawer’s only consumer is ui/responsive-dialog, which has 4 live consumers in connect-account, sam and dialogs.
  • responsive-dialog is “dialog on desktop, drawer on mobile”. Modulus has ModulusDrawer and ModulusPane but no single responsive equivalent, so this needs a decision before code.
  • Size: 4 sites + 2 modules. Risk: medium, blocked on design.

PR 12-14 — the mid-size swaps, one per PR

  • badge -> ModulusTag / ModulusChip (20 sites)
  • checkbox -> ModulusCheckbox (17 sites)
  • tabs -> ModulusTextTab (14 sites)

PR 15-17 — the large swaps, each split across several PRs by directory

  • card -> ModulusCard (77 sites)
  • input + textarea -> ModulusTextField (91 sites)
  • button -> ModulusButton (205 sites, of which 149 are shared components/*.tsx)

Blocked, not scheduled

  • dialog (107 sites) — Modulus has no general-purpose dialog. Needs design.
  • select (43 sites) — ModulusOptionList is a menu, not a form select with a value and validation contract. Needs design.

5. Constraints that must hold in every tranche

Check the scope before moving anything in app/(public)/**. A Modulus class outside a data-theme-variant='modulus' subtree resolves to nothing and renders unstyled, with no error. Some public routes do carry the scope in their own layout (blog, careers, the quizzes, calculators, financial-literacy) and some do not. This is per-route, not global. Leave the Landing V4 surfaces alone while that experiment is running. *.stories.tsx outside components/modulus/ may be deleted with their component (PR 1) but not edited. Migrations that need to edit one are blocked until that rule is settled — see PR 8. Before any delete PR, re-check every file on the commit you branch from. “Nothing imports it” is necessary but not sufficient. Each of these caught a real problem on 2026-09-22:
  1. Reachability, with the blind spots closed. Scan .ts, .tsx, .js, .mjs and .mdx; seed Next route files, middleware, instrumentation, every *.config.*, vitest.setup.ts, .storybook/, scripts/, supabase/, studio/; match bare side-effect imports (import 'x'); and resolve .js specifiers to .ts/.tsx/.jsx the way the bundler does (import x from './foo.js' is foo.tsx). Missing that last one made 51 live Landing V5 files look dead. Because the resolver can have gaps, also run a plain text search for each file’s name across all code — that check does not depend on resolution at all.
  2. Recency and intent. Find the last substantive commit (skip repo-wide sweeps) and read its message. Anything parked on purpose — “kept for”, “re-enable”, “before any data wiring” — is not dead. Anything created in the last few weeks is presumed in progress until its author says otherwise.
  3. Open PRs. Intersect every open PR’s changed files with the delete list; a hit is a modify/delete conflict for someone else.
  4. Non-import references. tsconfig.json, .storybook/main.ts aliases, package.json, .github/, vercel.json, tests that read source files from disk, and docs — each deletion must take its stale reference with it.
  5. Name matches in live code. If a deleted export’s name appears in a live file, find out why before deleting (on PR 2 it was commented-out JSX).
Every empty result gets a control that must fire. This is not a formality: on 2026-09-22 a zsh glob emptied a file list, a zsh subscript mangled a count, BSD sed ignored \L, and an unsplit variable turned a six-name loop into one nonsense search — all of which printed a clean-looking “nothing found”. Per-PR verification: tsc error set identical before and after (not a count), eslint compared as a warning set against staging, the full vitest suite, and a Storybook or app render for anything visual. Note that tsconfig.json has "incremental": true, so run tsc with --incremental false when a result looks surprising, and that a worktree sharing another worktree’s node_modules can lack newly added packages (on 2026-09-22 unframer, for landing-v5) — compare against a baseline from the same setup.

6. Rough size

Re-cut on 2026-09-22 for the UI-only scope (PR 7 dropped, the two deliberate files out of PR 5, two PR 6 files held for Shain). Counts are approximate until each PR re-measures on its own branch point. PRs 2–6 remove roughly 80 UI files without changing a rendered pixel, and none of it depends on the migrations.