From 905d4f9fb71a6d3adb1a659d735397c730c210af Mon Sep 17 00:00:00 2001 From: YaelAnaya Date: Sat, 5 Sep 2026 19:53:56 -0700 Subject: [PATCH 01/21] Add the 0.2.0 restructure design and implementation plan The package was built to guard the fidelity of what the shadcn CLI wrote, which cost 2,191 lines of snapshots to protect 24 lines of real difference. What was actually needed was the ability to install components with components.json. These two documents record the audit, the design that follows from it, and the task-by-task plan. --- .../plans/2026-09-05-ui-core-restructure.md | 1266 +++++++++++++++++ .../2026-09-05-ui-core-restructure-design.md | 479 +++++++ 2 files changed, 1745 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-05-ui-core-restructure.md create mode 100644 docs/superpowers/specs/2026-09-05-ui-core-restructure-design.md diff --git a/docs/superpowers/plans/2026-09-05-ui-core-restructure.md b/docs/superpowers/plans/2026-09-05-ui-core-restructure.md new file mode 100644 index 0000000..e518d25 --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-ui-core-restructure.md @@ -0,0 +1,1266 @@ +# Rediseño estructural de `@robomous/ui-core` 0.2.0 — Plan de implementación + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Retirar el aparato de fidelidad con shadcn, dejar los componentes en propiedad plena, y reestructurar el paquete alrededor de lo que es en vez de su origen. + +**Architecture:** Cuatro fases. Primero se borra lo muerto (snapshots, scripts, gate de canonicidad). Luego cada uno de los cuatro helpers de `src/lib/` se disuelve dentro de su componente por TDD, lo que vacía la carpeta. Después se poda `gates/` a sus ocho supervivientes, se reordenan las carpetas y los gates pasan a TypeScript bajo un solo runner. Al final, documentación, publicación y los dos PR de consumidores. + +**Tech Stack:** React 19, TypeScript, Tailwind v4, Radix + Base UI, vitest + Testing Library, pnpm 11, Node por `.nvmrc`. + +**Spec:** `docs/superpowers/specs/2026-09-05-ui-core-restructure-design.md` + +## Global Constraints + +- **Versión objetivo:** `0.2.0`. No `1.0.0`. +- **Estilo de mensajes de commit:** el del repositorio — modo imperativo, mayúscula inicial, sin prefijos tipo `feat:`/`chore:`. Ejemplos reales: *"Bump GitHub Actions off the deprecated Node 20 runtime"*, *"Paint menu, select and combobox surfaces on the page's own palette"*. +- **Idioma del código y la documentación pública:** inglés. `README.md`, `DESIGN.md`, comentarios y mensajes de commit en inglés. Solo el spec y este plan están en español. +- **Terminología prohibida** en código, comentarios y documentación a partir de la Tarea 10: "canonical", "snapshot", "primitive" (como capa), "Nova", "shadcn foundation", "SHADCN DEVIATION", y toda referencia al preset por su código (`b2iH`). +- **El especificador público de la hoja de estilos no cambia:** los consumidores siguen escribiendo `@import "@robomous/ui-core/styles.css"`. Solo cambia su ruta interna. +- **`components.json` no se modifica** salvo el alias `ui` en la Tarea 8. +- **Verificación por tarea:** `pnpm lint && pnpm build && pnpm test` en verde antes de cada commit. Hasta la Tarea 9 hay además `pnpm test:gates`. +- Los tests corren con `globals: false`: cada archivo de test importa explícitamente `describe`, `it`/`test` y `expect`. + +--- + +## Mapa de archivos + +| Archivo | Responsabilidad tras el cambio | +| --- | --- | +| `src/index.ts` | Superficie pública, listada explícitamente | +| `src/components/*.tsx` | 21 componentes de dueño pleno (antes `src/primitives/`) | +| `src/theme/styles.css` | El contrato Tailwind: `@theme`, `:root`, `.dark` | +| `src/theme/tokens.ts` | Espejo de la hoja para quien no puede leer CSS | +| `src/theme/statusTone.ts` | Vocabulario de status fuera del Badge | +| `src/gates/index.ts` | Ocho helpers de escaneo, en TypeScript | +| `src/gates/design.test.ts` | Reglas de diseño sobre el árbol propio | +| `src/gates/tokens.test.ts` | Un solo hogar para los tokens; `components.json` acotado | +| *(borrados)* | `shadcn/`, `scripts/`, `gates/` (raíz), `src/lib/` | + +--- + +## Task 1: Retirar el aparato de fidelidad + +Borra los snapshots, los scripts que los alimentaban, el gate de canonicidad y los tests que anclaban la API de un componente a la de upstream. También saca el CLI de shadcn de las dependencias de runtime. + +**Files:** +- Delete: `shadcn/` (21 `.tsx` + `README.md`), `scripts/shadcn_add.sh`, `scripts/shadcn_relativize.mjs`, `gates/canonical.test.mjs` +- Modify: `gates/index.mjs`, `gates/index.d.mts`, `gates/extensions.test.mjs`, `package.json` + +**Interfaces:** +- Consumes: nada. +- Produces: `gates/index.mjs` sin los símbolos de fidelidad. Las tareas 5 y 7 siguen podando el mismo archivo. + +- [ ] **Step 1: Verificar el punto de partida** + +```bash +pnpm install +pnpm lint && pnpm build && pnpm test && pnpm test:gates +``` + +Esperado: todo en verde. Si algo falla aquí, detenerse y reportar — el plan asume `main` limpio. + +- [ ] **Step 2: Borrar snapshots, scripts y el gate de canonicidad** + +```bash +git rm -r shadcn scripts gates/canonical.test.mjs +``` + +- [ ] **Step 3: Quitar de `gates/index.mjs` los símbolos de canonicidad** + +Borrar estas seis definiciones exportadas y sus bloques de comentario: +`additiveOnly`, `FRAMEWORK_ADAPTERS`, `ADAPTER_REMOVED_LINES`, `withoutLines`, `checkAdapter`, `snapshotsDir`. + +`snapshotsDir` es la única que usa `PKG` para apuntar a `shadcn/`; `foundationTokenNames` sigue usando `PKG` y **no se toca en esta tarea**. Si tras borrar `snapshotsDir` el import de `path` o `readFileSync` queda sin uso, el lint lo señalará: quitarlo. + +- [ ] **Step 4: Quitar de `gates/index.mjs` los símbolos que anclan APIs a upstream** + +Borrar: `variantKeys`, `variantClasses`, `OFFICIAL_BADGE`, `BUTTON_VARIANTS`, `BUTTON_SIZES`. + +Dejar `FOUNDATION_BADGE` por ahora — la Tarea 7 decide su destino final. + +- [ ] **Step 5: Borrar de `gates/extensions.test.mjs` los tests de fidelidad** + +Borrar exactamente estos tres, por su nombre: +- `variantKeys reads quoted and bare keys and ignores class text` +- `Button carries shadcn's variants and sizes, and nothing else` +- `index.ts exports every canonical primitive, drops the retired pattern Combobox, and adds no *Variants beyond shadcn's own three` + +Y reescribir este, que mezcla una regla propia con una de fidelidad: +- `Badge keeps shadcn's variants and adds exactly the four foundation status variants` + +Sustituirlo por una aserción que solo afirme lo propio — que el vocabulario de status es exactamente esos cuatro nombres: + +```js +test("Badge's status vocabulary is exactly the four owned names", () => { + const source = read("src/primitives/badge.tsx"); + for (const name of FOUNDATION_BADGE) { + assert.ok(source.includes(`${name}:`), `badge.tsx is missing the ${name} variant`); + } +}); +``` + +`read(rel)` ya existe en la cabecera del archivo; no redefinirlo. Ajustar también el bloque de import desde `./index.mjs`, quitando los símbolos borrados en los pasos 3 y 4. + +Conservar intactos los demás tests del archivo. + +- [ ] **Step 6: Actualizar `gates/index.d.mts`** + +Borrar las declaraciones de los once símbolos eliminados en los pasos 3 y 4. + +- [ ] **Step 7: Limpiar `package.json`** + +Tres cambios: +1. En `dependencies`, borrar `"shadcn": "^4.19.0"`. Es el CLI; no se importa desde `src/` y hoy entra al árbol de producción de cada consumidor. Se obtiene bajo demanda con `pnpm dlx`, así que tampoco va a `devDependencies`. +2. En `scripts`, borrar `"shadcn:add": "bash scripts/shadcn_add.sh"`. +3. En `files`, borrar `"shadcn"`. El array queda `["dist", "src", "gates", "components.json"]`. + +- [ ] **Step 8: Reinstalar y verificar** + +```bash +pnpm install +pnpm lint && pnpm build && pnpm test && pnpm test:gates +``` + +Esperado: verde. `pnpm test:gates` ya no encuentra `canonical.test.mjs` y corre solo `extensions` y `tokens`. + +- [ ] **Step 9: Confirmar que el CLI sigue funcionando sin el paquete instalado** + +```bash +pnpm dlx shadcn@latest add --help +``` + +Esperado: el CLI responde. Esto valida que quitarlo de `dependencies` no rompe el flujo de instalación de componentes. + +- [ ] **Step 10: Commit** + +```bash +git add -A +git commit -m "Retire the upstream-fidelity apparatus + +The snapshots under shadcn/ duplicated src/primitives/ almost byte for +byte: 19 of 21 components differed by a single import line. What they +guarded was a fidelity this package no longer wants, since the +components are going to be restyled. + +Also drops the shadcn CLI from dependencies, where it was never +imported and reached every consumer's production tree." +``` + +--- + +## Task 2: `Progress` reporta su valor sin ayuda del llamador + +`src/primitives/progress.tsx` desestructura `value` fuera de `props` para calcular el `translateX` y nunca se lo pasa a `ProgressPrimitive.Root`, que es quien deriva `aria-valuenow`. `progressAria` obligaba a cada llamador a repetir el valor, y ningún gate detectaba a quien lo olvidara. + +**Files:** +- Modify: `src/primitives/progress.tsx:6-19`, `src/primitives/primitives.test.tsx:325-334`, `src/index.ts:25` +- Delete: `src/lib/progress.ts` + +**Interfaces:** +- Consumes: nada. +- Produces: `` emite `aria-valuenow`. `progressAria` deja de existir. + +- [ ] **Step 1: Escribir el test que falla** + +En `src/primitives/primitives.test.tsx`, reemplazar el test `reports its value to assistive technology, not only as a width` por: + +```tsx +it("reports its value to assistive technology without help from the caller", () => { + render(); + const bar = screen.getByRole("progressbar", { name: "Ingest" }); + expect(bar.getAttribute("aria-valuenow")).toBe("42"); + expect(bar.getAttribute("data-state")).not.toBe("indeterminate"); +}); +``` + +Y quitar `import { progressAria } from "../lib/progress";` de la cabecera del archivo. + +- [ ] **Step 2: Correr el test para verificar que falla** + +```bash +pnpm vitest run src/primitives/primitives.test.tsx -t "without help from the caller" +``` + +Esperado: FALLA. `aria-valuenow` es `null` porque `Root` nunca recibe `value`. + +- [ ] **Step 3: Pasarle `value` a `Root`** + +En `src/primitives/progress.tsx`, añadir `value={value}` al `ProgressPrimitive.Root`: + +```tsx + +``` + +`value` sigue desestructurado porque el `translateX` del indicador lo necesita; lo que cambia es que ya no se retiene. + +- [ ] **Step 4: Correr el test para verificar que pasa** + +```bash +pnpm vitest run src/primitives/primitives.test.tsx -t "without help from the caller" +``` + +Esperado: PASA. + +- [ ] **Step 5: Eliminar el helper** + +```bash +git rm src/lib/progress.ts +``` + +Y borrar de `src/index.ts` la línea 25: + +```ts +export { progressAria } from "./lib/progress.js"; +``` + +- [ ] **Step 6: Verificar** + +```bash +pnpm lint && pnpm build && pnpm test && pnpm test:gates +``` + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "Let Progress report its own value + +Progress read value only to size the indicator and never forwarded it +to Radix's Root, which is what derives aria-valuenow. progressAria made +every caller repeat the number, and nothing caught the ones that +forgot — opt-in accessibility." +``` + +--- + +## Task 3: `Button` gana `size="inline"` + +`inlineLink` (`"h-auto p-0"`) existía porque no se podía añadir un tamaño al componente. Ahora sí. + +**Files:** +- Modify: `src/primitives/button.tsx:23-35`, `src/primitives/primitives.test.tsx`, `src/index.ts:21` +- Delete: `src/lib/button.ts` + +**Interfaces:** +- Consumes: nada. +- Produces: `); + const button = screen.getByRole("button", { name: "Read the docs" }); + expect(button.getAttribute("data-size")).toBe("inline"); + expect(button.className).toContain("h-auto"); + expect(button.className).toContain("p-0"); +}); +``` + +- [ ] **Step 2: Correr el test para verificar que falla** + +```bash +pnpm vitest run src/primitives/primitives.test.tsx -t "inline size" +``` + +Esperado: FALLA. TypeScript rechaza `size="inline"` y las clases no aparecen. + +- [ ] **Step 3: Añadir la variante de tamaño** + +En `src/primitives/button.tsx`, dentro del bloque `size` de `buttonVariants` (tras `"icon-lg"`, línea 34), añadir: + +```ts + inline: "h-auto p-0", +``` + +El comentario que justifica la variante va sobre la línea: + +```ts + // A link button is inline prose, not a boxed control: no height of its + // own and no padding, so it sits inside a sentence or a table cell. + inline: "h-auto p-0", +``` + +- [ ] **Step 4: Correr el test para verificar que pasa** + +```bash +pnpm vitest run src/primitives/primitives.test.tsx -t "inline size" +``` + +Esperado: PASA. + +- [ ] **Step 5: Migrar el test existente que usaba el helper** + +`src/primitives/primitives.test.tsx:100` renderiza hoy ` - - )} - - - ) -} - -function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function DialogFooter({ - className, - showCloseButton = false, - children, - ...props -}: React.ComponentProps<"div"> & { - showCloseButton?: boolean -}) { - return ( -
- {children} - {showCloseButton && ( - - - - )} -
- ) -} - -function DialogTitle({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function DialogDescription({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -export { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogOverlay, - DialogPortal, - DialogTitle, - DialogTrigger, -} diff --git a/shadcn/dropdown-menu.tsx b/shadcn/dropdown-menu.tsx deleted file mode 100644 index 006e74f..0000000 --- a/shadcn/dropdown-menu.tsx +++ /dev/null @@ -1,267 +0,0 @@ -import * as React from "react" -import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" - -import { cn } from "@/lib/cn" -import { CheckIcon, ChevronRightIcon } from "lucide-react" - -function DropdownMenu({ - ...props -}: React.ComponentProps) { - return -} - -function DropdownMenuPortal({ - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function DropdownMenuTrigger({ - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function DropdownMenuContent({ - className, - align = "start", - sideOffset = 4, - ...props -}: React.ComponentProps) { - return ( - - - - ) -} - -function DropdownMenuGroup({ - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function DropdownMenuItem({ - className, - inset, - variant = "default", - ...props -}: React.ComponentProps & { - inset?: boolean - variant?: "default" | "destructive" -}) { - return ( - - ) -} - -function DropdownMenuCheckboxItem({ - className, - children, - checked, - inset, - ...props -}: React.ComponentProps & { - inset?: boolean -}) { - return ( - - - - - - - {children} - - ) -} - -function DropdownMenuRadioGroup({ - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function DropdownMenuRadioItem({ - className, - children, - inset, - ...props -}: React.ComponentProps & { - inset?: boolean -}) { - return ( - - - - - - - {children} - - ) -} - -function DropdownMenuLabel({ - className, - inset, - ...props -}: React.ComponentProps & { - inset?: boolean -}) { - return ( - - ) -} - -function DropdownMenuSeparator({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function DropdownMenuShortcut({ - className, - ...props -}: React.ComponentProps<"span">) { - return ( - - ) -} - -function DropdownMenuSub({ - ...props -}: React.ComponentProps) { - return -} - -function DropdownMenuSubTrigger({ - className, - inset, - children, - ...props -}: React.ComponentProps & { - inset?: boolean -}) { - return ( - - {children} - - - ) -} - -function DropdownMenuSubContent({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -export { - DropdownMenu, - DropdownMenuPortal, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuLabel, - DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuSub, - DropdownMenuSubTrigger, - DropdownMenuSubContent, -} diff --git a/shadcn/field.tsx b/shadcn/field.tsx deleted file mode 100644 index af7e444..0000000 --- a/shadcn/field.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { useMemo } from "react" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/cn" -import { Label } from "@/primitives/label" -import { Separator } from "@/primitives/separator" - -function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { - return ( -
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", - className - )} - {...props} - /> - ) -} - -function FieldLegend({ - className, - variant = "legend", - ...props -}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { - return ( - - ) -} - -function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -const fieldVariants = cva( - "group/field flex w-full gap-2 data-[invalid=true]:text-destructive", - { - variants: { - orientation: { - vertical: "flex-col *:w-full [&>.sr-only]:w-auto", - horizontal: - "flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", - responsive: - "flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", - }, - }, - defaultVariants: { - orientation: "vertical", - }, - } -) - -function Field({ - className, - orientation = "vertical", - ...props -}: React.ComponentProps<"div"> & VariantProps) { - return ( -
- ) -} - -function FieldContent({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function FieldLabel({ - className, - ...props -}: React.ComponentProps) { - return ( -