From 526ca138fcde2e48018f94e27251d7f9e342caf2 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 22:27:36 -0400 Subject: [PATCH] Step 10E.2: UI kit Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 7 ++ apps/web/src/ui/Button.tsx | 57 +++++++++++ apps/web/src/ui/Panel.tsx | 74 ++++++++++++++ apps/web/src/ui/Sheet.tsx | 67 ++++++++++++ apps/web/src/ui/bits.tsx | 186 ++++++++++++++++++++++++++++++++++ apps/web/src/ui/icons.tsx | 135 ++++++++++++++++++++++++ apps/web/src/ui/index.ts | 6 ++ apps/web/test/ui-kit.test.tsx | 56 ++++++++++ memory-bank/progress.md | 1 + 9 files changed, 589 insertions(+) create mode 100644 apps/web/src/ui/Button.tsx create mode 100644 apps/web/src/ui/Panel.tsx create mode 100644 apps/web/src/ui/Sheet.tsx create mode 100644 apps/web/src/ui/bits.tsx create mode 100644 apps/web/src/ui/icons.tsx create mode 100644 apps/web/src/ui/index.ts create mode 100644 apps/web/test/ui-kit.test.tsx diff --git a/DECISIONS.md b/DECISIONS.md index 25f16bf..306a143 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -833,3 +833,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Fonts via `@fontsource`, latin subset only, weights 500–700 (Chakra Petch) and 400–600 (Barlow).** Self-hosting keeps the game working offline for the Phase 11 PWA; Google Fonts would not. - **The backdrop is `body::before` (fixed, `z-index: -1`)**, so it never scrolls and screens no longer paint their own background. The layout's `bg-slate-950` was removed so it shows through. + +## Step 10E.2 + +- **No component library.** The kit is a handful of small Tailwind components in `src/ui/`; a dependency like Radix or shadcn would be more than the game needs. +- **Icons are hand-drawn inline SVG paths** (`ui/icons.tsx`), not an icon package: 28 icons, no network, `currentColor`, always `aria-hidden`. +- **`Sheet` renders the title itself** so every dialog gets the same eyebrow/title/subtitle layout and the `aria-labelledby` wiring cannot be forgotten. +- **`Art` uses the CR-003 generated sprites directly** (`/assets/map-gen/@1x|2x.png`) through `ART_BASE_URL`, so screens and map share one art pipeline. diff --git a/apps/web/src/ui/Button.tsx b/apps/web/src/ui/Button.tsx new file mode 100644 index 0000000..6eb02f0 --- /dev/null +++ b/apps/web/src/ui/Button.tsx @@ -0,0 +1,57 @@ +import type { ButtonHTMLAttributes } from "react"; + +export type ButtonVariant = "primary" | "secondary" | "ghost" | "success" | "danger"; +export type ButtonSize = "sm" | "md" | "lg"; + +const VARIANTS: Record = { + primary: + "bg-linear-to-b from-amber-300 to-amber-500 text-space-950 shadow-glow-amber hover:from-amber-200 hover:to-amber-400", + secondary: + "border border-space-500 bg-space-800/80 text-ink-50 hover:border-amber-400/60 hover:bg-space-700", + ghost: "text-ink-300 hover:bg-white/5 hover:text-ink-50", + success: + "bg-linear-to-b from-emerald-300 to-emerald-500 text-space-950 shadow-glow-emerald hover:from-emerald-200 hover:to-emerald-400", + danger: "border border-rose-500/60 bg-rose-500/15 text-rose-200 hover:bg-rose-500/25", +}; + +const SIZES: Record = { + sm: "min-h-9 px-3 text-xs", + md: "min-h-11 px-4 text-sm", + lg: "min-h-13 px-6 text-base", +}; + +/** Classes for anything that should look like a button, e.g. a router `Link`. */ +export function buttonClass( + variant: ButtonVariant = "primary", + size: ButtonSize = "md", + block = false, +): string { + return [ + "inline-flex items-center justify-center gap-2 rounded-lg font-display font-semibold uppercase tracking-wider", + "transition duration-150 select-none active:translate-y-px active:scale-[0.98]", + "disabled:pointer-events-none disabled:opacity-40 disabled:shadow-none disabled:saturate-50", + VARIANTS[variant], + SIZES[size], + block ? "w-full" : "", + ].join(" "); +} + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; + /** Full width. */ + block?: boolean; +} + +export function Button({ + variant, + size, + block, + className = "", + type = "button", + ...rest +}: ButtonProps) { + return ( + ); + const button = screen.getByRole("button", { name: "Go" }); + expect(button).toHaveAttribute("type", "button"); + await userEvent.click(button); + expect(onClick).toHaveBeenCalledOnce(); + + rerender( + , + ); + expect(screen.getByRole("button", { name: "Go" })).toBeDisabled(); + }); + + it("meters expose their range and value", () => { + render(); + const bar = screen.getByRole("progressbar", { name: "Duty" }); + expect(bar).toHaveAttribute("aria-valuemin", "0"); + expect(bar).toHaveAttribute("aria-valuemax", "14"); + expect(bar).toHaveAttribute("aria-valuenow", "9"); + }); + + it("sheets are modal dialogs labelled by their title", () => { + render( + +

