Files
agentic-dev/docs/superpowers/specs/2026-05-11-core-ui-component-generator-design.md
Danijel Martinek 5878b8f493 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>
2026-05-11 08:47:27 +02:00

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 — 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:

// <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:

  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).
  • 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)