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 { forwardRef, type ButtonHTMLAttributes } from 'react';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
type Variant = 'primary' | 'secondary' | 'subtle' | 'ghost' | 'danger';
|
||||||
type Size = 'sm' | 'md' | 'icon';
|
type Size = 'sm' | 'md' | 'icon';
|
||||||
|
|
||||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
@@ -10,10 +10,12 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const variants: Record<Variant, string> = {
|
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',
|
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',
|
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> = {
|
const sizes: Record<Size, string> = {
|
||||||
@@ -31,7 +33,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
type={type ?? 'button'}
|
type={type ?? 'button'}
|
||||||
className={cn(
|
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',
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60',
|
||||||
'disabled:opacity-50 disabled:pointer-events-none',
|
'disabled:opacity-50 disabled:pointer-events-none',
|
||||||
variants[variant],
|
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';
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
const base =
|
const base =
|
||||||
'w-full bg-surface border border-line rounded-md px-3 py-2 text-sm text-ink placeholder:text-muted ' +
|
'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:ring-2 focus-visible:ring-accent/60';
|
'focus-visible:outline-none focus-visible:border-accent focus-visible:ring-[3px] focus-visible:ring-accent-glow';
|
||||||
|
|
||||||
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
|
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
|
||||||
function Input({ className, ...props }, ref) {
|
function Input({ className, ...props }, ref) {
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function Modal({ open, onClose, title, children, footer, className }: Mod
|
|||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -10,19 +10,26 @@ export function Page({ children }: { children: ReactNode }) {
|
|||||||
export function PageHeader({
|
export function PageHeader({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
|
eyebrow,
|
||||||
actions,
|
actions,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
|
/** small italic accent line above the title (editorial eyebrow) */
|
||||||
|
eyebrow?: string;
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 flex flex-wrap items-end justify-between gap-3">
|
<div className="mb-6">
|
||||||
<div>
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
<h1 className="font-display text-2xl font-bold text-ink">{title}</h1>
|
<div>
|
||||||
{subtitle && <p className="mt-0.5 text-sm text-muted">{subtitle}</p>}
|
{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>
|
</div>
|
||||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
<hr className="gilt-rule mt-3" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user