Skip to content

feat(onyx): @paper/onyx design system — wave 1 - #759

Draft
johnleider wants to merge 15 commits into
devfrom
feat/onyx
Draft

feat(onyx): @paper/onyx design system — wave 1#759
johnleider wants to merge 15 commits into
devfrom
feat/onyx

Conversation

@johnleider

Copy link
Copy Markdown
Member

What

New @paper/* design system: Onyx — dark-by-default, built entirely on @vuetify/v0 headless primitives with a lapidary ("jewel-box") material language: warm stone surfaces, depth drawn with light (girdle + pool) instead of shadow ramps, champagne action color, Fraunces/Instrument Sans/IBM Plex Mono type.

Surface

  • Token pipeline: onyx (dark, default) + onyx-light themes; OnyxStyleSheetAdapter extends V0StyleSheetThemeAdapter; prebaked dist/theme.css (exported — zero-config path); dependency-free CSS emitters (src/css.ts) shared by adapter and bake; --v0-* kit-interop aliases (Genesis-compatible); prefers-contrast: more + prefers-reduced-motion support; #000-never palette rule.
  • 16 component families (OnButton, OnInput, OnDialog, OnTabs, OnToast, OnTable, OnCard, …) composing v0 compound primitives — zero native form controls, data-attribute state styling, explicit disabled colors, real 2px focus outlines, color-mix() focus rings.
  • Docs app: /onyx in the dev harness — 18 pages built entirely from On components (live-token foundation pages, full interaction demos, APCA values computed live via v0's apca). Registers the notifications + storage plugins the harness previously lacked.

Verification

  • Spec + implementation plan each adversarially reviewed to zero findings before build (5 review rounds on the spec, 2 on the plan).
  • Every task pair-reviewed (implementer + independent reviewer) with source-level verification; all findings fixed and re-reviewed.
  • Full gate: pnpm typecheck / repo:check clean; zero-config render proof from :root alone; token-wiring audit; 72-capture visual matrix (18 pages × 2 viewports × 2 themes) run twice (pre/post visual-language pass); 11 interaction categories verified via DOM-state reads; APCA floors verified in both themes.

Notes

Shadcn-centric design system on @vuetify/v0: dark-by-default token
pipeline (onyx + onyx-light themes, graphite identity), dependency-free
CSS emitters shared by the stylesheet adapter and the theme prebake,
--v0-* kit-interop aliases, and 16 On* component families composing v0
compound primitives — zero native form controls, data-attribute state
styling, color-mix focus rings.
18-page docs app at /onyx built entirely from On* components: grouped
drawer nav with theme toggle (dark default, light variant), foundation
pages driven by live tokens, per-component exhibit pages with full
interaction demos; registers the notifications and storage plugins the
harness lacked.
Two real token-wiring bugs found while auditing every --onyx-* name
foundations()/block() emit against what components actually reference:

- OnToaster's .onyx-toast paired background: var(--onyx-popover, ...)
  with color: var(--onyx-foreground, ...) instead of the matching
  --onyx-popover-foreground. Same rendered value today (the two tokens
  happen to share values in both themes), but the wrong pairing would
  silently break if a future palette ever diverges them.
- OnPagination's .onyx-pagination__item/__ellipsis hardcoded
  height/width: 36px instead of var(--onyx-control-md, 36px) — the exact
  value OnButton's own md size uses for the same "interactive control
  height" concept.

SPEC.md: three accuracy fixes surfaced by the same audit — the
warning/success/info severity tokens are directly consumed by
OnAlert/OnToast for severity styling, not "alias-only" as previously
documented; brand-foreground was missing from the brand reserved-token
note; and the brand hue's color name was still "fuchsia" after the
graphite rebrand.

Remaining emitted-but-unreferenced tokens (brand/brand-foreground,
font-mono, radius-xl, spacing-xl/2xl, the 2xl-4xl text sizes) are
over-provisioned foundation scale, not bugs — no wave-1 component needs
them yet. Full reasoning in .superpowers/sdd/gate-a-report.md.
…ference

Keyboard-map grid used grid-template-columns: repeat(auto-fit,
minmax(380px, 1fr)) — at a 390px viewport the container's available
content width (minus padding) is under 380px, so the fixed minimum forced
a track wider than the viewport (confirmed: scrollWidth 404 vs clientWidth
375). Wrapped the minimum in min(380px, 100%) so the track never demands
more than the container has, at any width — same layout at desktop sizes,
correctly collapses to one column on mobile instead of overflowing.

Also fixed a leftover "Hb" reference in the focus-visible alert's prose
("Every interactive Hb component..." -> "On component") — a leftover from
the earlier Hummingbird->Onyx rename that the identifier-only sweep didn't
catch since it's plain text, not a code identifier.
The root RouterView wrapper's bg-background/p-4 sandbox styling (meant
for the Playground and create-overflow demo routes) was leaking through
as a light-gray frame around every Onyx page in both themes, since
OnyxDocs.vue owns its own full-bleed chrome. Bare-shell /onyx/* routes
while leaving the Playground/create-overflow routes untouched.
Full palette and material-system replacement per the commissioned redesign
spec (.superpowers/design/direction-a.md), applied to all 16 component
families plus the token layer.

Palette (theme.ts): the zinc/Inter ramp is replaced with nine named warm
surface values (pitch, stone, stoneRaised, intaglio, hairline,
hairlineStrong, mutedFg, bone, champagne) at OKLCH hue 56-62°/chroma
0.006-0.012 for the dark ramp — real onyx is warm brown-black, not
blue-black. #000000 never appears anywhere in the system (hard rule) —
every "black" reference in the material tokens is built from pitchDeep or
a color-mix() of it; the spec's own worked CSS violates this rule in
several spots (literal #000/rgb(0 0 0)), all substituted with the warm
equivalent at the same alpha. The two shadow-xs/shadow-sm foundation
tokens are the sole, spec-documented exception (kept for backward
compatibility, unused by any component).

Material system (css.ts, colors.ts): every raised surface gets a band
(subtle top-lightening gradient), a girdle (1px inset top-edge line — the
signature device: brighter = elevated, champagne = selected/active,
colored = severity), and a pool (wide shadow darkening the ground beneath
the object). Recessed surfaces (inputs, tab troughs, progress tracks)
invert the light direction instead. colors.ts's palette() now merges each
theme's semantic map with its material geometry map; a prefers-contrast:
more block (required deliverable, not polish, per the spec's own
accessibility floor) promotes --onyx-border to hairline-strong and raises
the dark girdle to 0.14 alpha, emitted once by css.ts's new
contrastMore().

Grafts from Direction B (direction-b.md), layered onto A's own system:
explicit disabled colors everywhere (never opacity:0.5 — it also faded
the focus ring and dropped text below the accessibility floor); a real
2px outline on every focusable control, additive to any girdle/glow
decoration, never replaced by a box-shadow (direction-a.md's own §5.5
input recipe sets outline:none, which this overrides); OnInput's already-
present recess lighting confirmed consistent with the graft's intent.

Typography (theme.ts): Fraunces (serif, display, 3xl+) / Instrument Sans
(sans, body) / IBM Plex Mono (mono, utility) replace the previous stack,
each with a graceful system fallback baked into the token. Every
fontSize step gained a letterSpacing value alongside size/line-height
(the "buying air" scale). Font files are the docs agent's install, not
this package's.

Components explicitly specified by the redesign (Card, Button all 6
variants, Input, Dialog, Tabs, Toast) implement the spec's worked CSS
directly (adapted for the #000 fix and this codebase's real class names).
The remaining 10 families (Alert, Avatar, Badge, Banner, Breadcrumbs,
Chip, List, Pagination, Progress, Table) extend the same material logic
coherently: card-like raised surfaces (Alert, Table) get the full
band/girdle/pool treatment; small inline elements (Badge, Chip, Avatar,
breadcrumb links) deliberately don't get a girdle, per the spec's own
discipline rule that only objects sitting above the ground catch light;
OnBanner (a full-width layout fixture, not a floating object) follows the
same reasoning.

Judgment calls made where the spec has gaps (all documented in
.superpowers/sdd/redesign-pkg-report.md): light-mode values for
surface-raised/hairline-strong/champagne (not given explicit overrides in
the spec's light block), popover-foreground and destructive-fg for light
mode, and OnCard's new interactive/selected props (state hooks the
spec's own appendix requires but never wires to a component API).

Verification: typecheck, build, and a comprehensive #000/#000000/rgb(0 0 0)
grep across all of src/ and dist/ (theme.css + style.css) all clean except
the documented shadow-xs/shadow-sm exception. --v0-* alias count holds at
42. Browser-verified (isolated browser contexts, per the technique from
the gate-A report) across buttons/cards/form-elements in both themes —
zero console errors, girdle/pool/band and excavated-input recesses render
correctly, disabled states show explicit flat colors, and a real 2px
outline appears on Tab-focus.

Does not touch dev/src/onyx — the docs agent owns those files in parallel.
Fonts: self-hosted Fraunces Variable (display), Instrument Sans Variable
(body), IBM Plex Mono (utility) per direction-a.md §4.1, wired into the
dev app entry and the root pnpm catalog.

Docs shell (OnyxDocs.vue): the app root now hosts the case lamp once
(`.onyx-app`); the nav rail switches to pitch-deep with no border and a
2px champagne active bar (the girdle rotated 90°) replacing the filled
pill; group labels move to the shared `.onyx-hallmark` utility. Also
adds `.onyx-exhibit`, the recessed "bench" treatment for component demo
sections (direction-a.md §8, grafted per direction-c.md §7), applied
across all 11 component-demo pages (Buttons, FormElements, Cards, Tabs,
DialogsAndBanners, Chips, Toast, Lists, Progress, Tables, Breadcrumbs).

Introduction.vue: full-bleed hero per §8 (hallmark eyebrow, Fraunces
5xl title, single champagne rule, one-sentence lede) — the shell's
generic page title is suppressed on this one route so it doesn't stack
a second heading above it. Provenance/name copy rewritten to match the
actual shipped palette instead of the prior graphite-era draft.

Depth.vue: full rewrite — the old Material shadow-ramp story is gone,
replaced with three live-token exhibits (the four material layers
built up incrementally, raised vs. recessed, and girdle-carries-state)
documenting the real band/girdle/hairline/pool system honestly.

Typography.vue: reflects the new three-face system and per-step
tracking from live tokens, with correct serif/weight-300 switch at 3xl+.
The new 5xl type-scale step (72px) exposed a layout bug in the type-scale
demo row: a single word in the Fraunces sample text at 3xl-5xl can be wider
than the space left over after the fixed 220px label column, and a flex
item can't shrink below the width of its longest unbreakable word — so the
row (and the whole page) was forced wider than the viewport at 390px.

Let the row wrap onto two lines when needed and give the sample text
overflow-wrap: anywhere as a last-resort floor, so no single word can force
the page wider than the viewport regardless of scale step or viewport width.

Found via the final re-gate's 390px overflow sweep across all 9 half-A
pages; every other page was clean.
materialLight defines an 'intaglio' key (light.muted, per direction-a.md
§3.4's explicit light-mode intaglio fill) but materialDark never did, so
--onyx-intaglio was entirely absent from the [data-theme="onyx"] block —
every dark-mode recess (Input, Tabs trough, Progress track, Depth.vue's
recessed swatch) fell through its own var(--onyx-intaglio, #090605) CSS
fallback instead of reading the real token, landing visually indistinct
from --onyx-muted whenever the fallback and the intended value happened
to diverge in a future edit.

Add the intaglio key to materialDark using the existing named surface
const (#090605, already the fallback literal every consuming component
already assumes) — restores emission symmetry with materialLight and
makes the CSS custom property authoritative again instead of the inline
fallback silently carrying the value.
The Onyx rename swept every mechanical identifier but left a provenance
sentence intact: "Onyx recreates the *principles* of the 2020 Onyx Design
System (johnleider/onyx — Vuetify 2, built for Medecision's Aerial
platform)". That names a real client and a fabricated repo URL in a public
spec file — a liability that must not exist at all, not just be reworded.

Rewrite the Identity opening Onyx-native, with zero predecessor/client/URL
references, mirroring how Introduction.vue already frames the same fact
(built headless from the start on @vuetify/v0, visual language redrawn
since a first flat-graphite pass) — same shadcn-centric technical content,
no lineage claim.

git grep -inE 'medecision|aerial' -- packages dev: zero matches.
git grep -i 'hummingbird' -- packages dev: zero matches (unaffected).
The published Lc60 floor can't be met by any text-color choice against
champagne-deep as a fill (verified: even pitch, the darkest available ink,
only reaches Lc 44.2 there) — champagne-deep is simply too mid-toned a
background to clear it in either polarity. Direction A's own light-mode
law is "the material law inverts": the box no longer emits light, so the
one object that reads as solid/metal in light mode isn't a gold fill with
pale text, it's the dark stone itself with a cream face — same fashion-
house logic as a black lacquer object photographed on a cream backdrop.

light.primary is now ink (#1f1915, the same value as light.foreground —
intentional, they're the same stone), light.primary-foreground stays bone.
champagne-deep keeps every other role it already had (brand, ring, active
markers, accents) — those are thin-line/accent contexts where APCA's
requirements are looser and gold is the correct read.

apca(bone, ink) = -99.4 — clears the floor with room to spare, and the
Accessibility page's own live table (which reads the tokens directly, not
a hardcoded number) now shows "Light — primary on background: Lc 97.1",
identical to "foreground on background" since they're literally the same
color now.

Consumer check (grep for --onyx-primary / --onyx-primary-foreground):
OnButton default (dark button on cream, intended), OnBadge default (dark
ink badge, verified on the Chips page), OnProgress fill (dark ink bar,
verified on the Progress page) all read correctly as the "dark stone"
material — no consumer semantically wants gold instead. OnPagination's
active-page marker uses the identical pattern but has no live consumer in
the docs app yet to screenshot; reviewed at the source level only.

Also fixes a regression this surfaces: OnButton's default-variant :hover
mixed --onyx-primary toward --onyx-foreground, which in light mode are now
the same color — a no-op hover with zero visible feedback (confirmed via
pixel comparison, not just computed-style string). Retargeted the mix to
--onyx-card (the same anchor :active already uses, at a lighter 95%/5%
touch vs active's 88%/12%), which produces a real, visible hover state in
both themes and makes hover/active move in the same direction, differing
only by intensity — arguably more coherent than the previous opposite-
direction hover/active pair.
@johnleider johnleider added this to the v1.1.0 milestone Aug 1, 2026
@johnleider johnleider added the enhancement New feature or request label Aug 1, 2026
@johnleider johnleider self-assigned this Aug 1, 2026
Same class of leak as the earlier Identity-section fix: "Original:
github.com/johnleider/onyx" is a fabricated repo URL that shouldn't exist
in a public spec regardless of accuracy. Dropped the claim entirely, kept
the captures pointer (renamed "Visual evidence" since "Original: ...;
captures in ..." read as one clause).
…ng, drift-proofing

1. OnInput reveal toggle dropped focus to <body> on every activation. The
   :key="effectiveType" on Input.Root forces a full subtree remount to work
   around v0 #757 (Input.Root's `type` context field is built once, plain,
   in Root's own setup — createInput's context object is assembled once and
   never rebuilt, so it stays frozen even if a child re-renders; verified
   against InputRoot.vue directly, confirming keying Control instead would
   remount the wrong layer and do nothing). The remount destroys the just-
   clicked reveal button along with everything else in the subtree.

   Fix: wrap the reveal Button.Root in a display:contents ref (Button.Root
   doesn't defineExpose its element, so this mirrors the same querySelector
   workaround Buttons.vue's onFocusDemo already uses), and refocus it after
   nextTick once the remounted subtree has painted. Verified via real
   keyboard flow (Tab to the reveal button, Enter, read document.activeElement)
   across two consecutive toggles, not just one.

2. OnTable's active page-size pill lost its highlight the instant the
   pointer moved over it — OnButton's own [data-variant='outline']:hover:
   not([data-disabled]) rule (4 selectors) beat OnTable's bare [data-active]
   override (3 selectors). Repeated the override at matching :hover:not
   specificity so the active pill's colors hold under hover. Verified via
   computed-style before/after a real .hover() call — background and color
   are now bit-identical across the hover transition.

3. css.ts's block() validated color values against UNSAFE_CSS but never
   validated color KEYS — colors.ts's palette() output flows through
   createOnyxPlugin({ theme: { themes } }), which merges consumer-supplied
   theme objects, so a caller-controlled key can reach this loop (verified
   in plugin.ts: `themes: { ...defaults.themes, ...options.theme.themes }`).
   Mirrors the base v0 ThemeAdapter's own SAFE_IDENT key guard, which checks
   both key and value together.

4. bake-theme.ts hand-enumerated 'onyx' and 'onyx-light' as two separate
   block() calls; a future third theme in colors.ts's `themes` record could
   silently miss the bake. Now iterates Object.entries(themes) directly (name
   + dark flag) and keys the :root block to whichever entry is flagged
   `dark: true`, throwing loudly instead of guessing if none is. Confirmed
   behavior-preserving: dist/theme.css is byte-identical before/after (same
   42 --v0-* aliases, both [data-theme] blocks, :root, and the
   prefers-contrast: more block).
Extends the earlier Identity/Reference provenance fixes: the Wave 1 table
listed 15 real predecessor component names verbatim (HdsBtn, HdsCard,
HdsDialog, HdsDataTable, ...) under an "Original" column, captioned
"mapped from the original Onyx's exported components" — a detailed
fingerprint of the real client's real design system sitting right next to
where the client name used to be, invisible to a "medecision|aerial|
johnleider|hummingbird" grep since it's a different string entirely.

Table becomes Component | v0 basis | Notes — no predecessor column. Folded/
dropped rows deleted outright (they only existed relative to the
predecessor); the deferred-DatePicker note stays but reworded as pure
v0-roadmap rationale with zero lineage framing (also corrected while
rewriting it: v0 has no DatePicker primitive at any maturity, composable
or component — verified via source grep, not carried over from the
imprecise "past draft maturity" phrasing the original note used). The
"15+3+1+1=20" accounting prose is gone; wave 1 is stated as what it is,
16 component families.

Also caught and fixed a second, same-class leftover the table removal
exposed as dangling: the Non-goals section's "Checkbox, Switch, Select,
Slider, Combobox — not in the original inventory" bullet referenced a
concept ("the original inventory") that no longer exists anywhere in this
file once the table's predecessor framing is gone.

git grep -n 'Hds': zero tracked matches.
git grep -inE 'medecision|aerial': zero matches (unaffected, still clean).
git grep -i 'hummingbird': zero matches (unaffected, still clean).
git grep -in 'original inventory|original onyx|original.*export': zero matches.
My previous fix over-corrected: I'd grepped packages/0/src for a source
file and found nothing, then wrote "planned, not yet drafted" — but
maturity.json (lines ~130/549) lists both createDatePicker and DatePicker
at draft maturity. Both facts are true simultaneously: draft-registered,
zero source implementation. QA's exact wording, verified against
maturity.json.
@johnleider johnleider removed this from the v1.1.0 milestone Aug 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant