Compare commits

...
Author SHA1 Message Date
IllusionandClaude Opus 5 2c51c35fe8 Fix the mobile navigation drawer, which painted a 64px strip
The bar carries the drafts' translucent ground with
`backdrop-filter: blur(14px)`, and a filtered element becomes the containing
block for every position:fixed descendant (CSS Filter Effects 1 s.6.1). The
drawer was rendered inside that header, so its `fixed inset-0` resolved
against the 64px bar rather than the viewport. Measured on the built page at
390x844, the panel came out 390x64: a strip of drawer background at the top,
the menu spilling over the hero with nothing painted behind it, and the page
underneath scroll-locked. The header is also a stacking context
(z-index: var(--layer-header)), so an aria-modal dialog nested inside it
could never rise above anything painted above the bar.

The drawer is now portalled to <body>, which resolves the containing block
and the stacking context at once, and is where a modal dialog belongs
regardless. The portal mounts one commit after hydration so the first client
render still matches the server's; nothing is lost, because the trigger is a
button with an onClick - without JavaScript the drawer could not be opened
at all - and every link in it already ships in the server-rendered
DesktopNav markup.

The inert walk benefits too. It inerts each ancestor's OTHER children up to
<body>, which previously could not reach the header without inerting the
drawer inside it; from the portal, body's other children are exactly the
header, main and footer.

Also gives the hamburger and its close twin a 44x44 hit area. They were
24x24, under the WCAG 2.5.5 target size; a logical negative inline-end
margin keeps the glyph on the same page gutter the wordmark opposite it
uses, so it still mirrors in Arabic.

Verified in a headless browser against the production build at 390x844: the
panel fills the viewport in both locales and mirrors under dir="rtl", focus
moves to Close on open and back to the trigger on Escape, the scroll lock
and the inert flags are set and cleared, and the desktop nav is unchanged -
three group triggers and a working dropdown at 1440px, the hamburger at
1279px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:14:29 +02:00
IllusionandClaude Opus 5 19fab4a783 Match the shipped styling to the Claude Design drafts
An audit of all twelve draft files against the build turned up a set of
mismatches, most of them in the tier-2 token layer rather than in any one
component. Tier 1 is untouched throughout - only the meanings moved.

SHAPE. The drafts draw three corners, not five: 2px for a tag, thumbnail,
flag or checkbox, 4px for anything with a box, and fully round for a filter
chip or status pill. Nothing in any draft is 8px or 12px. --radius-card and
--radius-surface therefore come down to radius/sm, a --radius-chip is added
for the 2px tier, and --radius-control-lg - a quirk of the Figma Button
frame's ghost cells - is retired along with its four consumers.

ELEVATION. A shadow is a fraction of the ground it falls on, and the ramp
was tuned for a white page: on the drafts' near-black ground a 12-28% black
is invisible. Tier 1 gains the two deep steps the drafts actually draw
(--shadow-dark-lg/xl), and --elevation-card/-popover/-modal move into the
per-surface blocks so the dark surface can take them while the light one
keeps the soft ramp. A resting card now has no shadow at all - the drafts
separate with a hairline - and the black is spent on what floats.

CARDS. The hover was a white-24% hairline and a faint glow; the drafts turn
the hairline accent green, lift the card 6px and land a deep black under it.
The lift is pointer-only and is dropped under prefers-reduced-motion.

GHOST IS A TEXT LINK. The drafts never fill a ghost control - every
"All updates ->" is plain text that turns green while the label-to-arrow gap
opens. The tokens carry no fill in any state and the variant opts out of the
3px lift, which also retires the WCAG workaround the old Figma hover forced
(green label on a green fill, 1.40:1).

NAVIGATION. A nav label rests in text/secondary and brightens to primary;
the current section stays primary and is marked by the accent rule, not by
green text.

HERO STRIPE. The middle block was painted near-black against a comment
claiming the redesign had dropped white; the drafts paint it #F8F9FA. Back
to background/inverse, and to the drafts' 390 : 495 : 363 split.

styles/README.md is updated to match, including a contrast table that still
described --surface-raised as the retired #2e2e2c.

