Skip to main content

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, the data-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

  1. Reuse first. Check the live @tedos/ui inventory (.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 in packages/ui (every consumer wins), never by forking it into an app.
  2. 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-workspace components.json; packages/ui/components.json exists), 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).
  3. 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:

RouteWhenLands in
PROMOTEGeneric + token-driven UI (values resolve from the consumer's token scope)packages/ui/src/components/* + barrel/subpath export
ADD to coreShared LOGIC — hook, data seam, view-model, type, derivation@tedos/core; each app builds its own view shell over it
CUSTOMIZE locallyGenuinely app-specific (skin, chrome, one-off screen) — under a name that is NOT a primitive familythe 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 importNames guard 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

GuardWhereWhat 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 appsapp→app imports; generics imported from the admin skin; any @tedos/ui/admin* reach from a non-admin app
.githooks/pre-pushevery pushruns pnpm lint — eslint errors AND the hygiene check block the push
PR template checklistevery 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.