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:
@@ -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`)
|
||||
Reference in New Issue
Block a user