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.
|
||||
- **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.
|
||||
|
||||
- [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