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:
@@ -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],
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user