Step 10E.2: UI kit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 22:27:36 -04:00
parent 45eafb2503
commit 526ca138fc
9 changed files with 589 additions and 0 deletions

View File

@ -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/<sprite>@1x|2x.png`) through `ART_BASE_URL`, so screens and map share one art pipeline.

View File

@ -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<ButtonVariant, string> = {
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<ButtonSize, string> = {
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<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
/** Full width. */
block?: boolean;
}
export function Button({
variant,
size,
block,
className = "",
type = "button",
...rest
}: ButtonProps) {
return (
<button type={type} className={`${buttonClass(variant, size, block)} ${className}`} {...rest} />
);
}

74
apps/web/src/ui/Panel.tsx Normal file
View File

@ -0,0 +1,74 @@
import type { ElementType, HTMLAttributes, ReactNode } from "react";
export type Tone = "default" | "amber" | "cyan" | "emerald" | "rose" | "fuchsia" | "muted";
const TONES: Record<Tone, string> = {
default: "border-space-600/70 bg-space-900/75",
muted: "border-space-700/70 bg-space-900/45",
amber: "border-amber-400/45 bg-amber-950/40",
cyan: "border-cyan-400/40 bg-cyan-950/40",
emerald: "border-emerald-400/40 bg-emerald-950/40",
rose: "border-rose-500/45 bg-rose-950/40",
fuchsia: "border-fuchsia-400/50 bg-fuchsia-950/40",
};
const BRACKETS: Record<Tone, string> = {
default: "border-cyan-300/60",
muted: "border-ink-500",
amber: "border-amber-300",
cyan: "border-cyan-300",
emerald: "border-emerald-300",
rose: "border-rose-300",
fuchsia: "border-fuchsia-300",
};
export interface PanelProps extends HTMLAttributes<HTMLElement> {
as?: ElementType;
tone?: Tone;
/** Corner brackets, for panels that should stand out. */
feature?: boolean;
children?: ReactNode;
}
/** A translucent HUD panel (decision 0.5.47). */
export function Panel({
as: Tag = "div",
tone = "default",
feature = false,
className = "",
children,
...rest
}: PanelProps) {
return (
<Tag
className={`relative rounded-xl border p-4 shadow-panel backdrop-blur-md ${TONES[tone]} ${className}`}
{...rest}
>
{feature && <Brackets tone={tone} />}
{children}
</Tag>
);
}
function Brackets({ tone }: { tone: Tone }) {
const c = `pointer-events-none absolute size-3 ${BRACKETS[tone]}`;
return (
<span aria-hidden="true">
<span className={`${c} -top-px -left-px rounded-tl-xl border-t-2 border-l-2`} />
<span className={`${c} -top-px -right-px rounded-tr-xl border-t-2 border-r-2`} />
<span className={`${c} -bottom-px -left-px rounded-bl-xl border-b-2 border-l-2`} />
<span className={`${c} -right-px -bottom-px rounded-br-xl border-r-2 border-b-2`} />
</span>
);
}
/** A small uppercase label above a value or section. */
export function Eyebrow({ children, className = "" }: { children: ReactNode; className?: string }) {
return (
<div
className={`font-display text-[11px] font-semibold tracking-[0.18em] text-ink-400 uppercase ${className}`}
>
{children}
</div>
);
}

67
apps/web/src/ui/Sheet.tsx Normal file
View File

@ -0,0 +1,67 @@
import type { ReactNode } from "react";
import type { Tone } from "./Panel";
const TITLE_TONES: Partial<Record<Tone, string>> = {
amber: "text-amber-300",
emerald: "text-emerald-300",
cyan: "text-cyan-200",
};
const BORDER_TONES: Partial<Record<Tone, string>> = {
amber: "border-amber-400/50",
emerald: "border-emerald-400/40",
cyan: "border-cyan-400/40",
};
/**
* A modal dialog: a bottom sheet on phones, a centred card from `sm` up.
* Labelled by its title, which it renders as an `h2` with `titleId`.
*/
export function Sheet({
titleId,
title,
eyebrow,
subtitle,
tone = "default",
layer = "z-20",
children,
}: {
titleId: string;
title: ReactNode;
eyebrow?: ReactNode;
subtitle?: ReactNode;
tone?: Tone;
/** Stacking layer, e.g. `z-30` for a sheet over other sheets. */
layer?: string;
children: ReactNode;
}) {
return (
<div
className={`fixed inset-0 ${layer} flex items-end justify-center bg-space-950/75 backdrop-blur-sm sm:items-center sm:p-4`}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className={`relative max-h-[88dvh] w-full max-w-md animate-sheet overflow-y-auto rounded-t-2xl border bg-space-900/95 p-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] shadow-panel sm:rounded-2xl ${BORDER_TONES[tone] ?? "border-space-600"}`}
>
<div
aria-hidden="true"
className="mx-auto -mt-2 mb-3 h-1 w-10 rounded-full bg-space-600 sm:hidden"
/>
{eyebrow && (
<div className="font-display text-[11px] font-semibold tracking-[0.2em] text-cyan-300/80 uppercase">
{eyebrow}
</div>
)}
<h2
id={titleId}
className={`font-display text-xl font-bold tracking-wide ${TITLE_TONES[tone] ?? "text-ink-50"}`}
>
{title}
</h2>
{subtitle && <div className="mt-1 text-sm text-ink-300">{subtitle}</div>}
{children}
</div>
</div>
);
}

186
apps/web/src/ui/bits.tsx Normal file
View File

@ -0,0 +1,186 @@
import { useState, type ReactNode } from "react";
import { ART_BASE_URL } from "../map/art/load";
import { Icon, type IconName } from "./icons";
import type { Tone } from "./Panel";
const PILL_TONES: Record<Tone, string> = {
default: "border-space-500 bg-space-800 text-ink-200",
muted: "border-space-600 bg-space-900 text-ink-400",
amber: "border-amber-400/50 bg-amber-500/15 text-amber-200",
cyan: "border-cyan-400/50 bg-cyan-500/15 text-cyan-200",
emerald: "border-emerald-400/50 bg-emerald-500/15 text-emerald-200",
rose: "border-rose-400/50 bg-rose-500/15 text-rose-200",
fuchsia: "border-fuchsia-400/50 bg-fuchsia-500/15 text-fuchsia-200",
};
/** A small status or tag chip. */
export function Pill({
tone = "default",
icon,
className = "",
children,
}: {
tone?: Tone;
icon?: IconName;
className?: string;
children: ReactNode;
}) {
return (
<span
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 font-display text-[11px] font-semibold tracking-wider whitespace-nowrap uppercase ${PILL_TONES[tone]} ${className}`}
>
{icon && <Icon name={icon} className="size-3" />}
{children}
</span>
);
}
const METER_FILLS: Partial<Record<Tone, string>> = {
amber: "from-amber-500 to-amber-300 shadow-[0_0_10px] shadow-amber-400/60",
cyan: "from-cyan-500 to-sky-300 shadow-[0_0_10px] shadow-cyan-400/60",
emerald: "from-emerald-500 to-emerald-300 shadow-[0_0_10px] shadow-emerald-400/60",
rose: "from-rose-600 to-rose-400 shadow-[0_0_10px] shadow-rose-500/60",
fuchsia: "from-fuchsia-500 to-fuchsia-300",
};
/** A labelled `progressbar`. `label` is its accessible name. */
export function Meter({
label,
value,
min = 0,
max = 100,
tone = "cyan",
size = "md",
className = "",
}: {
label: string;
value: number;
min?: number;
max?: number;
tone?: Tone;
size?: "sm" | "md";
className?: string;
}) {
const percent = max > min ? Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100)) : 0;
return (
<div
role="progressbar"
aria-label={label}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
className={`overflow-hidden rounded-full bg-space-950/80 ring-1 ring-space-600/60 ${size === "sm" ? "h-1.5" : "h-2.5"} ${className}`}
>
<div
className={`h-full rounded-full bg-linear-to-r transition-[width] duration-700 ${METER_FILLS[tone] ?? METER_FILLS.cyan}`}
style={{ width: `${percent}%` }}
/>
</div>
);
}
const STAT_VALUE_TONES: Partial<Record<Tone, string>> = {
amber: "text-amber-300",
cyan: "text-cyan-200",
emerald: "text-emerald-300",
rose: "text-rose-300",
};
/** A label over a big number, with an optional icon. */
export function StatTile({
label,
value,
icon,
tone = "default",
valueLabel,
className = "",
}: {
label: ReactNode;
value: ReactNode;
icon?: IconName;
tone?: Tone;
/** Accessible name of the value, when tests or screen readers need it. */
valueLabel?: string;
className?: string;
}) {
return (
<div
className={`rounded-xl border border-space-600/70 bg-space-900/70 px-3 py-2.5 shadow-panel backdrop-blur-md ${className}`}
>
<div className="flex items-center gap-1.5 font-display text-[11px] font-semibold tracking-[0.16em] text-ink-400 uppercase">
{icon && <Icon name={icon} className="size-3.5" />}
{label}
</div>
<div
aria-label={valueLabel}
className={`mt-1 font-display text-lg font-bold tabular-nums ${STAT_VALUE_TONES[tone] ?? "text-ink-50"}`}
>
{value}
</div>
</div>
);
}
/** The page title block. `title` is the screen's level-1 heading. */
export function ScreenHeader({
title,
eyebrow,
subtitle,
icon,
actions,
}: {
title: string;
eyebrow?: string;
subtitle?: ReactNode;
icon?: IconName;
actions?: ReactNode;
}) {
return (
<header className="flex items-end justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
{icon && (
<span className="grid size-11 shrink-0 place-items-center rounded-xl border border-amber-400/40 bg-amber-500/10 text-amber-300 shadow-glow-amber">
<Icon name={icon} className="size-6" />
</span>
)}
<div className="min-w-0">
{eyebrow && (
<div className="font-display text-[11px] font-semibold tracking-[0.22em] text-cyan-300/80 uppercase">
{eyebrow}
</div>
)}
<h1 className="font-display text-2xl leading-tight font-bold tracking-wide uppercase">
{title}
</h1>
{subtitle && <p className="text-sm text-ink-300">{subtitle}</p>}
</div>
</div>
{actions}
</header>
);
}
/** A generated map sprite's URL (decision 0.5.50). */
export function spriteUrl(sprite: string, density: "1x" | "2x" = "2x"): string {
return `${ART_BASE_URL}/${sprite}@${density}.png`;
}
/**
* Ship or location art. Decorative: the name is always in text beside it, so
* `alt` is empty. A missing image hides itself (decision 0.5.50).
*/
export function Art({ sprite, className = "size-12" }: { sprite: string; className?: string }) {
const [failed, setFailed] = useState(false);
if (failed) return null;
return (
<img
src={spriteUrl(sprite, "1x")}
srcSet={`${spriteUrl(sprite, "1x")} 1x, ${spriteUrl(sprite, "2x")} 2x`}
alt=""
draggable={false}
loading="lazy"
onError={() => setFailed(true)}
className={`object-contain select-none ${className}`}
/>
);
}

