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>
16 KiB
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— StorybookMeta+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
- New
pnpm turbo gen core-ui-componentgenerator. - Two prompts:
tier(list — atom / molecule / organism) →name(PascalCase, validated). - Targets live
packages/core-ui/only; refuses with a clear error if the package isn't scaffolded. - Emits 4 files per the established 4-file pattern.
- Generated component is a minimal stub:
<div>+HTMLAttributes<HTMLDivElement>+ forwardRef + cn + className passthrough. Developer edits the element, type, and behavior post-scaffold. - 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. - Storybook config is not touched by this generator — the stories glob added by
gen core-package ui's next-steps already covers new stories. - 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:
// <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:
// <gen:molecules>
export { FormField, type FormFieldProps } from "./form-field/index";
turbo/generators/templates/core-package/ui/src/organisms/index.ts.hbs:
// <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):
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:
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:
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:
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:
export { {{pascalCase name}}, type {{pascalCase name}}Props } from "./{{kebabCase name}}";
5.2 Generator registration
In turbo/generators/config.ts, alongside the existing setGenerator blocks:
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)
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
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:
-
Unit tests in
turbo/generators/config.test.ts— for each tier, build the action array viacoreUiComponentActions({ tier, name: "Spinner" })and assert:- 8 entries total: 2 guard functions (core-ui-exists + anchor-present), 4
addactions (one per emitted file), 1modifyaction (tier-barrel splice), 1 print function. - The 4
addactions write topackages/core-ui/src/<tier>s/spinner/<spinner.tsx | spinner.stories.tsx | spinner.test.tsx | index.ts>. - The
modifyaction'spathispackages/core-ui/src/<tier>s/index.tsand itspatternmatches// <gen:<tier>s>. - PascalCase validator rejects
spinner,123Foo,Foo-Bar; acceptsSpinner,IconButton. - Assert via the action objects' shape and the
typediscriminator rather than total array length (plop allows mixingActionTypeshapes; counting by type is more robust).
- 8 entries total: 2 guard functions (core-ui-exists + anchor-present), 4
-
Manual smoke test (documented in spec, run by the implementer):
- From a freshly scaffolded core-ui, run
pnpm turbo gen core-ui-componentfor 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.
- From a freshly scaffolded core-ui, run
-
Snapshot guard — the existing
core-package-ui.e2e.test.tscontinues to pass after Phase A's snapshot regen. No new e2e test is added forcore-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 tobutton.tsxas the canonical rich-pattern reference. CLAUDE.mdQuick Start: addpnpm 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.mdKey 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 thatpnpm turbo gen core-ui-componentis the canonical path.
8. Out of scope
gen feature-componentgenerator 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 existingbutton.tsxserves as a copy-paste reference for richer components. - Auto-updating
apps/storybook/.storybook/main.ts. The stories glob added bygen 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 (emptyindex.ts).
9. Related
docs/scaffolding/core-package-generator.md— the parent scaffold workflow (gen core-package uimust run before this generator)turbo/generators/templates/core-package/ui/src/atoms/button/— canonical 4-file component patternturbo/generators/config.ts— existingsetGeneratorpatterns (event, realtime, job, feature, core-package) that this generator mirrors- ADR-016-adjacent: core-ui slimming work (commit
e9d0356)