Step 10E.2: UI kit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
45eafb2503
commit
526ca138fc
|
|
@ -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.
|
- **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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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.
|
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.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`.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue