UI overhaul "Living Codex" B: primitives

- Button: primary is now a struck-gilt gradient (from-accent-soft to-accent +
  inset highlight + accent-deep edge); add a `subtle` variant.
- Input/Textarea/Select: gold focus (border-accent + 3px ring-accent-glow).
- PageHeader: optional italic eyebrow + larger Spectral title + a gilt hairline rule.
- Modal: rounded-xl + paper-grain panel.
- New display primitives (Codex.tsx): Meter, Badge, Avatar, StatCoin — token-driven,
  ready for the screen ports.

223 unit + 34 e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 20:57:45 +02:00
parent 9244c91c23
commit 542592972a
5 changed files with 79 additions and 12 deletions
+6 -4
View File
@@ -1,7 +1,7 @@
import { forwardRef, type ButtonHTMLAttributes } from 'react';
import { cn } from '@/lib/cn';
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger';
type Variant = 'primary' | 'secondary' | 'subtle' | 'ghost' | 'danger';
type Size = 'sm' | 'md' | 'icon';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
@@ -10,10 +10,12 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
}
const variants: Record<Variant, string> = {
primary: 'bg-accent text-accent-ink hover:opacity-90 font-medium',
// Gilt: a struck-coin gradient with a top highlight + deep edge.
primary: 'bg-gradient-to-b from-accent-soft to-accent text-accent-ink border border-accent-deep font-medium shadow-[inset_0_1px_0_rgba(255,255,255,0.3)] hover:brightness-105 active:brightness-95',
secondary: 'bg-elevated text-ink hover:bg-line border border-line',
subtle: 'bg-elevated text-ink border border-transparent hover:border-line',
ghost: 'text-muted hover:text-ink hover:bg-elevated',
danger: 'bg-danger text-white hover:opacity-90 font-medium',
danger: 'bg-danger text-white hover:brightness-110 font-medium border border-danger',
};
const sizes: Record<Size, string> = {
@@ -31,7 +33,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
ref={ref}
type={type ?? 'button'}
className={cn(
'inline-flex items-center justify-center gap-2 transition-colors',
'inline-flex items-center justify-center gap-2 transition-[filter,background-color,border-color,box-shadow]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60',
'disabled:opacity-50 disabled:pointer-events-none',
variants[variant],
+58
View File
@@ -0,0 +1,58 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/cn';
/**
* Living-Codex display primitives: a thin HP/resource Meter, a tinted Badge, an
* initials Avatar, and an embossed ability-score StatCoin. Token-driven so they
* follow the theme + the --app-accent-hue reskin.
*/
export function Meter({ value, max, tone, height = 7 }: { value: number; max: number; tone?: string; height?: number }) {
const pct = Math.max(0, Math.min(100, max > 0 ? (value / max) * 100 : 0));
return (
<div className="overflow-hidden rounded-full border border-line bg-surface-2" style={{ height }}>
<span className="block h-full rounded-full transition-[width] duration-300 ease-out" style={{ width: `${pct}%`, background: tone ?? 'var(--app-accent)' }} />
</div>
);
}
export type BadgeTone = 'default' | 'gold' | 'ember' | 'verdigris' | 'arcane' | 'success';
const badgeTone: Record<BadgeTone, string> = {
default: 'border-line text-muted bg-elevated',
gold: 'border-accent/60 text-accent-deep bg-accent-glow',
ember: 'border-danger/45 text-danger bg-danger-glow',
verdigris: 'border-verdigris/45 text-verdigris',
arcane: 'border-info/45 text-info',
success: 'border-success/45 text-success',
};
export function Badge({ tone = 'default', children, className }: { tone?: BadgeTone; children: ReactNode; className?: string }) {
return <span className={cn('inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium', badgeTone[tone], className)}>{children}</span>;
}
export function Avatar({ name, size = 40, tone }: { name: string; size?: number; tone?: string }) {
const initials = name.split(/\s+/).map((w) => w[0]).filter(Boolean).slice(0, 2).join('').toUpperCase() || '?';
const t = tone ?? 'var(--app-accent)';
return (
<div
className="grid shrink-0 place-items-center rounded-full font-display font-semibold"
style={{
width: size, height: size, fontSize: size * 0.36, color: t,
background: `color-mix(in oklch, ${t}, var(--app-panel) 78%)`,
border: `1.5px solid color-mix(in oklch, ${t}, transparent 55%)`,
}}
aria-hidden
>
{initials}
</div>
);
}
export function StatCoin({ label, value, mod, active }: { label: string; value: number | string; mod: string; active?: boolean }) {
return (
<div className={cn('flex flex-col items-center gap-0.5 rounded-xl border bg-panel px-2 py-2 text-center', active ? 'border-accent shadow-[0_0_0_3px_var(--app-accent-glow)]' : 'border-line')}>
<div className="smallcaps" style={{ fontSize: 10 }}>{label}</div>
<div className="font-display font-semibold leading-none text-ink" style={{ fontSize: 26 }}>{value}</div>
<div className="font-mono text-[13px] font-semibold text-accent-deep">{mod}</div>
</div>
);
}
+2 -2
View File
@@ -2,8 +2,8 @@ import { forwardRef, type InputHTMLAttributes, type SelectHTMLAttributes, type T
import { cn } from '@/lib/cn';
const base =
'w-full bg-surface border border-line rounded-md px-3 py-2 text-sm text-ink placeholder:text-muted ' +
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60';
'w-full bg-surface border border-line rounded-md px-3 py-2 text-sm text-ink placeholder:text-muted transition-[border-color,box-shadow] ' +
'focus-visible:outline-none focus-visible:border-accent focus-visible:ring-[3px] focus-visible:ring-accent-glow';
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
function Input({ className, ...props }, ref) {
+1 -1
View File
@@ -62,7 +62,7 @@ export function Modal({ open, onClose, title, children, footer, className }: Mod
aria-modal="true"
aria-label={title}
className={cn(
'relative w-full max-w-lg max-h-[85vh] overflow-auto rounded-lg border border-line bg-panel shadow-2xl',
'paper-grain relative w-full max-w-lg max-h-[85vh] overflow-auto rounded-xl border border-line bg-panel shadow-2xl',
className,
)}
>
+12 -5
View File
@@ -10,19 +10,26 @@ export function Page({ children }: { children: ReactNode }) {
export function PageHeader({
title,
subtitle,
eyebrow,
actions,
}: {
title: string;
subtitle?: string;
/** small italic accent line above the title (editorial eyebrow) */
eyebrow?: string;
actions?: ReactNode;
}) {
return (
<div className="mb-6 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="font-display text-2xl font-bold text-ink">{title}</h1>
{subtitle && <p className="mt-0.5 text-sm text-muted">{subtitle}</p>}
<div className="mb-6">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
{eyebrow && <div className="font-display text-sm italic text-accent">{eyebrow}</div>}
<h1 className="font-display text-3xl font-semibold tracking-tight text-ink">{title}</h1>
{subtitle && <p className="mt-1 text-sm text-muted">{subtitle}</p>}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
<hr className="gilt-rule mt-3" />
</div>
);
}