feat(webui): restyle the design-system primitives onto the Gemini shape scale

Second half of Phase 3 (Epic #7781 WS3): the control styling that the token
values in the previous commit exist to drive. Paired with those values in one
PR deliberately — tokens alone repaint the palette but leave every control the
old shape, which cannot be judged visually.

Shape values move to the Gemini scale. Controls become pills, fields 12px,
surfaces 16px, dialogs 28px:

  --v2-radius-control{,-sm,-lg,-xl}   0.625–1.125rem  ->  9999px
  --v2-radius-field                   0.375rem        ->  0.75rem
  --v2-radius-surface                 0.75rem         ->  1rem
  --v2-radius-surface-lg              1rem            ->  1.75rem
  --v2-radius-chip                    0.25rem         ->  0.5rem

The four control slots collapsing to one value is intentional: they carry
different heights and paddings, and at every height a pill is a pill.

The accent surface stops being a gradient. #7831 parked
--v2-accent-gradient/-hover/-edge/-glow as theme-invariant literals and asked
WS3 to decide whether the primary wants a per-theme value. It does — but by
REFERENCE rather than duplication: Gemini's primary is a flat tonal fill, so
each now resolves through --v2-accent, which is already per-theme. One
definition, two themes, no literals to keep in sync. The edge and glow go to
transparent/none, since depth is tonal now.

button.tsx: the primary label was hardcoded `text-white`. That is exactly the
2.98:1 failure the previous commit documented, and it becomes unreadable on the
light theme's tonal container. It now reads --v2-accent-on.

Six more primitives move off hardcoded radii onto the scale, so their shape is
reachable from app.css like Button's already was: card, input, modal,
select-menu, search-field, inline-notice. No other visual property is touched
in those files — this is shape only.

Verified at crates/product/ironclaw_webui/frontend: typecheck,
lint:conventions, test:storybook (105 tests / 32 files), pnpm build with bundle
budgets and the design-token bundle contract. The six radius utilities the
migrated primitives now depend on were confirmed present in dist/assets/*.css
(.rounded-surface, -surface-lg, -field, -chip, -control, -control-sm), and the
emitted custom properties confirmed to carry the new values — a class-string
assertion cannot prove either.

pnpm test is 18 failed / 1499 passed, the same two pre-existing files
(theme.test.tsx, inspector-panel.test.tsx) that fail identically on main under
Node 26.

Refs #7781

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Ron
2026-08-31 07:22:23 -07:00
parent c419197174
commit 43d226cafb
8 changed files with 39 additions and 39 deletions

View File

@@ -159,7 +159,7 @@ export function Button({
sizeClass,
fullClass,
disabledAnchorClass,
"relative overflow-hidden text-white group",
"relative overflow-hidden text-[var(--v2-accent-on)] group",
"hover:shadow-[var(--v2-accent-glow)]",
className
)}

View File

@@ -47,9 +47,9 @@ const VARIANTS = {
/* ─── Radius ──────────────────────────────────────────────────────── */
const RADII = {
sm: "rounded-[14px]",
md: "rounded-[1.25rem] md:rounded-[1.5rem]",
lg: "rounded-[1.5rem]",
sm: "rounded-surface",
md: "rounded-surface md:rounded-surface-lg",
lg: "rounded-surface-lg",
};
/* ─── Padding ─────────────────────────────────────────────────────── */

View File

@@ -65,7 +65,7 @@ export function InlineNotice({
role={role}
data-tone={tone}
className={cn(
"flex items-start gap-3 rounded-xl border px-4 py-3 text-sm",
"flex items-start gap-3 rounded-surface border px-4 py-3 text-sm",
TONE_STYLES[tone],
className,
)}
@@ -81,7 +81,7 @@ export function InlineNotice({
type="button"
onClick={onDismiss}
aria-label={dismissLabel}
className="shrink-0 self-center rounded-md p-1 opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current"
className="shrink-0 self-center rounded-chip p-1 opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current"
>
<Icon name="close" className="h-3.5 w-3.5" />
</button>

View File

@@ -3,8 +3,8 @@
*
* All styling via Tailwind + CSS variables — no app.css classes.
* Sizes and focus ring match the reference AppInput exactly:
* mobile h-[44px] rounded-[14px] px-3.5 text-ui
* desktop h-[50px] rounded-[16px] px-4 text-ui
* mobile h-[44px] rounded-field px-3.5 text-ui
* desktop h-[50px] rounded-field px-4 text-ui
*
* Exports
* Input — <input> wrapper
@@ -30,9 +30,9 @@ const INPUT_BASE =
/* Sizes mirroring reference AppInput */
const INPUT_SIZES = {
sm: "h-9 rounded-[10px] px-3 text-ui-sm",
md: "h-[44px] rounded-[14px] px-3.5 text-ui md:h-[50px] md:rounded-[16px] md:px-4",
lg: "h-[54px] rounded-[18px] px-4 text-ui-lg",
sm: "h-9 rounded-field px-3 text-ui-sm",
md: "h-[44px] rounded-field px-3.5 text-ui md:h-[50px] md:px-4",
lg: "h-[54px] rounded-field px-4 text-ui-lg",
};
export type InputProps = Omit<ComponentPropsWithoutRef<"input">, "size"> & {
@@ -97,7 +97,7 @@ export function Textarea({
rows={rows}
className={cn(
INPUT_BASE,
"rounded-[14px] px-3.5 py-3 text-ui md:rounded-[16px] md:px-4",
"rounded-field px-3.5 py-3 text-ui md:px-4",
"resize-y min-h-[80px]",
error && "border-[var(--v2-danger-text)] focus:ring-[color-mix(in_srgb,var(--v2-danger-text)_28%,transparent)]",
className

View File

@@ -107,7 +107,7 @@ export function Modal({
"relative z-10 w-full",
"bg-[var(--v2-card-bg)] border border-[var(--v2-panel-border)]",
"shadow-[0_24px_60px_rgba(0,0,0,0.35)]",
"rounded-[1.5rem]",
"rounded-surface-lg",
"flex flex-col max-h-[90dvh] overflow-hidden",
SIZES[size] ?? SIZES.md,
className
@@ -151,7 +151,7 @@ export function ModalHeader({
type="button"
onClick={onClose}
aria-label={effectiveCloseLabel}
className="grid h-8 w-8 shrink-0 place-items-center rounded-[10px]
className="grid h-8 w-8 shrink-0 place-items-center rounded-control-sm
border border-[var(--v2-panel-border)] bg-[var(--v2-surface-soft)]
text-[var(--v2-text-muted)]
hover:bg-[var(--v2-surface-muted)] hover:text-[var(--v2-text-strong)]"

View File

@@ -44,7 +44,7 @@ export function SearchField({
disabled={disabled}
onChange={(event) => onChange(event.currentTarget.value)}
className={cn(
"h-9 w-full rounded-[10px] border border-[var(--v2-panel-border)]",
"h-9 w-full rounded-control border border-[var(--v2-panel-border)]",
"bg-[var(--v2-input-bg)] pl-9 text-sm text-[var(--v2-text-strong)] outline-none",
"appearance-none placeholder:text-[var(--v2-text-faint)] focus:border-[var(--v2-accent)]",
"[&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden",
@@ -59,7 +59,7 @@ export function SearchField({
disabled={disabled}
onClick={onClear}
className={cn(
"absolute right-2 top-1/2 grid h-6 w-6 -translate-y-1/2 place-items-center rounded-md",
"absolute right-2 top-1/2 grid h-6 w-6 -translate-y-1/2 place-items-center rounded-chip",
"text-[var(--v2-text-faint)] hover:bg-[var(--v2-surface-muted)] hover:text-[var(--v2-text-strong)]",
"disabled:cursor-not-allowed disabled:opacity-50",
)}

View File

@@ -426,7 +426,7 @@ export function SelectMenu({
})}
onKeyDown={handleKeyDown}
className={cn(
"inline-flex w-full items-center justify-between gap-2 rounded-[8px] border",
"inline-flex w-full items-center justify-between gap-2 rounded-field border",
"border-[var(--v2-panel-border)] bg-[var(--v2-surface-soft)]",
"text-[var(--v2-text-strong)] shadow-none transition-colors",
"hover:bg-[var(--v2-surface-muted)]",
@@ -453,7 +453,7 @@ export function SelectMenu({
{open && (
<div
className={cn(
"absolute top-[calc(100%+0.35rem)] z-30 min-w-full overflow-hidden rounded-[10px]",
"absolute top-[calc(100%+0.35rem)] z-30 min-w-full overflow-hidden rounded-surface",
"border border-[color-mix(in_srgb,var(--v2-text-strong)_16%,var(--v2-panel-border))]",
"bg-[color-mix(in_srgb,var(--v2-canvas-strong)_92%,var(--v2-surface))] p-1",
"shadow-[0_30px_72px_-18px_rgba(0,0,0,0.86),0_10px_24px_-18px_rgba(0,0,0,0.68)]",
@@ -477,7 +477,7 @@ export function SelectMenu({
onChange={(event) => setSearchQuery(event.currentTarget.value)}
onKeyDown={handleSearchKeyDown}
className={cn(
"sticky top-0 z-10 mb-1 h-9 w-full rounded-[7px] border px-2.5",
"sticky top-0 z-10 mb-1 h-9 w-full rounded-chip border px-2.5",
"border-[var(--v2-panel-border)] bg-[var(--v2-input-bg)]",
"text-[var(--v2-text-strong)] placeholder:text-[var(--v2-text-faint)]",
"focus-visible:outline-none focus-visible:ring-2",
@@ -501,7 +501,7 @@ export function SelectMenu({
onMouseEnter={() => !option.disabled && setActiveIndex(index)}
onClick={() => chooseOption(option)}
className={cn(
"flex w-full items-center justify-between gap-3 rounded-[7px]",
"flex w-full items-center justify-between gap-3 rounded-chip",
"text-left text-[var(--v2-text)] transition-colors",
"focus-visible:outline-none",
"focus-visible:ring-2 focus-visible:ring-[color-mix(in_srgb,var(--v2-accent)_30%,transparent)]",

View File

@@ -275,14 +275,16 @@
surface rounded-xl ( 56 uses) cards, panels
surface-lg rounded-2xl ( 18 uses) modals, sheets
pill rounded-full ( 47 uses) avatars, toggles, pills */
--v2-radius-chip: 0.25rem;
--v2-radius-field: 0.375rem;
--v2-radius-control-sm: 0.625rem;
--v2-radius-control: 0.875rem;
--v2-radius-control-lg: 1rem;
--v2-radius-control-xl: 1.125rem;
--v2-radius-surface: 0.75rem;
--v2-radius-surface-lg: 1rem;
--v2-radius-chip: 0.5rem;
--v2-radius-field: 0.75rem;
/* Controls are pills. The four slots stay distinct because they carry
different heights and paddings; at every height a pill is a pill. */
--v2-radius-control-sm: 9999px;
--v2-radius-control: 9999px;
--v2-radius-control-lg: 9999px;
--v2-radius-control-xl: 9999px;
--v2-radius-surface: 1rem;
--v2-radius-surface-lg: 1.75rem;
--v2-radius-pill: 9999px;
/* ── Spacing ──────────────────────────────────────────────────────
@@ -332,17 +334,15 @@
as `button.tsx` hardcoded them so retheming the loudest control in
the app stops meaning "edit a component".
Theme-INVARIANT on purpose: the literals they replace were shared by
light and dark, so splitting them per theme now would be a visual
change wearing a refactor's clothes. Phase 3a (#7781 WS3) decides
whether the M3X primary wants a per-theme value and moves these into
the `[data-theme]` stanzas if so. */
--v2-accent-gradient:
radial-gradient(ellipse 100% 100% at 50% 130%, #4ca7e6 0%, #2882c8 65%);
--v2-accent-gradient-hover:
radial-gradient(ellipse 200% 220% at 50% 110%, #5bbaf5 0%, #2882c8 60%);
--v2-accent-edge: rgba(76, 167, 230, 0.72);
--v2-accent-glow: 0 24px 24px -20px rgba(76, 167, 230, 0.55);
#7831 left these theme-invariant and asked WS3 to decide whether the
primary wants a per-theme value. It does — but by REFERENCE rather than
by duplication: Gemini's primary is a flat tonal fill, so each of these
now resolves through `--v2-accent`, which is already per-theme. One
definition, two themes, no literals to keep in sync. */
--v2-accent-gradient: var(--v2-accent);
--v2-accent-gradient-hover: var(--v2-accent-strong);
--v2-accent-edge: transparent;
--v2-accent-glow: 0 0 #0000;
/* ── Z-index ──────────────────────────────────────────────────────
A stacking ORDER, not a set of magic numbers. The two four-digit