# 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`)