diff --git a/docs/superpowers/specs/2026-05-11-core-ui-component-generator-design.md b/docs/superpowers/specs/2026-05-11-core-ui-component-generator-design.md new file mode 100644 index 0000000..7f12c1a --- /dev/null +++ b/docs/superpowers/specs/2026-05-11-core-ui-component-generator-design.md @@ -0,0 +1,357 @@ +# core-ui component generator — Design + +**Date:** 2026-05-11 +**Status:** Draft (pending user review) +**Companion ADR:** none (small generator addition; spec is sufficient). +**Builds on:** ADR-016-adjacent slimming work that moved core-ui to optional (`pnpm turbo gen core-package ui`). + +--- + +## 1. Context and motivation + +`@repo/core-ui` is the design-system package organized by atomic design — `src/atoms/`, `src/molecules/`, `src/organisms/`, `src/templates/`. After the slim-template work, core-ui ships as a verbatim generator template (`turbo/generators/templates/core-package/ui/`) and is scaffolded back via `pnpm turbo gen core-package ui` when needed. + +Inside core-ui, components follow a well-established 4-file pattern (per `src/atoms/button/`): + +- `/.tsx` — component (forwardRef, cn from `../../lib/utils`, typed Props) +- `/.stories.tsx` — Storybook `Meta` + `StoryObj`, tier-prefixed title +- `/.test.tsx` — Vitest + Testing Library, smoke tests +- `/index.ts` — re-exports component + Props type + +Each tier's barrel (`atoms/index.ts`, etc.) explicitly re-exports each component. The root `src/index.ts` re-exports all four tier barrels wholesale. + +Today, adding a new component means hand-creating 4 files + adding a re-export to the tier barrel. This is mechanical, error-prone (forgetting one file, wrong import path, drifting from the established shape), and slows iteration. A generator removes the friction. + +Feature packages also have their own UI under `feature/src/ui/`. Those are **feature-specific compositions** built on top of core-ui primitives — not subject to atomic-design tiering. This generator targets core-ui exclusively; a future `gen feature-component` generator is out of scope. + +## 2. Decision summary + +1. New `pnpm turbo gen core-ui-component` generator. +2. Two prompts: `tier` (list — atom / molecule / organism) → `name` (PascalCase, validated). +3. Targets live `packages/core-ui/` only; refuses with a clear error if the package isn't scaffolded. +4. Emits 4 files per the established 4-file pattern. +5. Generated component is a **minimal stub**: `
` + `HTMLAttributes` + forwardRef + cn + className passthrough. Developer edits the element, type, and behavior post-scaffold. +6. Splices the new export line into the tier barrel via `// s>` anchors. Anchors must be added to the core-ui template first (Phase A) so freshly scaffolded core-ui packages have them ready. +7. Storybook config is **not** touched by this generator — the stories glob added by `gen core-package ui`'s next-steps already covers new stories. +8. Two-phase delivery: Phase A (template prep + snapshot regen), Phase B (generator + docs). + +## 3. Architecture overview + +| Phase | Ships | Verification | +|---|---|---| +| **A — Template prep** | Add `// ` / `// ` / `// ` anchors to the 3 tier barrels in `turbo/generators/templates/core-package/ui/src/*/index.ts.hbs`. Regenerate `turbo/generators/__snapshots__/core-package/ui.snapshot.json`. | Existing `core-package-ui.e2e.test.ts` byte-identical reconstruction test passes with the updated snapshot. | +| **B — Generator** | New `setGenerator("core-ui-component")` + 4 templates under `turbo/generators/templates/core-ui-component/`. Docs (`docs/scaffolding/core-ui-component-generator.md`, CLAUDE.md Quick Start, AGENTS.md Key Commands). | New unit tests in `config.test.ts` pass. Manual smoke: from a scaffolded core-ui, generate a component in each tier and verify `pnpm --filter @repo/core-ui lint typecheck test` is green. | + +## 4. Phase A — Template prep + +### 4.1 Anchor placement + +Three tier barrel templates get an anchor comment placed BEFORE the existing exports. This matches the established splice-after-anchor pattern in `event`, `realtime`, and `feature` generators — new exports are inserted immediately after the anchor (i.e., at the top of the tier barrel's export list). + +`turbo/generators/templates/core-package/ui/src/atoms/index.ts.hbs`: + +```ts +// +export { Button, type ButtonProps } from "./button/index"; +export { Input, type InputProps } from "./input/index"; +export { Label, type LabelProps } from "./label/index"; +``` + +`turbo/generators/templates/core-package/ui/src/molecules/index.ts.hbs`: + +```ts +// +export { FormField, type FormFieldProps } from "./form-field/index"; +``` + +`turbo/generators/templates/core-package/ui/src/organisms/index.ts.hbs`: + +```ts +// +export {}; +``` + +### 4.2 Snapshot regen + +The byte-identical e2e test compares the scaffolded core-ui against `turbo/generators/__snapshots__/core-package/ui.snapshot.json`. Adding the anchors changes file contents, so the snapshot must be regenerated. + +Regen recipe (one-shot bash): + +```bash +TMP=$(mktemp -d) +git clone . "$TMP" --shared +cd "$TMP" +pnpm install +pnpm turbo gen core-package --args ui +pnpm exec tsx -e ' + import { computeSnapshot } from "./turbo/generators/lib/snapshot.js"; + import { writeFileSync } from "node:fs"; + writeFileSync( + "./snapshot.tmp.json", + JSON.stringify(computeSnapshot("./packages/core-ui"), null, 2) + "\n", + ); +' +cp snapshot.tmp.json ../template-vertical/turbo/generators/__snapshots__/core-package/ui.snapshot.json +``` + +(The plan will encode this in a more polished form.) + +## 5. Phase B — Generator + +### 5.1 Generator template files + +Four `.hbs` files under `turbo/generators/templates/core-ui-component/`: + +**`component.tsx.hbs`:** + +```tsx +import { forwardRef, type HTMLAttributes } from "react"; +import { cn } from "../../lib/utils"; + +export interface {{pascalCase name}}Props extends HTMLAttributes {} + +export const {{pascalCase name}} = forwardRef( + ({ className, ...props }, ref) => ( +
+ ), +); +{{pascalCase name}}.displayName = "{{pascalCase name}}"; +``` + +**`component.stories.tsx.hbs`:** + +```tsx +import type { Meta, StoryObj } from "@storybook/react"; +import { {{pascalCase name}} } from "./{{kebabCase name}}"; + +const meta = { + title: "{{tierTitle}}/{{pascalCase name}}", + component: {{pascalCase name}}, + tags: ["autodocs"], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { args: {} }; +``` + +`tierTitle` is computed in the action layer (`"atom"` → `"Atoms"`). + +**`component.test.tsx.hbs`:** + +```tsx +import { describe, it, expect } from "vitest"; +import { createRef } from "react"; +import { renderWithProviders } from "@repo/core-testing/react"; +import { screen } from "@testing-library/react"; +import { {{pascalCase name}} } from "./{{kebabCase name}}"; + +describe("{{pascalCase name}}", () => { + it("renders without crashing", () => { + renderWithProviders(<{{pascalCase name}} data-testid="root" />); + expect(screen.getByTestId("root")).toBeInTheDocument(); + }); + + it("applies a passed className alongside its own", () => { + renderWithProviders(<{{pascalCase name}} data-testid="root" className="custom" />); + expect(screen.getByTestId("root")).toHaveClass("custom"); + }); + + it("forwards ref to the underlying element", () => { + const ref = createRef(); + renderWithProviders(<{{pascalCase name}} ref={ref} />); + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); +}); +``` + +**`index.ts.hbs`:** + +```ts +export { {{pascalCase name}}, type {{pascalCase name}}Props } from "./{{kebabCase name}}"; +``` + +### 5.2 Generator registration + +In `turbo/generators/config.ts`, alongside the existing `setGenerator` blocks: + +```ts +plop.setGenerator("core-ui-component", { + description: "Scaffold a core-ui atomic-design component (atom / molecule / organism)", + prompts: [ + { + type: "list", + name: "tier", + message: "Tier:", + choices: ["atom", "molecule", "organism"], + }, + { + type: "input", + name: "name", + message: "Component name (PascalCase, e.g. Spinner):", + validate: (input: string) => { + if (!input) return "Required"; + if (!/^[A-Z][A-Za-z0-9]*$/.test(input)) { + return "Must be PascalCase (e.g. Spinner, IconButton)"; + } + return true; + }, + }, + ], + actions: (answers) => { + const a = answers as { tier: "atom" | "molecule" | "organism"; name: string }; + return coreUiComponentActions(a); + }, +}); +``` + +### 5.3 Action sequence (`coreUiComponentActions`) + +```ts +function coreUiComponentActions(a: { + tier: "atom" | "molecule" | "organism"; + name: string; +}): PlopTypes.ActionType[] { + const tierPlural = `${a.tier}s`; + const tierTitle = tierPlural[0]!.toUpperCase() + tierPlural.slice(1); + const tierBarrel = `packages/core-ui/src/${tierPlural}/index.ts`; + const componentDir = `packages/core-ui/src/${tierPlural}/{{kebabCase name}}`; + + return [ + // 1. Guard: core-ui must be scaffolded + () => { + const pkgRoot = join(process.cwd(), "packages", "core-ui"); + if (!existsSync(pkgRoot)) { + throw new Error( + `packages/core-ui/ does not exist. Run \`pnpm turbo gen core-package ui\` first.`, + ); + } + return "Guard passed — packages/core-ui exists."; + }, + + // 2. Guard: tier barrel must have its anchor + () => { + assertAnchors(process.cwd(), tierBarrel, [`// `]); + return `Anchor // present in ${tierBarrel}.`; + }, + + // 3. Emit 4 component files + { + type: "add", + path: `${componentDir}/{{kebabCase name}}.tsx`, + templateFile: "templates/core-ui-component/component.tsx.hbs", + data: { name: a.name }, + }, + { + type: "add", + path: `${componentDir}/{{kebabCase name}}.stories.tsx`, + templateFile: "templates/core-ui-component/component.stories.tsx.hbs", + data: { name: a.name, tierTitle }, + }, + { + type: "add", + path: `${componentDir}/{{kebabCase name}}.test.tsx`, + templateFile: "templates/core-ui-component/component.test.tsx.hbs", + data: { name: a.name }, + }, + { + type: "add", + path: `${componentDir}/index.ts`, + templateFile: "templates/core-ui-component/index.ts.hbs", + data: { name: a.name }, + }, + + // 4. Splice export into tier barrel. + // Note: the `template` string is itself Handlebars-rendered by plop, + // so {{pascalCase name}} and {{kebabCase name}} are resolved at apply- + // time. This matches the pattern used by the existing `realtime` and + // `event` generators (no kebabCase JS helper exists in config.ts; + // pascalCase + camel + constantCase do). + { + type: "modify", + path: tierBarrel, + pattern: new RegExp(`// `), + template: `// \nexport { {{pascalCase name}}, type {{pascalCase name}}Props } from "./{{kebabCase name}}/index";`, + }, + + // 5. Print next-steps + () => printCoreUiComponentNextSteps(a), + ]; +} +``` + +### 5.4 Print next-steps + +```ts +function printCoreUiComponentNextSteps(a: { + tier: "atom" | "molecule" | "organism"; + name: string; +}): string { + const tierPlural = `${a.tier}s`; + const kebab = kebabCase(a.name); + return [ + "─────────────────────────────────────────────────────────────", + `${pascalCase(a.name)} scaffolded into packages/core-ui/src/${tierPlural}/${kebab}/.`, + "", + "Next steps (manual):", + ` 1. Implement the component body in ${kebab}.tsx (currently a div`, + ` passthrough — change the element/type to fit, add variants/sizes`, + ` if needed; see button.tsx for the canonical rich pattern).`, + ` 2. Flesh out the Default story in ${kebab}.stories.tsx; add variant`, + ` stories if your component takes a variant prop.`, + ` 3. Tighten the tests in ${kebab}.test.tsx beyond the smoke-test`, + ` baseline.`, + "", + "Verify:", + " pnpm --filter @repo/core-ui lint typecheck test", + " pnpm dev --filter @repo/storybook # view in Storybook", + "─────────────────────────────────────────────────────────────", + ].join("\n"); +} +``` + +## 6. Testing strategy + +Three layers: + +1. **Unit tests in `turbo/generators/config.test.ts`** — for each tier, build the action array via `coreUiComponentActions({ tier, name: "Spinner" })` and assert: + - 8 entries total: 2 guard functions (core-ui-exists + anchor-present), 4 `add` actions (one per emitted file), 1 `modify` action (tier-barrel splice), 1 print function. + - The 4 `add` actions write to `packages/core-ui/src/s/spinner/`. + - The `modify` action's `path` is `packages/core-ui/src/s/index.ts` and its `pattern` matches `// s>`. + - PascalCase validator rejects `spinner`, `123Foo`, `Foo-Bar`; accepts `Spinner`, `IconButton`. + - Assert via the action objects' shape and the `type` discriminator rather than total array length (plop allows mixing `ActionType` shapes; counting by type is more robust). + +2. **Manual smoke test** (documented in spec, run by the implementer): + - From a freshly scaffolded core-ui, run `pnpm turbo gen core-ui-component` for each tier with a unique name (e.g. `SpinnerAtom`, `MenuItemMolecule`, `HeaderOrganism`). + - Verify the 4 expected files exist in the correct path. + - Verify the tier barrel now contains the new export immediately after the anchor. + - `pnpm --filter @repo/core-ui lint typecheck test` — all green. + - Optionally open Storybook and confirm the new component shows up under the right tier section. + +3. **Snapshot guard** — the existing `core-package-ui.e2e.test.ts` continues to pass after Phase A's snapshot regen. No new e2e test is added for `core-ui-component`; the unit tests + manual smoke cover the generator's correctness. + +## 7. Documentation + +Phase B ships these doc updates: + +- **New file**: `docs/scaffolding/core-ui-component-generator.md` — usage, prompt walkthrough, file layout produced, link to `button.tsx` as the canonical rich-pattern reference. +- **`CLAUDE.md` Quick Start**: add `pnpm turbo gen core-ui-component # Scaffold a core-ui atomic-design component (see docs/scaffolding/core-ui-component-generator.md)` after the existing generator entries. +- **`AGENTS.md` Key Commands**: add the new generator to whatever section enumerates available generators. +- **`turbo/generators/templates/core-package/ui/AGENTS.md`** (if it has agent-facing content about how to add components) — note that `pnpm turbo gen core-ui-component` is the canonical path. + +## 8. Out of scope + +- **`gen feature-component` generator** for feature-package UI (`feature/src/ui/`). Separate, future generator. +- **Targeting the `templates/` tier folder** in core-ui. Stays unused by this generator. +- **Variants / sizes / HTML-element prompts.** Generated component is minimal (`
` + `HTMLAttributes`); developer edits as needed. The existing `button.tsx` serves as a copy-paste reference for richer components. +- **Auto-updating `apps/storybook/.storybook/main.ts`.** The stories glob added by `gen core-package ui`'s next-steps already covers new component stories. +- **End-to-end test that runs the new generator into a tmp workspace.** Unit tests + manual smoke are sufficient; adding a per-generator e2e would balloon CI runtime without proportional value. +- **Removing the `templates/` tier folder** from the core-ui template. Stays as-is (empty `index.ts`). + +## 9. Related + +- `docs/scaffolding/core-package-generator.md` — the parent scaffold workflow (`gen core-package ui` must run before this generator) +- `turbo/generators/templates/core-package/ui/src/atoms/button/` — canonical 4-file component pattern +- `turbo/generators/config.ts` — existing `setGenerator` patterns (event, realtime, job, feature, core-package) that this generator mirrors +- ADR-016-adjacent: core-ui slimming work (commit `e9d0356`)