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