135
apps/web/src/ui/icons.tsx Normal file
View File

@ -0,0 +1,135 @@
import type { ReactNode } from "react";
/** Line icons on a 24 px grid, drawn in `currentColor` (CR-005). */
const PATHS = {
hq: (
<>
<path d="M3 21h18M5 21V9l7-5 7 5v12" />
<path d="M9.5 21v-5h5v5M9 11.5h.01M15 11.5h.01" />
</>
),
map: (
<>
<circle cx="12" cy="12" r="3" />
<ellipse cx="12" cy="12" rx="10" ry="4.5" transform="rotate(-25 12 12)" />
<circle cx="20" cy="7.5" r="1" fill="currentColor" />
</>
),
jobs: (
<>
<rect x="5" y="4" width="14" height="17" rx="2" />
<path d="M9 4V2.5h6V4M8.5 10h7M8.5 14h7M8.5 18h4" />
</>
),
fleet: (
<>
<path d="M12 2.5c3 2 5 6 5 10l-1.6 3.5H8.6L7 12.5c0-4 2-8 5-10z" />
<circle cx="12" cy="10" r="1.8" />
<path d="M8.6 16 6 18.5V14l1.3-2.5M15.4 16l2.6 2.5V14l-1.3-2.5M10.5 19l1.5 2.5 1.5-2.5" />
</>
),
pilots: (
<>
<path d="M6 11a6 6 0 0 1 12 0v2H6v-2z" />
<path d="M8 13v1.5a4 4 0 0 0 8 0V13M4 21c1.2-2.6 4.2-4 8-4s6.8 1.4 8 4" />
</>
),
market: (
<>
<path d="M5 8h14l-1.2 13H6.2L5 8z" />
<path d="M9 10V6.5a3 3 0 0 1 6 0V10" />
</>
),
places: (
<>
<circle cx="12" cy="12" r="6" />
<path d="M5.2 15.6c-2.3 2-3 3.6-2.2 4.3 1.3 1.1 6.4-1.1 11-5s7.6-8.2 6.4-9.4c-.7-.7-2.4-.3-4.6 1" />
</>
),
credits: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M14.8 9.4A3 3 0 0 0 12 7.8c-2 0-3.3 1.6-3.3 4.2s1.3 4.2 3.3 4.2a3 3 0 0 0 2.8-1.6" />
</>
),
clock: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</>
),
fuel: <path d="M12 3s6 6.5 6 11a6 6 0 0 1-12 0c0-4.5 6-11 6-11z" />,
cargo: (
<>
<path d="M3 7.5 12 3l9 4.5v9L12 21l-9-4.5v-9z" />
<path d="M3 7.5 12 12l9-4.5M12 12v9" />
</>
),
arrow: <path d="M4 12h15M14 6l6 6-6 6" />,
lock: (
<>
<rect x="5" y="11" width="14" height="10" rx="2" />
<path d="M8 11V7.5a4 4 0 0 1 8 0V11" />
</>
),
check: <path d="M5 12.5 9.5 17 19 7" />,
bolt: <path d="M13 2 4 14h7l-1 8 9-12h-7l1-8z" />,
rest: <path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z" />,
alert: (
<>
<path d="M12 3 22 21H2L12 3z" />
<path d="M12 10v5M12 18v.01" />
</>
),
close: <path d="M6 6l12 12M18 6 6 18" />,
plus: <path d="M12 5v14M5 12h14" />,
target: (
<>
<circle cx="12" cy="12" r="9" />
<circle cx="12" cy="12" r="5" />
<circle cx="12" cy="12" r="1" fill="currentColor" />
</>
),
send: <path d="M3 11 21 3l-8 18-2-8-8-2z" />,
hire: (
<>
<circle cx="10" cy="8" r="4" />
<path d="M2.5 21c1-3.8 3.9-6 7.5-6 1.8 0 3.4.5 4.7 1.4M19 14v6M16 17h6" />
</>
),
route: (
<>
<circle cx="5" cy="18" r="2.5" />
<circle cx="19" cy="6" r="2.5" />
<path d="M7 16.5C12 14 9 9 17 7.5" strokeDasharray="2 2.5" />
</>
),
speed: <path d="M3 17a9 9 0 0 1 18 0M12 17l4-6M7 13.5h.01M12 10h.01M17 13.5h.01" />,
range: (
<>
<circle cx="12" cy="12" r="9" strokeDasharray="2.5 2.5" />
<circle cx="12" cy="12" r="2" />
</>
),
landing: <path d="M12 3v11M7.5 9.5 12 14l4.5-4.5M4 20h16M6 17h12" />,
star: <path d="m12 3 2.7 5.6 6.1.9-4.4 4.3 1 6.1L12 17l-5.4 2.9 1-6.1-4.4-4.3 6.1-.9L12 3z" />,
} satisfies Record<string, ReactNode>;
export type IconName = keyof typeof PATHS;
export function Icon({ name, className = "size-5" }: { name: IconName; className?: string }) {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
{PATHS[name]}
</svg>
);
}

