deadhead-space-freight-tycoon/apps/web/src/ui/bits.tsx

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}`}
/>
);
}