mirror of
https://github.com/hanzoai/design.git
synced 2026-10-30 14:58:37 +00:00
The dark theme drew every emphasised edge at --neutral-500 (#737373) — a 4.43:1 mid-grey. One of those around a card on near-black is a wireframe, and that is what "garish white on dark for outlines" was: hanzoai/id put it on .hanzo-id-input, base/ui-react put it on .input, .btn--outline, a dashed .btn--add and an iframe frame. They were following the file's own instruction to reach for --border-strong whenever an edge had to be visible, because the only alternative was --border at #1f1f1f — 1.27:1, invisible the moment it lands on anything lifted. Two rungs, an octave apart, and nothing in between. So there is a middle now, and the tiers split by DUTY rather than by degree. --border (.10) and --border-strong (.16) are DECORATIVE: alpha, so they composite correctly on the page, on a card and inside a popover, and they owe no ratio. --border-control (--neutral-500) is PERCEIVABLE: it is the edge that IS the affordance, it owes 3:1, and it is named for the duty so it cannot be spent on a card by someone who just wanted "stronger". The gate follows the duty — it pins --border-control and --ring, and lets the hairlines be as quiet as they look. --input, which every shadcn `border-input` resolves through, was #1f1f1f at 1.27:1 and is now the control rung: that was a live WCAG 1.4.11 failure on every field in the fleet, and nothing measured it because the gate watched a token the inputs were not using. The rest is the reference surface (hanzoai/extension sidebar.css) carried over: #0a0a0a rather than #000000, a 0a/0f/17/26 lift ladder that actually steps, #fafafa ink instead of #ffffff (21:1 on pure black is the maximum-contrast pair that exists, and it halates), a graded text ramp at .78/.55/#a3a3a3, and --glass/--glass-strong so a surface can rise by 5% of white instead of a block fill. --border-hairline and --border-card were two more names for the one hairline and are gone; the 46 components that used them now say --border. MOBILE-FIRST, and free. Spacing is authored at phone values and scales up at md. --gutter is max(1rem, safe-area-inset), so a notch is cleared with no per-app rule. Under pointer:coarse every control clears a 44px --tap-target, checkbox and radio hit areas expand to 44px via a pseudo-element while the 16px box is left alone (WCAG 2.5.8 puts the floor at 24px; every one of them shipped at 16), and fields render at --text-control = 16px so iOS stops zooming the viewport on focus. --text-control has to be a token rather than a media query because every component sets its size inline and an inline style outranks any stylesheet. Light was quietly broken and is not a theme you can leave for later: --card, --muted, --secondary and --accent were four names for #f5f5f5, so the surface ladder was flat; ::selection had no light value; and the components painted from the --white-* ladder, which does not invert — a tier2 badge was white-on-white and the table header was a black bar with grey text on it. Components now paint from --glass/--surface-*, and check 4 fails the build if any semantic token built from a white rung has no .light counterpart. Verified in Chromium at 390 / 768 / 1280 on the real components, reading computed styles: no horizontal overflow at any width, fields 13px -> 16px on touch, targets under 44px 16/19 -> 1/19 (the last is the 16px checkbox box, whose hit area measures 44x44 and takes a tap 18px off-centre), --ring and --border-control at 3.19:1 dark / 4.05:1 light worst case. Co-authored-by: Hanzo Dev <[email protected]>
51 lines
2.6 KiB
React
51 lines
2.6 KiB
React
import React from 'react'
|
|
|
|
/** Rank is carried by lift, never by hue (DESIGN.md §2.3).
|
|
The rungs come off the SURFACE ladder and --glass, not off --white-*: the
|
|
opacity ladder is white-on-white in the light theme, so a tier2 pill drawn
|
|
from --white-15 was an invisible chip the moment a surface went light. */
|
|
const V = {
|
|
default:{background:'var(--glass-strong)',color:'var(--text-primary)',border:'1px solid var(--border)'},
|
|
secondary:{background:'var(--secondary)',color:'var(--secondary-foreground)',border:'1px solid transparent'},
|
|
outline:{background:'transparent',color:'var(--text-secondary)',border:'1px solid var(--border)'},
|
|
tier1:{background:'var(--glass)',color:'var(--text-tertiary)',border:'1px solid var(--border)'},
|
|
tier2:{background:'var(--surface-2)',color:'var(--text-secondary)',border:'1px solid var(--border)'},
|
|
tier3:{background:'var(--surface-3)',color:'var(--text-primary)',border:'1px solid var(--border-strong)'},
|
|
error:{background:'var(--state-error-bg)',color:'var(--state-error-text)',border:'1px solid transparent'},
|
|
}
|
|
|
|
export function Badge({ variant = 'default', pill = true, style, children, ...rest }) {
|
|
return (
|
|
<span
|
|
style={{
|
|
display:'inline-flex',alignItems:'center',gap:6,
|
|
padding:'3px 10px',fontSize:'var(--text-xs)',lineHeight:1.35,
|
|
fontFamily:'var(--font-sans)',fontWeight:'var(--weight-medium)',whiteSpace:'nowrap',
|
|
borderRadius: pill ? 'var(--radius-full)' : 'var(--radius-sm)',
|
|
...(V[variant] || V.default), ...style,
|
|
}}
|
|
{...rest}
|
|
>
|
|
{children}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
/** The eyebrow pill above a hero headline. */
|
|
export function EyebrowBadge({ children, style, ...rest }) {
|
|
return (
|
|
<span style={{display:'inline-flex',alignItems:'center',gap:8,padding:'8px 16px',borderRadius:'var(--radius-full)',border:'1px solid var(--border)',background:'var(--glass)',fontSize:'var(--text-sm)',color:'var(--text-secondary)',...style}} {...rest}>{children}</span>
|
|
)
|
|
}
|
|
|
|
/** Live-status dot + label. The green is one of the few permitted hues. */
|
|
export function StatusBadge({ status = 'online', label, style }) {
|
|
const dot = status === 'online' ? 'var(--state-online)' : status === 'error' ? 'var(--state-error)' : 'var(--neutral-500)'
|
|
return (
|
|
<span style={{display:'inline-flex',alignItems:'center',gap:8,fontSize:'var(--text-sm)',color:'var(--text-secondary)',...style}}>
|
|
<span style={{width:8,height:8,borderRadius:'var(--radius-full)',background:dot,animation: status==='online' ? 'hanzo-pulse-dot 2s var(--ease-in-out) infinite' : 'none'}} />
|
|
{label || status}
|
|
</span>
|
|
)
|
|
}
|