6
apps/web/src/ui/index.ts Normal file
View File

@ -0,0 +1,6 @@
/** The client's UI kit (CR-005, decision 0.5.48). */
export { Button, buttonClass, type ButtonProps } from "./Button";
export { Eyebrow, Panel, type Tone } from "./Panel";
export { Sheet } from "./Sheet";
export { Art, Meter, Pill, ScreenHeader, spriteUrl, StatTile } from "./bits";
export { Icon, type IconName } from "./icons";

View File

@ -0,0 +1,56 @@
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Art, Button, Meter, ScreenHeader, Sheet, spriteUrl } from "../src/ui";
describe("UI kit (Step 10E.2)", () => {
it("buttons default to type=button and forward disabled and clicks", async () => {
const onClick = vi.fn();
const { rerender } = render(<Button onClick={onClick}>Go</Button>);
const button = screen.getByRole("button", { name: "Go" });
expect(button).toHaveAttribute("type", "button");
await userEvent.click(button);
expect(onClick).toHaveBeenCalledOnce();
rerender(
<Button onClick={onClick} disabled>
Go
</Button>,
);
expect(screen.getByRole("button", { name: "Go" })).toBeDisabled();
});
it("meters expose their range and value", () => {
render(<Meter label="Duty" value={9} max={14} />);
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(
<Sheet titleId="t" title="Deliver to which HQ?">
<p>Body</p>
</Sheet>,
);
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(<ScreenHeader title="Fleet" eyebrow="Your ships" />);
expect(screen.getByRole("heading", { level: 1, name: "Fleet" })).toBeInTheDocument();
});
it("art loads the generated sprite and hides itself if it fails", () => {
const { container } = render(<Art sprite="ship-courier" />);
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();
});
});

View File

@ -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`.