docs(spec): core-ui component generator design

2-phase design for `pnpm turbo gen core-ui-component`. Prompts for tier
(atom/molecule/organism) + name (PascalCase) and emits the established
4-file component pattern (component.tsx, component.stories.tsx,
component.test.tsx, index.ts) into the matching `packages/core-ui/src/<tier>s/`
folder. Splices the new export into the tier barrel via `// <gen:<tier>s>`
anchors.

Phase A adds the anchors to the core-ui template's three tier barrels and
regenerates the byte-identical reconstruction snapshot. Phase B ships the
generator, templates, tests, and docs. Refuses to run if core-ui isn't
scaffolded; doesn't touch Storybook config (existing stories glob covers
new component stories).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 08:47:27 +02:00
parent b593bea8ca
commit 5878b8f493

View File

@@ -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/`):
- `<name>/<name>.tsx` — component (forwardRef, cn from `../../lib/utils`, typed Props)
- `<name>/<name>.stories.tsx` — Storybook `Meta` + `StoryObj`, tier-prefixed title
- `<name>/<name>.test.tsx` — Vitest + Testing Library, smoke tests
- `<name>/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**: `<div>` + `HTMLAttributes<HTMLDivElement>` + forwardRef + cn + className passthrough. Developer edits the element, type, and behavior post-scaffold.
6. Splices the new export line into the tier barrel via `// <gen:<tier>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 `// <gen:atoms>` / `// <gen:molecules>` / `// <gen:organisms>` 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
// <gen:atoms>
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
// <gen:molecules>
export { FormField, type FormFieldProps } from "./form-field/index";
```
`turbo/generators/templates/core-package/ui/src/organisms/index.ts.hbs`:
```ts
// <gen:organisms>
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<HTMLDivElement> {}
export const {{pascalCase name}} = forwardRef<HTMLDivElement, {{pascalCase name}}Props>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("", className)} {...props} />
),
);
{{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<typeof {{pascalCase name}}>;
export default meta;
type Story = StoryObj<typeof meta>;
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<HTMLDivElement>();
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, [`// <gen:${tierPlural}>`]);
return `Anchor // <gen:${tierPlural}> 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(`// <gen:${tierPlural}>`),
template: `// <gen:${tierPlural}>\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/<tier>s/spinner/<spinner.tsx | spinner.stories.tsx | spinner.test.tsx | index.ts>`.
- The `modify` action's `path` is `packages/core-ui/src/<tier>s/index.ts` and its `pattern` matches `// <gen:<tier>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 (`<div>` + `HTMLAttributes<HTMLDivElement>`); 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`)