Body

+
, + ); + const dialog = screen.getByRole("dialog", { name: "Deliver to which HQ?" }); + expect(dialog).toHaveAttribute("aria-modal", "true"); + expect(screen.getByRole("heading", { level: 2, name: "Deliver to which HQ?" })).toBeVisible(); + }); + + it("the screen header is the level-1 heading", () => { + render(); + expect(screen.getByRole("heading", { level: 1, name: "Fleet" })).toBeInTheDocument(); + }); + + it("art loads the generated sprite and hides itself if it fails", () => { + const { container } = render(); + const img = container.querySelector("img")!; + expect(img).toHaveAttribute("src", spriteUrl("ship-courier", "1x")); + expect(img).toHaveAttribute("alt", ""); + expect(img.getAttribute("srcset")).toContain("/assets/map-gen/ship-courier@2x.png 2x"); + fireEvent.error(img); + expect(container.querySelector("img")).toBeNull(); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 05a6dfd..1480904 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -180,3 +180,4 @@ Spec: `memory-bank/change-requests/CR-004-pilot-duty-rest.md`. New decisions rec Spec: `memory-bank/change-requests/CR-005-ui-overhaul.md`. New decisions recorded as plan §0.5.47–0.5.51. - [x] **Step 10E.1 — Fonts, theme tokens and backdrop.** `@fontsource/chakra-petch` (display) and `@fontsource/barlow` (body), self-hosted. `index.css` `@theme`: `font-display`/`font-sans`, `space-*` surfaces, `ink-*` text, glow and panel shadows, `rise`/`glow`/`sheet` animations. `body::before` puts the map's `bg.webp` under a navy gradient behind every screen; global `:focus-visible` ring; all motion off under `prefers-reduced-motion`. Test: `theme.test.ts`. +- [x] **Step 10E.2 — UI kit.** `apps/web/src/ui/` (barrel `index.ts`): `Button`/`buttonClass` (primary, secondary, ghost, success, danger; sm/md/lg, 44 px tall at md; press feedback), `Panel` (tones, `feature` corner brackets, polymorphic `as`) and `Eyebrow`, `Sheet` (modal: bottom sheet on phones, centred card from `sm`, renders its own `h2`), `Pill`, `Meter` (labelled `progressbar`), `StatTile`, `ScreenHeader` (the screen's `h1`), `Art`/`spriteUrl` (generated map sprites, 1x/2x `srcSet`, hides on error) and `Icon` (28 line icons). Test: `ui-kit.test.tsx`.