diff --git a/.changeset/curly-cameras-yell.md b/.changeset/curly-cameras-yell.md new file mode 100644 index 000000000..7bb7bb8cd --- /dev/null +++ b/.changeset/curly-cameras-yell.md @@ -0,0 +1,5 @@ +--- +"@spencer-kit/coder-studio": minor +--- + +Align the published CLI package manifest with the development entrypoints while keeping dist-based publish overrides. diff --git a/docs/superpowers/plans/2026-05-06-ui-component-library-phase-a-bootstrap-and-button.md b/docs/superpowers/plans/2026-05-06-ui-component-library-phase-a-bootstrap-and-button.md new file mode 100644 index 000000000..7b61f85a5 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-ui-component-library-phase-a-bootstrap-and-button.md @@ -0,0 +1,1067 @@ +# UI Component Library Phase A Bootstrap + Button Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bootstrap `packages/web/src/components/ui/` as the new home for shared UI primitives, centralize `useViewport()` into a single source of truth, and land `Button` as the first production component with one real caller migrated. + +**Architecture:** Keep this first plan intentionally narrow. Do not attempt Tier 0 in bulk. Create the UI library skeleton (`README.md`, `MIGRATION.md`), move viewport logic into `components/ui/_internal/use-viewport.ts` with a compatibility re-export from `src/hooks/use-viewport.ts`, then add `Button` with CSS Modules and `clsx`. Migrate exactly one simple caller (`features/auth/index.tsx`) so the pattern is proven before the rest of Tier 0. + +**Tech Stack:** React 19, TypeScript 6, Vite, Vitest + Testing Library, Jotai, vanilla CSS Modules, existing `tokens.css`, new `clsx` dependency. + +**Spec reference:** `docs/superpowers/specs/2026-05-06-ui-component-library-design.md` §2, §3, §4, §5, §6, §7, §8. + +**Out of scope for this plan (deferred):** +- `IconButton`, `Input`, `Textarea`, `Tag`, `Badge`, `Pill`, `StatusDot`, `Kbd`, `Spinner`, `Switch` +- All Tier 1 and Tier 2 components +- `portal.tsx`, `focus-trap.ts`, `dismiss.ts`, `render-with-viewport.tsx` +- Deleting the legacy `.btn` block from `styles/components.css` +- Migrating any Button caller other than `features/auth/index.tsx` +- Adding `@floating-ui/react` + +--- + +## File Structure + +**New files:** +- `packages/web/src/components/ui/README.md` — project-level rules for the new UI library +- `packages/web/src/components/ui/MIGRATION.md` — 24-row migration inventory; source of truth for caller counts and deletion timing +- `packages/web/src/components/ui/_internal/use-viewport.ts` — single source of truth for `(max-width: 899px), (pointer: coarse)` viewport detection +- `packages/web/src/components/ui/_internal/use-viewport.test.tsx` — tests the combined media query and live updates +- `packages/web/src/components/ui/button/index.tsx` — first shared primitive; variant/size/loading API +- `packages/web/src/components/ui/button/index.module.css` — Button styles copied from the legacy `.btn` block, plus local hashed classes and temporary `:global()` aliases +- `packages/web/src/components/ui/button/index.test.tsx` — Button unit tests +- `packages/web/src/components/ui/button/README.md` — Button usage contract +- `packages/web/src/components/ui/index.ts` — public barrel export for `Button` + +**Modified files:** +- `packages/web/package.json` — add `clsx` +- `pnpm-lock.yaml` — lockfile update for `clsx` +- `packages/web/src/hooks/use-viewport.ts` — compatibility re-export from the new `_internal` hook +- `packages/web/src/app.test.tsx` — coarse-pointer expectation flips from desktop to mobile +- `packages/web/src/features/auth/index.tsx` — replace raw submit `); + + const button = screen.getByRole("button", { name: "Save" }); + expect(button).toHaveAttribute("type", "button"); + expect(button).toBeEnabled(); + }); + + it("supports the four visual variants", () => { + render( + <> + + + + + + ); + + expect(screen.getByRole("button", { name: "Primary" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Secondary" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Ghost" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Danger" })).toBeInTheDocument(); + }); + + it("supports the three size options", () => { + render( + <> + + + + + ); + + expect(screen.getByRole("button", { name: "Small" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Medium" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Large" })).toBeInTheDocument(); + }); + + it("disables click handlers while loading", async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + + render( + + ); + + const button = screen.getByRole("button", { name: "Create" }); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute("aria-busy", "true"); + + await user.click(button); + expect(onClick).not.toHaveBeenCalled(); + }); + + it("renders leading and trailing icons", () => { + render( + + ); + + expect(screen.getByTestId("leading-icon")).toBeInTheDocument(); + expect(screen.getByTestId("trailing-icon")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open" })).toBeInTheDocument(); + }); + + it("renders as an anchor when as='a'", () => { + render( + + ); + + expect(screen.getByRole("link", { name: "Settings" })).toHaveAttribute( + "href", + "/settings" + ); + }); +}); +``` + +- [ ] **Step 2: Run the Button tests and confirm they fail** + +```bash +pnpm --filter @coder-studio/web exec vitest run src/components/ui/button/index.test.tsx +``` + +Expected: FAIL with `Failed to resolve import "."` from `src/components/ui/button/index.test.tsx`, because `index.tsx` does not exist yet. + +- [ ] **Step 3: Add `clsx`** + +Run: +```bash +pnpm --filter @coder-studio/web add clsx +``` + +Expected: `packages/web/package.json` gains `"clsx"`, and `pnpm-lock.yaml` updates. + +The dependency section in `packages/web/package.json` should now include this exact line: + +```json +"clsx": "^2.1.1" +``` + +- [ ] **Step 4: Implement `Button`, its CSS Module, README, and the public barrel export** + +Create `packages/web/src/components/ui/button/index.tsx`: + +```tsx +import clsx from "clsx"; +import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from "react"; +import styles from "./index.module.css"; + +export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger"; +export type ButtonSize = "sm" | "md" | "lg"; + +interface ButtonBaseProps { + readonly children: ReactNode; + readonly className?: string; + readonly variant?: ButtonVariant; + readonly size?: ButtonSize; + readonly loading?: boolean; + readonly leadingIcon?: ReactNode; + readonly trailingIcon?: ReactNode; +} + +type ButtonElementProps = ButtonBaseProps & + Omit, keyof ButtonBaseProps> & { + readonly as?: "button"; + }; + +type AnchorElementProps = ButtonBaseProps & + Omit, keyof ButtonBaseProps> & { + readonly as: "a"; + }; + +export type ButtonProps = ButtonElementProps | AnchorElementProps; + +const variantClassMap: Record = { + primary: styles.primary, + secondary: styles.secondary, + ghost: styles.ghost, + danger: styles.danger, +}; + +const sizeClassMap: Record = { + sm: styles.sm, + md: undefined, + lg: styles.lg, +}; + +const ButtonContent = ({ + children, + leadingIcon, + loading, + trailingIcon, +}: Pick) => { + return ( + <> + {loading ?