Legacy UI removal — data and plan
Audit run againstorigin/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.
MoveHoldingsDialogis 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.ReferralProgresswas 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 onstagingyet; it has been since (4a5935e8c8), and it is live in production astest-bof the three-arm flaglanding-page-conversion-v02. Even routed, 51 of its 103 files still read as unreferenced: the Framer-generated tree underlanding-v5/framer/imports itself with.jsspecifiers that resolve to.jsx/.tsfiles, which the reachability scan did not map (corrected 2026-09-23, see §5). Nothing underlanding-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) andsecurity-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.jsonalready excludes its seven landing files with the note “delete in a follow-up cleanup”; they use removed Clerk v6 APIs and no longer compile.
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:
ModulusOptionListis a menu list, not a form<select>with a native value/validation contract.ModulusModalPickeris a picker.dialogis the generic Radix modal behind 107 call sites. Modulus has no general-purpose dialog, so “deleteui/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/mutationsandhooks/querieswhose 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 matchedfrom '…', 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:
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-dialogis not dead (unwired feature) and stays;use-referral-statsstays 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 incomponents/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 undercomponents/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 twotailwind.config.tskeys) 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 importcomponents/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.mddocs 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:
button216 /./button15), 0 stories, 0 tests, no config or doc references. - The one thing that looked like a user:
app/(app)/subscriptions/page.tsxcontains<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-reacthasCreditCardandDollarSign. - 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}.tsxandapp/(home)/{old-page,old2-page,net-worth-chart,total-cash}.tsx. - Stays (scope rule):
hooks/use-new-landing-experiment.ts(reads a PostHog flag) andcomponents/variant-persistence.tsx(renders nothing; writes thelp_experiment_variantcookie). 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 oflanding-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) andhooks/use-landing-engagement.tsstill namelanding-page-variant2.tsxas 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 sevenexcludeentries 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 afterlanding-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.mdgains a status note: historical, the components it names are gone, and where the live experiment lives now.- Name trap still stands:
use-landing-conversion-experimentis LIVE;use-new-landing-experimentis 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-tabscall 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 inadd-property-dialog.tsx(deleteResidentialProperty), commercial incommercial-property-dialog.tsx.components/empty-state(->ModulusEmptyState),components/feedback-modal(->support-feedback-modal)net-worth-trend-chart,onboarding-step-progress,stock-chart-examplereferral-progress— deleted by decision (Felipe, 2026-09-22), overriding Robert’s parking in6d284925af. Say so in the PR so it is not a surprise; the component is recoverable from that commit.hooks/queries/use-referral-statsstays (scope rule), as does its.storybook/main.tsmock 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-dialogand theinvestment-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-chartandcomponents/ui/skeleton-cardlose 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) andsecurity-faq.tsx(edited by #3562, the MX epic draft). Deleting either first hands him a modify/delete conflict. - Open:
components/research/index.tsonly re-exports its siblinganalysis-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 deadlib/, 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.mdsays the project uses “Neo4j for graph relationships”. In this repolib/neo4j.tshas never been imported by anything, no file mentions Neo4j or Cypher, andneo4j-driveris an unused dependency. Either that line describes a different service or it is stale — worth confirming.lib/blog/freshness.tsis documented inCLAUDE.md’s blog file map and indocs/blog/seo-infrastructure.mdas 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.tsxanddetail-section.stories.tsx. Then delete the module. - Conflicts with PR 1’s rule.
app/(app)/assets-liabilities/_components/ detail/detail-section.stories.tsxis outsidecomponents/modulus/, so it is still edit-frozen, and migrating it is an edit. Needs either a narrow exception inCLAUDE.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, pluscomponents/__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 isui/responsive-dialog, which has 4 live consumers in connect-account, sam and dialogs.responsive-dialogis “dialog on desktop, drawer on mobile”. Modulus hasModulusDrawerandModulusPanebut 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 sharedcomponents/*.tsx)
Blocked, not scheduled
dialog(107 sites) — Modulus has no general-purpose dialog. Needs design.select(43 sites) —ModulusOptionListis 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 inapp/(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:
- Reachability, with the blind spots closed. Scan
.ts,.tsx,.js,.mjsand.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.jsspecifiers to.ts/.tsx/.jsxthe way the bundler does (import x from './foo.js'isfoo.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. - 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.
- Open PRs. Intersect every open PR’s changed files with the delete list; a hit is a modify/delete conflict for someone else.
- Non-import references.
tsconfig.json,.storybook/main.tsaliases,package.json,.github/,vercel.json, tests that read source files from disk, and docs — each deletion must take its stale reference with it. - 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).
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.