187 lines
5.5 KiB
TypeScript
187 lines
5.5 KiB
TypeScript
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 (
|
|
<div 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}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 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}`}
|
|
/>
|
|
);
|
|
}
|