Not changed, because each is a decision rather than a defect: the drafts set
every eyebrow, tag and meta line in IBM Plex Mono at 10-11px with 1.2-1.4px
tracking (a third typeface, and no Arabic counterpart); draft headings are
weight 800, which needs a font file we do not have; and the EU badge
statement stays at --text-primary, since the visibility lockup is
contractual.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:14:15 +02:00
20 changed files with 347 additions and 217 deletions
+25 -5
View File
@@ -26,6 +26,9 @@
* "Ghost / Large / Default" is the one cell in the matrix with no padding * "Ghost / Large / Default" is the one cell in the matrix with no padding
while its own Hover and Disabled cells have it - an authoring slip that while its own Hover and Disabled cells have it - an authoring slip that
would make the button jump on hover. Padding is kept on all ghost states. would make the button jump on hover. Padding is kept on all ghost states.
* Ghost is repainted to the Claude Design drafts rather than to the Figma
frame: text-only throughout, no hover fill, no lift, widening gap. See
the block below and the ghost tokens in semantic.css.
* Disabled uses the opaque `-bg-disabled` / `-fg-disabled` tokens rather than * Disabled uses the opaque `-bg-disabled` / `-fg-disabled` tokens rather than
Figma's 40% opacity, so the page never shows through a disabled control. Figma's 40% opacity, so the page never shows through a disabled control.
`--control-opacity-disabled` still holds the 0.4 if that is ever wanted. `--control-opacity-disabled` still holds the 0.4 if that is ever wanted.
@@ -190,11 +193,28 @@
--sh-btn-border-disabled: var(--interactive-ghost-border-disabled); --sh-btn-border-disabled: var(--interactive-ghost-border-disabled);
} }
/* Figma gives the ghost md/lg cells radius.md while every other cell uses /* GHOST IS A TEXT LINK, NOT A BUTTON.
radius.sm - reproduced rather than normalised. */ The drafts draw "All updates ->" and "What we do ->" as plain text that
.sh-btn[data-variant="ghost"][data-size="md"], turns accent green while the label-to-arrow gap opens from 8px to 12px.
.sh-btn[data-variant="ghost"][data-size="lg"] { So the ghost variant opts out of the 3px lift the filled controls take -
border-radius: var(--radius-control-lg); nothing that has no box should appear to rise off the page - and animates
its gap instead. (The old Figma-quirk override that gave ghost md/lg
radius.md is gone with --radius-control-lg; every control is 4px.) */
.sh-btn[data-variant="ghost"] .sh-btn__content {
transition:
opacity var(--motion-duration-fast) var(--motion-ease-standard),
gap var(--motion-duration-base) var(--motion-ease-standard);
}
@media (hover: hover) {
.sh-btn[data-variant="ghost"]:hover:not(:disabled):not([aria-disabled="true"]):not([data-loading]) {
transform: none;
}
.sh-btn[data-variant="ghost"]:hover:not(:disabled):not([aria-disabled="true"]):not([data-loading])
.sh-btn__content {
gap: var(--spacing-sm);
}
} }
/* --------------------------------------------------------------------- /* ---------------------------------------------------------------------
+4 -1
View File
@@ -65,7 +65,10 @@ export function CardCTA({
data-surface="dark" data-surface="dark"
className={cx( className={cx(
"sh-card relative flex flex-col gap-lg rounded-surface p-10", "sh-card relative flex flex-col gap-lg rounded-surface p-10",
"bg-surface-accent text-text-on-accent shadow-[var(--elevation-popover)]", /* The drafts rest nothing on a shadow - a panel is a fill and an edge,
and the black is spent on what floats. --elevation-card is that
resting step: none on the dark surface, soft on the light one. */
"bg-surface-accent text-text-on-accent shadow-[var(--elevation-card)]",
"sm:flex-row sm:items-center", "sm:flex-row sm:items-center",
className, className,
)} )}
@@ -85,7 +85,7 @@ export function CardContent({
light on both surfaces - text-on-accent is the only always-white light on both surfaces - text-on-accent is the only always-white
role, text-primary would go dark on the light surface. */} role, text-primary would go dark on the light surface. */}
{badge ? ( {badge ? (
<span className="absolute start-md top-md rounded-control-lg bg-overlay-scrim px-xs py-2xs label-md text-text-on-accent backdrop-blur-[4px]"> <span className="absolute start-md top-md rounded-chip bg-overlay-scrim px-xs py-2xs label-md text-text-on-accent backdrop-blur-[4px]">
{badge} {badge}
</span> </span>
) : null} ) : null}
@@ -7,7 +7,7 @@
STYLING RULE for this whole directory: STYLING RULE for this whole directory:
* colour and radius come from the tier-2 Tailwind bridge * colour and radius come from the tier-2 Tailwind bridge
(bg-surface-raised, text-text-accent, border-border-subtle, (bg-surface-raised, text-text-accent, border-border-subtle,
rounded-surface, ...) - never a hex, never a tier-1 primitive; rounded-card, ...) - never a hex, never a tier-1 primitive;
* tokens without a bridge utility are spent as arbitrary values, * tokens without a bridge utility are spent as arbitrary values,
e.g. shadow-[var(--elevation-card)]; e.g. shadow-[var(--elevation-card)];
* spacing uses the named scale (p-lg = 24, gap-md = 16) so the Figma * spacing uses the named scale (p-lg = 24, gap-md = 16) so the Figma
@@ -143,8 +143,10 @@ export interface CardShellProps {
} }
/** /**
* The surface every card shares: raised fill, hairline border, 12px corner * The surface every card shares: raised fill, hairline border and the 4px
* (Figma radius/lg) and the card elevation. * corner the design drafts draw. `--elevation-card` is the drafts' resting
* shadow, which on the dark surface is none at all - the hairline does the
* separating, and the black is kept for the hover state in card.css.
*/ */
export function CardShell({ export function CardShell({
as = "article", as = "article",
@@ -160,7 +162,7 @@ export function CardShell({
"sh-card", "sh-card",
linked && "sh-card--linked", linked && "sh-card--linked",
"relative flex bg-[var(--surface-card)] text-text-primary", "relative flex bg-[var(--surface-card)] text-text-primary",
"rounded-surface border border-border-subtle shadow-[var(--elevation-card)]", "rounded-card border border-border-subtle shadow-[var(--elevation-card)]",
className, className,
)} )}
> >
+1 -1
View File
@@ -72,7 +72,7 @@ export function CardStat({
is falling. */ is falling. */
<span <span
className={cx( className={cx(
"inline-flex shrink-0 items-center gap-2xs rounded-control-lg px-xs py-2xs", "inline-flex shrink-0 items-center gap-2xs rounded-chip px-xs py-2xs",
"label-md text-text-on-accent", "label-md text-text-on-accent",
isDown ? "bg-surface-danger" : "bg-surface-accent", isDown ? "bg-surface-danger" : "bg-surface-accent",
)} )}
+17 -4
View File
@@ -2,7 +2,7 @@
CARD - mechanics that cannot be expressed as a utility CARD - mechanics that cannot be expressed as a utility
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
Everything token-driven lives in the TSX as a tier-2 utility Everything token-driven lives in the TSX as a tier-2 utility
(bg-surface-raised, text-text-accent, rounded-surface, ...). This file only (bg-surface-raised, text-text-accent, rounded-card, ...). This file only
holds the three things a utility cannot do: holds the three things a utility cannot do:
1. paint an exported SVG asset in the current token colour (mask), 1. paint an exported SVG asset in the current token colour (mask),
@@ -13,7 +13,7 @@
component rather than by app/app.css, so it is not scoped by anything else. component rather than by app/app.css, so it is not scoped by anything else.
Everything lives in @layer components so a Tailwind utility passed through Everything lives in @layer components so a Tailwind utility passed through
`className` (`rounded-surface`, `text-text-secondary`, ...) still wins. `className` (`rounded-card`, `text-text-secondary`, ...) still wins.
The layer statement is a defensive no-op when app.css has already declared The layer statement is a defensive no-op when app.css has already declared
the same order, and makes this file correct on its own if it ever loads first. the same order, and makes this file correct on its own if it ever loads first.
========================================================================= */ ========================================================================= */
@@ -88,17 +88,29 @@
/* --------------------------------------------------------------------------- /* ---------------------------------------------------------------------------
HOVER / FOCUS AFFORDANCES HOVER / FOCUS AFFORDANCES
------------------------------------------------------------------------ */ ------------------------------------------------------------------------ */
/* The drafts hover a card three ways at once: the hairline turns accent
green, the card rises, and a deep black lands under it. The lift is
pointer-only - a touch "hover" would leave the card raised after the tap -
and it is dropped entirely under prefers-reduced-motion below, because a
transform still lands instantly once app.css has flattened the duration. */
.sh-card--linked { .sh-card--linked {
transition: transition:
box-shadow var(--motion-duration-base) var(--motion-ease-standard), box-shadow var(--motion-duration-base) var(--motion-ease-standard),
border-color var(--motion-duration-base) var(--motion-ease-standard); border-color var(--motion-duration-base) var(--motion-ease-standard),
transform var(--motion-duration-base) var(--motion-ease-standard);
} }
.sh-card--linked:hover { .sh-card--linked:hover {
border-color: var(--border-default); border-color: var(--border-accent);
box-shadow: var(--elevation-popover); box-shadow: var(--elevation-popover);
} }
@media (hover: hover) {
.sh-card--linked:hover {
transform: translateY(-6px);
}
}
.sh-card--linked:hover .sh-card-title, .sh-card--linked:hover .sh-card-title,
.sh-card--linked:has(.sh-card-link:focus-visible) .sh-card-title { .sh-card--linked:has(.sh-card-link:focus-visible) .sh-card-title {
text-decoration: underline; text-decoration: underline;
@@ -116,6 +128,7 @@
/* app.css already neutralises transition durations under reduced motion, but a /* app.css already neutralises transition durations under reduced motion, but a
transform still lands instantly - drop it entirely. */ transform still lands instantly - drop it entirely. */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.sh-card--linked:hover,
.sh-card--linked:hover .sh-card-media img { .sh-card--linked:hover .sh-card-media img {
transform: none; transform: none;
} }
+1 -1
View File
@@ -82,7 +82,7 @@
background-color: var(--field-bg); background-color: var(--field-bg);
color: var(--field-fg); color: var(--field-fg);
border: var(--control-border-width) solid var(--field-border); border: var(--control-border-width) solid var(--field-border);
border-radius: var(--radius-control-lg); border-radius: var(--radius-control);
padding-inline: calc(var(--control-padding-inline-md) - var(--control-border-width)); padding-inline: calc(var(--control-padding-inline-md) - var(--control-border-width));
padding-block: calc(var(--control-padding-block-md) - var(--control-border-width)); padding-block: calc(var(--control-padding-block-md) - var(--control-border-width));
text-align: start; text-align: start;
@@ -436,10 +436,14 @@ export function DesktopNav({
className={cx( className={cx(
"body-sm-bold flex cursor-pointer items-center gap-2xs", "body-sm-bold flex cursor-pointer items-center gap-2xs",
"whitespace-nowrap pb-[2px] text-start", "whitespace-nowrap pb-[2px] text-start",
"text-[var(--text-primary)]", /* The drafts rest a nav label in text/secondary and
"hover:text-[var(--text-accent)]", brighten it to primary under the pointer; the section
"aria-expanded:text-[var(--text-accent)]", the reader is in stays primary and is marked by the
"data-[current=true]:text-[var(--text-accent)]", accent rule below, not by green text. */
"text-[var(--text-secondary)]",
"hover:text-[var(--text-primary)]",
"aria-expanded:text-[var(--text-primary)]",
"data-[current=true]:text-[var(--text-primary)]",
HOVER_TRANSITION, HOVER_TRANSITION,
FOCUS_RING, FOCUS_RING,
)} )}
@@ -512,9 +516,10 @@ export function DesktopNav({
onFocus={closeGroup} onFocus={closeGroup}
className={cx( className={cx(
"body-sm-bold whitespace-nowrap pb-[2px] no-underline", "body-sm-bold whitespace-nowrap pb-[2px] no-underline",
"text-[var(--text-primary)]", /* Same rest/hover/current treatment as a group trigger. */
"hover:text-[var(--text-accent)]", "text-[var(--text-secondary)]",
"aria-[current=page]:text-[var(--text-accent)]", "hover:text-[var(--text-primary)]",
"aria-[current=page]:text-[var(--text-primary)]",
HOVER_TRANSITION, HOVER_TRANSITION,
FOCUS_RING, FOCUS_RING,
)} )}
@@ -6,6 +6,7 @@ import {
useState, useState,
type KeyboardEvent as ReactKeyboardEvent, type KeyboardEvent as ReactKeyboardEvent,
} from "react"; } from "react";
import { createPortal } from "react-dom";
import { Link } from "react-router"; import { Link } from "react-router";
import { EuBadge } from "../EuBadge"; import { EuBadge } from "../EuBadge";
@@ -28,6 +29,18 @@ const FOCUSABLE_SELECTOR = [
'[tabindex]:not([tabindex="-1"])', '[tabindex]:not([tabindex="-1"])',
].join(", "); ].join(", ");
/**
* The hamburger and its close twin. The glyph is the drafts' 24px box, which is
* well under the 44x44 WCAG 2.5.5 target size, so the control is a 44px square
* around it. The negative inline-end margin gives back the 10px of padding that
* would otherwise push the glyph off the page gutter the wordmark opposite it
* lines up with - logical, so it mirrors in Arabic.
*/
const TAP_TARGET = cx(
"-me-[0.625rem] inline-flex size-11 shrink-0 cursor-pointer",
"items-center justify-center rounded-control",
);
function focusablesIn(root: HTMLElement): HTMLElement[] { function focusablesIn(root: HTMLElement): HTMLElement[] {
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter( return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
(element) => !element.hasAttribute("hidden") && element.getClientRects().length > 0, (element) => !element.hasAttribute("hidden") && element.getClientRects().length > 0,
@@ -55,6 +68,23 @@ export interface MobileNavProps {
* `inert` (so assistive technology cannot browse the page under an aria-modal * `inert` (so assistive technology cannot browse the page under an aria-modal
* dialog either), the page behind cannot scroll, Escape closes it, and focus * dialog either), the page behind cannot scroll, Escape closes it, and focus
* returns to the hamburger on close. * returns to the hamburger on close.
*
* THE DRAWER IS PORTALLED TO <body>, AND HAS TO BE.
* The bar it hangs off paints the drafts' translucent ground with
* `backdrop-filter: blur(14px)`, and a filtered element becomes the containing
* block for every `position: fixed` descendant (CSS Filter Effects 1 s.6.1).
* Rendered in place, `fixed inset-0` therefore resolved against the 64px bar
* rather than the viewport: the panel painted a 64px strip and its menu spilled
* over the page with no background behind it. The header also carries
* `z-index: var(--layer-header)`, which is a stacking context, so an
* `aria-modal` dialog nested inside it could never rise above anything painted
* above the bar either. A portal fixes both at once, and it is where a modal
* dialog belongs regardless.
*
* The portal is client-only, so the panel joins the DOM on mount. Nothing is
* lost: the trigger is a button with an onClick, so without JavaScript the
* drawer could not be opened anyway, and every link in it is already in the
* server-rendered DesktopNav markup.
*/ */
export function MobileNav({ export function MobileNav({
items, items,
@@ -69,6 +99,9 @@ export function MobileNav({
className, className,
}: MobileNavProps) { }: MobileNavProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
/* The portal target only exists in a browser, and the first client render has
to match the server's, so the panel is mounted one commit later. */
const [mounted, setMounted] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null); const triggerRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null); const closeRef = useRef<HTMLButtonElement>(null);
const drawerRef = useRef<HTMLDivElement>(null); const drawerRef = useRef<HTMLDivElement>(null);
@@ -81,6 +114,8 @@ export function MobileNav({
const close = useCallback(() => setOpen(false), []); const close = useCallback(() => setOpen(false), []);
useEffect(() => setMounted(true), []);
/* Focus in on open, and back to the hamburger on close. */ /* Focus in on open, and back to the hamburger on close. */
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -94,11 +129,11 @@ export function MobileNav({
/* The drawer is aria-modal, so the rest of the page has to be inert as well - /* The drawer is aria-modal, so the rest of the page has to be inert as well -
otherwise a screen reader still browses the header, main and footer behind otherwise a screen reader still browses the header, main and footer behind
it. Rather than naming those landmarks (the drawer is itself rendered it. Rather than naming those landmarks, every ancestor up to <body> has its
inside the header, so inerting the header would inert the drawer), every OTHER children inerted; from the portal that is simply body's other
ancestor up to <body> has its OTHER children inerted. `inert` is set as an children, which is exactly the header, main and footer. `inert` is set as
attribute so no DOM-lib typing is needed, and an element that was already an attribute so no DOM-lib typing is needed, and an element that was
inert is skipped so the restore never clears someone else's flag. already inert is skipped so the restore never clears someone else's flag.
This runs after the focus effect above on purpose: focus is already inside This runs after the focus effect above on purpose: focus is already inside
the drawer by the time the element it came from goes inert. */ the drawer by the time the element it came from goes inert. */
@@ -215,6 +250,114 @@ export function MobileNav({
FOCUS_RING, FOCUS_RING,
); );
const panel = (
<div
id={drawerId}
ref={drawerRef}
role="dialog"
aria-modal="true"
aria-label={strings.primaryNavLabel}
hidden={!open}
data-surface={surface}
onKeyDown={handleDrawerKeyDown}
className={cx(
"fixed inset-0 z-[var(--layer-modal)] flex flex-col",
"bg-[var(--surface-page)] xl:hidden",
)}
>
{/* Mirrors the compact bar it replaces (20:220): the same 64px row and
the same gutter, so the wordmark does not shift when it opens. */}
<div className="flex h-[64px] shrink-0 items-center justify-between gap-md px-[var(--layout-gutter)]">
<Brand href={homeHref} label={strings.homeLinkLabel} surface={surface} />
<button
ref={closeRef}
type="button"
aria-label={strings.closeMenu}
onClick={close}
className={cx(TAP_TARGET, FOCUS_RING)}
>
<MenuBars open />
</button>
</div>
<nav
aria-label={strings.primaryNavLabel}
className="flex-1 overflow-y-auto px-[var(--layout-gutter)] pt-md pb-2xl"
>
<ul className="flex flex-col gap-xl">
{items.map((item) =>
isNavGroup(item) ? (
<li key={item.id}>
<h2
id={headingId(item.id)}
className="body-md-bold text-[var(--text-primary)]"
>
{item.label}
</h2>
<ul
aria-labelledby={headingId(item.id)}
className="mt-sm flex flex-col gap-2xs border-s border-[var(--border-subtle)] ps-md"
>
{item.children.map((child) => (
<li key={child.id}>
<Link
to={child.href}
onClick={close}
aria-current={
isCurrentPath(child.href, pathname) ? "page" : undefined
}
className={cx(
linkClasses,
"body-md text-[var(--text-secondary)]",
)}
>
{child.label}
</Link>
</li>
))}
</ul>
</li>
) : (
<li key={item.id}>
<Link
to={item.href}
onClick={close}
aria-current={
isCurrentPath(item.href, pathname) ? "page" : undefined
}
className={cx(
linkClasses,
"body-md-bold text-[var(--text-primary)]",
)}
>
{item.label}
</Link>
</li>
),
)}
</ul>
</nav>
<div className="flex shrink-0 flex-wrap items-center justify-between gap-md border-t border-[var(--border-subtle)] px-[var(--layout-gutter)] py-lg">
<EuBadge
statement={strings.euStatement}
emblemAlt={strings.euEmblemAlt}
locale={locale}
surface={surface}
/>
{showLanguageSwitcher && (
<LanguageSwitcher
locale={locale}
pathname={pathname}
search={search}
hash={hash}
size="md"
/>
)}
</div>
</div>
);
return ( return (
<> <>
<button <button
@@ -224,123 +367,12 @@ export function MobileNav({
aria-controls={drawerId} aria-controls={drawerId}
aria-label={open ? strings.closeMenu : strings.openMenu} aria-label={open ? strings.closeMenu : strings.openMenu}
onClick={() => setOpen((value) => !value)} onClick={() => setOpen((value) => !value)}
className={cx( className={cx(TAP_TARGET, "xl:hidden", FOCUS_RING, className)}
"inline-flex shrink-0 cursor-pointer items-center justify-center rounded-control xl:hidden",
FOCUS_RING,
className,
)}
> >
<MenuBars /> <MenuBars />
</button> </button>
<div {mounted ? createPortal(panel, document.body) : null}
id={drawerId}
ref={drawerRef}
role="dialog"
aria-modal="true"
aria-label={strings.primaryNavLabel}
hidden={!open}
data-surface={surface}
onKeyDown={handleDrawerKeyDown}
className={cx(
"fixed inset-0 z-[var(--layer-modal)] flex flex-col",
"bg-[var(--surface-page)] xl:hidden",
)}
>
{/* Mirrors the compact bar it replaces (20:220): the same 64px row and
the same gutter, so the wordmark does not shift when it opens. */}
<div className="flex h-[64px] shrink-0 items-center justify-between gap-md px-[var(--layout-gutter)]">
<Brand href={homeHref} label={strings.homeLinkLabel} surface={surface} />
<button
ref={closeRef}
type="button"
aria-label={strings.closeMenu}
onClick={close}
className={cx(
"inline-flex shrink-0 cursor-pointer items-center justify-center rounded-control",
FOCUS_RING,
)}
>
<MenuBars open />
</button>
</div>
<nav
aria-label={strings.primaryNavLabel}
className="flex-1 overflow-y-auto px-[var(--layout-gutter)] pt-md pb-2xl"
>
<ul className="flex flex-col gap-xl">
{items.map((item) =>
isNavGroup(item) ? (
<li key={item.id}>
<h2
id={headingId(item.id)}
className="body-md-bold text-[var(--text-primary)]"
>
{item.label}
</h2>
<ul
aria-labelledby={headingId(item.id)}
className="mt-sm flex flex-col gap-2xs border-s border-[var(--border-subtle)] ps-md"
>
{item.children.map((child) => (
<li key={child.id}>
<Link
to={child.href}
onClick={close}
aria-current={
isCurrentPath(child.href, pathname) ? "page" : undefined
}
className={cx(
linkClasses,
"body-md text-[var(--text-secondary)]",
)}
>
{child.label}
</Link>
</li>
))}
</ul>
</li>
) : (
<li key={item.id}>
<Link
to={item.href}
onClick={close}
aria-current={
isCurrentPath(item.href, pathname) ? "page" : undefined
}
className={cx(
linkClasses,
"body-md-bold text-[var(--text-primary)]",
)}
>
{item.label}
</Link>
</li>
),
)}
</ul>
</nav>
<div className="flex shrink-0 flex-wrap items-center justify-between gap-md border-t border-[var(--border-subtle)] px-[var(--layout-gutter)] py-lg">
<EuBadge
statement={strings.euStatement}
emblemAlt={strings.euEmblemAlt}
locale={locale}
surface={surface}
/>
{showLanguageSwitcher && (
<LanguageSwitcher
locale={locale}
pathname={pathname}
search={search}
hash={hash}
size="md"
/>
)}
</div>
</div>
</> </>
); );
} }
@@ -409,7 +409,7 @@ export function ConsentCheckbox({
required required
aria-invalid={error ? true : undefined} aria-invalid={error ? true : undefined}
aria-describedby={describedBy} aria-describedby={describedBy}
className="mt-2xs size-md shrink-0 rounded-[var(--radius-control)] border border-border-strong accent-[var(--interactive-primary-bg)]" className="mt-2xs size-md shrink-0 rounded-chip border border-border-strong accent-[var(--interactive-primary-bg)]"
/> />
<label htmlFor={name} className="body-sm text-text-primary"> <label htmlFor={name} className="body-sm text-text-primary">
{label} {label}
+12 -15
View File
@@ -563,27 +563,24 @@ export function PageHero({
/> />
</div> </div>
{/* The colour band (478:16360): three blocks flush under the {/* The colour band (478:16360, redrawn in the drafts' home
picture, 6px tall. Figma lays out 390 / 495 / 363 inside a hero): three blocks flush under the picture, 6px tall, split
1244-wide clipping frame, so the last block loses 4px and the 390 : 495 : 363 and carried as flex-grow ratios so the split
split that is actually drawn is 390 : 495 : 359 - carried here holds at every width instead of only at the drafts' 1244.
as flex-grow ratios so it holds at every width instead of only
at 1244. The three fills are `background/accent`, The fills are `background/accent`, `background/inverse` and
`background/inverse` and `background/danger`, which is why the `background/danger` - the drafts paint the middle block
middle block reads white on the dark hero and near-black on a #F8F9FA, so the stripe reads green / white / red on the dark
light surface. hero and flips to green / near-black / red on a light
surface.
Purely decorative, so it is hidden from assistive technology; Purely decorative, so it is hidden from assistive technology;
as an ordinary flex row it mirrors in Arabic along with the as an ordinary flex row it mirrors in Arabic along with the
rest of the page. */} rest of the page. */}
<div aria-hidden="true" className="sh-bar-grow flex h-[6px] w-full"> <div aria-hidden="true" className="sh-bar-grow flex h-[6px] w-full">
<span className="basis-0 grow-[390] bg-[var(--surface-accent)]" /> <span className="basis-0 grow-[390] bg-[var(--surface-accent)]" />
{/* The middle block is the raised dark, not the inverse: <span className="basis-0 grow-[495] bg-[var(--surface-inverse)]" />
the redesign drops pure white from the page entirely, so <span className="basis-0 grow-[363] bg-[var(--surface-danger)]" />
the stripe reads green / dark / red on the near-black
band instead of green / white / red. */}
<span className="basis-0 grow-[495] bg-[var(--surface-raised)]" />
<span className="basis-0 grow-[359] bg-[var(--surface-danger)]" />
</div> </div>
</div> </div>
) : ( ) : (
+1 -1
View File
@@ -792,7 +792,7 @@ export function FacetGroup({
aria-hidden="true" aria-hidden="true"
className={cx( className={cx(
"mt-2xs flex size-[0.875rem] shrink-0 items-center justify-center", "mt-2xs flex size-[0.875rem] shrink-0 items-center justify-center",
"rounded-[var(--radius-control)] border", "rounded-chip border",
option.isCurrent option.isCurrent
? "border-border-accent" ? "border-border-accent"
: "border-border-default", : "border-border-default",
@@ -466,7 +466,7 @@ function StreamDisclosure({
<h3 className="flex-1 heading-h3">{copy.title}</h3> <h3 className="flex-1 heading-h3">{copy.title}</h3>
<span className="hidden shrink-0 rounded-[var(--radius-control)] bg-surface-accent-subtle px-xs py-2xs label-md uppercase text-text-accent sm:inline-block"> <span className="hidden shrink-0 rounded-chip bg-surface-accent-subtle px-xs py-2xs label-md uppercase text-text-accent sm:inline-block">
{copy.partners} {copy.partners}
</span> </span>
+1 -1
View File
@@ -638,7 +638,7 @@ function MonthCell({ day, locale }: { day: CalendarDay; locale: Locale }) {
to={entry.href} to={entry.href}
prefetch="intent" prefetch="intent"
className={[ className={[
"rounded-[var(--radius-control)] border-s-2 border-s-[var(--border-accent)]", "rounded-chip border-s-2 border-s-[var(--border-accent)]",
"bg-surface-accent-subtle px-2xs py-2xs body-sm-bold text-text-primary", "bg-surface-accent-subtle px-2xs py-2xs body-sm-bold text-text-primary",
"transition-colors duration-[var(--motion-duration-fast)]", "transition-colors duration-[var(--motion-duration-fast)]",
"ease-[var(--motion-ease-standard)] hover:text-text-accent", "ease-[var(--motion-ease-standard)] hover:text-text-accent",
+1 -1
View File
@@ -664,7 +664,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
key={member.name} key={member.name}
className="sh-lift sh-reveal sh-reveal--in flex h-full flex-col items-start gap-sm rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)] p-xl hover:border-[var(--border-accent)]" className="sh-lift sh-reveal sh-reveal--in flex h-full flex-col items-start gap-sm rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)] p-xl hover:border-[var(--border-accent)]"
> >
<span className="rounded-control bg-[var(--surface-accent-subtle)] px-xs py-2xs label-md uppercase text-[var(--text-accent)]"> <span className="rounded-chip bg-[var(--surface-accent-subtle)] px-xs py-2xs label-md uppercase text-[var(--text-accent)]">
{member.role} {member.role}
</span> </span>
<h3 className="heading-h3 text-[var(--text-primary)]">{member.name}</h3> <h3 className="heading-h3 text-[var(--text-primary)]">{member.name}</h3>
+1 -1
View File
@@ -327,7 +327,7 @@ function NewsMetaRow({ entry }: { entry: NewsListItem }) {
return ( return (
<div className="flex flex-wrap items-center gap-sm"> <div className="flex flex-wrap items-center gap-sm">
{entry.tag ? ( {entry.tag ? (
<span className="rounded-[var(--radius-control)] bg-surface-accent-subtle px-xs py-2xs label-md uppercase text-text-accent"> <span className="rounded-chip bg-surface-accent-subtle px-xs py-2xs label-md uppercase text-text-accent">
{entry.tag} {entry.tag}
</span> </span>
) : null} ) : null}
@@ -515,7 +515,7 @@ function ResourceRow({
{entry.fileKind ? ( {entry.fileKind ? (
<span <span
aria-hidden="true" aria-hidden="true"
className="flex h-14 w-11 shrink-0 items-center justify-center rounded-[var(--radius-control)] border border-border-default bg-surface-sunken label-md text-text-accent" className="flex h-14 w-11 shrink-0 items-center justify-center rounded-chip border border-border-default bg-surface-sunken label-md text-text-accent"
> >
{entry.fileKind} {entry.fileKind}
</span> </span>
+49 -24
View File
@@ -60,7 +60,8 @@ A Tailwind 4 `@theme static` block. Raw ramps only, named by value:
`--font-weight-regular..black` `--font-weight-regular..black`
* space `--spacing-2xs..5xl` - radius `--radius-none..full` - * space `--spacing-2xs..5xl` - radius `--radius-none..full` -
border width `--border-width-sm..xl` - opacity `--opacity-0..100` border width `--border-width-sm..xl` - opacity `--opacity-0..100`
* shadow `--shadow-xs..xl` - duration `--duration-instant..slower` - * shadow `--shadow-xs..xl` plus the dark-ground steps `--shadow-dark-lg|xl` -
duration `--duration-instant..slower` -
easing `--ease-standard | decelerate | accelerate | emphasized` easing `--ease-standard | decelerate | accelerate | emphasized`
* z ladder `--z-0..70` - containers `--container-prose | content | wide` * z ladder `--z-0..70` - containers `--container-prose | content | wide`
@@ -90,6 +91,25 @@ Plain CSS custom properties whose value is always `var(--<primitive>)`. Families
`--focus-ring-*` `--overlay-*` `--radius-*` `--control-*` `--layout-*` `--focus-ring-*` `--overlay-*` `--radius-*` `--control-*` `--layout-*`
`--elevation-*` `--motion-*` `--layer-*` `--type-body-base-*` `--elevation-*` `--motion-*` `--layer-*` `--type-body-base-*`
Two families are worth knowing by heart, because both were retuned to the
Claude Design drafts rather than to the older Figma Button frame:
* **Shape.** The drafts draw three corners, not five. `--radius-chip` (2px) for
a tag, thumbnail, flag or checkbox; `--radius-control` / `--radius-card` /
`--radius-surface` (all 4px) for a button, field, menu, card or panel;
`--radius-pill` for a filter chip or status pill. Nothing in the drafts is
8px or 12px, which is why the old `--radius-control-lg` is gone. The tier-1
ramp still carries `--radius-md` / `--radius-lg` - only the meanings moved.
* **Elevation.** A shadow is a fraction of the ground it falls on, so
`--elevation-card`, `--elevation-popover` and `--elevation-modal` are
declared **per surface**. On the dark surface a resting card has no shadow at
all (the drafts separate with a hairline) and the deep black - tier 1's
`--shadow-dark-lg` / `--shadow-dark-xl`, the drafts' own
`0 20px 44px rgba(0,0,0,.5)` and `0 24px 48px rgba(0,0,0,.6)` - is spent on
what actually floats: a card under the pointer, the nav dropdown. The light
surface keeps the soft ramp. Only `--elevation-flat` and `--elevation-raised`
are surface-independent.
Interactive tokens are fully regular - for each of the five Figma button Interactive tokens are fully regular - for each of the five Figma button
variants (`primary`, `secondary`, `dark`, `danger`, `ghost`) there is variants (`primary`, `secondary`, `dark`, `danger`, `ghost`) there is
@@ -163,12 +183,14 @@ Filenames are case-sensitive in production - match the table exactly.
## Known deviation from Figma ## Known deviation from Figma
The published Ghost button hover paints `background/accent` (`#008764`) and keeps **Ghost is a text link, not a button.** The published Figma Ghost cell fills on
`base/turquoise-dark` (`#006b4f`) as the label - 1.40:1, which fails WCAG AA. hover (`background/accent` behind a `base/turquoise-dark` label - 1.40:1, a
`--interactive-ghost-fg-hover` is therefore `--color-white` on **both** surfaces WCAG AA failure). The Claude Design drafts never fill a ghost control: every
(4.52:1, passes AA for body text). The two lines are marked `(a11y)` in "All updates ->" / "What we do ->" is plain text in `--text-primary` that turns
`semantic.css`; revert them to `var(--color-green-700)` for pixel-exact parity accent green under the pointer while the label-to-arrow gap opens from 8px to
if the designer prefers the failure. 12px. The drafts win, so the ghost tokens carry no fill in any state, the
variant opts out of the 3px lift in `Button.css`, and the old a11y workaround
is no longer needed.
## Contrast audit ## Contrast audit
@@ -179,25 +201,26 @@ bold) and for non-text UI boundaries (1.4.11).
### Dark surface (`:root`, `[data-surface="dark"]`) ### Dark surface (`:root`, `[data-surface="dark"]`)
| text token | page `#1d1d1b` | raised `#2e2e2c` | sunken `#0b0b0a` | muted (white 8%) | accent-subtle (green 24%) | danger-subtle (red 24%) | | text token | page `#1d1d1b` | raised `#141413` | sunken `#0b0b0a` | muted (white 8%) | accent-subtle (green 24%) | danger-subtle (red 24%) |
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- |
| `--text-primary` neutral-50 | 16.02 | 12.91 | 18.68 | 12.71 | 12.40 | 12.86 | | `--text-primary` neutral-50 | 16.02 | 17.49 | 18.68 | 12.71 | 12.40 | 12.86 |
| `--text-secondary` neutral-300 | 9.46 | 7.63 | 11.04 | 7.51 | 7.32 | 7.60 | | `--text-secondary` neutral-300 | 9.46 | 10.33 | 11.04 | 7.51 | 7.32 | 7.60 |
| `--text-tertiary` neutral-400 | 5.56 | 4.48 | 6.48 | 4.41 | 4.30 | 4.46 | | `--text-tertiary` neutral-400 | 5.56 | 6.07 | 6.48 | 4.41 | 4.30 | 4.46 |
| `--text-accent` / `--text-link` green-400 | 6.02 | 4.85 | 7.02 | 4.78 | 4.66 | 4.83 | | `--text-accent` / `--text-link` green-400 | 6.02 | 6.57 | 7.02 | 4.78 | 4.66 | 4.83 |
| `--text-link-hover` green-300 | 8.56 | 6.90 | 9.99 | 6.80 | 6.63 | 6.87 | | `--text-link-hover` green-300 | 8.56 | 9.35 | 9.99 | 6.80 | 6.63 | 6.87 |
| `--text-danger` red-400 | 5.39 | 4.34 | 6.29 | 4.28 | 4.17 | 4.33 | | `--text-danger` red-400 | 5.39 | 5.89 | 6.29 | 4.28 | 4.17 | 4.33 |
| `--text-disabled` neutral-500 | 2.36 | 1.91 | 2.76 | 1.88 | 1.83 | 1.90 | | `--text-disabled` neutral-500 | 2.36 | 2.58 | 2.76 | 1.88 | 1.83 | 1.90 |
`--text-primary`, `--text-secondary` and `--text-link-hover` pass AA everywhere. `--text-primary`, `--text-secondary` and `--text-link-hover` pass AA everywhere.
Everything below them is only cleared against `--surface-page` and Everything below them is only cleared against `--surface-page` and
`--surface-sunken`, which is where the design actually uses them: `--surface-sunken`, which is where the design actually uses them:
* `--surface-raised` is the drafts' `#141413`, DARKER than the page, so every
role gains contrast on it rather than losing it - the whole column clears AA.
* `--text-tertiary`, `--text-accent`, `--text-link` and `--text-danger` land * `--text-tertiary`, `--text-accent`, `--text-link` and `--text-danger` land
between 4.17 and 4.85 on `--surface-raised` and on the three translucent between 4.17 and 4.78 on the three translucent fills - AA for large text,
fills - AA for large text, **not** for body text. Card bodies use **not** for body text. Card bodies use `--text-secondary`; keep
`--text-secondary`; keep tertiary/accent/danger body copy on the page or tertiary/accent/danger body copy on an opaque surface, or raise it a step.
sunken surface, or raise the text a step.
* `--text-disabled` is 2.36:1 by design. WCAG 1.4.3 exempts the label of a * `--text-disabled` is 2.36:1 by design. WCAG 1.4.3 exempts the label of a
disabled control, so this is deliberate and must not be repointed - it is disabled control, so this is deliberate and must not be repointed - it is
the only token allowed below 3:1. the only token allowed below 3:1.
@@ -250,16 +273,18 @@ Everything below them is only cleared against `--surface-page` and
| dark white / `bg-hover` black | 21.00 | | dark white / `bg-hover` black | 21.00 |
| dark `fg-disabled` neutral-300 / `bg-disabled` neutral-600 (dark surface) | 5.39 | | dark `fg-disabled` neutral-300 / `bg-disabled` neutral-600 (dark surface) | 5.39 |
| dark `fg-disabled` neutral-200 / `bg-disabled` neutral-500 (light surface) | 4.91 | | dark `fg-disabled` neutral-200 / `bg-disabled` neutral-500 (light surface) | 4.91 |
| **ghost `fg-hover` white / `bg-hover` green-600** (was green-900, 2.62) | **4.52** | | ghost `fg` neutral-50 / dark page (no fill in any state) | 16.02 |
| ghost white / `bg-active` green-700 | 6.53 | | ghost `fg-hover` green-400 / dark page | 6.02 |
| ghost `fg` green-400 / dark page | 6.02 | | ghost `fg` neutral-800 / light page | 16.88 |
| ghost `fg` green-600 / light page | 4.52 | | ghost `fg-hover` green-700 / light page | 6.53 |
| **ghost `fg-disabled` neutral-500 / dark page** | **2.36** |
| **ghost `fg-disabled` neutral-400 / light page** | **3.04** |
| secondary `fg` neutral-50 / dark page | 16.02 | | secondary `fg` neutral-50 / dark page | 16.02 |
| secondary `fg` neutral-800 / light page | 16.88 | | secondary `fg` neutral-800 / light page | 16.88 |
| **secondary `fg-disabled` neutral-500 / dark page** | **2.36** | | **secondary `fg-disabled` neutral-500 / dark page** | **2.36** |
| **secondary `fg-disabled` neutral-400 / light page** | **3.04** | | **secondary `fg-disabled` neutral-400 / light page** | **3.04** |
The four bolded disabled rows are the WCAG 1.4.3 exemption again - a disabled The bolded disabled rows are the WCAG 1.4.3 exemption again - a disabled
control has no contrast minimum. Every enabled pair clears 4.5:1. control has no contrast minimum. Every enabled pair clears 4.5:1.
### Fields and boundaries ### Fields and boundaries
+13
View File
@@ -210,12 +210,25 @@
/* -------------------------------------------------------------------- /* --------------------------------------------------------------------
SHADOW ramp SHADOW ramp
Two ramps, because a shadow is a fraction of the ground it falls on.
The soft steps are tuned for a white page; on the drafts' near-black
ground a 12-28% black is simply invisible, so the deep steps below carry
the two shadows the drafts actually draw:
card hover 0 20px 44px rgba(0,0,0,0.5)
nav dropdown 0 24px 48px rgba(0,0,0,0.6)
`semantic.css` picks the ramp per surface via --elevation-*.
-------------------------------------------------------------------- */ -------------------------------------------------------------------- */
--shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.06); --shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.06);
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.08); --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.08);
--shadow-md: 0 4px 12px -2px rgb(0 0 0 / 0.12), 0 2px 4px -2px rgb(0 0 0 / 0.08); --shadow-md: 0 4px 12px -2px rgb(0 0 0 / 0.12), 0 2px 4px -2px rgb(0 0 0 / 0.08);
--shadow-lg: 0 12px 28px -6px rgb(0 0 0 / 0.18), 0 4px 8px -4px rgb(0 0 0 / 0.1); --shadow-lg: 0 12px 28px -6px rgb(0 0 0 / 0.18), 0 4px 8px -4px rgb(0 0 0 / 0.1);
--shadow-xl: 0 24px 48px -12px rgb(0 0 0 / 0.28); --shadow-xl: 0 24px 48px -12px rgb(0 0 0 / 0.28);
/* [drafts] the deep steps, for a shadow falling on the dark ground */
--shadow-dark-lg: 0 20px 44px rgb(0 0 0 / 0.5);
--shadow-dark-xl: 0 24px 48px rgb(0 0 0 / 0.6);
--shadow-none: 0 0 #0000; --shadow-none: 0 0 #0000;
/* -------------------------------------------------------------------- /* --------------------------------------------------------------------
+49 -29
View File
@@ -121,19 +121,18 @@
--interactive-danger-border-disabled: var(--color-transparent); --interactive-danger-border-disabled: var(--color-transparent);
/* --- interactive: ghost (text-only) ------------------------------------- /* --- interactive: ghost (text-only) -------------------------------------
DEVIATION FROM FIGMA (a11y). The published component paints the hover The drafts never fill a ghost control. Every "All updates ->" / "What we
background/accent (#008764) and keeps base/turquoise-dark (#006b4f) as do ->" link is plain text in --text-primary that turns accent green under
the label - 1.40:1, far below WCAG AA. Both surfaces therefore use white the pointer while the label-to-arrow gap opens; the fill the old Figma
on the accent fill, which is 4.52:1 and passes AA for body text. The two Button frame painted on hover (background/accent behind a green label,
lines marked (a11y) are the only ones that differ from Figma; reverting 1.40:1) is gone, and with it the a11y deviation it forced. */
them to var(--color-green-700) restores pixel parity and the failure. */
--interactive-ghost-bg: var(--color-transparent); --interactive-ghost-bg: var(--color-transparent);
--interactive-ghost-bg-hover: var(--color-green-600); --interactive-ghost-bg-hover: var(--color-transparent);
--interactive-ghost-bg-active: var(--color-green-700); --interactive-ghost-bg-active: var(--color-transparent);
--interactive-ghost-bg-disabled: var(--color-transparent); --interactive-ghost-bg-disabled: var(--color-transparent);
--interactive-ghost-fg: var(--color-green-400); --interactive-ghost-fg: var(--color-neutral-50);
--interactive-ghost-fg-hover: var(--color-white); /* (a11y) 4.52:1 on green-600; figma: green-700 = 1.40:1 */ --interactive-ghost-fg-hover: var(--color-green-400); /* 6.02:1 on the page ground */
--interactive-ghost-fg-disabled: var(--color-green-400); --interactive-ghost-fg-disabled: var(--color-neutral-500);
--interactive-ghost-border: var(--color-transparent); --interactive-ghost-border: var(--color-transparent);
--interactive-ghost-border-hover: var(--color-transparent); --interactive-ghost-border-hover: var(--color-transparent);
--interactive-ghost-border-disabled: var(--color-transparent); --interactive-ghost-border-disabled: var(--color-transparent);
@@ -165,6 +164,14 @@
they carry the same value on either surface. */ they carry the same value on either surface. */
--overlay-picture-shade: var(--color-black-a20); --overlay-picture-shade: var(--color-black-a20);
--overlay-picture-tint: var(--color-green-200-a12); --overlay-picture-tint: var(--color-green-200-a12);
/* --- elevation ----------------------------------------------------------
The drafts give a resting card a hairline and no shadow at all, and keep
the black for what genuinely floats: a card under the pointer and the
navigation dropdown. */
--elevation-card: var(--shadow-none);
--elevation-popover: var(--shadow-dark-lg);
--elevation-modal: var(--shadow-dark-xl);
} }
/* --------------------------------------------------------------------------- /* ---------------------------------------------------------------------------
@@ -262,14 +269,14 @@
--interactive-danger-border-hover: var(--color-transparent); --interactive-danger-border-hover: var(--color-transparent);
--interactive-danger-border-disabled: var(--color-transparent); --interactive-danger-border-disabled: var(--color-transparent);
/* --- interactive: ghost (text-only) - see the a11y note above ---------- */ /* --- interactive: ghost (text-only) - see the note above --------------- */
--interactive-ghost-bg: var(--color-transparent); --interactive-ghost-bg: var(--color-transparent);
--interactive-ghost-bg-hover: var(--color-green-600); --interactive-ghost-bg-hover: var(--color-transparent);
--interactive-ghost-bg-active: var(--color-green-700); --interactive-ghost-bg-active: var(--color-transparent);
--interactive-ghost-bg-disabled: var(--color-transparent); --interactive-ghost-bg-disabled: var(--color-transparent);
--interactive-ghost-fg: var(--color-green-600); --interactive-ghost-fg: var(--color-neutral-800);
--interactive-ghost-fg-hover: var(--color-white); /* (a11y) 4.52:1 on green-600; figma: green-700 = 1.40:1 */ --interactive-ghost-fg-hover: var(--color-green-700); /* 6.53:1 on white */
--interactive-ghost-fg-disabled: var(--color-green-600); --interactive-ghost-fg-disabled: var(--color-neutral-400);
--interactive-ghost-border: var(--color-transparent); --interactive-ghost-border: var(--color-transparent);
--interactive-ghost-border-hover: var(--color-transparent); --interactive-ghost-border-hover: var(--color-transparent);
--interactive-ghost-border-disabled: var(--color-transparent); --interactive-ghost-border-disabled: var(--color-transparent);
@@ -299,6 +306,11 @@
they carry the same value on either surface. */ they carry the same value on either surface. */
--overlay-picture-shade: var(--color-black-a20); --overlay-picture-shade: var(--color-black-a20);
--overlay-picture-tint: var(--color-green-200-a12); --overlay-picture-tint: var(--color-green-200-a12);
/* --- elevation - the soft ramp; the deep black belongs on the dark ground */
--elevation-card: var(--shadow-md);
--elevation-popover: var(--shadow-lg);
--elevation-modal: var(--shadow-xl);
} }
/* --------------------------------------------------------------------------- /* ---------------------------------------------------------------------------
@@ -308,7 +320,7 @@
------------------------------------------------------------------------ */ ------------------------------------------------------------------------ */
:root { :root {
/* --- shape ------------------------------------------------------------- /* --- shape -------------------------------------------------------------
--radius-control / --radius-control-lg / --radius-card / --radius-surface --radius-chip / --radius-control / --radius-card / --radius-surface
/ --radius-pill are declared in the @theme block near the bottom of this / --radius-pill are declared in the @theme block near the bottom of this
file so that they also generate rounded-* utilities. They are tier-2 file so that they also generate rounded-* utilities. They are tier-2
tokens like everything else here. */ tokens like everything else here. */
@@ -348,12 +360,13 @@
--layout-content-max: var(--container-content); --layout-content-max: var(--container-content);
--layout-prose-max: var(--container-prose); --layout-prose-max: var(--container-prose);
/* --- elevation ---------------------------------------------------------- */ /* --- elevation ----------------------------------------------------------
Only the two flattest steps are surface-independent. A shadow is a
fraction of the ground it falls on, so --elevation-card / -popover /
-modal are declared inside each surface block above: the dark surface
takes the drafts' deep black, the light one keeps the soft ramp. */
--elevation-flat: var(--shadow-none); --elevation-flat: var(--shadow-none);
--elevation-raised: var(--shadow-sm); --elevation-raised: var(--shadow-sm);
--elevation-card: var(--shadow-md);
--elevation-popover: var(--shadow-lg);
--elevation-modal: var(--shadow-xl);
/* --- focus ring geometry ------------------------------------------------- */ /* --- focus ring geometry ------------------------------------------------- */
--focus-ring-width: var(--border-width-lg); --focus-ring-width: var(--border-width-lg);
@@ -430,7 +443,6 @@
--color-interactive-dark-hover: var(--interactive-dark-bg-hover); --color-interactive-dark-hover: var(--interactive-dark-bg-hover);
--color-interactive-danger: var(--interactive-danger-bg); --color-interactive-danger: var(--interactive-danger-bg);
--color-interactive-danger-hover: var(--interactive-danger-bg-hover); --color-interactive-danger-hover: var(--interactive-danger-bg-hover);
--color-interactive-ghost-hover: var(--interactive-ghost-bg-hover);
--color-field-bg: var(--field-bg); --color-field-bg: var(--field-bg);
--color-field-border: var(--field-border); --color-field-border: var(--field-border);
@@ -443,13 +455,21 @@
} }
/* Shape semantics that also need to exist as Tailwind utilities /* Shape semantics that also need to exist as Tailwind utilities
(rounded-control / rounded-card / rounded-surface). They alias tier 1 (rounded-chip / rounded-control / rounded-card / rounded-surface). They
directly - never a tier-2 name, which would self-reference. */ alias tier 1 directly - never a tier-2 name, which would self-reference.
THE DRAFTS DRAW THREE CORNERS, NOT FIVE. Every panel, card, button, input
and menu in the Claude Design drafts is 4px; every tag, thumbnail, flag and
checkbox is 2px; filter chips and the status pill are fully round. Nothing
in the drafts is 8px or 12px, so `--radius-card` and `--radius-surface` come
down to radius/sm and the old `--radius-control-lg` (radius/md, a quirk of
the Figma Button frame's ghost cells) is gone. The tier-1 ramp is untouched:
only the meanings moved. */
@theme static { @theme static {
--radius-control: var(--radius-sm); --radius-chip: var(--radius-xs); /* 2px - tags, thumbnails, checkboxes */
--radius-control-lg: var(--radius-md); --radius-control: var(--radius-sm); /* 4px - buttons, fields, menu items */
--radius-card: var(--radius-md); --radius-card: var(--radius-sm); /* 4px */
--radius-surface: var(--radius-lg); --radius-surface: var(--radius-sm); /* 4px */
--radius-pill: var(--radius-full); --radius-pill: var(--radius-full);
} }