diff --git a/AGENTS.md b/AGENTS.md index cf85d94..f860fbe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,6 +95,7 @@ pnpm lint # Lint all packages (ESLint boundaries enforced) pnpm turbo boundaries # Validate workspace dependency graph (Turbo boundaries) pnpm turbo gen feature # Scaffold a new Lazar-conformant feature package (see docs/guides/scaffolding-a-feature.md) pnpm turbo gen core-package # Scaffold an optional core package back (realtime, events, trpc, ui — see docs/scaffolding/core-package-generator.md) +pnpm turbo gen core-ui-component # Scaffold a core-ui atomic-design component (atom/molecule/organism — see docs/scaffolding/core-ui-component-generator.md) pnpm test # Run all unit + integration tests (Vitest) pnpm test:e2e # Run e2e tests (Playwright across both apps) pnpm build # Build all packages (Turborepo) diff --git a/CLAUDE.md b/CLAUDE.md index 10d64d9..3a653dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,7 @@ pnpm turbo gen event # Scaffold an event contract (publish) or handler (consum pnpm turbo gen job # Scaffold a background job pnpm turbo gen realtime # Scaffold a realtime channel or inbound handler pnpm turbo gen core-package # Scaffold an optional core package (see docs/scaffolding/core-package-generator.md) +pnpm turbo gen core-ui-component # Scaffold a core-ui atomic-design component (see docs/scaffolding/core-ui-component-generator.md) docker compose up -d # Start PostgreSQL ``` diff --git a/docs/scaffolding/core-ui-component-generator.md b/docs/scaffolding/core-ui-component-generator.md new file mode 100644 index 0000000..74e209a --- /dev/null +++ b/docs/scaffolding/core-ui-component-generator.md @@ -0,0 +1,62 @@ +# core-ui component generator + +`pnpm turbo gen core-ui-component` scaffolds an atomic-design component (atom / molecule / organism) into `packages/core-ui/` using the established 4-file pattern. + +**Prerequisite:** `packages/core-ui/` must exist. If your project started from the slim template, scaffold core-ui first via `pnpm turbo gen core-package ui`. + +## Usage + +```bash +pnpm turbo gen core-ui-component +# → Tier: (use arrow keys) +# ❯ atom +# molecule +# organism +# → Component name (PascalCase, e.g. Spinner): +# › Spinner +``` + +The generator emits 4 files into `packages/core-ui/src/s//`: + +| File | Purpose | +|---|---| +| `.tsx` | Component implementation (forwardRef + cn + className passthrough) | +| `.stories.tsx` | Storybook stories (Meta + StoryObj + one Default story; tier-prefixed title) | +| `.test.tsx` | Vitest + Testing Library smoke tests (renders, className passthrough, ref forwarding) | +| `index.ts` | Barrel that re-exports the component + Props type | + +The new export is also spliced into `packages/core-ui/src/s/index.ts` immediately after the `// s>` anchor, so the component is reachable from the tier barrel (and transitively from the root `@repo/core-ui` export) without any manual wiring. + +## Generated scaffold (example: `pnpm turbo gen core-ui-component` → atom → Spinner) + +``` +packages/core-ui/src/atoms/spinner/ +├── spinner.tsx # forwardRef +├── spinner.stories.tsx # title: "Atoms/Spinner" +├── spinner.test.tsx # 3 smoke tests +└── index.ts # export { Spinner, type SpinnerProps } +``` + +And in `packages/core-ui/src/atoms/index.ts`: + +```ts +// +export { Spinner, type SpinnerProps } from "./spinner/index"; +export { Button, type ButtonProps } from "./button/index"; +// ... +``` + +## Customizing the scaffold + +The generated component is a minimal `
` passthrough — change the element/type and add variants/sizes to fit. The existing `button.tsx` in `src/atoms/button/` is the canonical reference for a richer component with `variant` and `size` props plus variant lookup tables. + +## Verification + +After scaffolding: + +```bash +pnpm --filter @repo/core-ui lint typecheck test +pnpm dev --filter @repo/storybook # view the new component in Storybook +``` + +The Storybook stories glob (`packages/core-ui/src/**/*.stories.@(ts|tsx)`) picks up the new file automatically — no Storybook config changes needed. diff --git a/turbo/generators/__snapshots__/core-package/ui.snapshot.json b/turbo/generators/__snapshots__/core-package/ui.snapshot.json index 93e6ad9..c9a9760 100644 --- a/turbo/generators/__snapshots__/core-package/ui.snapshot.json +++ b/turbo/generators/__snapshots__/core-package/ui.snapshot.json @@ -1,7 +1,7 @@ [ { "path": "AGENTS.md", - "sha256": "ea4a4eeea6cb08364fb7d9cf5c5757147442df1e9b1907b9118fcd313cd1c4a7" + "sha256": "626329a8a409b4428307ba0014fb8be179a5db7d1fc2a43b017941ab44476188" }, { "path": "eslint.config.js", @@ -29,7 +29,7 @@ }, { "path": "src/atoms/index.ts", - "sha256": "9ec29d14a7a7729b4eca9ba95b4016812a4c1c2cc9a46510d5f2d019744852b3" + "sha256": "2764f7cbc20b84cba7bde6ab1bb3a0726060e3db227dfd41c3781ce11cba6239" }, { "path": "src/atoms/input/index.ts", @@ -85,11 +85,11 @@ }, { "path": "src/molecules/index.ts", - "sha256": "eeb24cfbb8b6d2c1a5f5c144eae4db37f6829ad0f62eb163f428a666bc742774" + "sha256": "6c5a7132e904cf0592599347713093926b678422a4f0e9515216434fd3a643c1" }, { "path": "src/organisms/index.ts", - "sha256": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881" + "sha256": "3cef9504d6eca5a949207145c8e8787c988f2a389cb19d293c128a1afd617dcd" }, { "path": "src/styles/globals.css", diff --git a/turbo/generators/config.test.ts b/turbo/generators/config.test.ts index 69b1db9..fc89c34 100644 --- a/turbo/generators/config.test.ts +++ b/turbo/generators/config.test.ts @@ -56,3 +56,95 @@ describe("core-package events", () => { expect(actions.length).toBeGreaterThanOrEqual(18); }); }); + +describe("core-ui-component generator", () => { + it("is registered with tier and name prompts", () => { + const captured: Array<{ name: string; def: PlopTypes.PlopGeneratorConfig }> = []; + const plopMock = { + setHelper: () => {}, + setGenerator: (name: string, def: PlopTypes.PlopGeneratorConfig) => + captured.push({ name, def }), + } as unknown as PlopTypes.NodePlopAPI; + generator(plopMock); + + const entry = captured.find((c) => c.name === "core-ui-component"); + expect(entry).toBeDefined(); + const prompts = entry!.def.prompts as Array<{ name: string; choices?: unknown[] }>; + expect(prompts.map((p) => p.name)).toEqual(["tier", "name"]); + expect(prompts[0]!.choices).toEqual(["atom", "molecule", "organism"]); + }); + + it("for each tier, emits 4 add actions, 1 modify, plus guards and print", () => { + const captured: Array<{ name: string; def: PlopTypes.PlopGeneratorConfig }> = []; + const plopMock = { + setHelper: () => {}, + setGenerator: (name: string, def: PlopTypes.PlopGeneratorConfig) => + captured.push({ name, def }), + } as unknown as PlopTypes.NodePlopAPI; + generator(plopMock); + + const corePkg = captured.find((c) => c.name === "core-ui-component")!.def; + + for (const tier of ["atom", "molecule", "organism"] as const) { + const actions = (corePkg.actions as (a: { tier: string; name: string }) => PlopTypes.ActionType[])( + { tier, name: "Spinner" }, + ); + + const tierPlural = `${tier}s`; + + // 4 `add` actions, one per emitted file + const adds = actions.filter( + (a): a is PlopTypes.AddActionConfig => + typeof a === "object" && "type" in a && (a as { type: string }).type === "add", + ); + expect(adds).toHaveLength(4); + const addPaths = adds.map((a) => a.path); + expect(addPaths).toContain( + `packages/core-ui/src/${tierPlural}/{{kebabCase name}}/{{kebabCase name}}.tsx`, + ); + expect(addPaths).toContain( + `packages/core-ui/src/${tierPlural}/{{kebabCase name}}/{{kebabCase name}}.stories.tsx`, + ); + expect(addPaths).toContain( + `packages/core-ui/src/${tierPlural}/{{kebabCase name}}/{{kebabCase name}}.test.tsx`, + ); + expect(addPaths).toContain( + `packages/core-ui/src/${tierPlural}/{{kebabCase name}}/index.ts`, + ); + + // 1 `modify` action targeting the tier barrel + const modifies = actions.filter( + (a): a is PlopTypes.ModifyActionConfig => + typeof a === "object" && "type" in a && (a as { type: string }).type === "modify", + ); + expect(modifies).toHaveLength(1); + expect(modifies[0]!.path).toBe(`packages/core-ui/src/${tierPlural}/index.ts`); + expect(String(modifies[0]!.pattern)).toContain(``); + + // 3 function actions (2 guards + 1 print) + const fns = actions.filter((a) => typeof a === "function"); + expect(fns).toHaveLength(3); + } + }); + + it("PascalCase validator rejects bad names", () => { + const captured: Array<{ name: string; def: PlopTypes.PlopGeneratorConfig }> = []; + const plopMock = { + setHelper: () => {}, + setGenerator: (name: string, def: PlopTypes.PlopGeneratorConfig) => + captured.push({ name, def }), + } as unknown as PlopTypes.NodePlopAPI; + generator(plopMock); + + const corePkg = captured.find((c) => c.name === "core-ui-component")!.def; + const nameValidate = (corePkg.prompts as Array<{ name: string; validate?: (i: string) => string | true }>) + .find((p) => p.name === "name")!.validate!; + + expect(nameValidate("")).toBe("Required"); + expect(nameValidate("spinner")).toContain("PascalCase"); + expect(nameValidate("123Foo")).toContain("PascalCase"); + expect(nameValidate("Foo-Bar")).toContain("PascalCase"); + expect(nameValidate("Spinner")).toBe(true); + expect(nameValidate("IconButton")).toBe(true); + }); +}); diff --git a/turbo/generators/config.ts b/turbo/generators/config.ts index 8d1c1c9..bd5051c 100644 --- a/turbo/generators/config.ts +++ b/turbo/generators/config.ts @@ -736,6 +736,49 @@ import noRealtimeHandlerReexport from "./rules/no-realtime-handler-reexport.js"; return jobActions(a); }, }); + + /** + * Turbo generator: `core-ui-component` + * + * Scaffolds a core-ui atomic-design component (atom / molecule / organism) + * using the established 4-file pattern: `.tsx`, `.stories.tsx`, + * `.test.tsx`, `index.ts`. Splices the new export into the matching + * tier barrel via the `// s>` anchor. + * + * Refuses to run unless `packages/core-ui/` exists (see + * `pnpm turbo gen core-package ui`). + */ + 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); + }, + }); } function jobActions(a: { @@ -1281,6 +1324,106 @@ function printRealtimeNextSteps(): string { ].join("\n"); } +function coreUiComponentActions(a: { + tier: "atom" | "molecule" | "organism"; + name: string; +}): PlopTypes.ActionType[] { + const tierPlural = `${a.tier}s` as const; + 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, [`// `]); + return `Anchor // present in ${tierBarrel}.`; + }, + + // 3. Emit the 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. + // 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 `realtime` and `event` + // generators (no kebabCase JS helper exists in config.ts). + { + type: "modify", + path: tierBarrel, + pattern: new RegExp(`// `), + template: `// \nexport { {{pascalCase name}}, type {{pascalCase name}}Props } from "./{{kebabCase name}}/index";`, + }, + + // 5. Print next-steps + () => printCoreUiComponentNextSteps(a), + ]; +} + +function printCoreUiComponentNextSteps(a: { + tier: "atom" | "molecule" | "organism"; + name: string; +}): string { + const tierPlural = `${a.tier}s`; + const kebab = a.name + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2") + .toLowerCase(); + const pascal = a.name; + return [ + "─────────────────────────────────────────────────────────────", + `${pascal} 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"); +} + // Local helpers used inside the printNextSteps action — Plop's helpers aren't // available outside template strings, so we replicate the bits we need. function cap(input: string): string { diff --git a/turbo/generators/templates/core-package/ui/AGENTS.md.hbs b/turbo/generators/templates/core-package/ui/AGENTS.md.hbs index a984ec8..12ddc74 100644 --- a/turbo/generators/templates/core-package/ui/AGENTS.md.hbs +++ b/turbo/generators/templates/core-package/ui/AGENTS.md.hbs @@ -44,6 +44,8 @@ import { Modal, Tabs } from "@repo/core-ui"; ## Structure +> To scaffold a new component, use `pnpm turbo gen core-ui-component` rather than creating files manually. The generator emits the 4-file pattern below and splices the export into the tier barrel. + ``` src/ atoms/ diff --git a/turbo/generators/templates/core-package/ui/src/atoms/index.ts.hbs b/turbo/generators/templates/core-package/ui/src/atoms/index.ts.hbs index 51482ab..c02d37a 100644 --- a/turbo/generators/templates/core-package/ui/src/atoms/index.ts.hbs +++ b/turbo/generators/templates/core-package/ui/src/atoms/index.ts.hbs @@ -1,3 +1,4 @@ +// export { Button, type ButtonProps } from "./button/index"; export { Input, type InputProps } from "./input/index"; export { Label, type LabelProps } from "./label/index"; diff --git a/turbo/generators/templates/core-package/ui/src/molecules/index.ts.hbs b/turbo/generators/templates/core-package/ui/src/molecules/index.ts.hbs index 5ad534d..206d3b4 100644 --- a/turbo/generators/templates/core-package/ui/src/molecules/index.ts.hbs +++ b/turbo/generators/templates/core-package/ui/src/molecules/index.ts.hbs @@ -1 +1,2 @@ +// export { FormField, type FormFieldProps } from "./form-field/index"; diff --git a/turbo/generators/templates/core-package/ui/src/organisms/index.ts.hbs b/turbo/generators/templates/core-package/ui/src/organisms/index.ts.hbs index cb0ff5c..b868e75 100644 --- a/turbo/generators/templates/core-package/ui/src/organisms/index.ts.hbs +++ b/turbo/generators/templates/core-package/ui/src/organisms/index.ts.hbs @@ -1 +1,2 @@ +// export {}; diff --git a/turbo/generators/templates/core-ui-component/component.stories.tsx.hbs b/turbo/generators/templates/core-ui-component/component.stories.tsx.hbs new file mode 100644 index 0000000..1adfab9 --- /dev/null +++ b/turbo/generators/templates/core-ui-component/component.stories.tsx.hbs @@ -0,0 +1,13 @@ +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; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { args: {} }; diff --git a/turbo/generators/templates/core-ui-component/component.test.tsx.hbs b/turbo/generators/templates/core-ui-component/component.test.tsx.hbs new file mode 100644 index 0000000..3070868 --- /dev/null +++ b/turbo/generators/templates/core-ui-component/component.test.tsx.hbs @@ -0,0 +1,23 @@ +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(); + renderWithProviders(<{{pascalCase name}} ref={ref} />); + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); +}); diff --git a/turbo/generators/templates/core-ui-component/component.tsx.hbs b/turbo/generators/templates/core-ui-component/component.tsx.hbs new file mode 100644 index 0000000..72f77a4 --- /dev/null +++ b/turbo/generators/templates/core-ui-component/component.tsx.hbs @@ -0,0 +1,11 @@ +import { forwardRef, type HTMLAttributes } from "react"; +import { cn } from "../../lib/utils"; + +export interface {{pascalCase name}}Props extends HTMLAttributes {} + +export const {{pascalCase name}} = forwardRef( + ({ className, ...props }, ref) => ( +
+ ), +); +{{pascalCase name}}.displayName = "{{pascalCase name}}"; diff --git a/turbo/generators/templates/core-ui-component/index.ts.hbs b/turbo/generators/templates/core-ui-component/index.ts.hbs new file mode 100644 index 0000000..0a068fc --- /dev/null +++ b/turbo/generators/templates/core-ui-component/index.ts.hbs @@ -0,0 +1 @@ +export { {{pascalCase name}}, type {{pascalCase name}}Props } from "./{{kebabCase name}}";