Component sourcing — where UI components come from
canonical· frontend · updated 2026-09-05 · source
The contract that keeps ONE implementation per UI primitive family in the monorepo.
Written as the guards+docs deliverable of effort #1283 (José, hard requirement: "debemos tener
guardas y documentación para que esto no vuelva a ocurrir") after the 2026-07-11 five-surface
audit found 5 in-package duplicate pairs, 9 console clones of the admin skin, 4 copies of
ReadError, 3 SearchCommands, and ~19 hand-rolled primitives with a direct shadcn registry
equivalent. Governing decision records:
ADR-012 — app import boundaries ·
ADR-008 — React style guidelines.
The contract
packages/ui(@tedos/ui) owns ONE implementation per primitive family — button, badge, input, select, switch, checkbox, tooltip, alert, dialog, drawer/sheet, table, thedata-table*family, command, search-command, progress, tabs, dropdown-menu, hover-card, card, empty-state, read-error, list-row, page-header, page-container, section-label, segmented, status-dot, pagination.- Apps and skins consume the foundation primitive — directly, or via a thin wrapper under a different name that only injects app specifics (icon set, copy, defaults). A wrapper never re-implements behavior.
- A second implementation of a family — anywhere outside
packages/ui— is a lint error, not a review opinion (see the enforcement map below).
Sourcing flow for a NEW component
- Reuse first. Check the live
@tedos/uiinventory (.claude/rules/foundation-ui-conventions.md— the neutral foundation, shell, chat, and brand lists). If the family exists, consume or thinly wrap it. Extend a primitive by changing it inpackages/ui(every consumer wins), never by forking it into an app. - shadcn registry second (hard rule, José 2026-07-11 —
.claude/rules/shadcn-first-components.md). For a genuinely new primitive, search the shadcn registry before hand-rolling, install via the CLI (npx shadcn@latest add <component>, per-workspacecomponents.json;packages/ui/components.jsonexists), then adapt:- semantic tokens only — no raw hex / arbitrary
-[Npx]left from upstream; - typography snaps to the
type-*roles; - React 19 idioms — no
forwardRef, composition over boolean props, 4 states where it renders data, transform/opacity-only motion; - icons stripped to ReactNode slots / CSS glyph fallbacks (the foundation stays icon-agnostic; HugeIcons live in the admin skin).
- semantic tokens only — no raw hex / arbitrary
- Hand-roll only when the registry has no fit — and say so in the PR body ("shadcn registry checked: no fit because …"). The PR template carries the checklist line.
Placement — the decision tree
From ADR-012 / app-import-boundaries.md; an
app-local primitive (or an app→app / app→admin-skin import) is a smell resolved one of three ways:
| Route | When | Lands in |
|---|---|---|
| PROMOTE | Generic + token-driven UI (values resolve from the consumer's token scope) | packages/ui/src/components/* + barrel/subpath export |
| ADD to core | Shared LOGIC — hook, data seam, view-model, type, derivation | @tedos/core; each app builds its own view shell over it |
| CUSTOMIZE locally | Genuinely app-specific (skin, chrome, one-off screen) — under a name that is NOT a primitive family | the app |
Inventory contract (same-PR, always)
A new @tedos/ui primitive ships in ONE PR with all of:
- the component + its barrel or subpath export;
- an inventory row in
.claude/rules/foundation-ui-conventions.md(the relevant list); - its export names added to the eslint block-5c
importNamesguard when the admin skin must not re-export it (app-import-boundaries.md§ Enforcement); - the shadcn-registry check recorded in the PR body.
Enforcement map — what fails lint, and why
| Guard | Where | What it catches |
|---|---|---|
ds/no-app-primitive (eslint, block 5e) | apps/**/src/components/** | A file whose basename IS a primitive family, or a file importing radix-ui/@radix-ui/*/cva/cmdk that declares a component named after a family (a duplicate hidden behind another filename) |
scripts/component-hygiene.sh (wired into pnpm lint) | any NEW apps/**/packages/** file outside packages/ui/** | A primitive-named file added anywhere else — added vs the origin/develop merge-base, staged, or untracked; fail-open on infra errors; escape KIT_ALLOW_PRIMITIVE=1 |
block 5c/5d no-restricted-imports (eslint) | all apps | app→app imports; generics imported from the admin skin; any @tedos/ui/admin* reach from a non-admin app |
.githooks/pre-push | every push | runs pnpm lint — eslint errors AND the hygiene check block the push |
| PR template checklist | every PR | "New UI primitive? shadcn registry check recorded" |
Allowlist procedure (sanctioned app-singletons, e.g. a paused app's legacy files or a thin
wrapper that must keep the family name): add the path to BOTH the ds/no-app-primitive allow
option in eslint.config.ts and the ALLOW array in scripts/component-hygiene.sh, each with a
one-line justification comment. An allow entry without a justification is a review blocker.