Monorepo Architecture Review — 2026-06-25
Section titled “Monorepo Architecture Review — 2026-06-25”Verdict
Section titled “Verdict”The monorepo is in materially better architectural shape than the 2026-05-14 baseline. The single biggest prior finding — the “phantom” components package consumed by Vite alias instead of as a real workspace dep (prior #1) — is fully resolved: packages/components is now @monorepo/components with "workspace:*" declared by all four sites and imported via the package specifier (@monorepo/components/ui/*, /layout/*, /brand/*). The exports contract is now real, not fiction. Dependency direction is clean (no cross-site imports), the Astro-first/Alpine/Svelte split is genuinely honored (0 framework hydration directives and 0 .svelte files in sites/), brand CSS now has a true SSOT (342db93), and the new Tooltip lives correctly in the canonical library. The remaining issues are incomplete-migration debt and SSOT drift, not structural rot: a half-finished package-naming convention (@monorepo/* vs @smart-debt/*), a fully orphaned @smart-debt/i18n package, ~250 lines of duplicated focus-page glossary/markdown engine copy-pasted between mbr and sdc, residual @components alias config that one file still depends on, and committed build artifacts / Obsidian state. None are release-blocking; most are 1–3h cleanups that prevent future silent breakage.
Severity counts: Critical 0 · High 3 · Medium 5 · Low 4
Prior-finding status: #1 RESOLVED · #2 RESOLVED (sdc now uses workspace dep, no alias needed) · #3 UNRESOLVED (sd-app↔sd-api still unwired, now documented as roadmap item) · #4 RESOLVED (nested sites/template/sites/ gone) · #5 LIKELY RESOLVED · #6 RESOLVED (sd-math cursor docs gone) · #7 PARTIAL (.obsidian/ still tracked) · #8 not re-audited in depth · #9 UNRESOLVED (archive/ still in tree) · #10 RESOLVED (mbr now declares design-tokens) · #11/#12 informational.
[SEVERITY: high] [effort: 2h] Focus-page glossary/markdown engine is copy-pasted between mbr and sdc (SSOT violation)
Section titled “[SEVERITY: high] [effort: 2h] Focus-page glossary/markdown engine is copy-pasted between mbr and sdc (SSOT violation)”- Where:
sites/mbr/src/utils/load-focus-content.ts(271 lines) andsites/sdc/src/utils/load-focus-content.ts(272 lines). - What: A
diffshows the two files share substantial generic infrastructure verbatim:injectGlossaryTooltips()(Glossary(term)→tooltip-span regex),loadGlossaryTerms()(parsesCore/Misc/Glossary.mdinto aMap), the markdown-bold→<b>pipeline, the YAML frontmatter parser, and theFocusContent/FocusSection/FocusCard/FocusPlan/PlanItemtype definitions. Only the brand-injection function (injectBrands+ thembr-entity/smart-debtspans), the KB paths (MBR/_WorkingOn/Focus.mdvsSDC/_WorkingOn/Focus.md), and thedefaultContent()fallback are genuinely site-specific. - Why it matters: This is the exact SSOT failure pattern the prior review flagged for components. The glossary engine reads from one canonical source (
Core/Misc/Glossary.md) but the parser that reads it is forked. A fix to the glossary regex, a new tooltip ARIA pattern, or a frontmatter-schema change must be made in two files and will silently drift (sdc already added aFocusProminentsection type that mbr lacks — drift has begun). - Recommendation: Extract the generic engine into the canonical library — e.g.
packages/components/src/lib/focus-content.tsexportingloadGlossaryTerms(),injectGlossaryTooltips(), the frontmatter parser, and the shared types. Each site keeps only itsinjectBrands()+ KB path +defaultContent()and passes them in (or composes). Both sites already depend on@monorepo/componentsviaworkspace:*, so the wiring cost is near zero.
[SEVERITY: high] [effort: 1h] @smart-debt/i18n is a fully orphaned package
Section titled “[SEVERITY: high] [effort: 1h] @smart-debt/i18n is a fully orphaned package”- Where:
packages/i18n/(package.json,messages/en.json,messages/fr.json). - What: Grep across the entire repo (json/ts/astro/svelte/mjs, excluding the package itself) finds zero consumers — no site or app declares
@smart-debt/i18nas a dependency and nothing imports it.apps/sd-appdoes its i18n via its own Paraglide setup (apps/sd-app/src/lib/i18n.ts,src/paraglide-types.d.ts), not this package. - Why it matters: Dead workspace package with a real
package.jsonimplies an active contract that doesn’t exist (same class of problem as the prior phantom-components finding). It also carries the stale@smart-debt/prefix after the components rename, compounding the naming-convention confusion (next finding). New contributors will assume i18n flows through this package; it doesn’t. - Recommendation: Decide its fate explicitly. Either (a) delete
packages/i18n/if sd-app’s Paraglide catalogue is the real SSOT, or (b) if theseen.json/fr.jsonare meant to be the shared catalogue, wire sd-app (and any i18n-bound site) to consume it and make Paraglide compile from it. Do not leave it as untyped, unconsumed dead metadata.
[SEVERITY: high] [effort: 1.5h] Package-rename convention is half-finished — @monorepo/* vs @smart-debt/* split
Section titled “[SEVERITY: high] [effort: 1.5h] Package-rename convention is half-finished — @monorepo/* vs @smart-debt/* split”- Where:
packages/components/package.json(@monorepo/components) vspackages/design-tokens/package.json(@smart-debt/design-tokens),packages/i18n/package.json(@smart-debt/i18n),apps/sd-app/package.json(@smart-debt/sd-app). - What: Commit a741aa2 renamed
@smart-debt/components→@monorepo/componentsacross 50+ imports (cleanly — no dangling@smart-debt/componentsrefs remain except one stale line inCLAUDE.md:29). But the other three packages kept the@smart-debt/scope. The workspace now has two competing org scopes with no documented rationale. - Why it matters: Two scopes is a coin-flip for every future import and every new package — contributors won’t know which to use. The
@smart-debt/scope is also semantically wrong now that the repo brands itself as a multi-brand@monorepo(sd, mbr, ts). Mixed scopes are a classic source of “which one is canonical?” churn. - Recommendation: Pick one scope and complete the migration.
@monorepo/*matches the repo’s own framing and the just-renamed components package — renamedesign-tokens,i18n(if kept), andsd-appto@monorepo/*, update theworkspace:*consumers, and document the scope inCLAUDE.md/README.md. Also fix the stale@smart-debt/componentsreference atCLAUDE.md:29.
Medium
Section titled “Medium”[SEVERITY: medium] [effort: 0.5h] Residual @components Vite/tsconfig alias — ts still mixes two consumption models
Section titled “[SEVERITY: medium] [effort: 0.5h] Residual @components Vite/tsconfig alias — ts still mixes two consumption models”- Where: alias defined in
sites/template/astro.config.mjs:19andsites/ts/astro.config.mjs:19+sites/ts/tsconfig.json:8; the only live consumer issites/ts/src/layouts/BaseLayout.astro:3(import Search from '@components/ui/Search.astro'). - What: Now that all sites consume components via the
@monorepo/componentsworkspace specifier, the legacy@componentspath alias is dead config — excepttsstill has one import using it, sotsuses BOTH models at once.sdcandmbrhave already dropped the alias cleanly. - Why it matters: Two ways to import the same package is config drift that re-introduces the original phantom-package coupling for one file. If the
@componentsalias is ever removed (it should be),ts’s build breaks silently. - Recommendation: Change
sites/ts/src/layouts/BaseLayout.astro:3toimport Search from '@monorepo/components/ui/Search.astro', then delete the@componentsalias fromsites/ts/astro.config.mjs,sites/ts/tsconfig.json, andsites/template/astro.config.mjs(template no longer uses it).
[SEVERITY: medium] [effort: 0.5h] Committed build artifact: rates.generated.json is tracked, and the real rates SSOT lives in a different project
Section titled “[SEVERITY: medium] [effort: 0.5h] Committed build artifact: rates.generated.json is tracked, and the real rates SSOT lives in a different project”- Where:
sites/mbr/src/data/rates.generated.json(git-tracked), generated bysites/mbr/scripts/sync-rates.mjsfromSOURCE_PATH = /home/ta/projects/mbr/rate-scanner/data/rates.json(outside this repo), with in-reposites/mbr/src/data/rates.jsonas a fallback. - What: A file named
*.generated.jsonis committed to source control, and the canonical rate data lives in a separate sibling project (projects/mbr/rate-scanner). The in-reporates.jsonis only a CI/offline fallback. - Why it matters: Committing generated output invites merge noise and “edit the artifact instead of the source” mistakes. The cross-project SSOT is also fragile — the monorepo build depends on a hardcoded absolute path to another repo that isn’t a declared dependency. (It degrades gracefully to the fallback, which is the right call, but the coupling is undocumented at the monorepo level.)
- Recommendation: Either gitignore
rates.generated.jsonand generate it in a prebuild step (sync-rates.mjsalready exists; wire it into the build), or — if it must be committed for offline/CI builds — document explicitly insites/mbr/that it is a generated artifact whose SSOT isprojects/mbr/rate-scanner. Note the cross-project dependency in STATUS/README so it isn’t a surprise.
[SEVERITY: medium] [effort: 2h] rates.astro hand-rolls 9 inline <table> blocks instead of reusing RateTable.astro
Section titled “[SEVERITY: medium] [effort: 2h] rates.astro hand-rolls 9 inline <table> blocks instead of reusing RateTable.astro”- Where:
sites/mbr/src/pages/rates.astro(575 lines, contains 9 inline<table>, 0<RateTable>); componentsites/mbr/src/components/RateTable.astro(46 lines) is used only onsites/mbr/src/pages/index.astro. - What: The main rates dashboard renders all its tables as inline markup rather than via the existing
RateTablecomponent. The inline tables do carry extra/variable columns (Term, Type, lender-specific) that the currentRateTable(fullboolean, fixed columns) doesn’t model, so it isn’t a drop-in — but the result is a 575-line page with 9 near-parallel table structures and a 46-line component that the dashboard ignores. - Why it matters: Table markup, accessibility attributes (
role="region",tabindex,aria-label), and the footnote pattern are duplicated 9× inline and will drift fromRateTable. A11y or styling fixes must be applied in many places. - Recommendation: Generalize
RateTable.astroto accept a column schema (header defs + row-cell mapping) so the variable-column tables can flow through one component, then refactorrates.astroto use it. This is brand-local (correctly not shared cross-site), so it stays insites/mbr/.
[SEVERITY: medium] [effort: 0.5h] sd-app ↔ sd-api polyglot boundary still undefined (prior #3, unresolved)
Section titled “[SEVERITY: medium] [effort: 0.5h] sd-app ↔ sd-api polyglot boundary still undefined (prior #3, unresolved)”- Where:
apps/sd-app/src/(no reference tosd-api,localhost:8000, or/api/),packages/sd-api/(FastAPI, isolated). - What: The SvelteKit PWA still does not call the Python FastAPI service; there’s no generated client, shared schema, or fetch wrapper. This is the prior review’s #3 and remains open. It is now at least acknowledged in
STATUS.md/ROADMAP.md(“sd-app production deploy” / mini-apps workstream) but the contract is still implicit. - Why it matters: When either side moves, the other breaks with no compile-time signal. Pre-production this is the highest strategic coupling risk even though it’s currently inert.
- Recommendation: Before sd-app prod deploy, decide the boundary: generate a TS client from FastAPI’s OpenAPI into
packages/sd-api-client/, or markpackages/sd-api/explicitly not-yet-wired in its README with an integration smoke test that fails loudly on drift.
[SEVERITY: medium] [effort: 0.5h] design-tokens declared by all sites but barely consumed as a package
Section titled “[SEVERITY: medium] [effort: 0.5h] design-tokens declared by all sites but barely consumed as a package”- Where:
@smart-debt/design-tokensisworkspace:*in all four sites’package.json, but the only importers aresites/template/scripts/generate-color-system.mjsandapps/sd-app/src/design-tokens.d.ts. Brand sites consume tokens as generated CSS/JSON (sites/{site}/src/design-tokens/colors.oklch.json), not via the package API. - What: The dependency is declared four times but provides runtime value to only the color-gen script. This isn’t wrong (declaring it documents intent and enables the generator), but the gap between “declared everywhere” and “imported almost nowhere” obscures how tokens actually flow.
- Why it matters: Low risk, but it muddies the token architecture — a reader can’t tell from the deps whether tokens are a build-time generator input or a runtime import. Combined with the per-site committed
colors.oklch.json, the SSOT chain (brand contract → generator → per-site JSON → CSS) is implicit. - Recommendation: Document the token flow in one place (likely
CLAUDE.md’s Design Tokens section): contract insrc/brand/{brand}/index.ts→generate-color-system.mjs→ per-sitecolors.oklch.json→ Tailwind/CSS. Keep theworkspace:*deps (they’re harmless and correct), but make the generator-vs-runtime distinction explicit.
[SEVERITY: low] [effort: 0h] apps/CLAUDE.md is NOT stray — it is a correct layer-level context file
Section titled “[SEVERITY: low] [effort: 0h] apps/CLAUDE.md is NOT stray — it is a correct layer-level context file”- Where:
apps/CLAUDE.md(40 lines, git-tracked). - What: The review brief flagged this as possibly misplaced. It is not: it documents the
apps/-layer SvelteKit/PWA conventions (adapter-static, shadcn-svelte, Tailwind v3, Paraglide) that apply to every app underapps/, distinct from the app-specificapps/sd-app/CLAUDE.md. This is good layered-context hygiene. - Recommendation: No action. Noting it as healthy so it isn’t “cleaned up” by mistake.
[SEVERITY: low] [effort: 1h] archive/brand-assets-2025-12-22/ still committed in the working tree (prior #9, unresolved)
Section titled “[SEVERITY: low] [effort: 1h] archive/brand-assets-2025-12-22/ still committed in the working tree (prior #9, unresolved)”- Where:
archive/brand-assets-2025-12-22/(tracked:logo-*.svg,README.md, etc.). - What: Old brand assets remain in the repo tree, as flagged in the prior review.
- Recommendation: Move to KB or rely on git history; delete from the working tree. Repo is the SSOT for live assets, not an archive.
[SEVERITY: low] [effort: 1h] .obsidian/ state committed at repo root and docs/.obsidian/ (prior #7, partial)
Section titled “[SEVERITY: low] [effort: 1h] .obsidian/ state committed at repo root and docs/.obsidian/ (prior #7, partial)”- Where: tracked:
.obsidian/workspace.json,.obsidian/app.json,docs/.obsidian/*,sites/template/.gemini/GEMINI.md. - What: Obsidian vault state — including
workspace.json, which mutates on every vault open — is git-tracked at two levels. This guarantees recurring noise commits and treats the code repo partly as an Obsidian vault. - Recommendation: Gitignore
**/.obsidian/workspace.json(and ideally the whole.obsidian/dirs) andgit rm --cachedwhat’s tracked. Decide deliberately which AI-tool config (.gemini/, etc.) should be committed vs ignored.
[SEVERITY: low] [effort: 0.25h] MyBetterRatesEntity.astro is a redundant thin wrapper
Section titled “[SEVERITY: low] [effort: 0.25h] MyBetterRatesEntity.astro is a redundant thin wrapper”- Where:
sites/mbr/src/components/brand/MyBetterRatesEntity.astro(13 lines) → delegates to@monorepo/components/brand/MyBetterRates.astro. - What: A site-local component that just re-exports the canonical brand component with the same
class/entityprops. No added behavior. (Good news: it is NOT a duplicate of the brand markup — the prior brand-placement concern is resolved; the canonicalMyBetterRates.astrois the SSOT and this only forwards to it.) - Why it matters: Minor indirection. Call sites could import the canonical component directly; the wrapper buys nothing and is one more thing to keep in sync if the props change.
- Recommendation: Either drop the wrapper and import
@monorepo/components/brand/MyBetterRates.astrodirectly at the call sites, or keep it only if a brand-local default (presetclass) is intended — in which case give it that default so it earns its existence.
[SEVERITY: low] [effort: 0.1h] Minor astro script drift across sites
Section titled “[SEVERITY: low] [effort: 0.1h] Minor astro script drift across sites”- Where:
sites/sdc/package.jsonuses"astro": "astro"wheretemplate/mbr/tsuse"astro": "pnpm exec astro". - What: sdc’s astro script differs from its siblings. Cosmetic, but config drift.
- Recommendation: Align sdc to
"pnpm exec astro"to match the other three.
Architecturally healthy (no action — noted for honesty)
Section titled “Architecturally healthy (no action — noted for honesty)”- Prior #1 RESOLVED:
packages/componentsis a real workspace package (@monorepo/components,workspace:*in all 4 sites), imported via the package specifier;exports(./ui/*,./layout/*,./brand/*) now match real usage. The phantom-package problem is gone. - Dependency direction is clean: no site imports from another site; sites depend on
packages/*only. Correct one-way flow. - Astro-first / Alpine / Svelte split is honored: 0
client:framework directives and 0.sveltefiles insites/; Alpine (x-data) used for interactivity. Matches the stated 90/10/last-resort policy. (Svelte correctly confined toapps/sd-app.) - Brand CSS SSOT (342db93): shared structural + animation CSS extracted to
packages/components/src/brand/MyBetterRates.css, imported by bothMyBetterRates.astroandfocus.astro— eliminated the prior frozen inline copy in the PWA page. Genuine SSOT win. - Tooltip in the canonical library: new
Tooltiplives atpackages/components/src/ui/Tooltip.astrowith a showcase insites/template— correct placement, not duplicated per-site. - Glossary content SSOT: tooltip definitions read from one source (
Core/Misc/Glossary.md) at build time. (Only the parser is duplicated — see High #1.) - Rates feature scoping: rates logic/data/components are mbr-only and not copy-pasted across sites — correct, since this is a brand-specific feature, not shared infra.
- Astro version alignment:
^5.15.3across all four sites (prior strength sustained). - Prior #4 & #6 RESOLVED: nested
sites/template/sites/artifact gone;packages/sd-mathstalecursor-task-*.mdplanning docs gone. - Prior #10 RESOLVED: all four sites now declare
@smart-debt/design-tokens. - Polyglot boundary respected:
pnpm-workspace.yamlglobspackages/*; Python packages (sd-api,sd-math) coexist via pyproject/uv and are silently skipped by pnpm — works correctly.