diff --git a/content/chapters/data-viz-palettes.mdx b/content/chapters/data-viz-palettes.mdx new file mode 100644 index 0000000..989b71f --- /dev/null +++ b/content/chapters/data-viz-palettes.mdx @@ -0,0 +1,119 @@ +--- +title: 'Color for data visualization' +description: 'A UI accent ramp is one hue climbing twelve lightness steps — built to rank surfaces from quiet to loud. A chart palette has the opposite job: tell unordered things apart. Three families for three data shapes, why the ramp is the right source for exactly one of them, the ~8-hue ceiling, and the two meters — distinguishability and CVD-survival — the UI ramp never had to measure.' +part: 2 +question: 'Is the UI accent ramp the right source for chart colors — or does data-viz need its own palette engine?' +--- + +import { RampVsCategorical } from '@/components/playgrounds/ramp-vs-categorical' +import { ChromaCeiling } from '@/components/playgrounds/chroma-ceiling' +import { OkabeItoSet } from '@/components/playgrounds/okabe-ito-set' +import { SequentialVsRainbow } from '@/components/playgrounds/sequential-vs-rainbow' +import { DivergingMidpoint } from '@/components/playgrounds/diverging-midpoint' +import { SmallMarkArea } from '@/components/playgrounds/small-mark-area' +import { ChartPaletteLab } from '@/components/playgrounds/chart-palette-lab' + +Chapter 17 left an IOU: the token table has no name for a bar chart's fifth series, and it was right not to try. Chart color is a different problem with different physics, and this chapter decides whether the engine even touches it. There is a tempting shortcut — you already generated a twelve-step accent ramp across chapters 10–15, so reach into it and hand out steps 3, 5, 7, 9 as four series. That instinct is right for exactly one of the three chart jobs, wrong for a second, and useless for the third, which needs a piece of machinery the UI system never built. The decision this chapter reaches is a split one: **emit sequential and diverging scales from the seed — they are the ramp machinery you already run — but grow categorical color as its own axis, default it to a vetted CVD-safe set, and measure the two meters a UI ramp never owed.** + +## Three jobs, three geometries + +A chart palette is a function from data to color, and the data arrives in three shapes that want three different objects inside the OKLCH cylinder. + +- **Categorical** (qualitative): unordered classes — Chrome, Safari, Firefox. No class is _more_ than another, so the colors must imply no order and be maximally distinct. In OKLCH that is a **ring**: points at one lightness and one chroma, spread around the hue angle — chapter 5's equal-step guarantee, drawn as a circle. +- **Sequential**: ordered magnitude — 0 to 100%, low to high. One perceptual dimension, monotonic. In OKLCH that is a **path down the lightness axis**: one hue, lightness descending step by step. Chapter 11's lightness-anchored philosophy, applied to data instead of surfaces. +- **Diverging**: signed magnitude around a meaningful zero — profit and loss, above and below average. Two sequential arms meeting at a neutral middle. In OKLCH that is a **V**: two hues, both climbing to a light neutral at the center. + +The accent ramp _is_ the sequential shape — one hue, twelve lightnesses. Which is exactly why handing out its steps as categorical series fails: + + + +Two failures, both fatal. First, a single-hue ramp _encodes order_ — light-to-dark blue reads as a ranking, so the palette whispers "Email is last" about data with no order. That is the direct consequence of the ramp being the _sequential_ shape used for the wrong job. Second, the ramp was built so adjacent steps stay _close_ — chapter 10's adjacency contract, the thing that makes a smooth surface ladder — and closeness is the enemy of a categorical set. Four hues at one lightness imply nothing and sit several times farther apart. The ramp is not a bad palette; it is the _sequential_ palette used for the _categorical_ job. + +## Categorical: hue spacing under the tent + +Generate a categorical set the honest way: fix lightness, fix chroma, walk N hues evenly around the circle. The even spacing is chapter 5's payoff — in a perceptually uniform space, equal hue steps _are_ roughly equal perceptual steps, a promise no even spacing in HSL could make (chapter 4). + +But chapter 6's tent sets the ceiling on chroma. Maximum chroma depends on L _and_ H at once, and it is not the same for every hue — at L 0.65 a blue reaches far higher chroma than a yellow. An _equal_-chroma set can only be as saturated as its weakest hue: the achievable chroma is the minimum ceiling across all N hues. Push past it and one hue clips out of gamut, breaking the equality the set depends on. So a principled categorical generator is capped — colorful, never neon, because the tent's tightest corner votes for the whole ring. + + + +There is a ceiling of a second kind, and the honest move is to name it out loud: **hues collide around 7–8 categories.** The wheel is only 360° wide; nine evenly-spaced hues sit 40° apart, and at fixed moderate lightness 40° is near the edge of a confident glance — worse under CVD. The vetted sets all converge here. The Okabe–Ito palette — which Bang Wong reprised in _Nature Methods_ (2011) — was designed CVD-first, and ships exactly eight: + + + +Eight, and the set _is_ the ceiling, not an arbitrary stopping point. ColorBrewer's colorblind-safe qualitative subsets cap even lower; Tableau's `schemeTableau10` goes to ten but its last few are hard to separate. Past the ceiling the answer is never "add a ninth color" — it is re-encode: direct labels, position, shape, or grouping the tail into "other." An engine that keeps minting hues past eight is shipping collisions with a straight face. + +## Sequential: monotonic lightness is the signal + +For an ordered variable the eye reads magnitude primarily off lightness. So the one rule is: lightness must move monotonically, one direction, every step. That is chapter 11's lightness-anchored philosophy, applied literally — fix the lightness progression, let hue and chroma serve it. The bonus, from chapter 9: a lightness-ordered ramp survives grayscale and CVD by construction, because lightness order is the one thing a deficiency can't scramble. + +Do it wrong and you get the rainbow — the `jet` colormap that was matplotlib's default until version 2.0 replaced it with viridis in 2017. Toggle between them and watch the lightness curve: + + + +Jet's lightness is non-monotonic: dark at both ends, bright bands of yellow and cyan in the middle. Each local maximum in that curve reads as a boundary the data doesn't have; each flat stretch hides a real gradient. That is the empirical case against rainbows (Borland & Taylor, 2007): non-monotonic luminance invents edges and buries structure. Viridis's whole design brief was the opposite — perceptually uniform, monotonically increasing luminance, legible in grayscale and under CVD. In OKLCH you get that for free by fixing a lightness ramp and letting hue drift. + +## Diverging: two arms, one honest midpoint + +A diverging scale is two sequential ramps sharing a midpoint, and everything rides on what that midpoint _is_. Chapter 7 already showed the trap: interpolate straight from one saturated hue to another and the middle sags through gray concrete — `linear-gradient(blue, yellow)` puts a dead gray where the interesting zero should be. On a diverging chart that gray is not just ugly; it is _dark_, so the values nearest zero read as the most intense on the plot. The scale lies about where the action is. + + + +The fix is chapter 15's near-white neutral, not a mid-gray: ColorBrewer's RdBu puts `#f7f7f7` at the center — a real light neutral carrying almost no chroma — so zero recedes and both arms climb away from it in lightness. Two consequences for the engine. The midpoint is a _designed_ light neutral (chapter 15's tint budget keeps it from feeling clinical), not whatever a straight line happens to pass through. And the interpolation runs _per arm through OKLCH_, each arm a lightness ramp meeting the neutral — never one straight RGB line end to end that dips through the concrete. + +## CVD is the hard constraint, not a finishing touch + +Everything above is necessary and not sufficient, because the metric a categorical generator optimizes — perceptual distance for _your_ eyes — is not the metric one in twelve of your male readers uses. Chapter 9's number, from Okabe and Ito's own page: "One in twelve Caucasian (8%)… males are so-called 'red-green' colorblind." Red–green is the common axis, which is exactly why a set that looks maximally spread to you can collapse: evenly-spaced hues in OKLCH are _not_ evenly spaced once the eye drops a channel. + +This is why Okabe–Ito is CVD-_first_: its 8 colors were chosen for distance under simulated deficiency, and they carry a lightness spread so they survive even grayscale — the ultimate degradation. The engine lesson is chapter 9's, unchanged: never let hue be the only channel, and verify by _simulating_, not by hoping. A generator that can't see a collision it just shipped has no business shipping unverified. + +## Two contrast jobs — one of them not a standard + +A UI token owes one contrast promise: text versus its surface. A chart mark owes two. First, **series versus plot background** — a filled bar or a line must stand off the paper. WCAG 1.4.11 puts the floor for a meaningful non-text mark at 3:1, and a pale categorical color on white can fail it while looking fine in isolation. This is the auditable half, and it is where WCAG — not APCA — is the relevant instrument: APCA scores text, and offers no data-mark threshold. Second, **series versus adjacent series** — two lines that cross, two bars side by side. WCAG has no normative ratio for this; it is a design heuristic, the glance-apart distance the flagship measures as min pairwise ΔEOK. State that split honestly: the first is law, the second is judgment. + +And small marks need more of both — a 1px line or a scatter dot carries less area, so its color reads paler and closer to its neighbors than the same color in a big fill: + + + +## The lab + +Three palette types, one generator, on a real chart. Pick the type and the count; the set is built in OKLCH — categorical as N hues at fixed L/C capped by the tent, sequential as a monotonic lightness ramp, diverging as two arms to a light neutral. For categorical you can swap the generated set for Okabe–Ito's vetted 8. The simulate toggle recolors the chart and recomputes every distance _as seen_; the readouts hold the set to the glance-apart bound, the ceiling, and the background floor. + + + +Worth doing, in order: + +- **Categorical, generated, 6, then simulate Deutan.** The min pairwise ΔEOK is comfortable at first; flip to Deutan and watch it drop as two hues slide together. This is why even spacing is not safety. +- **Switch the source to Okabe–Ito, still Deutan.** The vetted set holds its minimum where the generated one collapsed — it was chosen for exactly this view. +- **Push the count to 9 or 10.** The ceiling flag fires and the collisions count climbs. The engine's honest answer is not a tenth color; it is "re-encode." +- **Sequential.** The lightness is monotonic and min-adjacent ΔEOK is even. Note the lightest step's contrast against white — the pale end of any sequential ramp is where the background floor bites. +- **Diverging.** The midpoint sits at a light neutral, zero recedes, the arms stay symmetric. This is the shape the accent ramp cannot make on its own — it has only one hue. + +## The decision this unlocks + +dotUI's engine today emits a UI system: primitive ramps, semantic tokens, no chart palettes. The question was whether the single-hue accent ramp is the right source for chart color. The answer splits by job, and it decides what the engine emits. + +1. **Chart color is its own axis — never aliased to accent.** `accent` is one hue; a categorical set is many. Wiring `chart-1…chart-n` to accent steps ships the ordered-looking, muddy, hard-to-separate failure from the first demo. Chart tokens get their own slots and their own contract. +2. **Sequential and diverging _are_ the ramp machinery — emit them from the seed.** A sequential scale is a single-hue monotonic-lightness ramp (chapter 11); a diverging scale is two seed-derived arms meeting at chapter 15's near-white neutral, interpolated per arm in OKLCH. Both are deterministic functions of the seed's hue; produce them directly rather than sending the user shopping. Never emit a non-monotonic (rainbow) sequential map; that is a bug, not an option. +3. **Categorical: even hues at fixed L, chroma capped by the tent's tightest hue** (chapters 5 and 6) — generable, but generate only _behind a CVD verifier_. Even spacing does not survive a dropped channel, and the generator can't see that without simulating. +4. **Default categorical to a vetted set; generation is the escape hatch.** Ship Okabe–Ito (8, CVD-safe, grayscale-safe) as the default and the recommendation. The OKLCH generator exists for brand-tinted or larger sets, but every candidate is run through chapter 9's simulation and rejected if its min as-seen ΔEOK falls below the glance-apart bound. The engine never ships an unverified categorical set. +5. **Name the ceiling in the API.** Past ~8 CVD-safe hues the engine returns the 8 and tells the caller to re-encode — position, shape, label, or grouping — never silently truncating or recycling colors. +6. **Every chart palette ships with its meters.** Distinguishability (min pairwise ΔEOK, CVD-aware) and CVD-survival for categorical; monotonic-lightness verification for sequential; light-neutral midpoint and arm symmetry for diverging. And two contrast partners: series-vs-background is the WCAG 1.4.11 3:1 floor, _enforced_; series-vs-series is the glance-apart heuristic, _reported_ — and labeled plainly as judgment, not standard. The UI ramp only ever owed a text pair; chart palettes owe distinguishability and CVD-survival, measured at generation. + +Rejected outright: rainbow/jet sequential scales; any categorical set whose only separation is red-versus-green; and diverging midpoints that are not a real light neutral. + +Left open, on purpose: how this whole table — UI tokens, states, and now chart families — ships as CSS custom properties is chapter 20; how Radix, Tableau, and Material each drew their categorical sets is Part 3. + +## Check yourself + +1. A teammate wires `chart-1…chart-4` to accent steps 3/5/7/9 "because they're already in the theme." Name the two distinct failures for a categorical chart — one about what the ramp implies, one about telling the colors apart — and the one chart job for which this wiring would actually be correct. +2. You generate six evenly hue-spaced colors at fixed L and C, all in gamut, min pairwise ΔEOK 0.15 — well above the glance-apart bound. Why is this still not safe to ship, and what must the engine run before it does? +3. A diverging scale runs blue to red. Why does the choice of midpoint decide whether the scale tells the truth, and what did chapter 7 call the wrong one? + +## Further reading + +- [Okabe & Ito — Color Universal Design](https://jfly.uni-koeln.de/color/) — the 8-color CVD-safe qualitative set this chapter ships as the categorical default, its exact hex, and the "one in twelve Caucasian (8%) males" prevalence. +- [ColorBrewer 2.0 — Cynthia Brewer](https://colorbrewer2.org/) — the three families as a UI (sequential / diverging / qualitative), the colorblind-safe filter, and RdBu's `#f7f7f7` light-neutral midpoint. +- [A Better Default Colormap for Matplotlib — Smith & van der Walt](https://bids.github.io/colormap/) — viridis's design brief: perceptual uniformity and monotonic luminance, and why it replaced jet in matplotlib 2.0. +- [Borland & Taylor — Rainbow Color Map (Still) Considered Harmful](https://ieeexplore.ieee.org/document/4118486) — the evidence that non-monotonic luminance invents false boundaries and hides real ones. +- [d3-scale-chromatic — categorical schemes](https://d3js.org/d3-scale-chromatic/categorical) — `schemeTableau10` and the ColorBrewer qualitative sets as machine-readable, permission-blessed hex, the form an engine would bundle. diff --git a/content/chapters/geist.mdx b/content/chapters/geist.mdx new file mode 100644 index 0000000..88c9fbb --- /dev/null +++ b/content/chapters/geist.mdx @@ -0,0 +1,95 @@ +--- +title: 'Vercel Geist' +description: "The capstone. Geist is Vercel's color: ten Radix-style job scales, a translucent alpha twin, a primary blue parked exactly at the meter crossover, and a hand-designed dark. Almost every decision this course argued for, embodied as a fixed destination — which is precisely what a seed-driven engine can never be. Read the whole course back through Geist and the difference between a destination and a road becomes the rewrite spec." +part: 3 +question: "Geist is one brand's hand-tuned destination — what does a seed-driven engine steal from it, and what can it never copy?" +--- + +import { GeistJobLadder } from '@/components/playgrounds/geist-job-ladder' +import { GeistAlphaComposite } from '@/components/playgrounds/geist-alpha-composite' +import { GeistAccentMeters } from '@/components/playgrounds/geist-accent-meters' +import { DesignedVsGenerated } from '@/components/playgrounds/designed-vs-generated' +import { EngineScorecard } from '@/components/playgrounds/engine-scorecard' + +Part 1 taught you to see: the same hex is two colors in two surrounds, OKLCH gives three honest knobs, the screen has an edge, two meters disagree about a button. Part 2 turned seeing into machinery: twelve jobs, three curves, a seed policy, tinted neutrals, a second design for the dark, a token grammar, states and alpha. Part 3 holds that machinery up to systems that shipped and asks what survives contact. This is the last of them, and it is the one that matters most for the engine — because Geist is what dotUI's output is trying to be, and is built the opposite way. + +Geist is Vercel's design system — "the colors, typography, materials, layout, and React components behind Vercel's products" — and its color layer is billed in five words: "A high contrast, accessible color system." Nearly every decision this course spent Part 2 arguing over is already made in Geist, correctly, by hand. That is exactly why it's the finale and not a victory lap. Geist is a **destination**: one brand, tuned once, sitting still. dotUI is a **road**: a function that must reach _any_ brand's destination from a seed it has never seen. That gap is the whole chapter. Almost everything Geist does, the engine should steal. Exactly one thing Geist has, no generator can copy — because it isn't a decision at all. + +## Ten steps, ten jobs + +Start under the hood, where chapter 10 started. Geist's color is ten scales: `backgrounds` (two values), `gray`, its translucent twin `gray-alpha`, and seven accent hues — blue, red, amber, green, teal, purple, pink. Every non-background scale runs exactly ten steps, numbered 100 to 1000, and each number is a _job_, not a lightness index — chapter 10's "steps are jobs," made literal in the naming: 100 default background, 200 hover, 300 active, 400 default border, 500 hover border, 600 active border, 700 solid fill, 800 solid hover, 900 secondary text, 1000 primary text. That step-to-job map is Vercel's own, spelled out in `design.md`. + + + +The shape is Radix's — chapter 21's model: backgrounds, then component backgrounds, then borders, then solid fills, then text, plus a translucent alpha companion. But it is Radix-_style_, not Radix-_derived_. Vercel's docs make no Radix claim anywhere, and the structure differs on two counts: Geist runs 10 steps (100–1000) where Radix runs 12 (1–12), and Radix ships an alpha twin for _every_ scale where Geist documents one, `gray-alpha`. Same philosophy, independent build. The step count is a formatting choice, not a difference in kind — both put the accessible text steps at the far end, both make the contract live between neighbours. + +The proof that these are jobs and not a lightness ramp is a blemish Geist ships on purpose. In the light gray scale, step 400 — the default border — is a hair _lighter_ than step 300, the active background (`#eaeaea` over `#e6e6e6`, OKLCH L 93.7% over 92.5%). A monotonic ramp forbids that. A ramp of jobs allows it, because 400 is tuned to read as a border against the surfaces it separates, not to be the next rung down. Chapter 10 said the contract is between neighbours doing specific work; Geist would rather break monotonicity than break the border. Hold that thought — the generator will have to decide whether it's allowed the same freedom. + +## Alpha is how it travels + +The `gray-alpha` twin is chapter 18's composability lesson as an engineering commitment. A solid gray divider is tuned for one background — its own. Lift it onto a colored surface and it becomes a mismatched band, because its lightness was chosen against white and nothing recomputed. A translucent gray composites onto whatever is underneath. + + + +The division of labor is the whole lesson. Solid `gray-*` is for the things that must hold their own contrast regardless of background — text, opaque fills. Translucent `gray-alpha-*` is for the things that must _blend_ — borders, dividers, overlays, hover states. Radix states the same rationale outright — "Each scale has a matching alpha color variant" — for exactly the components that need to blend into colored backgrounds. In the demo the alpha divider is tuned to land on solid `gray-400` over the default white surface, so it reads identical on the common case and keeps working on every other one. That is the trick — alpha is not a style, it is how a fixed value survives an unknown background. This is chapter 15's restraint and chapter 18's composability in one scale. + +## High contrast, on purpose + +"High contrast, accessible" is a claim, and chapter 8 gave you two instruments to audit it. Geist's primary interactive blue is `blue-700`, `#006bff`. Step across its neighbours and watch what the label decision does. + + + +At `blue-600` (`#48aeff`, lighter) black wins decisively — 8.77:1 to white's 2.39:1, APCA agreeing. At `blue-800` (`#0059ec`, darker) white wins on both. `blue-700` sits on the seam: WCAG scores white and black within a hundredth of each other — 4.62:1 versus 4.55:1, right on chapter 8's Y ≈ 0.18 tie point — and APCA breaks the tie decisively for white, Lc −76.5 against black's +33.1. This is the orange-button fight from chapter 8, except Geist _won_ it: it parked the primary blue at the one lightness where a white label is defensible on both meters at once. The landing slot (chapter 14) and the pairing decision (chapter 17) resolve here the chapter-8 way — WCAG 2 is the enforceable floor, APCA predicts the read — decided by hand, per color, once. Note too that Geist authors these in OKLCH and ships a Display-P3 `oklch()` alongside every sRGB hex — "P3 colors are used on supported browsers and displays" — chapter 6's gamut headroom, gated by capability, not assumed. + +## Designed vs generated + +Here is the spine, and two facts frame it. Geist has _no seed_ — the palette is complete in itself, one brand, hand-authored. And Geist ships light and dark from two separate files (the dark values live at `design.dark.md`) under identical token names, the same 100–1000 job structure in both: chapter 16's verdict wearing Vercel's clothes — dark mode is a second design, not a flip. The names are the contract; the values are two hand-tuned passes. + +Now feed Geist's `blue-700` — `#006bff` — into an engine as a _seed_ (chapter 14) and let it generate a full ramp: a monotone lightness skeleton (chapter 11), a chroma bell clamped to sRGB (chapter 12), constant hue (chapter 13). Then change the seed. + + + +First, on the Vercel-blue seed the engine's ramp lands _near_ Geist's but not _on_ it — ΔEOK ≈ 0.033 at the solid, a designed value and a generated one, two tunings of the same target. And the engine can't reproduce Geist's authorship. Read from Geist's authored Display-P3 `oklch()` values — not the sRGB hex, which lands about two degrees higher at the solid (260.1°) — the blue hue _drifts_ by hand: 251.6° at the top, dipping to 245.1° at the border step, swinging to 258.2° at the solid, settling at 254.3° for the ink. Its lightness is non-monotone too — step 900 sits lighter than step 800, because 900 is a color chosen for a job, not a rung on a descent. The plot draws both: Geist's hue wandering where the engine's holds flat, Geist's ladder dipping where the engine's descends. A generator that holds hue constant and lightness monotone (as most do, and should) cannot draw those curves, and mostly shouldn't try. + +Second — the capstone — switch the seed to violet or emerald and the engine follows instantly, into a region where _no Geist ramp exists_. That is not Geist failing; it is the definition of Geist. A designed system ships one destination; the engine ships the road. + +## The lab + +Now put the whole course on one page: Geist's hand-designed system against dotUI's generator, decision by decision, from working space down to output. The receipt at the top holds Geist's "accessible" claim to both meters, live. Each row carries a verdict — steal it, hold it as the one irreducible difference, or fix it where dotUI today misses its own target. Filter to the punch list and you're reading the rewrite's to-do; read it top to bottom and you're reading the rewrite spec. + + + +Worth doing, in order: + +- **Read the receipt.** `blue-700` `#006bff` with a white label — WCAG a near-tie leaning white (4.62 vs 4.55), APCA decisively white (Lc −76.5 vs 33.1). Both land on white. Geist's "accessible" claim, verified, not asserted. +- **Filter to _Steal_.** OKLCH working space, job scale, hand-shaped curves, restrained neutrals, the alpha companion, the accessible-by-design pairing — eight rows the engine adopts wholesale. Geist proves, as a shipped artifact, that a system built on these decisions holds. +- **Read the one _Core_ row.** The seed. Geist has none; dotUI is nothing but one. This is the difference in _kind_, not a gap to close. +- **Filter to _Fix_.** No P3 variant (chapter 6), pairing scored max-WCAG-only (chapters 8, 17), dark as a reversed light list (chapter 16), alpha companion still owed (chapter 18). That column is the reason this whole site exists. + +## What the engine should steal — and reject + +This is the last chapter, so the reading is the whole course's, consolidated. The site is done, per this project's charter, when the maintainer can write the color-engine rewrite spec — and the scorecard is that spec compressed. Expanded into commitments: + +1. **Steal the substrate.** Author in OKLCH; ship sRGB hex with an `oklch()` P3 upgrade gated by the display (chapters 5–6). Geist proves the two-format ship is production-real. The engine gamut-maps with `clampChroma` today but ships no P3 variant — a gap on the Fix list. +2. **Steal the job scale.** Ten steps or twelve, the rungs are jobs and the contract lives between neighbours — and the ramp is allowed to run non-monotone where a job demands it (chapter 10). The border step is a border, not a rung. dotUI's 11-step ramp (50–950) already is this; keep it. +3. **Steal the alpha commitment.** Ship a translucent neutral companion for anything that must ride an unknown surface — borders, dividers, overlays, hover — beside the solid neutrals that must hold contrast (chapter 18). The engine's alpha is still an IOU; Geist shows the shape of the payment. +4. **Steal restraint as a parameter.** Near-neutral grays (`#171717` ink), a tight accent set, tint kept below chapter 15's threshold. Geist bakes restraint into the design; an engine that _can_ generate a lurid ramp from any seed must still default to it — because a generator's default is somebody else's brand. +5. **Steal the dual-meter pairing.** Geist placed `blue-700` where white wins on both meters. The engine must _generate_ that guarantee: enforce the WCAG 4.5:1 floor at generation and report the APCA Lc, so the tie-point colors resolve as a visible engine decision — not the silent max-WCAG default it ships today (chapter 17's pairing gap). Partners should be drawn from the ramp, not only black and white. +6. **Steal the separate dark.** Two designs, one token contract (chapter 16). This is where dotUI diverges most today — it reverses the light list — and closing that gap is the largest single line item in the rewrite. +7. **Reject the fixed destination.** Geist has no seed and needs none; it is complete because it serves one brand forever. dotUI's seed is the entire reason it exists: it must reach violet, emerald, and a brand nobody has chosen yet, from three numbers in. The engine can never copy Geist's _authorship_ — the per-step hue drift, the non-monotone ladder placed by eye, the color-by-color meter call — and it shouldn't fake them. It should reach the same _quality bar_ — the same contrast guarantees, the same restraint, the same job contract — from a rule that runs on a seed nobody has seen. Its job is not to _be_ Geist. It is to be able to _generate_ Geist — and then generate the one Geist never will. + +That is the loop this course was built to close. Steal every Steal row as an invariant, clear every Fix row as a task, and hold the one Core row as the thing that makes dotUI a different kind of artifact than Geist — a road to every destination, not one more destination. + +## Check yourself + +1. Geist ships 10 steps (100–1000), Radix ships 12 (1–12), and in Geist's light gray, step 400 (default border) is lighter than step 300 (active background). Why is that blip not a bug, and which chapter-10 idea does it prove? +2. Geist's `gray-alpha` and its solid `gray` hold different promises. When must a component reach for the alpha companion, and what does the solid gray guarantee that the alpha one structurally can't? +3. Geist reached its palette by hand for one brand; dotUI must reach any brand's by function. Name one thing the generator _reaches for free_ that Geist reached by hand — and the one thing Geist has that no generator output can ever contain. + +## Further reading + +- [Geist — Colors](https://vercel.com/geist/colors) — the ten scales, the 100–1000 step jobs, the P3 note, and the "high contrast, accessible" self-description this chapter audited, from the source. +- [Vercel `design.md`](https://vercel.com/design.md) — the machine-readable palette: every sRGB hex with its Display-P3 `oklch()` twin, the step-to-job map, the `gray-alpha` companion, and the "minimal and high-contrast … restrained color" statement. +- [Vercel `design.dark.md`](https://vercel.com/design.dark.md) — the dark theme as a separate file of hand-tuned values under the same token names: chapter 16's second design, made literal. Diff it against `design.md`. +- [Radix Colors](https://www.radix-ui.com/colors) — the 12-step job model and per-scale alpha variant Geist echoes on a tighter grid; the kinship this chapter draws, and chapter 21's subject. +- [Geist — Introduction](https://vercel.com/geist/introduction) — what Geist is and what it is for, in Vercel's own scope statement. diff --git a/content/chapters/material-hct.mdx b/content/chapters/material-hct.mdx new file mode 100644 index 0000000..05db5a6 --- /dev/null +++ b/content/chapters/material-hct.mdx @@ -0,0 +1,116 @@ +--- +title: 'Material 3 & HCT' +description: 'Material ships a color system that never runs a contrast check on the pairs it hands you — it constructs every pair to pass. The trick is one number, Tone, that equals CIE lightness and therefore equals contrast. This is what a web engine already living in OKLCH should steal from HCT, and why it can leave CAM16 behind.' +part: 3 +question: 'Material ships thousands of color pairs and audits none of them — how does every pair pass?' +--- + +import { ToneIsContrast } from '@/components/playgrounds/tone-is-contrast' +import { ToneVersusOklchL } from '@/components/playgrounds/tone-versus-oklch-l' +import { TonalPaletteStrip } from '@/components/playgrounds/tonal-palette-strip' +import { SchemePairDeltas } from '@/components/playgrounds/scheme-pair-deltas' +import { HctChromaQuirk } from '@/components/playgrounds/hct-chroma-quirk' +import { TonalPaletteRoleLab } from '@/components/playgrounds/tonal-palette-role-lab' + +Chapter 17 left one job unfinished on purpose: pairing. Every surface token owes its consumers a text partner, and the naive way to keep that promise is to generate the whole system and then audit it — for every surface-and-text pair, run a contrast check; where a pair fails, nudge a color and check again. The audit runs downstream of the generator, so every fix is a patch: move one color to rescue one pair, and the ramp drifts off whatever curve produced it. + +Material 3 inverts the order. It never audits a pair, because it constructs every pair to pass. The whole system turns on a single number — **Tone** — chosen so that contrast is a property of the picks, not a test run against them afterward. This chapter is about what that number buys, and, for an engine already living in OKLCH, exactly which parts of Material's machinery are worth the freight. + +## Tone is lightness, and lightness is contrast + +HCT is the color space Google built for Material 3. Its README gives it in one line — "A new color space … based on CAM16 x L\*, that accounts for viewing conditions" — its three axes being hue, chroma, and tone. Read that as a fusion. **Hue** and **chroma** come from CAM16, a color-appearance model that adjusts for viewing conditions — the heavy, perceptually careful half. **Tone** is plain CIE `L*`, the `L*a*b*` lightness from chapter 5, running 0 to 100. + +Two pedigrees in one space because they are good at different jobs. CAM16 predicts _appearance_, so Material trusts it for hue and colorfulness. `L*` predicts _lightness_, and lightness is what drives contrast. HCT's own class doc says so outright — "Using L\* creates a link between the color system, contrast, and thus accessibility" — and the contrast module makes it mechanical: "When linearized to match human perception, Y becomes HCT's tone and `L*a*b*`'s `L*`." Tone is `L*` is a function of luminance Y, and WCAG contrast (chapter 8) is a ratio of luminances. So a gap in Tone is a gap in luminance is a contrast ratio. Move Tone and you move contrast; nothing else in HCT touches it. + +That is the lever. Two neutral tones far enough apart are guaranteed to contrast, whatever their hue or chroma: + + + +This is chapter 10's tone arithmetic — "a big enough tone gap **is** the contrast proof" — promoted from a property you can exploit to the _only_ axis the system spends on accessibility. A Tone gap near 40 clears 3:1 and a gap near 50 clears 4.5:1 — approximate, and tightest at the _extremes_, not the midtones: a light 100/50 pair reads about 4.48, a hair under 4.5, while a mid 75/25 pair has margin to spare. The live meters read the real number. Material's docs cite that 40/50 rule of thumb; the library actually solves for target ratios continuously rather than hard-coding it, so treat it as an accurate approximation, not a spec constant. + +## The lever is L\*, and OKLCH L is not quite L\* + +dotUI computes in OKLCH, whose `L` is also a perceptual lightness monotonic in luminance — so it can carry contrast exactly the way Tone does. But OKLCH `L` and CIE `L*` are different rulers, and the difference is the size of the recalibration you owe: + + + +An OKLCH gray at `L` 0.50 measures `L*` 42, not 50; through the dark and mid range OKLCH `L` sits eight to thirteen points above the `L*` it names. Both are monotonic in luminance, so they rank contrast identically and either works as the lever — they just number the axis differently. That is the first concrete engine warning. Material's "gap 50 → 4.5:1" is an `L*` fact; pasted as a constant into an OKLCH-`L` pipeline it means something else, because 50 OKLCH-`L` points and 50 `L*` points span different luminance. Steal the method; re-derive the number in your own space. (Every other swatch in this chapter solves the reverse — OKLCH `L` chosen so its `L*` equals Material's Tone — precisely so the tone numbers stay honest.) + +## Tonal palettes, and pairs built by delta + +From a seed, Material derives **tonal palettes**: "a convenience class for retrieving colors that are constant in hue and chroma, but vary in tone." One hue, one chroma, Tone sweeping 0 to 100. A dynamic scheme derives six — primary, secondary, tertiary, neutral, neutral-variant, error. This is chapter 11's lightness-anchored philosophy at its purest, and chapter 15's tinted neutrals fall out for free: a low-chroma tonal palette _is_ a warm-gray ramp. + + + +The palette is not the theme. **Roles** are, and a role is a Tone _selected_ from a palette. In the baseline light scheme, read straight from `scheme.ts`: `primary` is Tone 40, `on-primary` is Tone 100, `primary-container` is Tone 90, `on-primary-container` is Tone 10. Every `on-` role is the readable partner of the surface it names — and here the lever pays off. Material's docs promise "any color roles starting with `on-` are guaranteed to have sufficient contrast," a minimum of 3:1, and the guarantee is _structural_: it lives in the Tone gap between the pair, not in a measurement of the generated colors. + + + +Look at the deltas. `primary` 40 to `on-primary` 100 is a gap of 60. `container` 90 to `on-container` 10 is 80. `surface` 99 to `on-surface` 10 is 89. Every shipped pair clears a Tone gap of 50 or more, so every pair clears its target before a hex exists. This is chapter 17's pairing promise with the audit deleted: `on-primary` is not black-or-white chosen to beat a ratio, it is Tone 100 chosen because 100 minus 40 cannot fail. The generator subtracts; it never measures. + +## One palette, re-picked per room + +Toggle that demo between light and dark. The tonal palette does not move — same hue, same chroma, same columns. Only the _Tone the role reads_ changes: `primary` 40 becomes 80, `container` 90 becomes 30, `surface` 99 becomes 10. Light-to-dark flips the index, never the palette. + +That is chapter 16's verdict — "dark mode is a second design" — wearing chapter 17's mechanism, mode aliasing, at the _semantic_ tier. Chapter 17 gave two homes for per-mode values: redefine the primitives (Radix's `dark.ts`) or re-pick at the semantic tier. Material is the clean second case — the primitives (the tonal palettes) are invariant; the role selects a different Tone per scheme. And the re-pick is asymmetric by design — 40 goes to 80, never to 60 — because the dark scheme is a set of designed picks, not an arithmetic mirror of light. + +## Contrast as a knob, not a fork + +Material also makes accessibility strength a first-class axis. `DynamicScheme.contrastLevel` is a scalar: "Value from -1 to 1. -1 represents minimum contrast, 0 represents standard (i.e. the design as spec'd), and 1 represents maximum contrast." The four shipped levels are reduced (−1), standard (0), medium (0.5), and high (1.0), with standard targeting WCAG AA and high targeting AAA. (WCAG 2 is the normative standard those targets are named against; APCA, chapter 8, still isn't one.) Because contrast is Tone arithmetic, raising the level is a single operation — push each pair's Tones apart, uniformly. No re-audit, no patch. This is chapter 9's `prefers-contrast` promoted from a media query you react to into a dial the engine owns, and the lab below puts it in your hand. + +## Where HCT costs more than it returns + +The case so far is to steal everything. Now the reject. HCT's Tone half is `L*` — cheap, closed-form, exactly the lever a web engine wants. Its hue-and-chroma half is CAM16, and that half is expensive: an appearance model with viewing-condition parameters, and a chroma axis that is not a free knob. The source is candid — "chroma may decrease because chroma has a different maximum for any given hue and tone." Ask a tonal palette for one fixed chroma across its Tones and the gamut clamps it, differently for every hue: + + + +You have seen this shape: chapter 6's tent, chapter 12's chroma curve — the maximum chroma is a function of lightness _and_ hue at once — and it falls straight out of plain OKLCH with no CAM16 anywhere. Blue holds chroma through the midtones and collapses toward the light end; yellow climbs the whole way, because its ceiling is highest exactly where blue's is lowest. Same requested chroma, two unrelated delivered curves. That is the argument for the reject. The one truth HCT's quirk teaches — chroma isn't free, its ceiling moves with hue and tone — OKLCH already tells you, in a closed-form transform of XYZ, with no appearance model and no iterative gamut solve. For a design system rendered on a defined sRGB or P3 display, chapter 5's three honest knobs get you the whole tone lever without the CAM16 freight. What you want from Material is not its color space. It is its _policy_. + +## The lab + +Everything above, wired to one seed. Pick a brand color; the lab builds two tonal palettes from it — an accent palette and a low-chroma neutral — then resolves the Material roles by selecting Tones per scheme. Flip light and dark to watch the picks move while the palettes hold. Turn the contrast dial to watch every pair's Tone gap widen at once. The meter table proves the point Material's engine never has to check: each pair's contrast is a consequence of its Tone delta, on both instruments, by construction. + +Honest note, in the code and here: this lab keeps **Tone exact** — every swatch is solved so its CIE `L*` equals the requested Tone, the axis that carries the whole contrast argument — but it approximates HCT's hue and chroma in **OKLCH**, not CAM16. It is a faithful model of the _policy_, not a reimplementation of the _space_. That gap is exactly the one this chapter argues a web engine should accept. + + + +Worth doing, in order: + +- **Read standard, light.** Every pair passes WCAG and APCA, and no pair was measured to get there — each delta was picked to clear its target. Four pairs, zero audits. +- **Flip to dark.** Watch the palette strip hold its columns while the role markers jump to new tones — the invariant-palette, moving-index re-pick from two sections up, now with both meters live beside it. +- **Turn the dial to high.** Every Tone gap widens together and every ratio climbs. One scalar reshaped every pair — the contrast axis Material owns and most engines leave to a media query. +- **Change the seed to the red or the near-neutral.** The Tone picks are identical; only the hue and chroma under them change. Accessibility rode on Tone the whole time, so it survives the hue swap untouched. + +## What the engine should steal — and reject + +dotUI lives in OKLCH already, and chapters 10 through 17 built a system whose spine — jobs, an eased lightness skeleton, tinted neutrals, semantic tokens with `on-` partners — is structurally the same as Material's. Material's lesson is not a new color space; it is a discipline for the one it shares with you. + +Steal: + +1. **Make Tone the single accessibility lever, and Tone is `L*`.** Resolve every contrast decision as a gap in lightness — OKLCH `L` is your version. But recalibrate: OKLCH `L` and `L*` diverge by up to thirteen points, so derive your own delta thresholds against OKLCH `L` rather than importing Material's `L*`-based 40 and 50. This is chapter 10's tone arithmetic made into a rule, not an observation. +2. **Construct pairs by delta; do not audit them.** Every `on-` and container partner is a Tone offset chosen to clear the target before generation — and report _both_ meters (chapter 8), because a WCAG-passing delta over-credits dark backgrounds (chapter 16) and APCA catches it. Keep the WCAG floor and the APCA target as the _design_ of the offset, computed once, never a post-hoc check that patches the ramp. +3. **Re-pick per mode; keep the primitives invariant.** Light, dark, and high-contrast are different Tone selections against the same palette (chapter 17's second architecture). The picks are asymmetric and designed (chapter 16), never a mirror. +4. **Expose contrast as a scalar axis, fed by a seed.** One `contrastLevel`-style dial that widens every pair's Tone gap at once turns chapter 9's `prefers-contrast` into an engine feature instead of a special case. And seed → a small set of tonal palettes → roles is chapter 14's front door at OS scale; the neutral / neutral-variant split gives chapter 15's tinted neutrals for free. + +Reject: + +5. **Do not adopt HCT the space.** CAM16's viewing-condition machinery and its per-hue-per-tone chroma solving are heavier than a display-targeted engine needs, and OKLCH's closed-form transform already surfaces the one hard truth (chapters 6 and 12) that the chroma quirk is trying to teach. +6. **Do not ship fixed-chroma tonal ramps.** "Chroma has a different maximum for any given hue and tone" — a flat request clamps silently at both ends and at different heights per hue. Draw the chroma curve under the tent (chapter 12) instead of hugging the wall. + +Left open, on purpose: Material's scheme _variants_ (tonal-spot, vibrant, expressive) and its secondary/tertiary expansion are a seed-policy question that belongs with chapter 14's front door; Spectrum and Leonardo's contrast-_anchored_ generation — the philosophy Material inverts by anchoring lightness — is chapter 24; Radix and Tailwind are the neighboring case studies, 21 and 22. + +## Check yourself + +1. Material ships an `on-primary` color and never measures its contrast against `primary`. What single fact about Tone makes that safe, and what are the two roles' baseline Tones in the light scheme? +2. Switching a Material theme from light to dark leaves the tonal palettes untouched but changes what each role displays. What exactly changes, and which chapter-17 architecture is this — redefine the primitives, or re-pick at the semantic tier? +3. Your engine computes in OKLCH and you want to reuse Material's "tone gap 50 → 4.5:1" as a constant. Why is pasting 50 as an OKLCH-`L` gap a latent bug, and what must you do instead? + +## Further reading + +- [material-color-utilities — README](https://github.com/material-foundation/material-color-utilities) — the one-line HCT definition ("CAM16 x L\*, that accounts for viewing conditions") and the reference implementation behind every number here. +- [HCT — class reference (MaterialKolor mirror)](https://docs.materialkolor.com/material-color-utilities/com.materialkolor.hct/-hct/index.html) — the Kdoc that ties Tone to `L*` and accessibility ("Using L\* creates a link between the color system, contrast, and thus accessibility") and documents the per-hue-per-tone chroma ceiling. +- [`scheme.ts` — baseline role tones](https://raw.githubusercontent.com/material-foundation/material-color-utilities/main/typescript/scheme/scheme.ts) — the exact light and dark Tone assignments; diff the two lists and the mode re-pick is the diff. +- [`tonal_palette.ts`](https://raw.githubusercontent.com/material-foundation/material-color-utilities/main/typescript/palettes/tonal_palette.ts) — the "constant in hue and chroma, but vary in tone" class, and the per-hue-per-tone chroma caveat that makes the flat ramp clamp. +- [`contrast.ts`](https://raw.githubusercontent.com/material-foundation/material-color-utilities/main/typescript/contrast/contrast.ts) — where Tone, `L*`, and luminance Y are the same axis, and `lighter`/`darker` solve a Tone for a target ratio. +- [`dynamic_scheme.ts` — contrastLevel](https://raw.githubusercontent.com/material-foundation/material-color-utilities/main/typescript/dynamiccolor/dynamic_scheme.ts) — the −1 to 1 contrast axis, standard = AA and high = AAA. +- [Material 3 — Color roles](https://m3.material.io/styles/color/roles) — the `on-` contrast guarantee ("guaranteed to have sufficient contrast") stated as product policy. diff --git a/content/chapters/radix-colors.mdx b/content/chapters/radix-colors.mdx new file mode 100644 index 0000000..1fdb866 --- /dev/null +++ b/content/chapters/radix-colors.mdx @@ -0,0 +1,108 @@ +--- +title: 'Radix Colors' +description: "Radix's twelve steps are twelve UI jobs placed by hand in OKLCH — chapter 10's 'steps are jobs' at its source. Steal the job contract, the shared solid, the separately-designed dark scale, and the alpha layer; reject the one thing hand-tuning can't do — turn an arbitrary brand seed into a matching system." +part: 3 +question: "Radix's 12 steps are already dotUI's job list — so what should the engine steal, and what can Radix's hand-tuning never give it?" +--- + +import { RadixJobBands } from '@/components/playgrounds/radix-job-bands' +import { RadixStepNine } from '@/components/playgrounds/radix-step-nine' +import { RadixLightDarkCurves } from '@/components/playgrounds/radix-light-dark-curves' +import { RadixAlphaSurfaces } from '@/components/playgrounds/radix-alpha-surfaces' +import { RadixScaleReader } from '@/components/playgrounds/radix-scale-reader' + +Chapter 10 opened Part 2 with a sentence it borrowed and never paid back: **steps are jobs**. That is Radix's documentation, almost verbatim — twelve slots, each engineered for one UI use — and dotUI's job list is a rename of it. Part 3 reads the greats the way an engineer reads a competitor's teardown: not to admire, but to decide what goes into the engine and what stays out. Radix is dotUI's most direct ancestor, so the reading is unusually concrete. + +The mechanism is smaller than it looks and more stubborn than you'd hope. A Radix scale is twelve colors placed by hand in a perceptual space, each pinned to one job and checked against a contrast target — chapter 11's third philosophy, **hand-tuned reference**, executed with unusual discipline. Then the whole thing is done again, from scratch, for dark. There is no seed, no generator, no formula that turns a brand color into a scale: the base scales, Radix says outright, "are not intended to be customised." That single fact is the whole case study — everything the contract buys, and the one thing chapter 14 needs that it can't give. The steal list is long; the reject is one sentence, and it's the reason dotUI needs an engine at all. + +## The twelve steps are the job list + + + +The bands are the spine. Steps 1–2 are the two page backgrounds. Steps 3–5 are a component's own surface across three states — resting, hovered, active-or-selected. Steps 6–8 are borders — a subtle separator, an interactive edge that doubles as the focus ring, a hovered edge. Steps 9–10 are the solids. Steps 11–12 are the two text weights. A component author never asks "how blue is step 7"; they ask "do I need an interactive border," and the answer is always step 7, in every scale, forever. That invariance is the entire point — it is what lets one component style span thirty hues without a single per-hue exception, and it is the first thing the engine steals. + +The ends carry real contracts, not vibes. Radix guarantees step 11 clears **Lc 60** APCA and step 12 clears **Lc 90**, both measured on a step-2 background from the same scale. Run the meters on blue: light step 11 on step 2 reads 4.53:1 and Lc 68.9, step 12 reads 12.00:1 and Lc 94.7. The binding case is dark — `blueDark11` on `blueDark2` sits at Lc 60.1, the tuning run right up to the line and stopped. (This site's APCA port is version 0.0.98; Radix tuned against a slightly different revision, so treat the last digit as noise — the point is that the step is pinned to an APCA number, not that any one reading is canonical.) + +Then the honesty the engine has to notice: only steps 11–12 carry that promise. The borders don't. Blue's step 7 — the interactive border and focus ring — measures 1.70:1 against step 2; step 8, the hover border, 2.22:1. Both sit below the 3:1 that WCAG 1.4.11 asks of a non-text UI boundary. Radix tuned them to _look_ like borders, by eye, and let the ratio fall where perception put it — chapter 17's honest null case, that some tokens carry a contrast promise and some explicitly don't. Copy Radix's borders and you inherit that decision; want a 3:1 focus ring and you have to add a promise Radix didn't make. + +## Step 9, the shared solid + +One step is special. Radix calls step 9 the highest-chroma step — "the purest step, the step mixed with the least amount of white or black" — the most saturated color the hue reaches in gamut, chapter 6's tent at its peak. `blue9` is `#0090ff`, oklch(0.649 0.193 252), and it's the solid: filled buttons, checked boxes, the brand chip. It carries two properties worth stealing separately. + + + +First, it is the one hex an accent scale carries _unchanged_ into dark mode. `blue9` is `#0090ff` in both `light.ts` and `dark.ts`, byte for byte — a solid sits at mid lightness holding its own background, so the room barely touches it (chapter 16 predicted exactly this: the solid survives the flip while the text step can't). Grays get no such pass — `slate9` is `#8b8d98` in light and `#696e77` in dark — because a gray solid has no chroma to anchor it and must be re-read against each room. + +Second, its label is chapter 8's orange-button fight, shipped as a default. On `#0090ff`, WCAG's max-ratio rule votes black — 6.43:1 beats white's 3.26:1, and white fails the 4.5 floor. APCA and most eyes vote white — Lc −64.6 clears the body-text bar where black's 44.9 doesn't. Radix ships **white**. That's a deliberate stand: the pairing sides with the reading meter over the auditable ratio, on every accent solid at once. It's the same posture chapter 8 argued and chapter 17 wired into `fg-on-accent` — WCAG 2 is the normative law, APCA predicts the reading, and a serious system reports both and lets perception break the tie. The engine must make the same call consciously, because whichever meter it trusts gets baked into every filled button. + +## Dark is a separate design, not a flip + +Chapter 16's whole thesis — dark mode is a second design — has its cleanest proof in Radix's source. The dark scales are separate exports with entirely different hex, not a transform of the light ones. Overlay the lightness curves and the flip falls apart on sight. + + + +Read the failure precisely. Flip the light ramp's lightness (`L → 1 − L`, keeping each step's job) and steps 1–3 land at L 0.007, 0.018, 0.040 — three near-blacks that render as one color, collapsing app background, subtle background, and component surface into a single black. Radix's designed dark ramp spaces them at L 0.194, 0.213, 0.274. At the other end the flip tops text out at L 0.676 — too dark to read crisply on a dark field — where Radix lifts step 12 to L 0.907. And the quiet end spends differently: the light ramp steps from 2 to 3 by ΔL 0.022, the dark ramp by 0.062 — nearly three times the lightness per step, exactly where chapter 16 said the dark room needs it, because near-black is where the display and the eye resolve least. + +It isn't only lightness. The text step sheds chroma too: `blue11` goes from `#0d74ce` (oklch 0.556 0.162 252) in light to `#70b8ff` (oklch 0.764 0.126 249) in dark — up 0.21 in lightness, down a fifth in chroma, the anti-glow move from chapter 16. Two files, one shared solid, everything else re-tuned. No arithmetic on the light column contains that pattern; only a second designed pass does. Steal the _architecture_ — a separate generation per mode, with its own curves — not any transform. + +## Alpha ships the compositing layer + +Chapter 18's argument was that overlays and states want alpha, because a translucent color composites against whatever is under it instead of guessing. Radix ships that argument as product: every scale has an `…A` alpha variant, twelve translucent steps parallel to the twelve solid ones. + + + +The solid step is one fixed hex — tuned to read as a border on white (2.33:1 there), it blows to 8.09:1 on near-black: a hard bright line, not a subtle edge. The alpha step carries no fixed color; it borrows its surface, so a single token holds 2.0–2.6:1 across white, gray, and near-black alike, and over white it composites to the solid step exactly — which is how the two variants stay in sync. For anything painted over content you don't control — overlays, selection highlights, borders on arbitrary surfaces — alpha is the correct primitive, and Radix generates it for every step. There's a matching wide-gamut story — every scale also ships a P3 variant in `color(display-p3 …)` for the headroom chapter 6 mapped — plus black/white alpha overlays that "don't change across light and dark theme." All three are shipped answers to problems Part 2 only specified. + +## The reject: there is no front door + +Everything above is worth copying. Now the line the engine cannot cross, and it's structural. Chapter 14 defined the engine's front door: one brand hex in, a full system out — paste `#635bff`, get a scale. Radix has no such door. The base scales are a fixed hand-tuned reference set, and "not intended to be customised" is the library telling you the door is welded shut. + +There is a _separate_ in-browser tool — the custom-palette generator — but read what it does. It takes "a couple reference colors" and returns a scale that, in Radix's own words, is "based on the Radix Colors scales themselves." It doesn't build twelve steps from your color's OKLCH the way chapter 14's pipeline does; it finds the nearest existing Radix scale and bends it toward your input. (That nearest-match-and-interpolate mechanism is reconstructed from community write-ups, not spelled out in Radix's docs — treat the algorithm as directional.) The output is a fit to the reference, which is precisely why it can't be dotUI's engine: an arbitrary seed that lands between two reference scales gets snapped, not generated. Hand-tuning is a corpus, and you can only interpolate within a corpus you already have. Radix shows you the target the generation must hit; it can't be the generator. + +The semantic layer is the same shape of homework. Radix ships raw named scales — `blue`, `slate`, `red` — never `accent` or `danger`, and its own aliasing guide carries chapter 17's warning: one scale serves several meanings, `yellow` for both warning and pending, `red` for both danger and error. The engine owns that mapping; Radix leaves it open on purpose. + +## The lab + +One scale, both rooms, every variant, wired to read. Hover any step for its documented job, its OKLCH, and its live contract; toggle the room and watch every step re-tune, not invert; toggle alpha and the strip goes translucent over a checker. The flip-check line under each step reports where an `L → 1 − L` engine would have put it — and how far that is from what Radix actually shipped. + + + +Worth doing, in order: + +- **Land on step 9.** Light and dark are the same hex — the shared solid. Read the label split: white passes APCA where black fails, and Radix's shipped choice is white. The chapter-8 decision, live. Even here the flip-check is off by ~0.30 L: a mirror would move the one step that must stay put. +- **Walk steps 1 → 3, then toggle to Dark.** The flip-check diverges hardest at the quiet end — the mirror crushes three surface steps into near-black; Radix's dark spaces them. +- **Select step 11 in both rooms.** The text contract holds on both meters in light and dark, because the dark step was tuned against the dark background, not carried over. +- **Select step 7.** The interactive border reads below 3:1 in both modes. Radix promised nothing there — so your engine has to decide that on purpose, not inherit it by accident. +- **Toggle Alpha.** The `…A` steps are what Radix composites to reach the solids over the theme's own background. Nowhere does a control ask for a seed — that absence _is_ the reject. + +## What the engine should steal — and reject + +Radix is the reference dotUI's job list already descends from, so most of this is ratification, not adoption. + +**Steal, stated as commitments:** + +1. **The twelve-job contract, verbatim.** Two backgrounds, three component fills, three borders, two solids, two text steps — with contracts at the ends: step 9 the chroma peak and solid, steps 11/12 hitting Lc 60/90 on step 2, borders explicitly below text contrast. dotUI already uses this; hold the exact band boundaries as a hard invariant so one component style spans every hue. +2. **Anchor the scale at the solid.** Step 9 is placed first as the in-gamut chroma peak (chapter 6), and the ramp is built around it. The generator should treat the solid as a fixed point, not one more sample of a lightness curve — and pair it with a label chosen by a _stated_ meter, not a silent max-ratio default. +3. **A per-step contrast contract, stated in both meters.** Radix guarantees the two text steps in APCA against step 2. Adopt the target, report WCAG beside it (chapter 8), and — Radix's honest gap — decide _explicitly_ whether borders and focus rings owe 3:1, because Radix's don't. +4. **Dark as a second generation pass.** Separate lightness skeleton, separate chroma budgets, separate per-hue moves — never `L → 1 − L`. Two files, shared solid, everything else re-tuned, wider steps at the dark quiet end. Radix's `dark.ts` is the existence proof that this is worth the cost. +5. **Alpha (and P3) as first-class output.** Emit an `…A` twin for every step, composited to match the solids over the theme background, plus a P3 variant for wide-gamut headroom. The compositing promise is shippable, not aspirational. + +**Reject:** + +6. **Hand-tuning as the front door.** Radix cannot answer chapter 14: no arbitrary seed produces a from-scratch scale, and the custom tool interpolates within the reference corpus rather than generating from your color. dotUI exists _for_ that door — so it takes Radix's contract and targets as the spec its generator must hit, and rejects Radix's method of hitting them by hand. Steal the destination; build your own road. + +Left open, on purpose: the semantic aliasing on top (chapter 17, and Radix leaves it to you); the full state-and-alpha matrix (chapter 18); and how Tailwind makes the opposite bet — ship primitives, no semantic tier (chapter 22). + +## Check yourself + +1. Radix ships `blue9` as `#0090ff` in both light and dark, but `blue11` as `#0d74ce` in light and `#70b8ff` in dark. Why does the solid survive the room change when the text step can't — and which step does the engine treat as the fixed point? +2. `blue7` is the interactive border and focus ring, and it measures 1.70:1 against `blue2`. Is that a bug in Radix, a bug in your engine if you copy it, both, or neither? What did Radix actually promise, and where? +3. Radix's custom-palette tool takes a brand color and returns a scale — so why isn't it chapter 14's front door? Name the difference between what it does and what dotUI's generator must do. + +## Further reading + +- [Radix Colors — Understanding the scale](https://www.radix-ui.com/colors/docs/palette-composition/understanding-the-scale) — the twelve-step job table, the "purest step" definition of step 9, and the Lc 60 / Lc 90 guarantees, in Radix's own words. +- [`radix-ui/colors` — `light.ts` / `dark.ts`](https://github.com/radix-ui/colors/tree/main/src) — every hex in this chapter; diff `blue` against `blueDark` and the "dark is a second design" section is the diff, with step 9 the one line that matches. +- [Radix Colors — Composing a palette](https://www.radix-ui.com/colors/docs/palette-composition/composing-a-palette) — "not intended to be customised," plus the aliasing-to-roles homework and the multiple-semantics warning. +- [Radix Colors — Custom palettes](https://www.radix-ui.com/colors/docs/overview/custom-palettes) — the seed-to-reference generator, and the sentence ("based on the Radix Colors scales themselves") that makes it a fit, not a front door. +- [Radix Colors — Scales](https://www.radix-ui.com/colors/docs/palette-composition/scales) — the ~30 scales and the four variants (base, alpha, dark, dark-alpha) plus P3, that this chapter sampled one hue of. diff --git a/content/chapters/semantic-tokens.mdx b/content/chapters/semantic-tokens.mdx new file mode 100644 index 0000000..7e6abd3 --- /dev/null +++ b/content/chapters/semantic-tokens.mdx @@ -0,0 +1,111 @@ +--- +title: 'Semantic tokens: tiers, naming, pairing' +description: 'Nobody ships a ramp — components consume names, and every name is a promise. How token systems tier their vocabulary, what a good name grammar looks like, why every surface token needs a guaranteed text partner, and how one name serves light, dark, and high contrast.' +part: 2 +question: 'Repoint accent and the whole UI follows — which promises break, and what catches them?' +--- + +import { NumericVsSemantic } from '@/components/playgrounds/numeric-vs-semantic' +import { PairingFlip } from '@/components/playgrounds/pairing-flip' +import { ComponentTierFirewall } from '@/components/playgrounds/component-tier-firewall' +import { ModeAliasTable } from '@/components/playgrounds/mode-alias-table' +import { TokenCountLineup } from '@/components/playgrounds/token-count-lineup' +import { TokenWiringPlayground } from '@/components/playgrounds/token-wiring-playground' + +Chapter 1 planted a sentence and left it: tokens are promises about perception. Seven chapters of Part 2 have since built the machinery that could keep such promises — job slots, three curves, a seed policy, tinted neutrals, a dark pass. But none of that is what a component sees. A component sees `bg-accent`. The ramps are the factory; the tokens are the product — the API through which every developer, every component, and every AI codegen tool consumes your color system. This chapter designs that API: how many layers, what grammar names them, what each name guarantees, and what happens to one name across three rooms. + +## Three tiers + +The consensus architecture is a chain. At the bottom, **primitives**: `blue-900`, a value with a name and no opinion about where it goes. In the middle, **semantic tokens**: `accent`, `fg-muted`, `border-field` — names that state a role, resolved by pointing at a primitive. On top, optionally, **component tokens**: `tooltip`, `sidebar` — names scoped to one component, resolved by pointing at a semantic token or a primitive. A button's pixel is the end of a wire: `#4992dd ← --blue-500 ← accent ← the button`. + +This layering is standard enough to have a draft standard. The Design Tokens Community Group's Format Module gives tokens an interchange grammar — a token is a JSON object with a `$value`, an optional `$type`, and aliases that reference other tokens by path in curly braces, `"$value": "{color.blue.500}"` — so the same chain can travel between Figma, code, and whatever tool comes next. Status stated plainly: it is a draft Community Group report, not a W3C standard; useful as shared vocabulary today, not as a compliance target. + +The two lower tiers are uncontroversial — every serious system has primitives and roles, whatever it calls them. The component tier is the judgment call. Here's what it buys: + + + +A component token is a firewall. While `popover` merely shares `card`'s value, restyling cards restyles popovers; once it has its own name, the product can change its mind about one without dragging the other. The canonical earner is the tooltip: in dotUI it's an _inverse_ surface — near-black in light mode — which no general-purpose surface token could ever express. The cost is real, though: every promoted name needs a value in light, dark, and every contrast variant, forever. dotUI currently ships exactly six component tokens — `tooltip`, `fg-on-tooltip`, `card`, `popover`, `sidebar`, `border-sidebar` — a deliberately short list. The rule of thumb: promote a component when its color is a product decision that can plausibly diverge, not "for flexibility." + +## The naming grammar + +Look across shipped systems and there are two dialects. **Numeric**: Radix's `blue9`, Tailwind's `blue-500` — the name encodes position on a ramp and nothing else. **Semantic**: names built from a small grammar of _property × role × state_. dotUI's theme file is a clean specimen: property prefix (`bg` implied for surfaces, `fg-` for text, `border-` for edges), role (`neutral`, `accent`, `danger`, `muted`, `field`…), state suffix (`-hover`, `-active`, `-disabled`) — composing `--color-accent-hover`, `--color-fg-danger`, `--color-border-field`. shadcn's variant of the same idea pairs each surface with its text color by suffix; its docs state the convention in one breath: "The base token controls the surface color and the `-foreground` token controls the text and icon color that sits on that surface." + +The dialects trade the same two currencies in opposite directions: + + + +Numeric names are flexible and opaque. Any step can go anywhere — and chapter 10 already showed the price: nothing breaks loudly when someone ships `text-slate-400` on `bg-slate-100`, and the rebrand above is a codebase-wide find-and-replace, because every call site pinned the hue. Semantic names are guided and finite. `fg-muted` is a decision already made — a component author can't misfile it — and the theme owns the hue, so a rebrand is one line. The finiteness is also the weakness: the vocabulary can't say everything. Radix's own aliasing guide walks into it honestly — "creating semantic aliases like `accent`, `primary`, `neutral`, or `brand` can be helpful, especially when it comes to theming," but "you will likely run into issues where you need to use the same scale for multiple semantics": map `yellow` to "warning" and you'll need `yellow` again for "pending," `red` for both "danger" and "error." When no name fits, developers escape to primitives, and the API leaks. + +Mature systems land on both: numeric primitives underneath, a semantic tier on top, and a norm about which tier application code may touch. Radix ships the primitives and leaves the semantic tier as documented homework; Material ships only the semantic tier and hides the palette; Tailwind ships primitives and lets convention do the rest. + +## Pairing is the contract + +Here is the underrated part of the API — the part that makes tokens _promises_ rather than shorter names for hex codes. **Every token that paints a surface owes its consumers a guaranteed text partner.** shadcn encodes the debt in the suffix: `primary` ships with `primary-foreground`. Material encodes it in the role system — "on-primary rather than on-blue," in its own words, and the pairs are constructed, not audited: "Color role pairs (composed of container and on-container roles) have tonal values that provide accessible contrast" — chapter 10's tone arithmetic, where a large enough tone gap _is_ the contrast proof. dotUI generates the partner: a Tailwind plugin scans every primitive and emits `--on-accent-500` — black or white, whichever scores the higher WCAG ratio — and the semantic tier aliases it as `fg-on-accent`. + +That last sentence hides a decision worth dragging into the light. Whichever _meter_ the pairing generator trusts gets baked into every button it ships: + + + +Run dotUI's actual default accent through it. `--blue-500` is `#4992dd`, relative luminance 0.27 — above chapter 8's Y ≈ 0.18 tie point, so WCAG votes black categorically: black 6.44:1, white 3.26:1, and the max-ratio rule ships a black label. APCA sides with white, −65.1 against 44.4. This is the orange-button fight from chapter 8, reincarnated as a token default — and it lands exactly on steps 500–600 of the ramp, where solid buttons live. Chapter 8's posture resolves it here the same way it did there: the pairing promise must name both instruments — a WCAG floor, because that's the auditable, legal half — and an APCA target, because that's the half that predicts reading. A generator that quietly optimizes one meter will systematically ship pairs the other meter — and many eyes — dispute. + +The contract also has an honest null case: some tokens promise nothing. dotUI's subtle `border` on `bg` measures 1.33:1 — decorative by design, chapter 10's quiet card edge. But `border-field`, an input's boundary, arguably owes the 3:1 non-text minimum of WCAG 1.4.11 and currently measures 1.87:1. Either answer can be right; an API is only honest if it says _which tokens carry promises and which explicitly don't_. + +## One name, every room + +Chapter 16 ended with an IOU: name the light/dark pairs so components consume one token. This is the mechanism — **mode aliasing**: one semantic name resolving to different primitives per context. + + + +The component consumes `fg-muted` and never learns which room it's in. And the readouts echo chapter 16: the token resolves to slate 11 at 5.79:1 in light, slateDark 11 at 9.06:1 in dark — a _bigger_ ratio whose APCA magnitude is nonetheless smaller (Lc 78.0 versus −61.0), the flare-term over-credit again. `bg-elevated` pays the elevation IOU: in light it coincides with `bg` (an alias may share a value), in dark it lifts to slateDark 3. And the third column pays chapter 9's: `prefers-contrast` support is a _value_ variant on the same slots — borders promoted from step 6 to step 9 (past the 3:1 bar), muted text promoted to step 12 — never a new set of names. + +Architecturally there are two places the aliasing can live, and shipped systems split. **Redefine the primitives**: Radix's `dark.ts` makes `blue1` itself a different color in dark mode, so a semantic tier built on it never changes — and Radix's docs add a small third pattern ("mutable aliases") for the cases where one name must cross scales between modes. **Re-pick at the semantic tier**: Material keeps one tonal palette and has each role select a different tone per scheme — primary is tone 40 in light, tone 80 in dark. Both are defensible; the invariant they share is chapter 16's lesson wearing token clothes: the _names_ are constant, the _values_ are per-mode designs. dotUI today does the first — its `.dark` block redefines every primitive — but fills the dark values by literally reversing the light list, step 950 becoming step 50. The semantic tier is well-shaped; what it aliases in the dark is chapter 16's un-designed flip, wired in at the primitive tier. + +## How many names + + + +The counts measure different things — Radix's 12 are primitives per scale, the rest are public APIs — but the spread is the argument. More tokens buy precision: dotUI's 83 distinguish `border-field` from `border-control`, `fg-disabled` from `fg-primary-disabled`, and each distinction is a real design decision somebody gets to make exactly once. More tokens also cost decisions: "which of 83 applies here" is an onboarding tax that "which of 18" barely charges. Notice what the systems agree on, though: strip any of them to the tokens a component author touches daily and you get nearly the same ~20 — a surface trio (`bg`, `card`, `popover`), a text pair (`fg`, `fg-muted`), a border, a field, a focus ring, one accent family with its partners, one danger family with its partners. shadcn's 18 _is_ that list, almost verbatim. + +So the test for token 21 and beyond isn't taste, it's two questions: does some component need to pair two roles that no existing token distinguishes — and does the distinction survive a mode switch? Yes to both: it's a token. Otherwise it's just a value, and a primitive already holds it. + +## The lab + +Everything above, wired into one UI. The card below is painted entirely through a three-tier chain — hover any part to read its wire, end to end. The remap panel re-points the `accent` alias (Radix's semantic aliasing, live) and re-picks its step and its label policy; the checker holds all six pairing promises to both meters, continuously — the part every naming scheme leaves as homework. + + + +Worth doing, in order: + +- **Read the default.** `fg-on-accent` is on Auto — the max-WCAG rule dotUI's generator actually uses — and it picked black: 6.44:1, pass. The APCA column disagrees at Lc 44.4. Four of six promises hold on both meters; the button holds on one. You shipped this tension, not a resolution of it. +- **Force White.** Now it's the designers' pick: 3.26:1, below the legal floor, flagged red — while APCA scores it the _better_ label. When the meters split, the pair is fragile either way. +- **Move the solid step to 700.** The boring fix, same as chapter 8's orange: all six promises hold on both meters, and white didn't need forcing — Auto agreed. +- **Repoint accent to Amber or Red.** The whole UI follows — badge, button, link — and Auto re-decides the partner per hue, because the pairing is recomputed, not copied. Watch the checker re-verify every promise on the way through. +- **Hover the badge, then the link.** Two different elements, one wire: both consume `fg-accent`. That shared name is why they can never drift apart — and why renaming a semantic token is an API break, not a restyle. + +## The decision this unlocks + +This chapter _is_ dotUI's semantic-token axis, so the engine reading comes first. What ships today, read from the source: three tiers, with a twist — the "primitives" are already semantic families (`--accent-500`, `--danger-500`), their hue chosen at generation time, so tier 1's hue-to-role aliasing lives inside the generator rather than in CSS. On top, 77 semantic plus 6 component tokens in a consistent property × role × state grammar. Pairing is generated — `--on-*` for every primitive step — but by the max-WCAG black-or-white rule only. Mode aliasing happens at the primitive tier, and the dark values are the reversed light list: no dark design (chapter 16's verdict), and no contrast variants at all. + +The rewrite spec, stated as commitments: + +1. **The semantic tier is the public API.** Three tiers stay; the property × role × state grammar stays; application code and exported components consume semantic names only, with primitives as the documented escape hatch. +2. **Every surface token declares its text partner, and the promise names both meters.** The WCAG 4.5:1 floor is enforced at generation — never ship a default pair below it — and the APCA Lc is computed and reported, so the 500-step disagreement zone becomes a visible engine decision instead of a silent plugin default. Partners should be drawn from the ramp, not just black/white — the ramp's own step 800 already clears both bars on its muted 100. +3. **Modes are value columns, not new slots.** Light, dark, and `prefers-contrast` variants re-point the same token table — and each column is a designed pass (chapter 16), not an arithmetic of another column. +4. **The component tier is an opt-in axis.** Six is close to the right floor; each promotion multiplies across every mode column, so the builder should make the cost visible. +5. **Audit the 83 against the 20-core.** Every token beyond the core answers the two questions — a distinct pairing, surviving mode switches — or demotes to a value. + +Left open, on purpose: state and alpha tokens (`-hover` is a suffix today; whether states derive or get designed is chapter 18), chart palettes (chapter 19), and how the whole table ships as CSS (chapter 20). + +## Check yourself + +1. A teammate proposes `button-bg`, `button-fg`, and `button-border` component tokens "for flexibility," for every component. What must be true for a component token to earn its place, and what does each one cost across modes? +2. Your accent solid is `#4992dd`. The pairing generator ships a black label at 6.44:1; the designer insists white reads better, and APCA agrees (−65.1 versus 44.4). What are the engine's two honest options — and which chapter-8 rule does each follow? +3. `fg-muted` resolves to slate 11 in light mode and slateDark 11 in dark. Name the two places that aliasing can live architecturally, one shipped system for each, and the thing both architectures must never do to the token's _name_. + +## Further reading + +- [Design Tokens Format Module (draft)](https://www.designtokens.org/TR/drafts/format/) — the DTCG interchange format: `$value`, `$type`, curly-brace aliases; read the status block for exactly how standard it isn't yet. +- [Radix Colors — Aliasing](https://www.radix-ui.com/colors/docs/overview/aliasing) — semantic, use-case, and mutable aliases, with the multiple-semantics warning quoted in this chapter. +- [Material 3 — Color roles](https://m3.material.io/styles/color/roles) — the role system and its on-pairs; the "connective tissue between elements of the UI and what color goes where." +- [shadcn/ui — Theming](https://ui.shadcn.com/docs/theming) — the `-foreground` pairing convention and the ~20-token core, live in the `:root` block. +- [dotUI — `theme.css`](https://github.com/mehdibha/dotUI/blob/main/www/src/registry/base/theme.css) — the 83 tokens this chapter counted, in the property × role × state grammar. diff --git a/content/chapters/shipping-color.mdx b/content/chapters/shipping-color.mdx new file mode 100644 index 0000000..0f917be --- /dev/null +++ b/content/chapters/shipping-color.mdx @@ -0,0 +1,139 @@ +--- +title: 'Shipping color in CSS' +description: 'The token table from chapter 17 is a spec, not a stylesheet. Compiling it has exactly two settings — bake every value to static hex, or emit CSS that computes at runtime with color-mix, relative color, and light-dark. This chapter is that fork, priced across four axes: browser support, CSS bytes, live theming, and whether contrast is still readable off the value.' +part: 2 +question: 'Ship the token table as baked hex or as CSS that computes at runtime — what does each artifact trade away?' +--- + +import { TailwindThemeOutput } from '@/components/playgrounds/tailwind-theme-output' +import { MixVsRamp } from '@/components/playgrounds/mix-vs-ramp' +import { RelativeColorSteps } from '@/components/playgrounds/relative-color-steps' +import { LightDarkOneliner } from '@/components/playgrounds/light-dark-oneliner' +import { P3Fallback } from '@/components/playgrounds/p3-fallback' +import { CompileTheToken } from '@/components/playgrounds/compile-the-token' + +Chapter 17 ended with an IOU stated in as many words: how the whole token table ships as CSS is chapter 20. This is chapter 20. That table is an abstract object — roughly twenty core names, each a promise about perception, each resolving to a value per mode. To ship it you _compile_ it, and the compiler has exactly two settings. **Bake**: freeze every value to a static hex at build time — `--accent: #4992dd`. **Compute**: leave an expression on the right-hand side and let the browser resolve it — `--accent-hover: color-mix(in oklab, var(--accent), black 12%)` — using the CSS color functions that reached Baseline between 2023 and 2024. Baked is bigger and dumber and bulletproof. Computed is tiny, themeable live, and load-bearing on functions some of your users' browsers shipped last year. + +Everything below is the four things that choice trades: browser support, CSS bytes, live theming, and whether you can still audit contrast by reading the value. The engine has to pick a side — per token — so pick it with the trade in front of you. + +## The delivery vehicle is a custom property + +Whichever setting the compiler uses, it writes into the same slot: a CSS custom property. That isn't a choice — it's the delivery layer. `var(--accent)` is a name a component reads and the theme owns, chapter 17's semantic tier in its CSS clothes. dotUI ships this through Tailwind v4's `@theme`, and the mechanism is worth seeing exactly, because it explains why you write `@theme` and not a plain `:root` block: one token definition emits _both_ the custom property and a family of utilities. + + + +Tailwind's docs state the dual role plainly — theme variables "also instruct Tailwind to create new utility classes." So chapter 17's `bg-accent` isn't hand-written; it falls out of `--color-accent`. The token table becomes a block of `@theme` definitions, and the vehicle underneath is always the custom property. The rest of this chapter is about what you are allowed to put on the right-hand side of one. + +## The fork: two artifacts, four costs + +State the decision before the tools, because the tools are only interesting as ways to move along four axes. + +**Baked hex.** Every token resolves to a literal. Support is _universal_ — any browser that reads a hex triplet renders it, no feature detection anywhere. Contrast is _auditable at the source_ — the pairing promise from chapter 17 is a number your build can compute and refuse to ship below. The price sits on the other two axes: the CSS _enumerates every value_ — every step, every state, both modes — so it is the largest artifact; and a literal is _frozen at compile time_, so a rebrand is a rebuild, not a variable change. + +**Computed.** States and modes are expressions: hover mixed from the accent, a step offset from a seed, both modes carried in one property. The CSS is _smaller_ — you derive instead of enumerate — and it is _live-themeable_: repoint one seed variable and everything downstream repaints in the browser. The price is the mirror image: you inherit the _support story_ of every function you use, and the resolved color is _not in the source_, so contrast can't be read off the value — you have to compute what the browser will compute before you can audit it. + +Neither is "modern versus legacy." They are two points on a support / bytes / theming / auditability trade, and a serious engine ships a blend. First the toolbox — and its support tiers, honestly. + +## States by mixing + +The cheapest computed lever. Chapter 17 left `-hover` as a bare suffix and pushed the real question forward: chapter 18 is where it got priced. Chapter 18's thesis was that a state is _a modification of what's already there, not a color_, and that "derive a state" itself forks — _a solid step versus a layer of alpha_. `color-mix()` is how you compute either at runtime: a hover is the accent nudged toward black, a disabled fill the accent nudged toward transparent. + +The mechanism is exactly chapter 7: `color-mix` **is** interpolation, and the space is the whole game. `in oklab` walks a straight line in OKLab 12% of the way to black; `in srgb` walks it through the muddy middle — and note _why_ it muddies: an `in srgb` mix interpolates in the gamma-encoded channels (chapter 3), not linear light, so the midpoint darkens through a dead grey. The space keyword is grammatically optional but there is no default you'd want — always write it. `color-mix()` has been Baseline **widely** available since **May 2023** (Safari 16.2, Chrome 111, Firefox 113): safe barefoot, no fallback. + +One caveat the mechanism hides — chapter 6 arriving under a runtime expression: + + + +The mixed hover lands _near_ the ramp's own step 600 but not _on_ it — about ΔEOK **0.013** in OKLab here, under the 0.02 just-noticeable difference (chapter 5), so at this hue and step it's close enough; a more saturated hue or a bigger step can clear it. `color-mix` draws a straight line toward black; the ramp's step 600 was placed at a target lightness and then _gamut-mapped_, its chroma pulled inward to fit the sRGB tent (chapter 6). Those are different operations landing on different colors — and switching the mix space moves the result again without ever hitting the baked step. A runtime-mixed state is an _approximation_ of the designed ramp step, defensible and often close, but not the same value, and you can't read the gap off the CSS — only off a meter. + +There's a second cost the toward-black hover hides, and chapter 18 already named it. A `color-mix(in oklab, var(--accent), black 12%)` resolves to _one fixed literal_. It is surface-blind: drop that same hover onto a tinted panel and it ignores the panel — chapter 18's "gray smudge on a colored surface" failure, reintroduced by a runtime mix. The demo's lower row shows it: the solid chip is identical on both surfaces, while the toward-_transparent_ alpha chip picks up the panel it sits on. Alpha composites over whatever is behind it; a solid mix doesn't. So "derive a state at runtime" is not one choice — it's a solid step (surface-blind, but auditable as a flat color) versus a layer of alpha (surface-correct, but its resolved contrast depends on what it lands on, so it can't be audited off the value at all). The engine has to price both. + +## Steps from a seed + +The stronger, newer lever: derive a whole step by pulling a color's channels apart. Relative color syntax does it — `oklch(from var(--accent) calc(l - 0.08) c h)` reads the accent's L, C, H as _numbers_, subtracts 0.08 from the lightness, keeps chroma and hue. For OKLCH the extracted lightness resolves on the **0–1** scale, so `calc(l - 0.08)` is a genuine, correctly-sized darken. Repoint the seed and every color derived from it follows, at parse time. + + + +That's the seductive part — a whole scale from one seed, chapter 14's front door running in the browser instead of the build. State its support honestly, because it is a _different tier_ from `color-mix` and it matters: relative color reached Baseline **newly** available on **2024-09-16**, gated by Safari 18 (Chrome 125 and Firefox 128 shipped earlier that year), and isn't projected widely available until **March 2027**. "Newly available" means safe today with a roughly two-and-a-half-year tail of older browsers. Deriving your _only_ accent value from relative color today ships _nothing usable_ to a Safari 17 user: the custom property still holds the token stream (custom properties parse with the permissive any-value grammar), but `var(--accent)` fails to substitute, so the consuming declaration is invalid at computed-value time and the element falls back to its inherited or initial color. It is progressive enhancement, not a foundation. Lumping it in with `color-mix` because "both are new color functions" is the common mistake; they are eighteen months and one Baseline tier apart. + +## One property, both modes + +Chapter 16 insisted dark mode is a second design; chapter 17 wired it as mode aliasing — one name, two per-mode values, expressed as a `:root` value and a `.dark` override. `light-dark()` collapses that to a single declaration: + + + +The function returns its first argument when the resolved `color-scheme` is light, its second when dark. The requirement is exact and easy to miss — MDN states it in one breath: "the `color-scheme` must have a value of `light dark`," usually on `:root`. That property is the opt-in, and it does double duty (Baseline since January 2022): it also switches native scrollbars and form controls to match, which a `.dark` class alone never does. Without it, `light-dark()` has no scheme to read and silently does nothing. `light-dark()` itself is Baseline **newly** available since **May 2024** — same tier as relative color, same tail. + +What `light-dark()` carries is still chapter 16's designed pair — two authored values, not a derive. The one-liner buys you fewer names; it does not buy you a second design for free. That distinction becomes the sharpest edge in the lab. + +## Wide gamut needs two different tests + +Chapter 6 called the screen's limit the edge of the screen and measured the headroom P3 holds past sRGB. Delivering into that headroom is a fallback-first cascade: declare the sRGB value first, override it with the wider one, so a browser that can't parse the P3 line keeps the fallback (the last valid declaration wins). The trap is testing for the wrong thing. + + + +The correction worth burning in: there is no `@supports (color-gamut: p3)`. `@supports` tests whether the browser can _parse_ a property–value pair; `color-gamut` is a media feature, not a value, so that query never fires and the P3 upgrade silently does nothing. The two questions are different and need different tests. **Can the screen show it** is `@media (color-gamut: p3)`. **Can the browser parse it** is `@supports (color: color(display-p3 1 0 0))`. Both are widely available — `color()` and `@media (color-gamut)` since 2023. And note `oklch()` can itself name colors outside sRGB, so an OKLCH value needs the same discipline: clamp it (chapter 6's `clampChroma`) or give it an sRGB fallback of its own. + +## The Baseline that decides the fork + +The whole support question reduces to two tiers, and the tier decides whether a feature is a foundation or an enhancement: + +| Feature | Baseline | Since | Barefoot today? | +| ---------------------- | ---------------- | -------- | --------------- | +| `color-mix()` | Widely available | May 2023 | yes | +| `oklch()` / `oklab()` | Widely available | May 2023 | yes | +| `color(display-p3 …)` | Widely available | May 2023 | yes | +| `@media (color-gamut)` | Widely available | Feb 2023 | yes | +| `color-scheme` | Widely available | Jan 2022 | yes | +| `prefers-contrast` | Widely available | May 2022 | yes | +| `forced-colors` | Widely available | Sep 2022 | yes | +| `light-dark()` | Newly available | May 2024 | needs fallback | +| Relative color | Newly available | Sep 2024 | needs fallback | + +The top block is safe today with no ceremony. The bottom block — the two most powerful levers, one-property modes and seed-derived scales — carries a tail. That single line in the table is the trade the engine has to price. And the media features are chapter 9 read as _value columns_, never a fork in the code: `prefers-color-scheme` reads the OS choice, `prefers-contrast: more` re-points the same slots to a higher-contrast set, and `forced-colors: active` hands color to the OS entirely (replacing your values with system-color keywords and forcing some properties — `box-shadow` among them — to `none`, so any edge you cared about must be a `border`, not a shadow, or it vanishes). + +## The lab + +One token family — `accent`, its hover, and its `fg-on-accent` partner, in light and dark — compiled three ways at once: fully **baked** hex per mode; **mix + `light-dark()`**; and everything **derived from one seed** by relative color. The swatches are computed here with culori running the same operations the CSS would, so they render in every browser; the generated CSS text is shown as text. Repoint the seed and watch which artifacts can follow. + + + +Worth doing, in order: + +- **Read the default (Blue).** All three render the same button, because at the build seed they resolve to the same place. The divergence is in the scorecard: baked is universally supported and fully auditable but the largest; the computed columns are smaller and live-themeable but inherit Baseline-2024 support and read as an expression, not a value. +- **Repoint the seed to Violet or Red.** Columns 2 and 3 follow instantly — one variable moved. Column 1 can't; it shows its frozen Blue values and flags "needs rebuild," because a literal is fixed at compile time. That is the whole theming trade in one click. +- **Watch column 3's dark accent.** It builds the dark value by `oklch(from var(--seed) calc(l + 0.20) calc(c * 0.75) h)` — the exact lift-and-desaturate _flip_ chapter 16 said fails. Read the ΔEOK against the _designed_ dark value: deriving one seed all the way to dark reintroduces the un-designed flip. Column 2 avoids it precisely because its `light-dark()` carries two authored values. The convenience of full derivation has a perceptual bill. +- **Read the `fg-on-accent` line on the computed columns.** The contrast is real, but you had to resolve the color to measure it — the number isn't in the source. And notice `fg-on-accent` stays a `light-dark()` of two literals _even there_: CSS can't branch on which of black or white _passes_ the floor, so chapter 17's pairing decision remains a compile-time pick in all three artifacts. + +## The decision this unlocks + +dotUI compiles to baked hex today: the generator emits static per-step, per-mode values into `@theme`, every primitive and every `--on-*` partner enumerated, no runtime color functions in the output. That's the maximum-support, fully-auditable artifact — and it pays exactly the two costs the fork predicts: the CSS enumerates everything, and a rebrand is a rebuild. The rewrite keeps that floor and decides, per token, where computing buys more than it costs. + +The spec, as commitments: + +1. **Baked hex is the default artifact — the auditable floor.** Every semantic token resolves to a literal the build can run WCAG and APCA against and refuse to ship if the pairing promise fails (chapter 17, enforced where the value is actually known). For a library shipped to every app, universal support isn't a nice-to-have; it is the product. +2. **Runtime computation is progressive enhancement, never the sole declaration.** Where `color-mix` or relative color shrinks the CSS or enables live theming, emit it layered over the baked literal in correct cascade order — literal first, computed override after — and gate any newly-available function so an older browser keeps the literal. `color-mix` (2023) and relative color (2024) are different tiers; the fallback strategy must know which one it is enhancing with. +3. **Every computed color names its space, chosen per job.** A `color-mix` hover in OKLab is a straight line, not the ramp's gamut-mapped step (chapters 3, 6, 7). Where that difference clears the 0.02 just-noticeable threshold, bake the step instead of deriving it — accuracy beats a few bytes. +4. **A derived state is a solid step _or_ a layer of alpha — decide per state.** A `color-mix` toward black is a surface-blind literal that reintroduces chapter 18's one-surface failure; a `color-mix` toward transparent composites over any surface but its resolved contrast can't be read off the value. Overlays and hovers that must survive tinted surfaces ship as alpha; flat states audited against a known surface ship as a solid step. +5. **Modes ship as `light-dark()` over a baked two-column fallback.** One name carries both designs with `color-scheme: light dark` on the root, and the `.dark` literals remain underneath for the tail. But the two values `light-dark()` carries stay chapter 16's designed pair. +6. **Never let seed-derivation smuggle back the flip.** Relative color deriving a hover from the accent is fine; deriving the whole _dark_ palette from the light seed is chapter 16's arithmetic flip wearing new syntax. Dark accents stay authored even when states are computed. +7. **Wide gamut is an enhancement with two guards, not one.** sRGB literal first; the P3 override under `@media (color-gamut: p3)` for the display and `@supports (color: color(display-p3 1 0 0))` for the syntax — never conflated. Clamp `oklch()` that leaves sRGB. +8. **Preferences are value columns, not new artifacts.** `prefers-contrast` and `forced-colors` (chapter 9) re-point the same token table under a query — the same lesson as chapter 17's mode aliasing, one more column. + +Left open, on purpose: this is the last of Part 2 — the table is now CSS. How Radix, Tailwind, Material, Spectrum, and Geist each actually made these calls — who bakes, who computes, who tokenizes P3 — is Part 3 (chapters 21–25). + +## Check yourself + +1. A teammate ships the whole token table as `color-mix` and relative color "to keep the CSS tiny," with no fallbacks. Two costs from this chapter bite — which one is a hard support failure and which is a soft audit failure, and which single function gates the support? +2. You need `--accent-hover` derived from `--accent`. Give the `color-mix` form and the relative-color form, and state the one Baseline fact that makes you pick `color-mix` for a broad-audience product. +3. You're computing a hover overlay that has to sit on both a white card and a tinted panel. Why does `color-mix(… black 12%)` fail here, what's the fix, and what do you give up on the audit axis by using it? +4. Why does `@supports (color-gamut: p3)` never fire, and what are the two correct tests for shipping a P3 color with an sRGB fallback — one for the display, one for the syntax? + +## Further reading + +- [MDN — `color-mix()`](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/color-mix) — the formal syntax, why the interpolation space is optional in grammar but not in practice, and the "widely available since May 2023" banner. +- [MDN — Using relative colors](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_colors/Relative_colors) — how `from` extracts channels as numbers, and the `@supports` guards for the older Safari 16.4 variant. +- [MDN — `light-dark()`](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/light-dark) — the one-property mode carrier and its exact requirement that `color-scheme` be `light dark`. +- [MDN — `@media (color-gamut)`](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/color-gamut) — the display-gamut test (`srgb`/`p3`/`rec2020`), distinct from the `@supports` syntax test. +- [web-features explorer — relative color](https://web-platform-dx.github.io/web-features-explorer/features/relative-color/) — the 2024-09-16 newly-available date and the March 2027 widely-available projection, per engine — the tail this chapter prices. +- [Tailwind CSS v4 — Theme](https://tailwindcss.com/docs/theme) — why `@theme` emits both a `:root` custom property and utility classes from one token definition. diff --git a/content/chapters/spectrum-leonardo.mdx b/content/chapters/spectrum-leonardo.mdx new file mode 100644 index 0000000..b9f3148 --- /dev/null +++ b/content/chapters/spectrum-leonardo.mdx @@ -0,0 +1,92 @@ +--- +title: 'Adobe Spectrum & Leonardo' +description: 'A lightness-anchored ramp fixes L and discovers its contrast last — by audit, per background. Adobe flipped the axis: Leonardo takes the target contrast ratio as input and solves for the color that hits it. The guarantee becomes true by construction, a second mode falls out of one parameter — and whichever contrast meter you target is the flaw you bake into every step.' +part: 3 +question: 'Make contrast the input and lightness the output — what falls out free, and what do you bake in?' +--- + +import { FixedLAudit } from '@/components/playgrounds/fixed-l-audit' +import { ContrastAsInput } from '@/components/playgrounds/contrast-as-input' +import { MeterSplitRamps } from '@/components/playgrounds/meter-split-ramps' +import { ContrastRampLab } from '@/components/playgrounds/contrast-ramp-lab' + +Chapter 11 laid out three ways to draw a ramp: lightness-anchored (fix L, let contrast drift), contrast-anchored (fix contrast, let L drift), and hand-tuned reference (copy a human). dotUI ships the first — chapter 14's seed-to-scale pins each step to a fixed lightness anchor, and the contrast against the background is whatever falls out. This chapter is the case study of the system that made the second choice all the way down. Adobe Spectrum's colors are generated by Leonardo, Adobe's open contrast engine, and Leonardo's input is the contrast ratio itself. The colors are the _output_. One decision to extract: which half of that flip belongs in dotUI's engine, and which half quietly re-imports chapter 8's problems. + +## The guarantee you compute last + +Start with what the lightness-anchored engine can't promise. Chapter 10 wanted contrast guarantees at the ends — the text steps must clear their ratios, no exceptions. A fixed-L ramp delivers those guarantees only after the fact: you generate the colors, then you _measure_, then you nudge whatever missed. The ratio is a consequence you audit, not a target you set. + + + +Worse, the audit is per-room. Fixed lightness can't follow the background — swap the surface from light to dark and every color's contrast moves while its L sits still, so the same ramp that passed on paper collapses on the other mode. This is chapter 16's lesson wearing a different hat: a mode change isn't a transform of the old numbers, and a lightness-anchored ramp has no mechanism to re-solve. It just fails, silently, until someone reads the meter. + +## Flip the axis + +Leonardo's move is to make contrast the independent variable. Its repo states the whole idea in one line: "Generate colors based on a desired contrast ratio." You hand it key colors, a list of target ratios, and a background; it returns the colors that _hit_ those ratios against that background. Lightness is now the dependent variable — whatever value lands the target. + + + +Mechanically, Leonardo interpolates a smooth scale through your key colors in a chosen space, then samples the points along that scale that meet each target ratio. Contrast is the axis; the sampled lightness is the readout. Spectrum drives this at production scale: 14 tints and shades per color, generated in CAM02, with index-to-ratio anchors baked in — color-700 clears at least 3:1 against gray-100 (large text and icons), color-900 at least 4.5:1 (body text). The guarantee is no longer audited; it's the input. (The demo above re-implements the idea in culori — walk OKLCH lightness until `wcagContrast` hits the target — which approximates Leonardo's interpolate-then-sample method, not its exact algorithm.) + +Two honest footnotes. Generated colors often overshoot the target slightly: RGB quantization means the nearest displayable step usually lands a hair above the ratio, which per WCAG is fine — exceeding the minimum only helps. And the guarantee is one-directional — "at least the target" — which is exactly the promise chapter 10 asked for at the ends. + +## Background-relative → modes for free + +Because every output is defined by its contrast to a shared background, the background is a parameter, not a constant. Leonardo makes a `BackgroundColor` a required part of every `Theme`, and each color's contrast is measured against it. Change that background and re-run the same target ratios, and you get a coherent second palette without touching the targets. The dark-mode guidance is literally one setter: "pass a different lightness value (of your choice) to Leonardo." + + + +This is the elegant half. Chapter 16 insisted dark mode is a second design, not a flip — and here most of that second design's _skeleton_ falls out of re-declaring one number. The lightness solve and the contrast guarantee regenerate for the new room automatically. Be precise about what does and doesn't come free, though: what regenerates is the lightness that hits each ratio. The chroma and hue re-negotiation chapter 16 spent its length on — accents shedding chroma against dark, yellow moving off the brown ridge back to a vivid light yellow — is _not_ automatic from a background swap. Contrast-relative generation hands you the lightness axis of dark mode for free and leaves the colorfulness decisions where chapter 16 left them: designed, per hue. + +## The meter is the flaw you inherit + +Here is the reject. Leonardo solves against one contrast formula: WCAG 2's ratio. There is no escape hatch — the shipped `contrast-colors` API takes a target ratio and nothing else, and every ratio you hand it is a WCAG 2 ratio. So you don't just target a number; you inherit everything chapter 8 said about that number. The orange-button fight, the Y ≈ 0.18 tie point, and above all the flare term that over-credits dark backgrounds — all of it moves from a post-hoc audit finding into the generation rule. + +The demo above shows it: on a dark surface, WCAG's formula is satisfied by a dimmer color than APCA would accept, because the +0.05 flare term inflates the dark-background ratio. Target WCAG 4.5:1 and you ship a body-text step that clears its legal ratio and still reads thin — the meter you targeted certified a color the other meter, and many eyes, dispute. This is the orange-button problem generalized and welded into the pipeline. State the standards status plainly, as chapter 8 did: WCAG 2 is the normative standard and the only formula Leonardo actually solves against; APCA / WCAG 3 is not yet normative, and Leonardo does not yet target it. An APCA option has been an open request (issue #197) and remains unbuilt. The fix Leonardo still lacks is the one dotUI's engine has to add: a generator that can solve toward the perceptual measure, not only the ratio. + +## Equal contrast is not equal lightness + +The subtler cost is chapter 11's drift. Contrast-anchored generation fixes contrast and lets lightness wander — and the map from contrast to lightness is nonlinear, so ratios that climb in even-ish jumps produce lightness steps that don't. The perceived spacing between your steps stops being something you control and becomes a byproduct of the contrast targets you happened to pick. For the text ends, where a guarantee is the whole point, that's the right trade. For chapter 10's interior jobs — the three background steps, the hover, the borders — even perceptual spacing _is_ the point, and a pure contrast anchor gives it up. The flagship makes this visible with a ΔL column: read how unevenly lightness lands even as the ratios march. + +## The lab + +Background, hue, and meter — three knobs over one contrast-anchored ramp. Every swatch is the color that hits its target ratio against the current background, verified live on both meters. Toggle the background and the same targets regenerate a coherent second mode (chapter 16). Toggle the meter and the whole ramp _shifts_ — proof that the meter you target is a color decision, not a neutral measurement (chapter 8). Watch the ΔL column while you do: the guarantee is exact, the lightness spacing wanders (chapter 11). + + + +Worth doing, in order: + +- **Read the light-mode WCAG ramp.** Every step meets its target by construction — the 3:1 and 4.5:1 rows are Spectrum's 700 and 900 anchors. No audit ran; the ratios are the input. +- **Toggle the surface to dark.** Same six targets, six new colors, coherent against the new room. That is the modes-for-free payoff — one parameter, a second lightness design. +- **On dark, switch the meter to APCA.** The ramp moves. The WCAG ramp's body step passed 4.5:1 but its Lc magnitude was low; the APCA ramp spends more lightness to satisfy Lc 75. Whichever you left selected is the flaw you would ship. (The meter switch is this lab's own culori re-implementation — Leonardo itself solves only WCAG 2.) +- **Scan the ΔL column on any ramp.** The ratios climb on a schedule; the lightness deltas don't. Contrast is anchored, perceived spacing drifts. + +## What the engine should steal — and reject + +dotUI's engine today is lightness-anchored end to end (chapter 14): fixed L anchors, contrast discovered by the test suite after the fact. Leonardo is the clean opposite, and the rewrite should take exactly two things from it and refuse two. + +Steal: + +1. **Contrast as input at the ends.** Chapter 10's end guarantees — text steps clear their ratio, solid steps carry their label — become true by construction instead of by audit. Solve for the lightness that hits the target; never ship a text step whose ratio you learned from a test. +2. **Background-relative generation.** Make the background a generation parameter, not a constant. One swap yields the lightness half of a second mode (chapter 16) for free — which is more than the current mode-agnostic producer does, and the honest floor for dark mode's lightness skeleton. + +Reject: + +3. **WCAG 2 as the sole anchor.** Targeting the ratio inherits chapter 8's errors — the dark-background over-credit especially — baked into every generated step. If you anchor on contrast, anchor on the perceptual meter too: enforce the WCAG floor because it is the law, but solve toward APCA Lc so the step actually reads. Leonardo solves only WCAG 2, and that is precisely the hole — dotUI's engine should add the APCA target Leonardo lacks: the WCAG ratio for compliance, the Lc solve for legibility, both at once. +4. **Letting lightness spacing drift everywhere.** Contrast-anchored ends are right; contrast-anchored _backgrounds_ throw away the even perceptual spacing chapter 10's interior jobs depend on. The synthesis is the hybrid chapter 11 hinted at: anchor the ends on contrast where a guarantee is owed, shape the interior on lightness where spacing is what matters. Not one philosophy for the whole ramp — the right one per job. + +Left open, on purpose: the chroma and hue curves a background swap does _not_ regenerate stay chapter 12's and 13's work; whether the interior uses APCA or lightness spacing is chapter 11's philosophy fork, re-decided per slot; and the neutral ramp's contrast anchors ride on chapter 15's tint budget. + +## Check yourself + +1. A lightness-anchored ramp and a contrast-anchored ramp both pass their WCAG audit on a light background. You swap to a dark background. Which one keeps its guarantees without new work, and _why_ — what is the parameter that does the work? +2. You generate a dark-mode ramp with Leonardo, and every text step clears 4.5:1. A designer says the body text reads thin. Which chapter-8 mechanism explains how both can be true, and what must dotUI's engine add that Leonardo does not provide? +3. Contrast-anchored generation guarantees each step's ratio exactly. Name the thing it gives up in exchange (chapter 11), and say which of chapter 10's jobs that loss actually hurts — and which it doesn't. + +## Further reading + +- [adobe/leonardo](https://github.com/adobe/leonardo) — the repo whose tagline _is_ the philosophy: "Generate colors based on a desired contrast ratio." +- [Leonardo `contrast-colors` README](https://github.com/adobe/leonardo/blob/main/packages/contrast-colors/README.md) — the `Theme` / `BackgroundColor` / `Color` API, the ratio-to-name scheme, and the note that the tool "most often outputs a contrast ratio slightly higher" than the target because of "the available colors in the RGB color space." +- [Leonardo JS API](https://leonardocolor.io/api.html) — background-relative generation (a required `BackgroundColor` that every theme's ratios are measured against) and the one-setter dark-mode recipe ("pass a different lightness value (of your choice) to Leonardo"). +- [Reinventing Adobe Spectrum's colors](https://adobe.design/stories/design-for-scale/reinventing-adobe-spectrum-s-colors) — 14 steps in CAM02, three themes, and the 700 ≈ 3:1 / 900 ≈ 4.5:1 anchors against gray-100. +- [Leonardo issue #197](https://github.com/adobe/leonardo/issues/197) — the still-open APCA / WCAG 3 request, with the plain statement that WCAG 3 is not yet the required standard. diff --git a/content/chapters/states-alpha-status.mdx b/content/chapters/states-alpha-status.mdx new file mode 100644 index 0000000..b1d00c7 --- /dev/null +++ b/content/chapters/states-alpha-status.mdx @@ -0,0 +1,106 @@ +--- +title: 'States, alpha, and status colors' +description: 'A hover tuned on the card turns to a gray smudge on a colored panel — the failure that proves a state is a modification of what is already there, not a color. When a state comes from a ramp step versus a layer of alpha, why the focus ring and status families are their own designs, and why disabled contrast is broken on purpose.' +part: 2 +question: 'One hover, every surface — does the state come from a ramp step or a layer of alpha?' +--- + +import { HoverOnTwoSurfaces } from '@/components/playgrounds/hover-on-two-surfaces' +import { FocusRingEverySurface } from '@/components/playgrounds/focus-ring-every-surface' +import { DisabledOnPurpose } from '@/components/playgrounds/disabled-on-purpose' +import { StatusSoftAndSolid } from '@/components/playgrounds/status-soft-and-solid' +import { StatusNeedsASecondChannel } from '@/components/playgrounds/status-needs-a-second-channel' +import { StateStatusLab } from '@/components/playgrounds/state-status-lab' + +Chapter 10 already spent three of the twelve steps on states — "steps are jobs," and three of the jobs are interaction: step 3 the component's rest background, step 4 its hover, step 5 its active-or-selected. Chapter 17 shipped `-hover` as a state suffix in the grammar. So a hover looks solved: a designed ramp step with a name. This chapter is where that answer breaks, and the break tells you what the ramp can't: a state isn't a color, it's a modification of whatever is already there. Get it wrong and the same hover that looks right on the card turns to garbage on a colored panel. Get it right and one token composes over anything. That fork — a solid step versus a layer of alpha — is the decision, and it decides where states, focus, and status colors all come from. + +## The hover that fits one surface + +Start with the failure. Tune a hover until it looks right on the card, then drop the exact same control on a tinted panel: + + + +The solid step is a single hex — `neutral-200`, a designed gray one notch darker than the card. On the blue panel that gray has no relationship to what it covers: it's a near-zero-chroma smudge sitting on a saturated surface, ΔEok 0.096 off the panel where it was a 0.030 whisper on the card. It was never a hover; it was one surface's hover, wearing a role name. + +The alpha overlay is a different kind of thing. It's not a color, it's an operation — black at 8%, composited over whatever is underneath. And it's worth seeing the operation literally, because two things about it decide the whole chapter. When the browser paints `rgba(0,0,0,0.08)` over a surface, it blends per channel: + +``` +out = α · overlay + (1 − α) · surface +``` + +First, the formula references `surface` — the result is defined _relative to whatever is underneath_, so one alpha token produces the right delta over a plain card, a tinted panel, a photo, or a gradient. A solid step has no `surface` term; it's an absolute value, correct only where it was measured. Second, the blend runs in gamma-encoded sRGB, not linear light — chapter 3's "screen values aren't light" — which is why an 8% black overlay darkens far less than 8% of the way to black, and why moving the math to linear would re-weight every state layer. This is the spine of the chapter: **a solid step encodes a result; an alpha overlay encodes the operation that produces it.** The operation travels; the result doesn't. + +Both mechanisms ship, and the split is instructive. Radix builds the solid answer into the scale — steps 3/4/5 are rest/hover/active for subtle components, and a solid button at step 9 hovers to a separately-tuned step 10 (two hovers, because there are two rest surfaces). But Radix _also_ ships a full 12-step alpha variant of every scale, plus theme-independent Black and White alpha scales "designed for overlays." Material 3 goes almost entirely alpha: a state layer is the on-color at a fixed opacity — hover 0.08, focus 0.12, pressed 0.12, dragged 0.16 — composited over the component. eBay's playbook is the same mechanism with its own ladder (hover +4%, pressed +8%, dragged +12%). The opacities are a house style; the mechanism is one bet made repeatedly. + +The honest catch is chapter 1's **simultaneous contrast** wearing work clothes — a color has no fixed appearance until you know what surrounds it: because an alpha overlay's final color depends on what's beneath it, you can't know its contrast in advance. The same 8% black is a different color — a different _look_ — over every surface it lands on. That's not a bug, it's the definition; but it means an alpha state's contrast has to be checked against each surface it can travel over, or the worst one, never asserted once. + +## Focus is its own token + +A hover modifies one component. A focus ring has a harder job: it must stay visible on _every_ surface the focusable thing can sit on — a card, a colored panel, an inverse toolbar, an image. No step from the component's own ramp can promise that, because that ramp is tuned to the component, not to the world around it. So the focus ring is a dedicated token, not a derived step. + + + +dotUI ships it as `border-focus` (aliased to `accent-500`) at 2px with a 2px offset. Here's the honest audit, though: WCAG 1.4.11 asks a focus indicator for 3:1 against the adjacent color, and measured, `accent-500` grazes 2.99:1 on the card and dips to 2.26:1 on the blue panel — it misses the floor on the light surfaces it most often lands on. The offset gap is a genuine shape cue (chapter 9's never-hue-alone, satisfied for free by a ring being a shape), and it helps the eye, but it does not raise the ratio against the adjacent color. The engine fix is real: pick the focus token to clear 3:1 on the surfaces it actually lands on, or ship a two-tone ring (a light and a dark contour) so one edge always passes. Either way, focus is designed as its own high-contrast token — never borrowed from a per-surface state step. + +## Disabled is broken on purpose + +Every other state fights for contrast. Disabled throws it away, deliberately: + + + +Material 3 states the recipe as opacity — disabled content at 38%, container at 12%. dotUI states it as a step — `fg-disabled` is `neutral-500`. Both land under the 4.5:1 floor (dotUI's stepped neutral at 3.25:1, M3's 0.38 opacity lower still at 2.55:1), and both are correct, because WCAG 2.2 is explicit in both SC 1.4.3 and 1.4.11: "User Interface Components that are not available for user interaction (e.g., a disabled control in HTML) are not required to meet contrast requirements." But the exemption is a floor the law drops, not one your design should. A disabled control still has to read as a control and never be mistaken for an enabled one — fade it too far and it vanishes, too little and it invites dead clicks. The engine decision is narrow and real: disabled is a computed reduction, consistent across roles, and the stepped neutral is the more legible default — raw opacity can dim past reading on a busy surface. + +## Status is a parallel family, not a step + +Success, warning, danger, info aren't states of the accent — they're their own accent-like families, and each owes the full kit chapter 17 defined: a solid, a soft surface, a text partner, a border, and the pairing promise binding them. dotUI ships exactly that per family — solid at 500, soft surface at 100 (info at 50), text at 800 — in two styles that don't measure the same: + + + +The soft style clears both meters comfortably; family-800 text on the family-muted surface is a big tone gap by construction (chapter 10). The solid style is chapter 8's orange-button fight, four times over. Every status solid sits at step 500, right in the contested zone: a black label passes WCAG (5.8–7.1:1) but fails APCA (Lc 42–49), and a white label passes APCA (Lc 61–68) but fails WCAG (2.97–3.60:1). At step 500 _neither_ label passes both meters — so a status solid that carries a label belongs at step 600–700, where white clears both (danger-600 + white: 4.87:1, Lc 78). A step-500 solid is fine only as a label-free dot or bar. Status doesn't get a contrast exemption for being "just" a warning. + +Two more traps come with status, both from earlier chapters. First, naming: chapter 17 quoted Radix's warning that one scale serves multiple semantics — yellow is both "warning" and "pending," red both "danger" and "error," and a red brand accent collides head-on with a red danger family. The fix is either many aliases onto one scale or deferring to the raw scale name; you can't pretend the mapping is one-to-one. Second, and worse, status leans hardest on the one channel chapter 9 told you never to trust alone: + + + +Under deuteranopia — the dichromatic end of red-green color vision deficiency, which in some form (mostly the milder deuteranomaly) affects roughly one in twelve men — the success green, warning amber, and danger red collapse into a single olive band, the exact hues users must never squint at made indistinguishable. Only blue holds. A status system encoded in hue alone _is_ this failure. The rule is non-negotiable for the status families specifically: pair every status color with a second channel — an icon, a shape, a word — so it survives with the color removed. And all of this needs a dark pass (chapter 16): status colors go soft against a dark room, yellow re-negotiates its whole position, and the soft/solid pair gets re-picked per mode. + +## The lab + +Every axis in one control. Pick a role, a state, and — for the state layers — a mechanism, then watch the same button render on a plain card and a tinted panel at once. The readout is the argument: the solid step's fill is one hex frozen against the card, so its chroma dies on the panel; the alpha overlay recomputes and keeps the tint. Switch to focus to see the mechanism toggle go dark — focus is its own token — and to disabled to watch contrast drop on purpose. + + + +Worth doing, in order: + +- **Neutral, hover, alpha.** Read the panel row: chroma stays tinted, the fill stays blue. Now switch mechanism to **Solid step** — the panel fill collapses toward chroma 0.00, a value frozen off the card. Same state, same role; only the mechanism decides whether it survives the second surface. +- **Active.** The overlay deepens to 12% (Material's pressed value). Alpha tracks the surface; the frozen solid drifts further from it. +- **Switch role to Danger, then Warning.** The state layer takes the family's tint — status slots into the exact same machinery as neutral, no separate code path. Watch the label contrast move with it. +- **Focus.** The mechanism toggle disables itself, because focus doesn't derive from the component. The verdict reads its ratio against each surface — and both land under WCAG 1.4.11's 3:1, the honest gap in the shipped `accent-500` ring. +- **Disabled.** Contrast drops below the floor and the verdict says so — exempt, but still legible. That's the line WCAG lets you cross and your design shouldn't. + +## The decision this unlocks + +dotUI today ships states as suffixes (`-hover`, `-active`) resolving to stepped ramp values, a dedicated `border-focus` token, computed `fg-disabled` neutrals, and a full per-family status kit (`danger`/`warning`/`success`/`info`, each with solid, soft, text, border, and on-colors). What it lacks is an alpha primitive set — every state is a solid step, so no state composes over an arbitrary surface — and there's no dark design for any of it. The rewrite spec, as commitments: + +1. **Ship alpha as a first-class primitive set.** Alongside the solid ramp, generate an alpha ramp per family plus theme-independent black/white alpha overlays (Radix's model). This is what makes one hover, one scrim, one focus wash compose over any surface — the solid step is the special case for the one surface it was tuned against. Composite in gamma sRGB (chapter 3); the opacity ladder is a design choice (M3's 0.08 / 0.12 / 0.12 / 0.16 is a defensible default). +2. **State layers are the operation, checked per surface.** Hover/active/pressed are the on-color at fixed opacity, and because the composited contrast depends on the surface, the generator verifies each state against every surface it can travel over — or the worst one — never once. Keep the solid step only for a component's own opaque background, which never travels. +3. **Focus is a dedicated high-contrast token with an offset, held to 3:1 on every surface it lands on.** The current `accent-500` ring misses (≈2.3–3.0:1 on light surfaces) — raise the token or ship a two-tone ring. It already carries a shape channel (chapter 9), and it owes a `prefers-contrast` value column. +4. **Disabled is a computed reduction, exempt but legible.** A stepped-neutral (or reduced-opacity) content layer, consistent across roles, with an asserted floor of "still perceptibly a control, never mistaken for enabled" — above the letter of the WCAG exemption. Prefer the stepped neutral (more legible) over raw opacity. +5. **Status families are parallel accents, default soft.** Every family owes solid + soft + text + border + pairing promise on both meters. The soft style clears both for all four; the solid style is a per-family meter split (chapter 8), so a labeled solid lives at 600–700 and its label policy names the WCAG floor _and_ the APCA target. Naming allows many aliases onto one scale (Radix), no status ships without a non-hue channel available, and each family re-picks its values per mode. + +Left open, on purpose: chart and categorical palettes are chapter 19; how the whole table — states, alpha, status, modes — ships as CSS custom properties is chapter 20; and the Radix and Material deep dives on exactly these mechanisms are chapters 21 and 23. + +## Check yourself + +1. You tune a hover step until it looks perfect on the card, then a colored callout ships and the hover reads as a gray smudge. Name the mechanism you used, the mechanism that would have survived, and — pointing at one term in the blend formula — the reason the second one composes over both surfaces. +2. A teammate wants the focus ring to be "accent-600, one step down from the button, to keep it on-brand." Give the reason a per-component step can't do the focus ring's job, and — knowing `accent-500` already measures 2.99:1 on the card — the two honest ways to make the token clear WCAG 1.4.11's 3:1. +3. Your warning solid ships a white label at 3.27:1 and passes review because "disabled and status controls are exempt from contrast." Untangle the claims: which control is actually exempt, which chapter-8 fight the warning button is really having, why neither black nor white passes both meters at step 500, and what the fix is. + +## Further reading + +- [Radix Colors — Understanding the scale](https://www.radix-ui.com/colors/docs/palette-composition/understanding-the-scale) — steps 3/4/5 as rest/hover/active and 9/10 as solid rest/hover: the designed-step answer, stated per step. +- [Radix Colors — Scales](https://www.radix-ui.com/colors/docs/palette-composition/scales) — the 12-step alpha variant of every scale plus theme-independent Black/White alpha, "designed for overlays." +- [Material 3 — State layers](https://m3.material.io/foundations/interaction/states/state-layers) — the state layer as a fixed-opacity overlay of the on-color; the source token values are hover 0.08, focus/pressed 0.12, dragged 0.16. +- [WCAG 2.2 — Understanding SC 1.4.11 Non-text Contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html) — the 3:1 requirement for UI components, their states, and focus indicators, with the disabled-control exemption quoted verbatim. +- [Radix Colors — Aliasing](https://www.radix-ui.com/colors/docs/overview/aliasing) — the multiple-semantics problem (yellow = warning and pending) and the two shipped fixes. +- [eBay Playbook — Interaction States](https://playbook.ebay.com/foundations/interaction-states) — the same overlay mechanism on its own ladder: light-mode color-fill steps by +4% hover / +8% pressed / +12% dragged (black in light mode, white in dark; a different set for graphic elements). diff --git a/content/chapters/tailwind.mdx b/content/chapters/tailwind.mdx new file mode 100644 index 0000000..7fde552 --- /dev/null +++ b/content/chapters/tailwind.mdx @@ -0,0 +1,100 @@ +--- +title: 'Tailwind CSS' +description: 'Tailwind v4 rewrote 286 colors in OKLCH to go vivid on P3 and ships them as CSS variables that mint their own utilities — the color engine, its hard part done right. Then it stops exactly where chapter 17 began: numeric names, no semantic tier, no pairing promise, so every contrast failure Part 2 warned about is one utility class away.' +part: 3 +question: 'Pick any two Tailwind steps for text and background — what stops you shipping a pair no one can read?' +--- + +import { TailwindGamutHeadroom } from '@/components/playgrounds/tailwind-gamut-headroom' +import { TailwindCrossHueLightness } from '@/components/playgrounds/tailwind-cross-hue-lightness' +import { TailwindThemeToUtility } from '@/components/playgrounds/tailwind-theme-to-utility' +import { TailwindPaletteInspector } from '@/components/playgrounds/tailwind-palette-inspector' + +Chapter 17 named two naming dialects and spent its whole budget on the semantic one. This chapter is the other dialect — numeric — in its purest, most-shipped form. Tailwind is the CSS half the web actually writes, and v4 got the color engine's hard part right: the palette is OKLCH now, authored to go vivid on P3 (chapters 5 and 6), and delivered as CSS variables that mint their own utilities (chapter 20's mechanism, in production at web scale). Then it stops exactly where chapter 17 started. There is no semantic tier and no pairing promise — `blue-500` names a position on a ramp and nothing else. So this is a clean case study: two mechanisms to steal outright, and one whole missing floor to reject. + +## The palette is OKLCH now + +v4 rewrote every default color from `rgb` to `oklch`. In the authors' words, they "upgraded the entire default color palette from rgb to oklch, taking advantage of the wider gamut to make the colors more vivid in places where we were previously limited by the sRGB color space." The shape is familiar from chapter 10: 11 steps per family, `50` (lightest) through `950` (darkest). The count grew — 22 families at the v4.0 launch, 26 as of v4.2 once `mauve`, `olive`, `mist`, and `taupe` landed — so the default theme is now 26 × 11 = 286 colors. + +The detail that matters for the engine is which value is the source of truth. In the docs' color reference you "copy the OKLCH value or shift+click to copy the nearest hex value" — the `oklch()` constant is authoritative, hex is the derived fallback for old browsers. That is dotUI's own front-end format: the space the engine computes in, Tailwind now authors in. + +## Vivid, and measurably so + +The P3 upgrade is not marketing. Take a mid-ramp step, read its chroma at the P3 ceiling, then gamut-map it back to sRGB (chapter 6's `clampChroma`) and measure how much chroma it had to give up: + + + +`amber-500` is authored at C 0.188. Clamp it to sRGB and it drops enough to cost ΔEOK 0.022 — past chapter 14's 0.02 just-noticeable difference. That gap is exactly the vividness v4 bought: color that "was previously limited by the sRGB color space." On an sRGB screen you see the clamped version and nothing that was ever there is lost; on a P3 screen the extra chroma is real and visible. The honest footnote is that the win is hue-shaped — `blue-500` and `red-600` also exceed sRGB, but only by ΔEOK 0.010, under the JND, so their headroom is real yet barely perceptible. Amber, out where the sRGB tent is lowest, is where OKLCH pays. Authoring at the P3 ceiling and mapping down is the right default, and it's the first thing to steal. + +## Vivid, but not level + +Here is the claim everyone assumes and the primary sources never make: that v4 equalized perceived lightness across hues. It didn't. Line up step `500` across families and the lightnesses don't agree: + + + +`blue-500` sits at L 0.623; `amber-500` at L 0.769 — a 0.146 spread you can see in the strip. Two reasons, both Part 1 facts wearing framework clothes. First, the gamut tent (chapter 6): there is no dark vivid amber, so to stay saturated the amber family rides high — chapter 13's "no dark vivid yellow," one hue over. Second, Tailwind deliberately "kept the balance between colors the same as v3" so the upgrade wouldn't break anyone — a hand-tuned reference target (chapter 11), not a lightness grid. State the status plainly: even lightness is an observable near-property of a few mid-band steps, never a documented design goal. The `500` row is a _same-as-v3_ row, not an iso-lightness row. + +The engine consequence is sharp. If you lift Tailwind's numbers expecting chapter 10's contrast-by-construction, you don't get it. `500` is not one lightness, so `bg-*-500` is not one contrast — a white label that clears the bar on `amber-500` (the lighter one) can fail on `blue-500`. A utility palette optimized for vivid, recognizable per-hue color is behaving correctly; it only becomes a trap when you mistake it for a system. + +## `@theme` is the delivery + +The distribution mechanism is chapter 20's, shipped. A single declaration does two jobs at once: + + + +`@theme { --color-blue-500: oklch(…) }` emits a real `:root` custom property _and_ generates the matching `bg-`, `text-`, `border-`, and `ring-` utilities from it. Tailwind "takes all of your design tokens and makes them available as CSS variables by default," in the `--color-*` namespace, "so you can reference any value you need at run-time using just CSS." The palette _is_ CSS variables now — runtime-addressable, themeable in plain CSS, no build step to read a color. This is the delivery layer chapter 20 argued for, validated at scale. Steal it. + +## The name is the ramp position + +Now the reject. Tailwind's names are numeric only: `blue-500` encodes hue and step and carries no role. No `primary`, no `surface`, no `danger` ships in the default palette — the whole thing is chapter 17's primitive tier and only that tier. The trade is chapter 17's exactly: flexible, because any step can go anywhere; opaque, because the name can never tell you it's wrong. + +And chapter 10's verdict lands on cue — nothing breaks loudly. `text-slate-400` on `bg-slate-100` is valid Tailwind that compiles, ships, and measures 2.40:1, a body-text failure. The compiler's contract is to emit the class, not to check the pair. There is no layer whose job is to notice. + +## No pairing promise + +Dark mode makes the gap vivid. Tailwind's `dark:` variant ships no baked dark values — you re-pick every step by hand (`bg-white dark:bg-gray-800`, `text-gray-900 dark:text-white`), and nothing verifies the result. So the single most common button on the web, `text-white bg-blue-500`, ships with no guarantee at all. Measure it: WCAG 3.76:1, below the 4.5 body floor; APCA Lc −70, above the 60 body target. Chapter 8's orange-button fight, reincarnated as the framework's default gesture — and Tailwind takes no position, because there's no semantic tier that could hold one. + +## The lab + +Every Tailwind color, two you pick, and the meters chapter 17 said a palette owes its consumers. Choose a family, a background step, and a text color — the preview paints the pair, the class names show it's real Tailwind, and both meters read out live. The default is `text-white` on `bg-blue-500`: the button everyone ships. + + + +Worth doing, in order: + +- **Read the default.** `text-white bg-blue-500` — WCAG 3.76:1 fails the body floor, APCA Lc −70 passes it. The meters split; Tailwind ships it silently either way. This is chapter 8's fight, defaulted. +- **Force it worse.** Move the background to `amber-500` with `text-white`: 2.13:1, Lc −46 — both meters fail, and the class still compiles. Nothing in Tailwind objected. +- **The slate trap.** Family `Slate`, background `100`, text `400`: 2.40:1, the mis-wire from the prose. Plausible, common, unreadable, valid. +- **Find a pair that holds.** Background `100`, text `700` or `800`: now both meters clear. It was reachable all along — the palette contains safe pairs and unsafe pairs and treats them identically. The guarantee is the reader's job, which is precisely what chapter 17's `-foreground` promise moves off the reader. + +## What the engine should steal — and reject + +Tailwind is two things stacked: a genuinely modern color engine under a deliberately dumb naming layer. dotUI should take the engine and refuse the layer. + +**Steal:** + +1. **OKLCH as the authoring format, hex as fallback.** The engine already computes in OKLCH; Tailwind proves at web scale that _shipping_ OKLCH constants as the source of truth — with the nearest hex as a derived compatibility value — is the right call, not a research toy. +2. **Author at the P3 ceiling, gamut-map down for sRGB.** The chroma headroom is real and measurable (amber's 0.022 past the JND, even where blue's and red's fall under it). Author against P3 and clamp per chapter 6; never author against the sRGB ceiling and leave the vividness on the table. +3. **`@theme`-style delivery.** Emit the whole token table as CSS custom properties that also generate utilities. Chapter 20's mechanism, battle-tested — the palette should be addressable in plain CSS at runtime, and it is exactly the substrate a semantic tier aliases onto. + +**Reject:** + +4. **Numeric names as the public API.** Keep the semantic tier (chapter 17) _on top_ of the numeric primitives. Application code consumes `accent`, not `blue-500`. Tailwind's palette is a legitimate primitive tier and nothing more. +5. **A palette with no pairing floor.** Every surface the engine emits owes a text partner, checked to a WCAG 4.5:1 floor and reported against an APCA target (chapters 8 and 17). Tailwind's silence on `text-white bg-blue-500` is the exact failure the semantic tier exists to prevent — don't inherit the silence with the OKLCH. +6. **Even lightness by assumption.** Do not borrow Tailwind's numbers expecting a contrast grid; `500` is not one lightness. If the engine wants contrast-by-construction (chapter 10), it sets the lightness curve itself (chapter 11) — it does not copy a hand-tuned reference and hope the ends line up. And unlike Tailwind's frozen, seedless palette, dotUI's front door takes a brand hex and generates (chapter 14): borrow Tailwind's per-hue curves as reference, never its non-generativity. + +Left open, by design: Radix's hand-tuned reference scale is chapter 21; Material's tonal generation is chapter 23; Leonardo's contrast-anchored generation is chapter 24 — four corners of chapter 11's philosophy triangle, and this chapter placed the numeric one. + +## Check yourself + +1. v4 authored `amber-500` at L 0.769 and `blue-500` at L 0.623. Give the two reasons the same step number sits at different lightness, and name the Part-1 fact behind each. +2. `text-white bg-blue-500` measures 3.76:1 (WCAG) and Lc −70 (APCA). Which meter passes, which fails, and why can Tailwind resolve the split neither way that chapter 17's semantic tier could? +3. Name the two mechanisms this chapter says to steal from Tailwind's engine and the one naming decision to reject — one sentence each. + +## Further reading + +- [Tailwind CSS v4.0 — announcement](https://tailwindcss.com/blog/tailwindcss-v4) — "Modernized P3 color palette" (the rgb→oklch upgrade, in the authors' words) and the `@theme` / CSS-variable delivery model. +- [Tailwind CSS docs — Colors](https://tailwindcss.com/docs/colors) — the OKLCH reference, the `--color-*` namespace, and the shift-click-for-nearest-hex fallback that tells you which value is authoritative. +- [Tailwind CSS docs — Dark mode](https://tailwindcss.com/docs/dark-mode) — the `dark:` variant and hand-repicked example pairs; read it for the _absence_ of any contrast guarantee. +- [`theme.css` — default theme](https://github.com/tailwindlabs/tailwindcss/blob/main/packages/tailwindcss/theme.css) — every `oklch()` constant this chapter measured; diff a family top to bottom and its lightness curve is in the numbers. +- [Tailwind CSS v4.2.0 — release](https://github.com/tailwindlabs/tailwindcss/releases/tag/v4.2.0) — `mauve`, `olive`, `mist`, `taupe`: the four neutral-adjacent families that took the default from 22 to 26. diff --git a/src/components/playgrounds/chart-palette-lab.tsx b/src/components/playgrounds/chart-palette-lab.tsx new file mode 100644 index 0000000..c86458e --- /dev/null +++ b/src/components/playgrounds/chart-palette-lab.tsx @@ -0,0 +1,546 @@ +import { useMemo, useState } from 'react' +import { + clampChroma, + converter, + differenceEuclidean, + filterDeficiencyDeuter, + filterDeficiencyProt, + filterDeficiencyTrit, + formatHex, + samples, + wcagContrast, +} from 'culori' + +import { Playground } from '@/components/playground' +import { ToggleButton } from '@/ui/toggle-button' +import { ToggleButtonGroup } from '@/ui/toggle-button-group' + +const toOklch = converter('oklch') +const toRgb = converter('rgb') +const dEOK = differenceEuclidean('oklab') + +type PType = 'categorical' | 'sequential' | 'diverging' +type Source = 'generated' | 'okabe' +type Cvd = 'none' | 'deutan' | 'protan' | 'tritan' +type Tone = 'ok' | 'warn' | 'bad' + +const PLOT_BG = '#ffffff' +const RISKY = 0.12 // ch9's glance-apart heuristic (ΔEOK) +const MERGED = 0.06 +const CEILING = 8 + +// Okabe-Ito 8-color CVD-safe qualitative set (jfly.uni-koeln.de/color), canonical order. +const OKABE = [ + '#E69F00', + '#56B4E9', + '#009E73', + '#F0E442', + '#0072B2', + '#D55E00', + '#CC79A7', + '#000000', +] + +const maxC = (l: number, h: number): number => + clampChroma({ mode: 'oklch', l, c: 0.4, h }, 'oklch').c ?? 0 + +// N hues evenly spaced at one L and one C; C capped by the tent's tightest hue (ch6). +function categorical(n: number): string[] { + const L = 0.65 + const base = 25 + const hues = Array.from({ length: n }, (_, i) => (base + (i * 360) / n) % 360) + const cSet = Math.min(...hues.map((h) => maxC(L, h))) + return hues.map((h) => + formatHex(clampChroma({ mode: 'oklch', l: L, c: cSet, h }, 'oklch')), + ) +} + +// One hue, monotonic lightness top to bottom (ch11 lightness-anchored). +function sequential(n: number): string[] { + const h = 255 + return samples(n).map((t) => + formatHex( + clampChroma( + { + mode: 'oklch', + l: 0.95 - t * (0.95 - 0.3), + c: 0.02 + 0.12 * Math.sin(Math.PI * t), + h, + }, + 'oklch', + ), + ), + ) +} + +// Two arms climbing to a light neutral center (ch15), never gray concrete (ch7). +// Parity-independent: the innermost sample(s) sit at a near-white neutral for any n, +// so the midpoint meter reads a real light center whether n is even or odd. +function diverging(n: number): string[] { + const center = (n - 1) / 2 + const ds = Array.from({ length: n }, (_, i) => Math.abs(i - center)) + const levels = Array.from(new Set(ds.filter((d) => d > 1e-9))).sort( + (a, b) => a - b, + ) + const maxRank = levels.length + return ds.map((d, i) => { + let l: number + let c: number + if (d < 1e-9) { + l = 0.96 + c = 0.006 + } else { + const rank = levels.indexOf(d) + 1 + const f = maxRank > 1 ? (rank - 1) / (maxRank - 1) : 1 + l = 0.94 - f * (0.94 - 0.44) + c = 0.02 + f * (0.16 - 0.02) + } + const h = i < center ? 250 : 25 + return formatHex(clampChroma({ mode: 'oklch', l, c, h }, 'oklch')) + }) +} + +function makeSeen(cvd: Cvd): (hex: string) => string { + if (cvd === 'none') return (hex) => hex + const f = + cvd === 'deutan' + ? filterDeficiencyDeuter(1) + : cvd === 'protan' + ? filterDeficiencyProt(1) + : filterDeficiencyTrit(1) + return (hex) => { + const rgb = toRgb(hex) + return rgb ? (formatHex(f(rgb)) ?? hex) : hex + } +} + +function minPairwise(p: string[], seen: (h: string) => string): number { + let m = Infinity + for (let i = 0; i < p.length; i++) { + const a = p[i] + if (!a) continue + for (let j = i + 1; j < p.length; j++) { + const b = p[j] + if (!b) continue + m = Math.min(m, dEOK(seen(a), seen(b))) + } + } + return Number.isFinite(m) ? m : 0 +} + +function minAdjacent(p: string[], seen: (h: string) => string): number { + let m = Infinity + for (let i = 1; i < p.length; i++) { + const a = p[i - 1] + const b = p[i] + if (!a || !b) continue + m = Math.min(m, dEOK(seen(a), seen(b))) + } + return Number.isFinite(m) ? m : 0 +} + +function collisions(p: string[], seen: (h: string) => string): number { + let n = 0 + for (let i = 0; i < p.length; i++) { + const a = p[i] + if (!a) continue + for (let j = i + 1; j < p.length; j++) { + const b = p[j] + if (!b) continue + if (dEOK(seen(a), seen(b)) < RISKY) n++ + } + } + return n +} + +const GROUPS = ['Q1', 'Q2', 'Q3', 'Q4'] +const catData = (n: number): number[][] => + GROUPS.map((_, g) => + Array.from({ length: n }, (_, i) => 24 + ((i * 37 + g * 19) % 60)), + ) +const seqData = (n: number): number[] => + Array.from({ length: n }, (_, i) => 16 + (n > 1 ? i / (n - 1) : 0) * 80) + +const DW = 520 +const DH = 150 +const DPAD = 6 +const DGAP = 6 + +const toneOf = (de: number): Tone => + de < MERGED ? 'bad' : de < RISKY ? 'warn' : 'ok' + +const COUNTS = [3, 4, 5, 6, 7, 8, 9, 10] + +export function ChartPaletteLab() { + const [type, setType] = useState('categorical') + const [count, setCount] = useState(6) + const [source, setSource] = useState('generated') + const [cvd, setCvd] = useState('none') + + const palette = useMemo(() => { + if (type === 'sequential') return sequential(count) + if (type === 'diverging') return diverging(count) + if (source === 'okabe') return OKABE.slice(0, Math.min(count, 8)) + return categorical(count) + }, [type, count, source]) + + const seen = useMemo(() => makeSeen(cvd), [cvd]) + + const n = palette.length + const minPair = minPairwise(palette, seen) + const minAdj = minAdjacent(palette, seen) + const cols = collisions(palette, seen) + const minBg = Math.min(...palette.map((h) => wcagContrast(h, PLOT_BG))) + const ls = palette.map((h) => toOklch(h)?.l ?? 0) + const monotone = ls.every((l, i) => i === 0 || l < (ls[i - 1] ?? 1)) + // The scale's midpoint is its lightest point — parity-independent, so even + // counts (no exact center swatch) still report the near-white neutral. + const midL = ls.length ? Math.max(...ls) : 0 + let sym = 0 + for (let i = 0; i < Math.floor(n / 2); i++) { + sym = Math.max(sym, Math.abs((ls[i] ?? 0) - (ls[n - 1 - i] ?? 0))) + } + const lightEnd = wcagContrast(palette[0] ?? '#ffffff', PLOT_BG) + + const rows: { title: string; body: string; tone: Tone }[] = [] + if (type === 'categorical') { + rows.push({ + title: 'Distinguishability (as seen)', + body: `min pairwise ΔEOK ${minPair.toFixed(2)} — ${ + minPair < MERGED + ? 'a pair has merged' + : minPair < RISKY + ? 'a pair is hard at a glance' + : 'all pairs glance-apart' + }${cvd !== 'none' ? ` under ${cvd}` : ''}.`, + tone: toneOf(minPair), + }) + rows.push({ + title: 'Collisions', + body: `${cols} of ${(n * (n - 1)) / 2} pairs below the glance-apart bound.`, + tone: cols === 0 ? 'ok' : cols <= 1 ? 'warn' : 'bad', + }) + rows.push({ + title: 'Background floor', + body: `min series-vs-white ${minBg.toFixed(2)}:1 (WCAG 1.4.11 wants 3:1 for a meaningful mark).`, + tone: minBg >= 3 ? 'ok' : 'warn', + }) + } else if (type === 'sequential') { + rows.push({ + title: 'Monotonic lightness', + body: monotone + ? 'lightness decreases every step — magnitude is legible.' + : 'lightness reverses — the ramp invents a false boundary.', + tone: monotone ? 'ok' : 'bad', + }) + rows.push({ + title: 'Even steps', + body: `min adjacent ΔEOK ${minAdj.toFixed(2)} — the smallest jump between neighbors.`, + tone: toneOf(minAdj), + }) + rows.push({ + title: 'Light-end floor', + body: `lightest step vs white ${lightEnd.toFixed(2)}:1 — the pale end is where the background bites.`, + tone: lightEnd >= 3 ? 'ok' : 'warn', + }) + } else { + rows.push({ + title: 'Midpoint', + body: `center L ${midL.toFixed(2)} — ${ + midL >= 0.9 + ? 'a light neutral; zero recedes.' + : 'too dark; zero reads as high-magnitude (ch7 gray concrete).' + }`, + tone: midL >= 0.9 ? 'ok' : 'bad', + }) + rows.push({ + title: 'Arm symmetry', + body: `mirror-pair lightness differs by at most ${sym.toFixed(3)} — the two arms climb in step.`, + tone: sym < 0.02 ? 'ok' : sym < 0.05 ? 'warn' : 'bad', + }) + rows.push({ + title: 'Background floor', + body: `min series-vs-white ${minBg.toFixed(2)}:1.`, + tone: minBg >= 3 ? 'ok' : 'warn', + }) + } + + const data = catData(n) + const sdata = seqData(n) + const bw = (DW - DPAD * 2 - DGAP * Math.max(n - 1, 0)) / Math.max(n, 1) + + return ( + { + setType('categorical') + setCount(6) + setSource('generated') + setCvd('none') + }} + > +
+
+
+ type + { + const next = [...keys][0] + if ( + next === 'categorical' || + next === 'sequential' || + next === 'diverging' + ) + setType(next) + }} + size="sm" + aria-label="Palette type" + className="max-w-full overflow-x-auto" + > + Categorical + Sequential + Diverging + +
+
+ count + { + const next = Number([...keys][0]) + if (next >= 3 && next <= 10) setCount(next) + }} + size="sm" + aria-label="Number of colors" + className="max-w-full overflow-x-auto" + > + {COUNTS.map((c) => ( + + {c} + + ))} + +
+ {type === 'categorical' && ( +
+ source + { + const next = [...keys][0] + if (next === 'generated' || next === 'okabe') setSource(next) + }} + size="sm" + aria-label="Categorical source" + className="max-w-full overflow-x-auto" + > + Generated + Okabe–Ito + +
+ )} +
+ simulate + { + const next = [...keys][0] + if ( + next === 'none' || + next === 'deutan' || + next === 'protan' || + next === 'tritan' + ) + setCvd(next) + }} + size="sm" + aria-label="Color vision deficiency" + className="max-w-full overflow-x-auto" + > + None + Deutan + Protan + Tritan + +
+
+ +
+
+
+
+ {type === 'categorical' + ? 'Traffic by channel — series side by side' + : type === 'sequential' + ? 'One ordered variable, low to high' + : 'Signed variable around zero'} +
+ {type === 'categorical' && ( +
+ {GROUPS.map((g, gi) => { + const vals = data[gi] ?? [] + return ( +
+
+ {palette.map((hex, i) => ( +
+ ))} +
+ + {g} + +
+ ) + })} +
+ )} + {type === 'sequential' && ( +
+ {palette.map((hex, i) => ( +
+ ))} +
+ )} + {type === 'diverging' && ( + + + {palette.map((hex, i) => { + const t = n > 1 ? (i / (n - 1)) * 2 - 1 : 0 + const hh = Math.abs(t) * (DH / 2 - 14) + const x = DPAD + i * (bw + DGAP) + const y = t >= 0 ? DH / 2 - hh : DH / 2 + return ( + + ) + })} + + )} +
+ +
+ {palette.map((hex, i) => ( + + + {type === 'categorical' ? `S${i + 1} ` : ''} + {hex} + + ))} +
+
+ +
+
+ Readout +
+ {type === 'categorical' && count > CEILING && ( +
+ Past ~{CEILING} CVD-safe hues. The honest engine returns{' '} + {CEILING} and asks you to re-encode with position, shape, or + labels — not a {count}th color. +
+ )} + {rows.map((r) => ( + + ))} +
+
+ +

+ {type === 'categorical' + ? 'Categorical wants many hues at one lightness — and even spacing in OKLCH is necessary, not sufficient. Simulate a deficiency and watch the min distance drop; the vetted Okabe–Ito set was chosen to survive exactly this.' + : type === 'sequential' + ? 'Sequential is the accent ramp: one hue, monotonic lightness. This is the one chart job the UI system already builds.' + : 'Diverging is two arms and a light-neutral center — a shape one hue cannot make. The midpoint decides whether zero recedes or shouts.'} +

+
+ + ) +} + +function Readout({ + title, + body, + tone, +}: { + title: string + body: string + tone: Tone +}) { + const dot = + tone === 'ok' ? 'bg-success' : tone === 'warn' ? 'bg-warning' : 'bg-danger' + return ( +
+
+ + {title} +
+

{body}

+
+ ) +} diff --git a/src/components/playgrounds/chroma-ceiling.tsx b/src/components/playgrounds/chroma-ceiling.tsx new file mode 100644 index 0000000..e96656c --- /dev/null +++ b/src/components/playgrounds/chroma-ceiling.tsx @@ -0,0 +1,87 @@ +import { clampChroma, converter, formatHex } from 'culori' + +import { Demo } from '@/components/demo' + +const toOklch = converter('oklch') + +const L = 0.65 +const HUES = [25, 75, 110, 160, 250, 320] + +// Max in-gamut chroma at L 0.65 for each hue — the tent's ceiling varies by hue (ch6). +const ceilingC = (h: number): number => + clampChroma({ mode: 'oklch', l: L, c: 0.4, h }, 'oklch').c ?? 0 + +const ceilings = HUES.map(ceilingC) +const shared = Math.min(...ceilings) +const weakestHue = HUES[ceilings.indexOf(shared)] ?? 0 + +const ownMax = HUES.map((h) => + formatHex(clampChroma({ mode: 'oklch', l: L, c: 0.4, h }, 'oklch')), +) +const capped = HUES.map((h) => formatHex({ mode: 'oklch', l: L, c: shared, h })) + +function Swatch({ hex, weak }: { hex: string; weak: boolean }) { + return ( +
+
+ + {(toOklch(hex)?.c ?? 0).toFixed(3)} + +
+ ) +} + +export function ChromaCeiling() { + return ( + + Same lightness (L {L}), six hues. Top row: each hue at its own gamut + ceiling — the chroma number under each swatch. They are not + equal: at this lightness a blue reaches far past a yellow. An{' '} + equal-chroma categorical set can only be as saturated as its + weakest hue, so the bottom row caps every hue at the minimum ceiling + (C {shared.toFixed(3)}, set by the ~{weakestHue}° yellow). The + tent’s tightest corner votes for the whole ring — + colorful, never neon. + + } + > +
+
+ + Each hue at its own ceiling + +
+ {ownMax.map((hex, i) => ( + + ))} +
+
+
+ + Equal-chroma set — capped at the weakest hue + +
+ {capped.map((hex, i) => ( + + ))} +
+
+ + ceiling spread {Math.min(...ceilings).toFixed(3)}– + {Math.max(...ceilings).toFixed(3)} · the ~{weakestHue}° hue caps the + equal set at C {shared.toFixed(3)} + +
+
+ ) +} diff --git a/src/components/playgrounds/compile-the-token.tsx b/src/components/playgrounds/compile-the-token.tsx new file mode 100644 index 0000000..fdaa39b --- /dev/null +++ b/src/components/playgrounds/compile-the-token.tsx @@ -0,0 +1,326 @@ +import { useState } from 'react' +import { + clampChroma, + converter, + differenceEuclidean, + formatHex, + interpolate, + wcagContrast, +} from 'culori' + +import { apcaLc } from '@/lib/apca' +import { cn } from '@/lib/utils' +import { Playground } from '@/components/playground' +import { ToggleButton } from '@/ui/toggle-button' +import { ToggleButtonGroup } from '@/ui/toggle-button-group' + +type Hue = 'blue' | 'violet' | 'red' + +const toOklch = converter('oklch') +const dEOK = differenceEuclidean('oklab') +const clamp01 = (x: number) => Math.min(1, Math.max(0, x)) + +// Each seed carries a light-room value AND a *designed* dark-room value +// (chapter 16's second design) — not a derivation of the light one. +const SEEDS: Record = { + blue: { label: 'Blue', light: '#4992dd', dark: '#6fb2fb' }, + violet: { label: 'Violet', light: '#6e56cf', dark: '#b8a6ff' }, + red: { label: 'Red', light: '#e5484d', dark: '#ff9592' }, +} + +const BUILD_TIME: Hue = 'blue' // the hue the baked artifact froze at build +const LIGHT_SURFACE = '#fcfcfd' +const DARK_SURFACE = '#111113' + +const mixBlack = (hex: string, t: number) => + formatHex(interpolate([hex, '#000000'], 'oklab')(t)) ?? hex + +const rel = (hex: string, dL: number, cMul = 1) => { + const c = toOklch(hex) + if (!c) return hex + return ( + formatHex( + clampChroma( + { + mode: 'oklch' as const, + l: clamp01((c.l ?? 0) + dL), + c: Math.max(0, (c.c ?? 0) * cMul), + h: c.h ?? 0, + }, + 'oklch', + ), + ) ?? hex + ) +} + +const fgOn = (bg: string) => + wcagContrast('#000000', bg) >= wcagContrast('#ffffff', bg) + ? '#000000' + : '#ffffff' + +interface Room { + accent: string + hover: string + fg: string +} + +function ButtonPreview({ surface, room }: { surface: string; room: Room }) { + return ( +
+ + Reply + + + hover + +
+ ) +} + +function StrategyCard({ + title, + light, + dark, + css, + stale, + note, +}: { + title: string + light: Room + dark: Room + css: string + stale?: boolean + note?: string +}) { + const w = wcagContrast(light.fg, light.accent) + const lc = apcaLc(light.fg, light.accent) + return ( +
+
+ {title} + {stale && ( + + needs rebuild + + )} +
+ + + + fg-on-accent {w.toFixed(2)}:1 · Lc {lc.toFixed(1)} + +
+
{css}
+
+ {note && {note}} +
+ ) +} + +type Tone = 'ok' | 'warn' | 'bad' +const toneClass = (t: Tone) => + t === 'ok' + ? 'text-fg-success' + : t === 'warn' + ? 'text-fg-warning' + : 'text-fg-danger' + +const SCORE: { k: string; cells: [string, Tone][] }[] = [ + { + k: 'Baked hex', + cells: [ + ['Universal', 'ok'], + ['Largest', 'warn'], + ['Rebuild', 'bad'], + ['In source', 'ok'], + ], + }, + { + k: 'mix + light-dark', + cells: [ + ['Baseline 2024', 'warn'], + ['Small', 'ok'], + ['Live', 'ok'], + ['Must resolve', 'warn'], + ], + }, + { + k: 'Relative from seed', + cells: [ + ['Baseline 2024-09', 'warn'], + ['Smallest', 'ok'], + ['Live', 'ok'], + ['Must resolve', 'warn'], + ], + }, +] + +export function CompileTheToken() { + const [hue, setHue] = useState('blue') + const s = SEEDS[hue] + + // Strategy 1 — baked: frozen to the build-time hue, ignores the runtime seed. + const b = SEEDS[BUILD_TIME] + const bakedLight: Room = { + accent: b.light, + hover: mixBlack(b.light, 0.12), + fg: fgOn(b.light), + } + const bakedDark: Room = { + accent: b.dark, + hover: mixBlack(b.dark, 0.12), + fg: fgOn(b.dark), + } + + // Strategy 2 — mix + light-dark: designed per-mode accent, computed hover. + const mixLight: Room = { + accent: s.light, + hover: mixBlack(s.light, 0.12), + fg: fgOn(s.light), + } + const mixDark: Room = { + accent: s.dark, + hover: mixBlack(s.dark, 0.12), + fg: fgOn(s.dark), + } + + // Strategy 3 — derive everything from one seed: dark is the ch16 flip. + const derivedDark = rel(s.light, 0.2, 0.75) + const derLight: Room = { + accent: s.light, + hover: rel(s.light, -0.08), + fg: fgOn(s.light), + } + const derDark: Room = { + accent: derivedDark, + hover: rel(derivedDark, -0.08), + fg: fgOn(derivedDark), + } + const flipCost = dEOK(derivedDark, s.dark) + + const stale = hue !== BUILD_TIME + + const bakedCss = `:root {\n --accent: ${b.light};\n --accent-hover: ${bakedLight.hover};\n --fg-on-accent: ${bakedLight.fg};\n}\n.dark {\n --accent: ${b.dark};\n --accent-hover: ${bakedDark.hover};\n --fg-on-accent: ${bakedDark.fg};\n}` + + const mixCss = `:root { color-scheme: light dark; }\n--accent: light-dark(${s.light}, ${s.dark});\n--accent-hover:\n color-mix(in oklab, var(--accent), black 12%);\n--fg-on-accent:\n light-dark(${mixLight.fg}, ${mixDark.fg});` + + const derCss = `:root { color-scheme: light dark; --seed: ${s.light}; }\n--accent: light-dark(\n var(--seed),\n oklch(from var(--seed) calc(l + 0.20) calc(c * 0.75) h)\n);\n--accent-hover:\n oklch(from var(--accent) calc(l - 0.08) c h);` + + return ( + setHue('blue')} + > +
+
+ runtime seed → + { + const next = [...keys][0] + if (next === 'blue' || next === 'violet' || next === 'red') + setHue(next) + }} + size="sm" + aria-label="Runtime seed hue" + className="max-w-full overflow-x-auto" + > + {(Object.keys(SEEDS) as Hue[]).map((k) => ( + + {SEEDS[k].label} + + ))} + + + (each card: light room over dark room) + +
+ +
+ + + +
+ +
+ Scorecard +
+ + + + + + + + + + + + {SCORE.map((row) => ( + + + {row.cells.map((c, i) => ( + + ))} + + ))} + +
strategysupportbytesthemingaudit
{row.k} + {c[0]} +
+
+ + support = worst Baseline tier the strategy needs · bytes = CSS to + enumerate the family · theming = can a runtime seed repaint it · + audit = can contrast be read off the source value. + +
+ +

+ {stale + ? `Seed is ${SEEDS[hue].label}. Columns 2 and 3 followed — one variable moved. Column 1 is still Blue: baked values can't be repointed at runtime, so it needs a rebuild. And column 3's dark accent is ΔEOK ${flipCost.toFixed(3)} off the designed dark — full derivation reintroduces chapter 16's flip.` + : `All three render the same accent at the build seed. Column 1's contrast is readable straight off its hex; columns 2 and 3 resolve the same color, but their source is an expression — the value you'd audit isn't there. Repoint the seed to see which artifact can follow.`} +

+
+
+ ) +} diff --git a/src/components/playgrounds/component-tier-firewall.tsx b/src/components/playgrounds/component-tier-firewall.tsx new file mode 100644 index 0000000..03c60fb --- /dev/null +++ b/src/components/playgrounds/component-tier-firewall.tsx @@ -0,0 +1,119 @@ +import { useState } from 'react' + +import { Demo } from '@/components/demo' +import { ToggleButton } from '@/ui/toggle-button' +import { ToggleButtonGroup } from '@/ui/toggle-button-group' + +// dotUI neutral/accent primitives (registry base/colors.css), oklch → hex. +const CARD_VALUES = { + neutral: '#f7f7f7', + tinted: '#d0edff', + dark: '#1d1d1d', +} as const + +type CardChoice = keyof typeof CARD_VALUES + +const FG = '#070707' +const FG_MUTED = '#626262' +const BORDER = '#dbdbdb' +const INVERSE_BG = '#1d1d1d' +const INVERSE_FG = '#fafafa' + +function Surface({ + name, + token, + bg, +}: { + name: string + token: string + bg: string +}) { + return ( +
+ + {name} + + + {token} + +
+ ) +} + +export function ComponentTierFirewall() { + const [card, setCard] = useState('neutral') + const cardValue = CARD_VALUES[card] + + return ( + + One product decision — restyle the card surface — and two + blast radii. On the left, popover and tooltip merely reuse{' '} + card’s value, so they follow + it everywhere it goes. On the right they own component-tier names, + pinned to their own values: the tooltip can stay inverse — a + choice no shared surface token could express. The firewall costs a + name per component, times every mode. + + } + > +
+
+ Card surface + { + const next = [...keys][0] + if (next === 'neutral' || next === 'tinted' || next === 'dark') + setCard(next) + }} + size="sm" + aria-label="Card surface value" + className="max-w-full overflow-x-auto" + > + Neutral + Tinted + Charcoal + +
+ +
+
+ + Two tiers — everything aliases{' '} + card + +
+ + + +
+
+
+ + Three tiers — component tokens on top + +
+ + + +
+
+
+
+
+ ) +} diff --git a/src/components/playgrounds/contrast-as-input.tsx b/src/components/playgrounds/contrast-as-input.tsx new file mode 100644 index 0000000..7751be3 --- /dev/null +++ b/src/components/playgrounds/contrast-as-input.tsx @@ -0,0 +1,124 @@ +import { useState } from 'react' +import { clampChroma, converter, formatHex, wcagContrast } from 'culori' + +import { apcaLc } from '@/lib/apca' +import { Demo } from '@/components/demo' +import { ToggleButton } from '@/ui/toggle-button' +import { ToggleButtonGroup } from '@/ui/toggle-button-group' + +type Bg = 'light' | 'dark' + +const toOklch = converter('oklch') +const BG: Record = { light: '#f8f8f8', dark: '#151517' } +const HUE = 255 +const CHROMA = 0.14 +const RATIOS = [3, 4.5, 7, 12] + +// Approximates Leonardo: walk OKLCH lightness until wcagContrast hits the target. +function solveWcag(bgHex: string, target: number) { + const bgL = toOklch(bgHex)?.l ?? 1 + const wantDarker = bgL > 0.5 + let best: { hex: string; w: number } | null = null + let extreme: { hex: string; w: number } | null = null + const N = 240 + for (let i = 0; i <= N; i++) { + const l = i / N + if (wantDarker && l > bgL) continue + if (!wantDarker && l < bgL) continue + const hex = formatHex( + clampChroma({ mode: 'oklch' as const, l, c: CHROMA, h: HUE }, 'oklch'), + ) + const w = wcagContrast(hex, bgHex) + if (extreme === null || w > extreme.w) extreme = { hex, w } + if (w >= target && (best === null || w < best.w)) best = { hex, w } + } + return best ?? extreme ?? { hex: bgHex, w: 1 } +} + +export function ContrastAsInput() { + const [target, setTarget] = useState(4.5) + const [bg, setBg] = useState('light') + + const bgHex = BG[bg] + const { hex, w } = solveWcag(bgHex, target) + const lc = apcaLc(hex, bgHex) + + return ( + + Contrast is the input; the color is the output. You ask for a ratio + and get the OKLCH color that hits it — guaranteed by + construction, no audit. Swap the surface and the same target + regenerates against the new room: chapter 16’s modes-for-free, + from one parameter. + + } + > +
+
+
+ target + { + const next = Number([...keys][0]) + if (RATIOS.includes(next)) setTarget(next) + }} + size="sm" + aria-label="Target contrast ratio" + className="max-w-full overflow-x-auto" + > + {RATIOS.map((r) => ( + + {r}:1 + + ))} + +
+
+ surface + { + const next = [...keys][0] + if (next === 'light' || next === 'dark') setBg(next) + }} + size="sm" + aria-label="Background" + className="max-w-full overflow-x-auto" + > + Light + Dark + +
+
+ +
+
+ + You asked for {target}:1 + +
+ + + solved {hex} — measures {w.toFixed(2)}:1 (≥ {target}, by construction) + · Lc {lc.toFixed(1)} + +
+ + ) +} diff --git a/src/components/playgrounds/contrast-ramp-lab.tsx b/src/components/playgrounds/contrast-ramp-lab.tsx new file mode 100644 index 0000000..2c7269d --- /dev/null +++ b/src/components/playgrounds/contrast-ramp-lab.tsx @@ -0,0 +1,332 @@ +import { useMemo, useState } from 'react' +import { clampChroma, converter, formatHex, wcagContrast } from 'culori' + +import { apcaLc } from '@/lib/apca' +import { cn } from '@/lib/utils' +import { Playground } from '@/components/playground' +import { ToggleButton } from '@/ui/toggle-button' +import { ToggleButtonGroup } from '@/ui/toggle-button-group' + +type Bg = 'light' | 'dark' +type Meter = 'wcag' | 'apca' +type HueKey = 'blue' | 'red' | 'green' | 'amber' + +const toOklch = converter('oklch') + +const BG: Record = { light: '#f8f8f8', dark: '#151517' } +const HUES: Record = { + blue: { h: 255, c: 0.14, label: 'Blue' }, + red: { h: 27, c: 0.16, label: 'Red' }, + green: { h: 150, c: 0.15, label: 'Green' }, + amber: { h: 75, c: 0.15, label: 'Amber' }, +} + +// Spectrum's index-to-ratio anchors live in the WCAG column (700 ≈ 3:1, 900 ≈ 4.5:1). +// The APCA column mirrors chapter 8's thresholds (Lc 75 = 18px body-text minimum). +const TARGETS: Record = { + wcag: [ + { t: 1.5, label: 'subtle surface' }, + { t: 2.2, label: 'hovered surface' }, + { t: 3, label: '700 · 3:1 large text / icon' }, + { t: 4.5, label: '900 · 4.5:1 body text' }, + { t: 7, label: 'AAA body text' }, + { t: 12, label: 'max text' }, + ], + apca: [ + { t: 15, label: 'subtle surface' }, + { t: 30, label: 'hovered surface' }, + { t: 45, label: 'large headline' }, + { t: 60, label: 'large / secondary text' }, + { t: 75, label: 'body text' }, + { t: 90, label: 'preferred body' }, + ], +} + +interface Solved { + t: number + label: string + hex: string + l: number + w: number + a: number + met: boolean +} + +// Approximates Leonardo: walk OKLCH lightness until the chosen meter hits the target. +function solve( + bgHex: string, + bgL: number, + target: number, + h: number, + c: number, + meter: Meter, +) { + const wantDarker = bgL > 0.5 + let best: { hex: string; l: number; w: number; a: number; c: number } | null = + null + let extreme: { + hex: string + l: number + w: number + a: number + c: number + } | null = null + const N = 240 + for (let i = 0; i <= N; i++) { + const l = i / N + if (wantDarker && l > bgL) continue + if (!wantDarker && l < bgL) continue + const hex = formatHex( + clampChroma({ mode: 'oklch' as const, l, c, h }, 'oklch'), + ) + const w = wcagContrast(hex, bgHex) + const a = Math.abs(apcaLc(hex, bgHex)) + const metric = meter === 'wcag' ? w : a + if (extreme === null || metric > extreme.c) + extreme = { hex, l, w, a, c: metric } + if (metric >= target && (best === null || metric < best.c)) + best = { hex, l, w, a, c: metric } + } + const pick = best ?? extreme ?? { hex: bgHex, l: bgL, w: 1, a: 0, c: 0 } + return { + hex: pick.hex, + l: pick.l, + w: pick.w, + a: pick.a, + met: best !== null, + } +} + +const W = 520 +const H = 96 +const PAD = { left: 8, right: 8, top: 10, bottom: 10 } + +export function ContrastRampLab() { + const [bg, setBg] = useState('light') + const [hue, setHue] = useState('blue') + const [meter, setMeter] = useState('wcag') + + const bgHex = BG[bg] + const bgL = toOklch(bgHex)?.l ?? 1 + const { h, c } = HUES[hue] + + const steps: Solved[] = useMemo( + () => + TARGETS[meter].map((tg) => ({ + t: tg.t, + label: tg.label, + ...solve(bgHex, bgL, tg.t, h, c, meter), + })), + [bgHex, bgL, h, c, meter], + ) + + const allMet = steps.every((s) => s.met) + + const n = steps.length + const px = (i: number) => + PAD.left + (i / (n - 1)) * (W - PAD.left - PAD.right) + const py = (l: number) => PAD.top + (1 - l) * (H - PAD.top - PAD.bottom) + const first = steps[0]?.l ?? 0 + const last = steps[n - 1]?.l ?? 0 + const evenRef = `M${px(0).toFixed(1)},${py(first).toFixed(1)} L${px(n - 1).toFixed(1)},${py(last).toFixed(1)}` + const actual = steps + .map( + (s, i) => + `${i === 0 ? 'M' : 'L'}${px(i).toFixed(1)},${py(s.l).toFixed(1)}`, + ) + .join(' ') + + const meterLabel = meter === 'wcag' ? 'WCAG ratio' : 'APCA Lc' + + return ( + { + setBg('light') + setHue('blue') + setMeter('wcag') + }} + > +
+
+
+ surface + { + const next = [...keys][0] + if (next === 'light' || next === 'dark') setBg(next) + }} + size="sm" + aria-label="Background" + className="max-w-full overflow-x-auto" + > + Light + Dark + +
+
+ hue + { + const next = [...keys][0] + if (typeof next === 'string' && next in HUES) + setHue(next as HueKey) + }} + size="sm" + aria-label="Key hue" + className="max-w-full overflow-x-auto" + > + {(Object.keys(HUES) as HueKey[]).map((k) => ( + + {HUES[k].label} + + ))} + +
+
+ target meter + { + const next = [...keys][0] + if (next === 'wcag' || next === 'apca') setMeter(next) + }} + size="sm" + aria-label="Contrast formula the ramp targets" + className="max-w-full overflow-x-auto" + > + WCAG + APCA + +
+
+ +
+ {steps.map((s, i) => ( +
+
+ + {meter === 'wcag' ? `${s.t}:1` : `Lc ${s.t}`} + +
+ ))} +
+ +
+ + Lightness per step — solid line is what landed, dashed is even + spacing. The gap is chapter 11’s drift. + + + + + {steps.map((s, i) => ( + + ))} + +
+ +
+ + + + + + + + + + + + {steps.map((s, i) => { + const prev = steps[i - 1] + const dL = prev ? s.l - prev.l : 0 + return ( + + + + + + + + ) + })} + +
job · targetLΔLWCAGAPCA
{s.label} + {s.l.toFixed(3)} + + {i === 0 ? '—' : `${dL >= 0 ? '+' : ''}${dL.toFixed(3)}`} + + {s.w.toFixed(2)}:1 + + Lc {s.a.toFixed(0)} +
+
+ + + Targeting {meterLabel} against the {bg} surface.{' '} + {allMet + ? 'Every step meets its target by construction — no audit ran.' + : 'A target sits past this hue’s reach; that step ships the strongest color available.'}{' '} + Toggle the surface for a second mode at the same targets; toggle the + meter to watch the ramp shift. + +
+ + ) +} diff --git a/src/components/playgrounds/designed-vs-generated.tsx b/src/components/playgrounds/designed-vs-generated.tsx new file mode 100644 index 0000000..cecfbb7 --- /dev/null +++ b/src/components/playgrounds/designed-vs-generated.tsx @@ -0,0 +1,241 @@ +import { useMemo, useState } from 'react' +import { clampChroma, converter, differenceEuclidean, formatHex } from 'culori' + +import { cn } from '@/lib/utils' +import { Demo } from '@/components/demo' +import { ToggleButton } from '@/ui/toggle-button' +import { ToggleButtonGroup } from '@/ui/toggle-button-group' + +const toOklch = converter('oklch') +const dEOK = differenceEuclidean('oklab') + +// Geist blue, light theme, steps 100–1000 — verified from vercel.com/design.md. +const GEIST_BLUE = [ + '#f0f7ff', + '#e9f4ff', + '#dfefff', + '#cae7ff', + '#94ccff', + '#48aeff', + '#006bff', + '#0059ec', + '#005ff2', + '#002359', +] + +// Geist's AUTHORED Display-P3 oklch() values (design.md) — the source the prose +// quotes. The hue drifts by hand and the lightness is non-monotone (900 > 800). +// These differ from the sRGB-hex conversions above (e.g. hex solid reads ~260°). +const GEIST_H = [ + 251.56, 250.59, 249.85, 245.12, 248.48, 248.13, 258.23, 257.85, 256.99, + 254.34, +] +const GEIST_L = [ + 97.32, 96.29, 94.58, 91.58, 82.75, 73.08, 57.61, 51.51, 53.18, 26.67, +] + +// A minimal engine: fixed lightness skeleton (ch11) + chroma bell peaking at +// the solid (ch12), hue from the seed (ch13). Monotone by construction. +const L_ANCHORS = [0.972, 0.955, 0.93, 0.895, 0.83, 0.72, 0.6, 0.52, 0.42, 0.29] +const C_SHAPE = [0.06, 0.12, 0.22, 0.38, 0.62, 0.85, 1.0, 0.93, 0.72, 0.42] + +function genRamp(seed: string): string[] { + const s = toOklch(seed) + const c = s?.c ?? 0.1 + const h = s?.h ?? 250 + return L_ANCHORS.map((l, i) => + formatHex( + clampChroma( + { mode: 'oklch' as const, l, c: c * (C_SHAPE[i] ?? 0), h }, + 'oklch', + ), + ), + ) +} + +const SEEDS = [ + { label: 'Vercel blue', value: '#006bff' }, + { label: 'Violet', value: '#635bff' }, + { label: 'Emerald', value: '#00ad47' }, + { label: 'Crimson', value: '#f34847' }, +] + +function Row({ ramp, dim }: { ramp: string[]; dim?: boolean }) { + return ( +
+ {ramp.map((hex, i) => ( +
+ ))} +
+ ) +} + +function LinePlot({ + title, + geist, + engine, + min, + max, + fmt, +}: { + title: string + geist: number[] + engine: number[] + min: number + max: number + fmt: (v: number) => string +}) { + const W = 300 + const H = 72 + const padX = 10 + const padY = 12 + const x = (i: number) => padX + (i / 9) * (W - 2 * padX) + const y = (v: number) => padY + (1 - (v - min) / (max - min)) * (H - 2 * padY) + const path = (vals: number[]) => + vals.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ') + return ( +
+ + {title} · Geist {fmt(geist[0] ?? 0)}→{fmt(geist[9] ?? 0)} · engine{' '} + {fmt(engine[0] ?? 0)} + + + {/* engine — generated, flat/monotone by rule */} + + {/* Geist — hand-authored */} + + {geist.map((v, i) => ( + + ))} + +
+ ) +} + +export function DesignedVsGenerated() { + const [seed, setSeed] = useState('#006bff') + const engine = useMemo(() => genRamp(seed), [seed]) + const isVercel = seed === '#006bff' + const drift = dEOK(engine[6] ?? '#000000', '#006bff') + const seedHue = toOklch(seed)?.h ?? 250 + const engineHue = Array(10).fill(seedHue) + const engineL = L_ANCHORS.map((l) => l * 100) + + return ( + + Geist reached its blue by hand, for one brand. The engine reaches a + ramp for any seed by function — near Geist’s on the + Vercel blue (ΔEOK ≈ 0.033 at the solid), but it + can’t copy Geist’s per-step hue drift or non-monotone + ladder (the plots, read from Geist’s authored P3{' '} + oklch() values). Switch to violet + or emerald and the engine follows into a region where no Geist ramp + exists. The hand can’t be generated; the reach can’t be + hand-authored. + + } + > +
+
+ seed + { + const next = [...keys][0] + if (typeof next === 'string') setSeed(next) + }} + size="sm" + aria-label="Engine seed" + className="max-w-full overflow-x-auto" + > + {SEEDS.map((s) => ( + + {s.label} + + ))} + +
+ +
+
+ Geist — hand-designed + {!isVercel && ( + + no Geist ramp for this brand + + )} +
+ +
+ +
+ + Engine — generated from {seed} + + +
+ + {isVercel ? ( +
+ `${v.toFixed(0)}°`} + /> + `${v.toFixed(0)}`} + /> +
+ ) : ( + + Curves compare only against Geist — switch back to Vercel blue to + see the hand-drawn hue drift and non-monotone ladder against the + engine’s flat, monotone rule. + + )} + + + {isVercel + ? `engine solid ${engine[6]} vs Geist blue-700 #006bff — ΔEOK ${drift.toFixed(3)} (near, not on: two tunings of one target)` + : `engine solid ${engine[6]} — the road reached a destination Geist never designed`} + +
+
+ ) +} diff --git a/src/components/playgrounds/disabled-on-purpose.tsx b/src/components/playgrounds/disabled-on-purpose.tsx new file mode 100644 index 0000000..6e83beb --- /dev/null +++ b/src/components/playgrounds/disabled-on-purpose.tsx @@ -0,0 +1,119 @@ +import { converter, formatHex, wcagContrast } from 'culori' + +import { apcaLc } from '@/lib/apca' +import { Demo } from '@/components/demo' + +const toRgb = converter('rgb') + +function over(fg: string, bg: string, a: number): string { + const f = toRgb(fg) + const b = toRgb(bg) + if (!f || !b) return bg + return ( + formatHex({ + mode: 'rgb', + r: f.r * a + b.r * (1 - a), + g: f.g * a + b.g * (1 - a), + b: f.b * a + b.b * (1 - a), + }) ?? bg + ) +} + +const CARD = '#f6f5f1' // neutral-100 +const FG = '#090807' // neutral-950 — enabled label +const FG_DISABLED = '#8a8883' // neutral-500 — dotUI's fg-disabled (stepped) +const OPACITY = 0.38 // Material 3 disabled content opacity + +// dotUI ships a stepped neutral; M3 ships a raw-opacity content layer. +const OPACITY_DISABLED = over(FG, CARD, OPACITY) + +const rows = [ + { + state: 'enabled', + fg: FG, + verdict: 'must pass 4.5:1', + tone: 'text-fg-muted', + }, + { + state: 'disabled · stepped (dotUI)', + fg: FG_DISABLED, + verdict: 'exempt · keep legible', + tone: 'text-fg-warning', + }, + { + state: 'disabled · 0.38 opacity (M3)', + fg: OPACITY_DISABLED, + verdict: 'exempt · dimmer still', + tone: 'text-fg-warning', + }, +] + +export function DisabledOnPurpose() { + return ( + + Disabled is the one state that reduces contrast on purpose. + The enabled label clears {wcagContrast(FG, CARD).toFixed(2)}:1; + dotUI’s stepped disabled (neutral-500) drops to{' '} + {wcagContrast(FG_DISABLED, CARD).toFixed(2)}:1, and Material’s + 0.38-opacity recipe lands lower still at{' '} + {wcagContrast(OPACITY_DISABLED, CARD).toFixed(2)}:1 — both under the + 4.5:1 floor, both correct. WCAG 2.2 says the same thing in SC 1.4.3 + and 1.4.11: “User Interface Components that are not available + for user interaction (e.g., a disabled control in HTML) are not + required to meet contrast requirements.” Exempt is not + invisible: the control must still read as a control and never be + mistaken for enabled. The stepped neutral is the more legible default; + raw opacity can dim past reading on a busy surface. + + } + > +
+
+ {rows.map((r) => ( + + Save changes + + ))} +
+
+ + + + + + + + + + + {rows.map((r) => ( + + + + + + + ))} + +
stateWCAGAPCAverdict
{r.state} + {wcagContrast(r.fg, CARD).toFixed(2)}:1 + + Lc {apcaLc(r.fg, CARD).toFixed(1)} + {r.verdict}
+
+
+
+ ) +} diff --git a/src/components/playgrounds/diverging-midpoint.tsx b/src/components/playgrounds/diverging-midpoint.tsx new file mode 100644 index 0000000..e34eb9e --- /dev/null +++ b/src/components/playgrounds/diverging-midpoint.tsx @@ -0,0 +1,85 @@ +import { converter, formatHex, interpolate, samples } from 'culori' + +import { Demo } from '@/components/demo' + +const toOklch = converter('oklch') + +// ColorBrewer RdBu 5-class: two sequential arms, a near-white light-neutral mid. +const GOOD = ['#ca0020', '#f4a582', '#f7f7f7', '#92c5de', '#0571b0'] + +// The chapter-7 trap: red straight to blue in RGB sags through muddy gray at zero. +const badFn = interpolate(['#ca0020', '#0571b0'], 'rgb') +const BAD = samples(5).map((t) => formatHex(badFn(t))) + +const lOf = (hex: string) => (toOklch(hex)?.l ?? 0).toFixed(2) +const cOf = (hex: string) => (toOklch(hex)?.c ?? 0).toFixed(3) + +function Row({ + title, + colors, + verdict, + tone, +}: { + title: string + colors: string[] + verdict: string + tone: 'ok' | 'bad' +}) { + const mid = colors[2] ?? '#000' + return ( +
+ {title} +
+ {colors.map((hex, i) => ( +
+ {i === 2 && ( + + midpoint + + )} +
+ ))} +
+ + mid {mid} · L {lOf(mid)} · C {cOf(mid)} — {verdict} + +
+ ) +} + +export function DivergingMidpoint() { + return ( + + A diverging scale is two sequential arms glued at zero. RdBu’s + midpoint is a near-white light neutral, so zero reads as “no + signal.” Interpolate red straight to blue and zero becomes the + gray nobody chose — chapter 7’s gray concrete, muddy and + dark, sitting on your most important data point. + + } + > +
+ + +
+
+ ) +} diff --git a/src/components/playgrounds/engine-scorecard.tsx b/src/components/playgrounds/engine-scorecard.tsx new file mode 100644 index 0000000..ef9201d --- /dev/null +++ b/src/components/playgrounds/engine-scorecard.tsx @@ -0,0 +1,286 @@ +import { useState } from 'react' +import { wcagContrast } from 'culori' + +import { apcaLc } from '@/lib/apca' +import { cn } from '@/lib/utils' +import { Playground } from '@/components/playground' +import { ToggleButton } from '@/ui/toggle-button' +import { ToggleButtonGroup } from '@/ui/toggle-button-group' + +type Lens = 'all' | 'steal' | 'fix' +type Status = 'steal' | 'core' | 'fix' + +interface Row { + ch: string + decision: string + geist: string + dotui: string + status: Status + swatches?: string[] +} + +// Verdicts across the course, read through Geist (designed) vs the dotUI engine +// (generated). Swatch hexes are verified Geist light-theme values. +const ROWS: Row[] = [ + { + ch: '5', + decision: 'Working space', + geist: 'Authors in OKLCH; ships an oklch() twin for P3.', + dotui: 'Computes every ramp in OKLCH — same space.', + status: 'steal', + }, + { + ch: '6', + decision: 'Gamut & P3', + geist: 'sRGB hex baseline, Display-P3 via oklch(), gated by capability.', + dotui: 'Gamut-maps with clampChroma. No P3 variant shipped — a gap.', + status: 'fix', + swatches: ['#006bff'], + }, + { + ch: '8 · 17', + decision: 'Contrast & pairing', + geist: 'High-contrast by hand; blue-700 wins on both meters.', + dotui: 'Pairing scored max-WCAG only; APCA ignored. Name both meters.', + status: 'fix', + swatches: ['#006bff', '#ffffff'], + }, + { + ch: '10', + decision: 'Steps are jobs', + geist: '10 steps, 100–1000, each a job.', + dotui: '11-step job scale (50–950) — same contract, more rungs.', + status: 'steal', + }, + { + ch: '11', + decision: 'Lightness curve', + geist: 'Hand-tuned per scale; non-monotone by design.', + dotui: 'Fixed lightness anchors.', + status: 'steal', + }, + { + ch: '12', + decision: 'Chroma curve', + geist: 'Hand-drawn under the tent; restrained, never neon.', + dotui: 'Chroma envelope, clamped to gamut.', + status: 'steal', + }, + { + ch: '13', + decision: 'Hue along the ramp', + geist: 'Hue drifts by hand — blue 251° → 258° → 254°, dips to 245°.', + dotui: 'Per-family bend term; otherwise constant hue.', + status: 'steal', + }, + { + ch: '14', + decision: 'Seed policy', + geist: 'No seed. One brand, hand-authored, frozen.', + dotui: 'Any seed in, full system out. The whole product.', + status: 'core', + }, + { + ch: '15', + decision: 'Neutrals', + geist: 'Near-pure neutral grays; #171717 ink; restraint by default.', + dotui: 'Tint budget below C 0.02.', + status: 'steal', + swatches: ['#f2f2f2', '#171717'], + }, + { + ch: '16', + decision: 'Dark mode', + geist: 'Separate hand-designed dark (design.dark.md), same names.', + dotui: + 'Reverses the light list — an un-designed flip. Owe a second design.', + status: 'fix', + }, + { + ch: '17', + decision: 'Token tiers', + geist: 'Job scale consumed by semantic usage.', + dotui: 'Three tiers, 83 tokens, property × role × state.', + status: 'steal', + }, + { + ch: '18', + decision: 'States & alpha', + geist: 'States step up the scale; gray-alpha companion for overlays.', + dotui: 'States as -hover suffixes; alpha companion still owed.', + status: 'fix', + }, + { + ch: '20', + decision: 'Shipping', + geist: 'sRGB hex + P3 oklch, light + dark, machine-readable.', + dotui: 'CSS custom-property tiers.', + status: 'steal', + }, +] + +const DOT: Record = { + steal: 'bg-fg-success', + core: 'bg-fg', + fix: 'bg-fg-warning', +} +const LABEL: Record = { + steal: 'steal', + core: 'core', + fix: 'fix', +} + +function visible(rows: Row[], lens: Lens): Row[] { + if (lens === 'all') return rows + if (lens === 'fix') return rows.filter((r) => r.status === 'fix') + return rows.filter((r) => r.status === 'steal' || r.status === 'core') +} + +const SOLID = '#006bff' // Geist blue-700 + +export function EngineScorecard() { + const [lens, setLens] = useState('all') + + const rows = visible(ROWS, lens) + const stealCount = ROWS.filter((r) => r.status === 'steal').length + const fixCount = ROWS.filter((r) => r.status === 'fix').length + + const wW = wcagContrast('#ffffff', SOLID) + const wB = wcagContrast('#000000', SOLID) + const aW = apcaLc('#ffffff', SOLID) + const aB = apcaLc('#000000', SOLID) + + return ( + setLens('all')} + > +
+
+ + Deploy + + + blue-700 {SOLID} + white — WCAG white {wW.toFixed(2)}:1 / black{' '} + {wB.toFixed(2)}:1 · APCA white Lc {aW.toFixed(0)} / black{' '} + {aB.toFixed(0)} → both pick white + +
+ +
+ { + const next = [...keys][0] + if (next === 'all' || next === 'steal' || next === 'fix') + setLens(next) + }} + size="sm" + aria-label="Scorecard lens" + className="max-w-full overflow-x-auto" + > + Everything + Steal + Fix + + + {stealCount} to steal · {fixCount} to fix · 1 irreducible + +
+ +
+ + + + + + + + + + {rows.map((r) => ( + + + + + + ))} + +
decisionGeist — designeddotUI — generated
+
+ + {r.decision} +
+ + ch{r.ch} · {LABEL[r.status]} + +
+ {r.geist} + {r.swatches && ( + + {r.swatches.map((s) => ( + + ))} + + )} + + {r.dotui} +
+
+ +

+ {lens === 'fix' ? ( + <> + The punch list. Every row here is dotUI diverging from the + destination it’s aiming at — clear them and the + engine’s output holds Geist’s bar. + + ) : lens === 'steal' ? ( + <> + What Geist proves works as a shipped artifact — the engine’s + job is to embody these as a function, not to reinvent them. The + one core row is the difference + in kind: Geist has no seed, dotUI is nothing but one. + + ) : ( + <> + Read top to bottom, this is the rewrite spec in miniature — steal + the green rows as invariants, clear the amber rows as tasks, hold + the one neutral row as the reason dotUI is a road and Geist a + destination. + + )} +

+
+
+ ) +} diff --git a/src/components/playgrounds/fixed-l-audit.tsx b/src/components/playgrounds/fixed-l-audit.tsx new file mode 100644 index 0000000..9481477 --- /dev/null +++ b/src/components/playgrounds/fixed-l-audit.tsx @@ -0,0 +1,131 @@ +import { useState } from 'react' +import { clampChroma, formatHex, wcagContrast } from 'culori' + +import { apcaLc } from '@/lib/apca' +import { cn } from '@/lib/utils' +import { Demo } from '@/components/demo' +import { ToggleButton } from '@/ui/toggle-button' +import { ToggleButtonGroup } from '@/ui/toggle-button-group' + +type Bg = 'light' | 'dark' + +const BG: Record = { light: '#f8f8f8', dark: '#191919' } + +// Lightness-anchored ramp (ch11, philosophy 1): L is fixed, contrast is whatever falls out. +const HUE = 255 +const CHROMA = 0.13 +const L_ANCHORS = [0.97, 0.92, 0.85, 0.75, 0.65, 0.55, 0.45, 0.33] +const RAMP = L_ANCHORS.map((l) => + formatHex( + clampChroma({ mode: 'oklch' as const, l, c: CHROMA, h: HUE }, 'oklch'), + ), +) + +// two steps carry text jobs (ch10): a large-text step (3:1) and a body step (4.5:1) +const JOBS = [ + { idx: 6, label: 'large text / icon', need: 3 }, + { idx: 7, label: 'body text', need: 4.5 }, +] + +export function FixedLAudit() { + const [bg, setBg] = useState('light') + const bgHex = BG[bg] + + return ( + + Lightness-anchored (chapter 11, philosophy 1): the eight L values are + fixed, so the contrast against the surface is a consequence{' '} + you read off the meter. On light both text jobs pass. Flip to dark and + the same colors keep their L and lose their contrast — fixed + lightness can’t follow the room, and the guarantee was only ever + post-hoc. + + } + > +
+ { + const next = [...keys][0] + if (next === 'light' || next === 'dark') setBg(next) + }} + size="sm" + aria-label="Background" + className="max-w-full overflow-x-auto" + > + Light surface + Dark surface + + +
+ {RAMP.map((hex, i) => ( +
+
j.idx === i) && + 'outline-2 outline-offset-2 outline-fg/50', + )} + style={{ backgroundColor: hex }} + /> +
+ ))} +
+ +
+ + + + + + + + + + + {JOBS.map((j) => { + const hex = RAMP[j.idx] ?? '#000000' + const w = wcagContrast(hex, bgHex) + const lc = apcaLc(hex, bgHex) + const pass = w >= j.need + return ( + + + + + + + ) + })} + +
text jobneedsWCAG hereAPCA
{j.label} + {j.need.toFixed(1)}:1 + + {w.toFixed(2)}:1 {pass ? '✓' : '✕'} + Lc {lc.toFixed(1)}
+
+ + + {bg === 'light' + ? 'Both jobs clear their ratio — but you only know because you measured.' + : 'Same L, new room: the text steps have collapsed toward the surface. Nothing regenerated.'} + +
+ + ) +} diff --git a/src/components/playgrounds/focus-ring-every-surface.tsx b/src/components/playgrounds/focus-ring-every-surface.tsx new file mode 100644 index 0000000..f2dfddc --- /dev/null +++ b/src/components/playgrounds/focus-ring-every-surface.tsx @@ -0,0 +1,98 @@ +import { converter, formatHex, wcagContrast } from 'culori' + +import { Demo } from '@/components/demo' + +const toRgb = converter('rgb') + +function over(fg: string, bg: string, a: number): string { + const f = toRgb(fg) + const b = toRgb(bg) + if (!f || !b) return bg + return ( + formatHex({ + mode: 'rgb', + r: f.r * a + b.r * (1 - a), + g: f.g * a + b.g * (1 - a), + b: f.b * a + b.b * (1 - a), + }) ?? bg + ) +} + +// dotUI: --color-border-focus = accent-500 (#4992dd); the focus-ring utility is +// ring-2 + ring-offset-2. WCAG 1.4.11 asks a focus indicator for 3:1 vs adjacent. +const RING = '#4992dd' + +const SURFACES = [ + { name: 'card', value: '#f6f5f1' }, // neutral-100 + { name: 'accent panel', value: '#b0dcff' }, // accent-200 + { name: 'danger soft', value: '#ffdcd7' }, // danger-100 + { name: 'inverse', value: '#090807' }, // neutral-950 +] + +export function FocusRingEverySurface() { + const rows = SURFACES.map((s) => ({ + ...s, + ratio: wcagContrast(RING, s.value), + })) + const misses = rows.filter((r) => r.ratio < 3).length + + return ( + + One focus token, four surfaces. The ring is{' '} + border-focus — accent-500 ({RING}) + at 2px with a 2px offset — a dedicated token, not a step borrowed from + the component’s own ramp, because it has to stay visible on{' '} + every surface at once. WCAG 1.4.11 wants a focus indicator at + 3:1 against the adjacent color. Measured, the shipped accent-500 ring{' '} + misses on {misses} of 4 surfaces{' '} + — it grazes 2.99:1 on the card and dips to 2.26:1 on the blue panel. + The offset gap is a real shape cue (chapter 9), but it does not raise + the ratio against the adjacent color. The honest engine fix: pick the + focus token to clear 3:1 on the surfaces it lands on, or ship a + two-tone ring so one edge always passes. + + } + > +
+
+ {rows.map((s) => { + const offset = over('#ffffff', s.value, 0.9) + return ( +
+ + Focused + + = 3 ? 'text-fg-success' : 'text-fg-danger' + }`} + > + {s.ratio.toFixed(2)}:1 {s.ratio >= 3 ? '✓' : '✕'} + +
+ ) + })} +
+ + ring vs adjacent surface — WCAG 1.4.11 floor is 3:1 + +
+
+ ) +} diff --git a/src/components/playgrounds/geist-accent-meters.tsx b/src/components/playgrounds/geist-accent-meters.tsx new file mode 100644 index 0000000..5f13e00 --- /dev/null +++ b/src/components/playgrounds/geist-accent-meters.tsx @@ -0,0 +1,142 @@ +import { useState } from 'react' +import { wcagContrast } from 'culori' + +import { apcaLc } from '@/lib/apca' +import { cn } from '@/lib/utils' +import { Demo } from '@/components/demo' +import { ToggleButton } from '@/ui/toggle-button' +import { ToggleButtonGroup } from '@/ui/toggle-button-group' + +type Step = '600' | '700' | '800' + +// Geist blue solids, verified from vercel.com/design.md. 700 is the primary. +const BLUE: Record = { + '600': '#48aeff', + '700': '#006bff', + '800': '#0059ec', +} + +export function GeistAccentMeters() { + const [step, setStep] = useState('700') + const solid = BLUE[step] + const wW = wcagContrast('#ffffff', solid) + const wB = wcagContrast('#000000', solid) + const aW = apcaLc('#ffffff', solid) + const aB = apcaLc('#000000', solid) + const pick = wW >= wB ? 'white' : 'black' + const label = pick === 'white' ? '#ffffff' : '#000000' + + return ( + + Step the solid across blue’s neighbours. At 600 black wins, at + 800 white wins; 700 sits on the seam — WCAG a near-tie (white 4.62:1, + black 4.55:1), APCA breaking it decisively for white. Geist parked its + primary blue at 700, on the white side of the crossover. WCAG 2 is the + floor; APCA predicts the read. + + } + > +
+
+ solid step + { + const next = [...keys][0] + if (next === '600' || next === '700' || next === '800') + setStep(next) + }} + size="sm" + aria-label="Geist blue solid step" + className="max-w-full overflow-x-auto" + > + blue-600 + blue-700 + blue-800 + + {step === '700' && ( + + ← Geist’s primary + + )} +
+ +
+ + Deploy + + + {solid} · auto-picks {pick} + +
+ +
+ + + + + + + + + + + + + + + + + + + + +
labelWCAGAPCA
white= 4.5 ? 'text-fg-success' : 'text-fg-danger', + )} + > + {wW.toFixed(2)}:1 {wW >= 4.5 ? '✓' : '✕'} + = 60 ? 'text-fg-success' : 'text-fg-warning', + )} + > + Lc {aW.toFixed(0)} {Math.abs(aW) >= 60 ? '✓' : '⚠'} +
black= 4.5 ? 'text-fg-success' : 'text-fg-danger', + )} + > + {wB.toFixed(2)}:1 {wB >= 4.5 ? '✓' : '✕'} + = 60 ? 'text-fg-success' : 'text-fg-warning', + )} + > + Lc {aB.toFixed(0)} {Math.abs(aB) >= 60 ? '✓' : '⚠'} +
+
+ + + {step === '700' + ? 'Both meters lean white — the pair is unambiguous. This is the slot Geist shipped.' + : pick === 'black' + ? 'Lighter solid — black wins here; a white label would fail the floor.' + : 'Darker solid — white wins comfortably on both meters.'} + +
+
+ ) +} diff --git a/src/components/playgrounds/geist-alpha-composite.tsx b/src/components/playgrounds/geist-alpha-composite.tsx new file mode 100644 index 0000000..9ca06eb --- /dev/null +++ b/src/components/playgrounds/geist-alpha-composite.tsx @@ -0,0 +1,110 @@ +import { useState } from 'react' + +import { Demo } from '@/components/demo' +import { ToggleButton } from '@/ui/toggle-button' +import { ToggleButtonGroup } from '@/ui/toggle-button-group' + +type Surface = 'white' | 'tint' | 'mid' + +// Geist light surfaces, verified from vercel.com/design.md. +const SURFACES: Record = { + white: { label: 'background-100', value: '#ffffff' }, + tint: { label: 'blue-100', value: '#f0f7ff' }, + mid: { label: 'blue-400', value: '#cae7ff' }, +} + +// Illustrative construction: black at this alpha composites to gray-400 +// #eaeaea over white (255·(1−α)=234), so both dividers match on the common case. +const A = 1 - 234 / 255 // ≈ 0.082 +const SOLID_DIVIDER = '#eaeaea' // gray-400 +const ALPHA_DIVIDER = `rgba(0,0,0,${A.toFixed(3)})` + +const ROWS = ['Production', 'Preview', 'Development'] + +function Panel({ + divider, + bg, + title, +}: { + divider: string + bg: string + title: string +}) { + return ( +
+ {title} +
+ {ROWS.map((r, i) => ( +
+ {r} +
+ ))} +
+
+ ) +} + +export function GeistAlphaComposite() { + const [surface, setSurface] = useState('white') + const bg = SURFACES[surface].value + + return ( + + Both dividers match on white — the alpha is tuned so it lands on solid{' '} + gray-400 there. Switch the surface: + the solid divider becomes a lighter mismatched band; the alpha divider + just darkens whatever is under it, staying in family. Alpha is how a + fixed value survives an unknown background. + + } + > +
+
+ surface + { + const next = [...keys][0] + if (next === 'white' || next === 'tint' || next === 'mid') + setSurface(next) + }} + size="sm" + aria-label="Surface the divider sits on" + className="max-w-full overflow-x-auto" + > + White + Tint + Mid + +
+ +
+ + +
+ + + on {SURFACES[surface].label} — solid {SOLID_DIVIDER} · alpha + rgba(0,0,0,{A.toFixed(3)}) + +
+
+ ) +} diff --git a/src/components/playgrounds/geist-job-ladder.tsx b/src/components/playgrounds/geist-job-ladder.tsx new file mode 100644 index 0000000..bb3db64 --- /dev/null +++ b/src/components/playgrounds/geist-job-ladder.tsx @@ -0,0 +1,183 @@ +import { useState } from 'react' +import { converter, wcagContrast } from 'culori' + +import { apcaLc } from '@/lib/apca' +import { cn } from '@/lib/utils' +import { Demo } from '@/components/demo' +import { ToggleButton } from '@/ui/toggle-button' +import { ToggleButtonGroup } from '@/ui/toggle-button-group' + +type Family = 'gray' | 'blue' + +const toOklch = converter('oklch') + +// Geist light-theme ramps, steps 100–1000, verified from vercel.com/design.md. +// Steps are JOBS, not a monotone lightness ramp — gray 400 (border) is a hair +// lighter than 300 (active bg): #eaeaea over #e6e6e6. +const RAMPS: Record = { + gray: [ + '#f2f2f2', + '#ebebeb', + '#e6e6e6', + '#eaeaea', + '#c9c9c9', + '#a8a8a8', + '#8f8f8f', + '#7d7d7d', + '#4d4d4d', + '#171717', + ], + blue: [ + '#f0f7ff', + '#e9f4ff', + '#dfefff', + '#cae7ff', + '#94ccff', + '#48aeff', + '#006bff', + '#0059ec', + '#005ff2', + '#002359', + ], +} +const STEPS = [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000] +const JOBS = [ + 'default bg', + 'hover bg', + 'active bg', + 'default border', + 'hover border', + 'active border', + 'solid fill', + 'solid hover', + 'secondary text', + 'primary text', +] +const BG = '#ffffff' // background-100 + +function lightness(hex: string): number { + return (toOklch(hex)?.l ?? 0) * 100 +} + +export function GeistJobLadder() { + const [family, setFamily] = useState('gray') + const ramp = RAMPS[family] + const L = ramp.map(lightness) + const border = ramp[3] ?? '#000000' + const solid = ramp[6] ?? '#000000' + const secondary = ramp[8] ?? '#000000' + const primary = ramp[9] ?? '#000000' + const label = + wcagContrast('#ffffff', solid) >= wcagContrast('#000000', solid) + ? '#ffffff' + : '#000000' + const showBlip = family === 'gray' + + return ( + + Ten steps, ten jobs — the same structure for every hue, on a tighter + grid than Radix’s twelve. Step 700 is the solid, 1000 the ink. + The L row is each step’s + OKLCH lightness: in gray it does not fall monotonically — + step 400 (default border) sits lighter than step 300 (active bg). A + ramp of jobs, not a lightness index. + + } + > +
+ { + const next = [...keys][0] + if (next === 'gray' || next === 'blue') setFamily(next) + }} + size="sm" + aria-label="Geist scale" + className="max-w-full overflow-x-auto" + > + Gray + Blue + + +
+
+ {ramp.map((hex, i) => { + const marked = showBlip && (i === 2 || i === 3) + return ( +
+
+ + {STEPS[i]} + + + L{(L[i] ?? 0).toFixed(0)} + + + {JOBS[i]} + +
+ ) + })} +
+
+ + {showBlip && ( + + blip: gray-400 L {(L[3] ?? 0).toFixed(1)}% > gray-300 L{' '} + {(L[2] ?? 0).toFixed(1)}% — the border reads lighter than the active + bg it separates. + + )} + +
+ + Deploy summary + + + Building · 2 checks pending + + + Redeploy + +
+ + + step 1000 on background-100 {wcagContrast(primary, BG).toFixed(2)}:1 · + Lc {apcaLc(primary, BG).toFixed(0)} — step 900{' '} + {wcagContrast(secondary, BG).toFixed(2)}:1 · Lc{' '} + {apcaLc(secondary, BG).toFixed(0)} + +
+ + ) +} diff --git a/src/components/playgrounds/hct-chroma-quirk.tsx b/src/components/playgrounds/hct-chroma-quirk.tsx new file mode 100644 index 0000000..4465088 --- /dev/null +++ b/src/components/playgrounds/hct-chroma-quirk.tsx @@ -0,0 +1,158 @@ +import { clampChroma, converter, formatHex } from 'culori' + +import { Demo } from '@/components/demo' + +const toLab = converter('lab') + +// Solve an OKLCH color whose CIE L* equals the tone, then read the chroma that +// actually survived the gamut clamp. The point: the chroma ceiling moves with +// hue AND tone — HCT's documented quirk, and it falls straight out of OKLCH. +function toneColor(hue: number, chroma: number, tone: number) { + let lo = 0 + let hi = 1 + for (let i = 0; i < 22; i++) { + const mid = (lo + hi) / 2 + const c = clampChroma( + { mode: 'oklch' as const, l: mid, c: chroma, h: hue }, + 'oklch', + ) + if ((toLab(c)?.l ?? 0) < tone) lo = mid + else hi = mid + } + return clampChroma( + { mode: 'oklch' as const, l: (lo + hi) / 2, c: chroma, h: hue }, + 'oklch', + ) +} + +const REQUESTED = 0.2 +const TONES = [10, 20, 30, 40, 50, 60, 70, 80, 90] +const HUES = [ + { name: 'blue', hue: 264, opacity: 0.9 }, + { name: 'yellow', hue: 100, opacity: 0.55 }, +] + +const W = 520 +const H = 190 +const PAD = { left: 44, right: 60, top: 12, bottom: 26 } +const CMAX = 0.26 +const px = (tone: number) => + PAD.left + (tone / 100) * (W - PAD.left - PAD.right) +const py = (c: number) => PAD.top + (1 - c / CMAX) * (H - PAD.top - PAD.bottom) + +export function HctChromaQuirk() { + const series = HUES.map((h) => { + const points = TONES.map((tone) => { + const col = toneColor(h.hue, REQUESTED, tone) + return { tone, c: col.c ?? 0, hex: formatHex(col) } + }) + const path = points + .map( + (p, i) => + `${i === 0 ? 'M' : 'L'}${px(p.tone).toFixed(1)},${py(p.c).toFixed(1)}`, + ) + .join(' ') + return { ...h, points, path } + }) + + return ( + + Ask both hues for chroma {REQUESTED} at every Tone; plot what the + gamut actually delivers. Blue peaks in the midtones and collapses + toward the light end; yellow climbs the whole way, because its ceiling + is highest exactly where blue’s is lowest — “chroma + has a different maximum for any given hue and tone.” Computed in + OKLCH with clampChroma, no CAM16: + the one truth HCT’s quirk teaches, OKLCH already knows. + + } + > + + + + + {series.map((s) => ( + + + {s.points.map((p) => ( + + ))} + + {s.name} + + + ))} + + + 0 + + + 100 + + + Tone + + + .20 + + + 0 + + + + + ) +} diff --git a/src/components/playgrounds/hover-on-two-surfaces.tsx b/src/components/playgrounds/hover-on-two-surfaces.tsx new file mode 100644 index 0000000..eba009c --- /dev/null +++ b/src/components/playgrounds/hover-on-two-surfaces.tsx @@ -0,0 +1,144 @@ +import { converter, differenceEuclidean, formatHex } from 'culori' + +import { Demo } from '@/components/demo' + +const toOklch = converter('oklch') +const toRgb = converter('rgb') +const dEok = differenceEuclidean('oklab') + +// CSS composites alpha in gamma-encoded sRGB — match it channel-for-channel. +function over(fg: string, bg: string, a: number): string { + const f = toRgb(fg) + const b = toRgb(bg) + if (!f || !b) return bg + return ( + formatHex({ + mode: 'rgb', + r: f.r * a + b.r * (1 - a), + g: f.g * a + b.g * (1 - a), + b: f.b * a + b.b * (1 - a), + }) ?? bg + ) +} + +const CARD = '#f6f5f1' // neutral-100 — the surface the solid hover was tuned on +const PANEL = '#b0dcff' // accent-200 — a colored callout it was never tuned for +const SOLID = '#ecebe7' // neutral-200 — the designed hover step +const TEXT = '#1d1d1a' // neutral-800 +const OPACITY = 0.08 // Material 3 hover state-layer opacity + +const chroma = (hex: string) => (toOklch(hex)?.c ?? 0).toFixed(3) + +function Item({ surface, fill }: { surface: string; fill: string }) { + return ( +
+ + Rename + + + Duplicate — hover + +
+ ) +} + +export function HoverOnTwoSurfaces() { + const alphaCard = over('#000000', CARD, OPACITY) + const alphaPanel = over('#000000', PANEL, OPACITY) + + const rows = [ + { + mech: 'solid step', + card: SOLID, + panel: SOLID, + good: false, + note: 'gray on blue', + }, + { + mech: 'alpha overlay', + card: alphaCard, + panel: alphaPanel, + good: true, + note: 'stays blue', + }, + ] + + return ( + + The hover is tuned to look right on the card, then dropped on a tinted + panel. The solid step is one + frozen hex ({SOLID}, chroma {chroma(SOLID)}) — a whisper on the card + (ΔEok {dEok(CARD, SOLID).toFixed(3)}) and a gray smudge on the panel + (ΔEok {dEok(PANEL, SOLID).toFixed(3)}), because it has no term for + what it covers. The alpha overlay{' '} + is one token — black at {OPACITY * 100}% — that recomputes per surface + ({alphaPanel}, chroma {chroma(alphaPanel)}): it keeps the + panel’s blue and holds a consistent delta (ΔEok{' '} + {dEok(CARD, alphaCard).toFixed(3)} on the card,{' '} + {dEok(PANEL, alphaPanel).toFixed(3)} on the panel). One overlay + composes over anything; one solid step only fits the surface it was + tuned against. + + } + > +
+
+
+ Solid step — one hex +
+ + +
+
+
+ + Alpha overlay — one token + +
+ + +
+
+
+
+ + + + + + + + + + {rows.map((r) => ( + + + + + + ))} + +
hover fillon cardon tinted panel
{r.mech} + {r.card} · C {chroma(r.card)} + + {r.panel} · C {chroma(r.panel)} {r.good ? '✓' : '✕'}{' '} + {r.note} +
+
+
+
+ ) +} diff --git a/src/components/playgrounds/light-dark-oneliner.tsx b/src/components/playgrounds/light-dark-oneliner.tsx new file mode 100644 index 0000000..97f661e --- /dev/null +++ b/src/components/playgrounds/light-dark-oneliner.tsx @@ -0,0 +1,89 @@ +import { useState } from 'react' +import { wcagContrast } from 'culori' + +import { Demo } from '@/components/demo' +import { ToggleButton } from '@/ui/toggle-button' +import { ToggleButtonGroup } from '@/ui/toggle-button-group' + +type Mode = 'light' | 'dark' + +// Radix slate 1/12 and slateDark 1/12, verified against light.ts / dark.ts. +const BG: Record = { light: '#fcfcfd', dark: '#111113' } +const FG: Record = { light: '#1c2024', dark: '#edeef0' } + +export function LightDarkOneliner() { + const [mode, setMode] = useState('light') + const bg = BG[mode] + const fg = FG[mode] + + return ( + + Same result, two ways to say it: one name carrying both values, or two + rules. light-dark() needs{' '} + color-scheme: light dark on the + root to resolve — without it the function does nothing. It has + been Baseline newly available only since May 2024. + + } + > +
+ { + const next = [...keys][0] + if (next === 'light' || next === 'dark') setMode(next) + }} + size="sm" + aria-label="Active color scheme" + className="max-w-full overflow-x-auto" + > + Light + Dark + + +
+
+ one name, two values +
+
{`:root {
+  color-scheme: light dark;
+  --bg: light-dark(#fcfcfd, #111113);
+  --fg: light-dark(#1c2024, #edeef0);
+}`}
+
+
+
+ + a name and an override + +
+
{`:root { --bg: #fcfcfd; --fg: #1c2024; }
+.dark { --bg: #111113; --fg: #edeef0; }`}
+
+
+
+ +
+ + Quarterly review + + + {mode} · fg on bg {wcagContrast(fg, bg).toFixed(2)}:1 (both + approaches resolve to this) + +
+
+
+ ) +} diff --git a/src/components/playgrounds/meter-split-ramps.tsx b/src/components/playgrounds/meter-split-ramps.tsx new file mode 100644 index 0000000..f20a489 --- /dev/null +++ b/src/components/playgrounds/meter-split-ramps.tsx @@ -0,0 +1,138 @@ +import { useState } from 'react' +import { clampChroma, converter, formatHex, wcagContrast } from 'culori' + +import { apcaLc } from '@/lib/apca' +import { cn } from '@/lib/utils' +import { Demo } from '@/components/demo' +import { ToggleButton } from '@/ui/toggle-button' +import { ToggleButtonGroup } from '@/ui/toggle-button-group' + +type Bg = 'light' | 'dark' +type Meter = 'wcag' | 'apca' + +const toOklch = converter('oklch') +const BG: Record = { light: '#f8f8f8', dark: '#151517' } +const HUE = 255 +const CHROMA = 0.14 + +// Body-text bar per chapter 8: WCAG 4.5:1 vs APCA Lc 75 (the 18px-regular minimum). +const APCA_BODY = 75 + +// Solve for the body-text step two ways: WCAG 4.5 target vs APCA Lc 75 target. +function solve(bgHex: string, target: number, meter: Meter) { + const bgL = toOklch(bgHex)?.l ?? 1 + const wantDarker = bgL > 0.5 + let best: { hex: string; w: number; a: number; c: number } | null = null + let extreme: { hex: string; w: number; a: number; c: number } | null = null + const N = 240 + for (let i = 0; i <= N; i++) { + const l = i / N + if (wantDarker && l > bgL) continue + if (!wantDarker && l < bgL) continue + const hex = formatHex( + clampChroma({ mode: 'oklch' as const, l, c: CHROMA, h: HUE }, 'oklch'), + ) + const w = wcagContrast(hex, bgHex) + const a = Math.abs(apcaLc(hex, bgHex)) + const metric = meter === 'wcag' ? w : a + if (extreme === null || metric > extreme.c) + extreme = { hex, w, a, c: metric } + if (metric >= target && (best === null || metric < best.c)) + best = { hex, w, a, c: metric } + } + return best ?? extreme ?? { hex: bgHex, w: 1, a: 0, c: 0 } +} + +export function MeterSplitRamps() { + const [bg, setBg] = useState('dark') + const bgHex = BG[bg] + + const byWcag = solve(bgHex, 4.5, 'wcag') + const byApca = solve(bgHex, APCA_BODY, 'apca') + + const cards = [ + { title: 'Targeted WCAG 4.5:1', pick: byWcag }, + { title: 'Targeted APCA Lc 75', pick: byApca }, + ] + + return ( + + One body-text step, solved two ways against the same surface. + Targeting the WCAG ratio and targeting APCA Lc pick{' '} + different colors — and on dark the gap is widest, + because WCAG’s flare term over-credits dark backgrounds (chapter + 8). The meter you target is the flaw you bake in: the WCAG pick clears + its 4.5:1 ratio and still falls short of the Lc 75 body-text bar. + + } + > +
+ { + const next = [...keys][0] + if (next === 'light' || next === 'dark') setBg(next) + }} + size="sm" + aria-label="Background" + className="max-w-full overflow-x-auto" + > + Light surface + Dark surface + + +
+ {cards.map((card) => { + const passW = card.pick.w >= 4.5 + const passA = card.pick.a >= APCA_BODY + return ( +
+
+
+ + Body text sample + +
+
+ {card.title} + + WCAG {card.pick.w.toFixed(2)}:1 {passW ? '✓' : '✕'} + + + APCA Lc {card.pick.a.toFixed(0)} {passA ? '✓' : '⚠'} + +
+
+ ) + })} +
+ + + {bg === 'dark' + ? 'On dark, the WCAG-targeted step passes 4.5:1 yet lands far below Lc 75 — certified and thin.' + : 'On light the gap is modest; against the dark surface the two picks diverge sharply.'} + +
+ + ) +} diff --git a/src/components/playgrounds/mix-vs-ramp.tsx b/src/components/playgrounds/mix-vs-ramp.tsx new file mode 100644 index 0000000..815f8ed --- /dev/null +++ b/src/components/playgrounds/mix-vs-ramp.tsx @@ -0,0 +1,177 @@ +import { useState } from 'react' +import { converter, differenceEuclidean, formatHex, interpolate } from 'culori' + +import { Demo } from '@/components/demo' +import { ToggleButton } from '@/ui/toggle-button' +import { ToggleButtonGroup } from '@/ui/toggle-button-group' + +type Space = 'oklab' | 'oklch' | 'srgb' + +const toOklch = converter('oklch') +const toRgb = converter('rgb') +const dEOK = differenceEuclidean('oklab') + +// dotUI blue ramp (registry base/colors.css): 500 is the accent, 600 the baked +// hover — placed at a target lightness, then gamut-mapped under chapter 6's tent. +const ACCENT = '#4992dd' // blue 500 +const BAKED_HOVER = '#347bc2' // blue 600, the ramp's designed step + +// color-mix names its space as srgb/oklab/oklch; culori's sRGB mode is 'rgb'. +const CULORI: Record = { + oklab: 'oklab', + oklch: 'oklch', + srgb: 'rgb', +} + +// Two surfaces the hover has to survive: a near-white card and a tinted panel. +const SURFACES = [ + { label: 'white card', bg: '#f6f6f7' }, + { label: 'tinted panel', bg: '#e6dcc7' }, +] + +const fmt = (hex: string) => { + const c = toOklch(hex) + if (!c) return '' + return `oklch(${(c.l ?? 0).toFixed(3)} ${(c.c ?? 0).toFixed(3)} ${(c.h ?? 0).toFixed(0)})` +} + +// color-mix(in oklab, accent, transparent 12%) — the accent at 88% alpha, which +// composites over whatever surface is behind it. +const alphaOf = (hex: string, a: number) => { + const c = toRgb(hex) + if (!c) return hex + const to255 = (x: number) => Math.round(Math.min(1, Math.max(0, x)) * 255) + return `rgba(${to255(c.r ?? 0)}, ${to255(c.g ?? 0)}, ${to255(c.b ?? 0)}, ${a})` +} + +export function MixVsRamp() { + const [space, setSpace] = useState('oklab') + const mixed = + formatHex(interpolate([ACCENT, '#000000'], CULORI[space])(0.12)) ?? ACCENT + const drift = dEOK(mixed, BAKED_HOVER) + const alphaHover = alphaOf(ACCENT, 0.88) + + const swatches = [ + { tag: 'accent (500)', hex: ACCENT, note: 'the seed' }, + { tag: 'color-mix hover', hex: mixed, note: `in ${space}, black 12%` }, + { tag: 'baked step 600', hex: BAKED_HOVER, note: 'gamut-mapped' }, + ] + + return ( + + color-mix is interpolation (chapter + 7): a straight line 12% toward black. The ramp’s step 600 was a + lightness pick then gamut-mapped under chapter 6’s tent, so the + mix and the baked rung land at different colors — and switching + the space moves the mix again without ever hitting the baked value. + The lower row is chapter 18’s point: a solid toward-black hover + is surface-blind (identical on both panels), while a + toward-transparent alpha hover composites over whatever it sits on. + + } + > +
+
+ mix in + { + const next = [...keys][0] + if (next === 'oklab' || next === 'oklch' || next === 'srgb') + setSpace(next) + }} + size="sm" + aria-label="Interpolation space" + className="max-w-full overflow-x-auto" + > + oklab + oklch + srgb + +
+ +
+ {swatches.map((s) => ( +
+
+ + {s.tag} + + + {s.hex} · {s.note} + + + {fmt(s.hex)} + +
+ ))} +
+ +
+
{`--accent:       ${ACCENT};\n--accent-hover: color-mix(in ${space}, var(--accent), black 12%);`}
+
+ + + mix vs baked step 600: ΔEOK {drift.toFixed(3)} + {drift > 0.02 + ? ' — past the 0.02 just-noticeable difference; a careful eye sees it.' + : ' — under the 0.02 just-noticeable difference; close enough here.'} + + +
+ + same solid hover vs an alpha hover, on two surfaces + +
+ {SURFACES.map((surf) => ( +
+ + {surf.label} + +
+
+
+ + solid (black 12%) + +
+
+
+ + alpha (transparent 12%) + +
+
+
+ ))} +
+ + solid chip is the same color on both panels; the alpha chip shifts + with the surface — that is the surface-blind vs surface-correct + trade. + +
+
+ + ) +} diff --git a/src/components/playgrounds/mode-alias-table.tsx b/src/components/playgrounds/mode-alias-table.tsx new file mode 100644 index 0000000..382fee6 --- /dev/null +++ b/src/components/playgrounds/mode-alias-table.tsx @@ -0,0 +1,188 @@ +import { useState } from 'react' +import { wcagContrast } from 'culori' + +import { apcaLc } from '@/lib/apca' +import { Demo } from '@/components/demo' +import { ToggleButton } from '@/ui/toggle-button' +import { ToggleButtonGroup } from '@/ui/toggle-button-group' + +type Mode = 'light' | 'dark' | 'contrast' + +// Radix slate / slateDark / blue / blueDark, verified against light.ts / dark.ts. +// The high-contrast column is constructed here with chapter 9's mechanism: +// same slots, values re-picked (border 6 → 9, muted text 11 → 12). +interface Resolved { + primitive: string + value: string +} + +const TOKENS: { name: string; note: string; modes: Record }[] = + [ + { + name: 'bg', + note: 'app background', + modes: { + light: { primitive: 'slate 1', value: '#fcfcfd' }, + dark: { primitive: 'slateDark 1', value: '#111113' }, + contrast: { primitive: 'white', value: '#ffffff' }, + }, + }, + { + name: 'bg-elevated', + note: 'raised surfaces', + modes: { + light: { primitive: 'slate 1', value: '#fcfcfd' }, + dark: { primitive: 'slateDark 3', value: '#212225' }, + contrast: { primitive: 'white', value: '#ffffff' }, + }, + }, + { + name: 'border', + note: 'separators', + modes: { + light: { primitive: 'slate 6', value: '#d9d9e0' }, + dark: { primitive: 'slateDark 6', value: '#363a3f' }, + contrast: { primitive: 'slate 9', value: '#8b8d98' }, + }, + }, + { + name: 'fg-muted', + note: 'secondary text', + modes: { + light: { primitive: 'slate 11', value: '#60646c' }, + dark: { primitive: 'slateDark 11', value: '#b0b4ba' }, + contrast: { primitive: 'slate 12', value: '#1c2024' }, + }, + }, + { + name: 'fg', + note: 'primary text', + modes: { + light: { primitive: 'slate 12', value: '#1c2024' }, + dark: { primitive: 'slateDark 12', value: '#edeef0' }, + contrast: { primitive: 'black', value: '#000000' }, + }, + }, + { + name: 'fg-accent', + note: 'links', + modes: { + light: { primitive: 'blue 11', value: '#0d74ce' }, + dark: { primitive: 'blueDark 11', value: '#70b8ff' }, + contrast: { primitive: 'blue 12', value: '#113264' }, + }, + }, + ] + +const get = (name: string, mode: Mode) => + TOKENS.find((t) => t.name === name)?.modes[mode].value ?? '#000000' + +export function ModeAliasTable() { + const [mode, setMode] = useState('light') + + const bg = get('bg', mode) + const elevated = get('bg-elevated', mode) + const border = get('border', mode) + const muted = get('fg-muted', mode) + const fg = get('fg', mode) + const accent = get('fg-accent', mode) + + return ( + + One column of names, three columns of values. The component consumes{' '} + fg-muted and never learns which + room it’s in; the theme re-points every slot per mode. Light and + dark are Radix’s shipped scales; the high-contrast column is + constructed here with chapter 9’s mechanism — same slots, + stronger values. + + } + > +
+ { + const next = [...keys][0] + if (next === 'light' || next === 'dark' || next === 'contrast') + setMode(next) + }} + size="sm" + aria-label="Theme mode" + className="max-w-full overflow-x-auto" + > + Light + Dark + High contrast + + +
+
+ + Quarterly review + + + Sarah · 2h ago + + + Open thread + +
+
+ +
+ + + + + + + + + + {TOKENS.map((t) => { + const r = t.modes[mode] + return ( + + + + + + + ) + })} + +
tokenresolves tovalue +
{t.name}{r.primitive}{r.value} + +
+
+ + + fg-muted on bg {wcagContrast(muted, bg).toFixed(2)}:1 · Lc{' '} + {apcaLc(muted, bg).toFixed(1)} — fg-accent on bg{' '} + {wcagContrast(accent, bg).toFixed(2)}:1 · Lc{' '} + {apcaLc(accent, bg).toFixed(1)} + +
+
+ ) +} diff --git a/src/components/playgrounds/numeric-vs-semantic.tsx b/src/components/playgrounds/numeric-vs-semantic.tsx new file mode 100644 index 0000000..81b62df --- /dev/null +++ b/src/components/playgrounds/numeric-vs-semantic.tsx @@ -0,0 +1,135 @@ +import { useState } from 'react' + +import { Demo } from '@/components/demo' +import { ToggleButton } from '@/ui/toggle-button' +import { ToggleButtonGroup } from '@/ui/toggle-button-group' + +// dotUI accent/success primitives (registry base/colors.css), oklch → hex via culori. +const BLUE = { 100: '#d0edff', 700: '#1a5c9b', 800: '#0f487b' } +const GREEN = { 100: '#b0ffbf', 700: '#00711e', 800: '#005912' } + +type Brand = 'blue' | 'green' + +function MiniCard({ brand }: { brand: Brand }) { + const c = brand === 'blue' ? BLUE : GREEN + return ( +
+
+ Invoices + + 4 due + +
+ + Next payout on Friday + +
+ + Review + + + See history + +
+
+ ) +} + +function DiffLine({ from, to }: { from: string; to: string }) { + return ( +
+ − {from} + + {to} +
+ ) +} + +export function NumericVsSemantic() { + const [brand, setBrand] = useState('blue') + const green = brand === 'green' + + return ( + + The same rebrand in two dialects. Numeric names pin the hue into every + call site — the rebrand is a find-and-replace across the + codebase, four edits in this one card. Semantic names moved the + decision into the theme — one alias re-points and every consumer + follows, untouched. + + } + > +
+
+ Brand + { + const next = [...keys][0] + if (next === 'blue' || next === 'green') setBrand(next) + }} + size="sm" + aria-label="Brand color" + className="max-w-full overflow-x-auto" + > + Blue + Rebrand to green + +
+ +
+
+ + Numeric dialect — bg-blue-700 + + +
+ {green ? ( + <> + + + + + + ) : ( + + 4 edits waiting, in this card alone + + )} +
+
+
+ + Semantic dialect — bg-accent + + +
+ {green ? ( + <> + theme.css, one line + + + ) : ( + + 1 edit waiting, in the theme + + )} +
+
+
+
+
+ ) +} diff --git a/src/components/playgrounds/okabe-ito-set.tsx b/src/components/playgrounds/okabe-ito-set.tsx new file mode 100644 index 0000000..e4caac0 --- /dev/null +++ b/src/components/playgrounds/okabe-ito-set.tsx @@ -0,0 +1,65 @@ +import { wcagContrast } from 'culori' + +import { cn } from '@/lib/utils' +import { Demo } from '@/components/demo' + +// Okabe-Ito Color Universal Design palette — 8 colors, CVD-safe, distinct in +// luminance so it survives grayscale. jfly.uni-koeln.de/color (Bang Wong +// reprised it in Nature Methods, 2011). +const OKABE = [ + { hex: '#E69F00', name: 'Orange' }, + { hex: '#56B4E9', name: 'Sky blue' }, + { hex: '#009E73', name: 'Bluish green' }, + { hex: '#F0E442', name: 'Yellow' }, + { hex: '#0072B2', name: 'Blue' }, + { hex: '#D55E00', name: 'Vermillion' }, + { hex: '#CC79A7', name: 'Reddish purple' }, + { hex: '#000000', name: 'Black' }, +] + +export function OkabeItoSet() { + return ( + + Eight colors, designed CVD-first — and the count is the ceiling, + not a stopping point. Note Yellow’s{' '} + + {wcagContrast('#F0E442', '#ffffff').toFixed(2)}:1 + {' '} + against white: even a vetted set has a mark you must never put on a + pale background. Contrast shown vs white. + + } + > +
+ {OKABE.map((c) => { + const cr = wcagContrast(c.hex, '#ffffff') + return ( +
+
+ {c.name} + + {c.hex} + + = 3 ? 'text-fg-muted' : 'text-fg-warning', + )} + > + {cr.toFixed(2)}:1 {cr >= 3 ? '' : '⚠ pale-bg'} + +
+ ) + })} +
+ + ) +} diff --git a/src/components/playgrounds/p3-fallback.tsx b/src/components/playgrounds/p3-fallback.tsx new file mode 100644 index 0000000..db2ba1b --- /dev/null +++ b/src/components/playgrounds/p3-fallback.tsx @@ -0,0 +1,121 @@ +import { useState } from 'react' +import { clampChroma, converter, formatHex } from 'culori' + +import { Demo } from '@/components/demo' +import { ToggleButton } from '@/ui/toggle-button' +import { ToggleButtonGroup } from '@/ui/toggle-button-group' + +type HueKey = 'green' | 'red' | 'orange' + +const toOklch = converter('oklch') + +// P3-space primaries that sit past the sRGB edge (chapter 6's headroom). The P3 +// swatch renders a real color(display-p3 …): on a wide-gamut display it pops; +// elsewhere the browser keeps the sRGB fallback. +const TARGETS: Record = { + green: { label: 'Green', p3: 'color(display-p3 0 0.9 0.35)' }, + red: { label: 'Red', p3: 'color(display-p3 1 0 0)' }, + orange: { label: 'Orange', p3: 'color(display-p3 1 0.5 0)' }, +} + +const chromaOf = (color: string) => toOklch(color)?.c ?? 0 + +export function P3Fallback() { + const [hue, setHue] = useState('green') + const target = TARGETS[hue].p3 + // Pass the string straight into clampChroma so it hits the (color: string) + // overload — gamut-maps the P3 target back under the sRGB tent for the fallback. + const fallback = formatHex(clampChroma(target, 'oklch')) ?? '#000000' + const cP3 = chromaOf(target) + const cSRGB = chromaOf(fallback) + const headroom = + cP3 > 0 && cSRGB > 0 ? (((cP3 - cSRGB) / cSRGB) * 100).toFixed(0) : '0' + + return ( + + Declare sRGB first, override for P3 — the last valid declaration + wins, so a browser that can’t parse the P3 line keeps the + fallback. The headroom is chapter 6’s tent, and it changes with + hue. Two guards, not one:{' '} + @media (color-gamut: p3) tests the + display, @supports tests the + syntax. + + } + > +
+
+ hue + { + const next = [...keys][0] + if (typeof next === 'string' && next in TARGETS) + setHue(next as HueKey) + }} + size="sm" + aria-label="Hue" + className="max-w-full overflow-x-auto" + > + {(Object.keys(TARGETS) as HueKey[]).map((k) => ( + + {TARGETS[k].label} + + ))} + +
+ +
+
+
+ + sRGB fallback · {fallback} + + + C {cSRGB.toFixed(3)} + +
+
+
+ + P3 upgrade · {target} + + + C {cP3.toFixed(3)} · +{headroom}% chroma + +
+
+ +
+
{`.brand { background: ${fallback}; }              /* sRGB, always */
+
+@supports (color: color(display-p3 1 0 0)) {   /* syntax test */
+  @media (color-gamut: p3) {                    /* display test */
+    .brand { background: ${target}; }
+  }
+}`}
+
+ +

+ There is no{' '} + @supports (color-gamut: p3) —{' '} + @supports tests properties and + values, not media features, so that block never applies. +

+
+ + ) +} diff --git a/src/components/playgrounds/pairing-flip.tsx b/src/components/playgrounds/pairing-flip.tsx new file mode 100644 index 0000000..d1bb144 --- /dev/null +++ b/src/components/playgrounds/pairing-flip.tsx @@ -0,0 +1,116 @@ +import { useState } from 'react' +import { wcagContrast } from 'culori' + +import { apcaLc } from '@/lib/apca' +import { cn } from '@/lib/utils' +import { Demo } from '@/components/demo' +import { Slider, SliderControl } from '@/ui/slider' + +// dotUI accent primitives (registry base/colors.css), oklch → hex via culori. +const STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] +const BLUE = [ + '#e8faff', + '#d0edff', + '#b0dcff', + '#8fcbff', + '#69b0fb', + '#4992dd', + '#347bc2', + '#1a5c9b', + '#0f487b', + '#0a345b', + '#05203a', +] + +function Candidate({ bg, label }: { bg: string; label: string }) { + return ( + + Deploy + + ) +} + +export function PairingFlip() { + const [idx, setIdx] = useState(5) + + const bg = BLUE[idx] ?? '#4992dd' + const step = STEPS[idx] ?? 500 + const w = wcagContrast('#ffffff', bg) + const b = wcagContrast('#000000', bg) + const lw = Math.abs(apcaLc('#ffffff', bg)) + const lb = Math.abs(apcaLc('#000000', bg)) + const wcagPick = b > w ? 'black' : 'white' + const apcaPick = lb > lw ? 'black' : 'white' + const disagree = wcagPick !== apcaPick + + return ( + + Two candidate partners for one surface token, judged by both of + chapter 8’s meters. Through most of the ramp they agree; on this + blue they split at steps 500–600 — exactly where solid + buttons live. A pairing promise that doesn’t name its meter is + undefined right where it matters. + + } + > +
+
+ + Surface step + + setIdx(v as number)} + minValue={0} + maxValue={10} + step={1} + className="flex-1" + > + + + + {step} + +
+ +
+ + + + {bg} + +
+ +
+ + WCAG picks {wcagPick} — white {w.toFixed(2)}:1 · black{' '} + {b.toFixed(2)}:1 + + + APCA picks {apcaPick} — white |Lc| {lw.toFixed(1)} · black |Lc|{' '} + {lb.toFixed(1)} + + + {disagree + ? 'The meters disagree here. A generator that picks by WCAG alone ships the black label.' + : 'Both meters agree here.'} + +
+
+
+ ) +} diff --git a/src/components/playgrounds/radix-alpha-surfaces.tsx b/src/components/playgrounds/radix-alpha-surfaces.tsx new file mode 100644 index 0000000..b75d7db --- /dev/null +++ b/src/components/playgrounds/radix-alpha-surfaces.tsx @@ -0,0 +1,94 @@ +import { converter, formatHex, wcagContrast } from 'culori' + +import { Demo } from '@/components/demo' +const toRgb = converter('rgb') +// Source-over compositing: what the eye actually sees for a translucent step. +function over(fg: string, bg: string): string { + const f = toRgb(fg) + const b = toRgb(bg) + if (!f || !b) return bg + const a = f.alpha ?? 1 + return formatHex({ + mode: 'rgb', + r: f.r * a + b.r * (1 - a), + g: f.g * a + b.g * (1 - a), + b: f.b * a + b.b * (1 - a), + }) +} +const labelText = (hex: string) => + wcagContrast('#000000', hex) >= wcagContrast('#ffffff', hex) + ? '#000000' + : '#ffffff' +// radix-ui/colors src/light.ts — blue8 (solid) and blueA8 (alpha, α 0.63). +const SOLID = '#5eb1ef' +const ALPHA = '#0084e6a1' +const SURFACES = [ + { name: 'white', bg: '#ffffff' }, + { name: 'gray', bg: '#e0e1e6' }, + { name: 'near-black', bg: '#111113' }, +] +export function RadixAlphaSurfaces() { + return ( + + blue8 as a solid vs blueA8 (α 0.63) over three surfaces. The + solid is tuned for white — 2.33:1 there — and blows to + 8.09:1 on near-black: a harsh line, not a subtle edge. The alpha tints + whatever it’s over and holds 2.0–2.6:1 everywhere; over + white it composites to the solid step exactly. Chapter 18’s + compositing primitive, generated for every step. + + } + > +
+ {SURFACES.map((s) => { + const comp = over(ALPHA, s.bg) + const solidC = wcagContrast(SOLID, s.bg) + const alphaC = wcagContrast(comp, s.bg) + const fg = labelText(s.bg) + return ( +
+ + {s.name} {s.bg} + +
+
+
+ + solid {solidC.toFixed(2)}:1 + +
+
+
+ + alpha {alphaC.toFixed(2)}:1 + +
+
+
+ ) + })} +
+ + ) +} diff --git a/src/components/playgrounds/radix-job-bands.tsx b/src/components/playgrounds/radix-job-bands.tsx new file mode 100644 index 0000000..d3c76b7 --- /dev/null +++ b/src/components/playgrounds/radix-job-bands.tsx @@ -0,0 +1,96 @@ +import { Demo } from '@/components/demo' +// Radix blue (light), verified against radix-ui/colors src/light.ts. +// Roles are Radix's own, verbatim from "Understanding the scale". +const BLUE = [ + '#fbfdff', + '#f4faff', + '#e6f4fe', + '#d5efff', + '#c2e5ff', + '#acd8fc', + '#8ec8f6', + '#5eb1ef', + '#0090ff', + '#0588f0', + '#0d74ce', + '#113264', +] +const BANDS: { title: string; steps: number[]; roles: string[] }[] = [ + { + title: 'Backgrounds', + steps: [1, 2], + roles: ['App background', 'Subtle background'], + }, + { + title: 'Component backgrounds', + steps: [3, 4, 5], + roles: [ + 'UI element background', + 'Hovered UI element background', + 'Active / selected UI element background', + ], + }, + { + title: 'Borders', + steps: [6, 7, 8], + roles: [ + 'Subtle borders and separators', + 'UI element border and focus ring', + 'Hovered UI element border', + ], + }, + { + title: 'Solids', + steps: [9, 10], + roles: ['Solid background', 'Hovered solid background'], + }, + { + title: 'Text', + steps: [11, 12], + roles: ['Low-contrast text', 'High-contrast text'], + }, +] +export function RadixJobBands() { + return ( + + One scale, twelve jobs — chapter 10’s “steps are + jobs” is not a metaphor here, it is Radix’s literal + documentation, and dotUI’s job list is a rename of this column. + A step number never means “how blue”; it means{' '} + which contract. + + } + > +
+ {BANDS.map((band) => ( +
+ + {band.title} + +
+ {band.steps.map((step, j) => ( +
+ + + {step} + + + {band.roles[j]} + + + {BLUE[step - 1]} + +
+ ))} +
+
+ ))} +
+
+ ) +} diff --git a/src/components/playgrounds/radix-light-dark-curves.tsx b/src/components/playgrounds/radix-light-dark-curves.tsx new file mode 100644 index 0000000..be5c0d0 --- /dev/null +++ b/src/components/playgrounds/radix-light-dark-curves.tsx @@ -0,0 +1,152 @@ +import { converter } from 'culori' + +import { Demo } from '@/components/demo' +const toOklch = converter('oklch') +// radix-ui/colors src/light.ts + src/dark.ts (blue / blueDark), verified at HEAD. +const LIGHT = [ + '#fbfdff', + '#f4faff', + '#e6f4fe', + '#d5efff', + '#c2e5ff', + '#acd8fc', + '#8ec8f6', + '#5eb1ef', + '#0090ff', + '#0588f0', + '#0d74ce', + '#113264', +] +const DARK = [ + '#0d1520', + '#111927', + '#0d2847', + '#003362', + '#004074', + '#104d87', + '#205d9e', + '#2870bd', + '#0090ff', + '#3b9eff', + '#70b8ff', + '#c2e6ff', +] +const LL = LIGHT.map((h) => toOklch(h)?.l ?? 0) +const DL = DARK.map((h) => toOklch(h)?.l ?? 0) +const FLIP = LL.map((l) => 1 - l) +const W = 560 +const H = 200 +const PAD = { left: 34, right: 12, top: 12, bottom: 26 } +const px = (i: number) => PAD.left + (i / 11) * (W - PAD.left - PAD.right) +const py = (l: number) => PAD.top + (1 - l) * (H - PAD.top - PAD.bottom) +const linePath = (ls: number[]) => + ls + .map( + (l, i) => `${i === 0 ? 'M' : 'L'}${px(i).toFixed(1)},${py(l).toFixed(1)}`, + ) + .join(' ') +export function RadixLightDarkCurves() { + return ( + + OKLCH lightness per step, Radix blue. The dashed line is what a flip + would produce — each dark step at 1 minus its light lightness. + The shipped dark curve ignores it: the quiet end sits at L 0.19, not + 0.007, and its steps are nearly 3× wider. Dark is a second + hand-tuning, not an inversion (chapter 16). + + } + > + + + + + + + {LIGHT.map((hex, i) => ( + + ))} + {DARK.map((hex, i) => ( + + ))} + + + 1 + + + 12 + + + 1 + + + 0 + + + +
+ — light L + — dark L (fainter) + ---- flip of light (1 − L) +
+
+ ) +} diff --git a/src/components/playgrounds/radix-scale-reader.tsx b/src/components/playgrounds/radix-scale-reader.tsx new file mode 100644 index 0000000..870071e --- /dev/null +++ b/src/components/playgrounds/radix-scale-reader.tsx @@ -0,0 +1,287 @@ +import { useState } from 'react' +import { converter, wcagContrast } from 'culori' + +import { apcaLc } from '@/lib/apca' +import { cn } from '@/lib/utils' +import { Playground } from '@/components/playground' +import { ToggleButton } from '@/ui/toggle-button' +import { ToggleButtonGroup } from '@/ui/toggle-button-group' +const toOklch = converter('oklch') +// radix-ui/colors src/light.ts + src/dark.ts (blue / blueDark), verified at HEAD. +const LIGHT = [ + '#fbfdff', + '#f4faff', + '#e6f4fe', + '#d5efff', + '#c2e5ff', + '#acd8fc', + '#8ec8f6', + '#5eb1ef', + '#0090ff', + '#0588f0', + '#0d74ce', + '#113264', +] +const DARK = [ + '#0d1520', + '#111927', + '#0d2847', + '#003362', + '#004074', + '#104d87', + '#205d9e', + '#2870bd', + '#0090ff', + '#3b9eff', + '#70b8ff', + '#c2e6ff', +] +// Alpha twins (RRGGBBAA) — blueA / blueDarkA. +const LIGHT_A = [ + '#0080ff04', + '#008cff0b', + '#008ff519', + '#009eff2a', + '#0093ff3d', + '#0088f653', + '#0083eb71', + '#0084e6a1', + '#0090ff', + '#0086f0fa', + '#006dcbf2', + '#002359ee', +] +const DARK_A = [ + '#004df211', + '#1166fb18', + '#0077ff3a', + '#0075ff57', + '#0081fd6b', + '#0f89fd7f', + '#2a91fe98', + '#3094feb9', + '#0090ff', + '#3b9eff', + '#70b8ff', + '#c2e6ff', +] +const JOBS = [ + 'App background', + 'Subtle background', + 'UI element background', + 'Hovered UI element background', + 'Active / selected UI element', + 'Subtle border & separator', + 'UI element border & focus ring', + 'Hovered UI element border', + 'Solid background', + 'Hovered solid background', + 'Low-contrast text', + 'High-contrast text', +] +type Mode = 'light' | 'dark' +type View = 'solid' | 'alpha' +const at = (a: readonly string[], i: number) => a[i] ?? '#000000' +const labelText = (hex: string) => + wcagContrast('#000000', hex) >= wcagContrast('#ffffff', hex) + ? '#000000' + : '#ffffff' +const bandLabel = (i: number) => + i <= 1 + ? 'Backgrounds' + : i <= 4 + ? 'Component backgrounds' + : i <= 7 + ? 'Borders' + : i <= 9 + ? 'Solids' + : 'Text' +interface Line { + text: string + tone: 'muted' | 'ok' | 'warn' +} +function contract(idx: number, scale: readonly string[]): Line { + const s2 = at(scale, 1) + if (idx <= 1) + return { + text: 'Carries the room — a background job, spacing not a contrast promise.', + tone: 'muted', + } + if (idx <= 4) { + const fg = at(scale, 10) + const r = wcagContrast(fg, at(scale, idx)) + const lc = Math.abs(apcaLc(fg, at(scale, idx))) + return { + text: `Component surface. Low-contrast text (step 11) sits here: ${r.toFixed(2)}:1 · Lc ${lc.toFixed(1)}.`, + tone: 'muted', + } + } + if (idx <= 7) { + const r = wcagContrast(at(scale, idx), s2) + return { + text: `Border. Against step 2: ${r.toFixed(2)}:1 — eye-tuned to read as an edge, below the 3:1 non-text floor.`, + tone: r >= 3 ? 'ok' : 'warn', + } + } + if (idx <= 9) { + const wl = wcagContrast('#ffffff', at(scale, idx)) + const bl = wcagContrast('#000000', at(scale, idx)) + const wlc = Math.abs(apcaLc('#ffffff', at(scale, idx))) + const blc = Math.abs(apcaLc('#000000', at(scale, idx))) + return { + text: `Solid label — white ${wl.toFixed(2)}:1 / Lc ${wlc.toFixed(1)}, black ${bl.toFixed(2)}:1 / Lc ${blc.toFixed(1)}. The meters split; Radix ships white.`, + tone: 'warn', + } + } + const r = wcagContrast(at(scale, idx), s2) + const lc = Math.abs(apcaLc(at(scale, idx), s2)) + const target = idx === 10 ? 60 : 90 + return { + text: `Text on step 2: ${r.toFixed(2)}:1 · Lc ${lc.toFixed(1)} — Radix's APCA target here is Lc ${target}.`, + tone: 'ok', + } +} +export function RadixScaleReader() { + const [mode, setMode] = useState('light') + const [view, setView] = useState('solid') + const [sel, setSel] = useState(8) + const scale = mode === 'light' ? LIGHT : DARK + const alpha = mode === 'light' ? LIGHT_A : DARK_A + const selHex = at(scale, sel) + const selAlpha = at(alpha, sel) + const ok = toOklch(selHex) + const line = contract(sel, scale) + const lightL = toOklch(at(LIGHT, sel))?.l ?? 0 + const darkL = toOklch(at(DARK, sel))?.l ?? 0 + const flip = 1 - lightL + const off = darkL - flip + const toneClass = + line.tone === 'ok' + ? 'text-fg-success' + : line.tone === 'warn' + ? 'text-fg-warning' + : 'text-fg-muted' + const stripStyle = + view === 'alpha' + ? { + backgroundColor: mode === 'dark' ? '#111113' : '#ffffff', + backgroundImage: + 'linear-gradient(45deg,#80808026 25%,transparent 25%,transparent 75%,#80808026 75%),linear-gradient(45deg,#80808026 25%,transparent 25%,transparent 75%,#80808026 75%)', + backgroundSize: '14px 14px', + backgroundPosition: '0 0,7px 7px', + } + : undefined + return ( + { + setMode('light') + setView('solid') + setSel(8) + }} + > +
+
+
+ theme + { + const n = [...keys][0] + if (n === 'light' || n === 'dark') setMode(n) + }} + size="sm" + aria-label="Theme mode" + className="max-w-full overflow-x-auto" + > + Light + Dark + +
+
+ variant + { + const n = [...keys][0] + if (n === 'solid' || n === 'alpha') setView(n) + }} + size="sm" + aria-label="Scale variant" + className="max-w-full overflow-x-auto" + > + Solid + Alpha + +
+
+
+
+ {JOBS.map((job, i) => ( + + ))} +
+
+
+
+ + step {sel + 1} · {bandLabel(sel)} + + {JOBS[sel] ?? ''} +
+
+ + + {selHex} + + + oklch({(ok?.l ?? 0).toFixed(3)} {(ok?.c ?? 0).toFixed(3)}{' '} + {(ok?.h ?? 0).toFixed(1)}) + + {view === 'alpha' && ( + alpha: {selAlpha} + )} +
+

{line.text}

+

+ flip check — light L {lightL.toFixed(3)} · dark L {darkL.toFixed(3)}{' '} + · a mirror would put dark at {flip.toFixed(3)} (off by{' '} + {off >= 0 ? '+' : ''} + {off.toFixed(3)}) +

+
+
+
+ ) +} diff --git a/src/components/playgrounds/radix-step-nine.tsx b/src/components/playgrounds/radix-step-nine.tsx new file mode 100644 index 0000000..6425adb --- /dev/null +++ b/src/components/playgrounds/radix-step-nine.tsx @@ -0,0 +1,127 @@ +import { wcagContrast } from 'culori' + +import { apcaLc } from '@/lib/apca' +import { cn } from '@/lib/utils' +import { Demo } from '@/components/demo' +// Verified against radix-ui/colors: blue9 is byte-identical in light.ts and +// dark.ts (#0090ff). Grays are not — slate9 differs across the two files. +const BLUE9 = '#0090ff' +const SLATE9_LIGHT = '#8b8d98' +const SLATE9_DARK = '#696e77' +function Meter({ fg, label }: { fg: string; label: string }) { + const w = wcagContrast(fg, BLUE9) + const lc = apcaLc(fg, BLUE9) + const wPass = w >= 4.5 + const aPass = Math.abs(lc) >= 60 + return ( +
+ + {label} label + + + + WCAG {w.toFixed(2)}:1 {wPass ? '✓' : '✗'} + {' '} + ·{' '} + + Lc {lc.toFixed(1)} {aPass ? '✓' : '⚠'} + + +
+ ) +} +export function RadixStepNine() { + return ( + + Step 9 is the one hex an accent scale carries unchanged into dark mode + — a solid sits at mid lightness holding its own background, so + the room barely touches it (chapter 16). Grays get no such pass: + slate’s solid is re-tuned per room. And its label is chapter + 8’s orange-button fight, shipped as a default — + WCAG’s max-ratio rule votes black, most eyes and APCA vote + white, and Radix ships white. + + } + > +
+
+
+ + Accent solid · shared + +
+ {(['Light', 'Dark'] as const).map((room) => ( +
+ + + {room} {BLUE9} + +
+ ))} +
+ + blue 9 — identical in both files. + +
+
+ + Gray solid · re-tuned + +
+ {( + [ + ['Light', SLATE9_LIGHT], + ['Dark', SLATE9_DARK], + ] as const + ).map(([room, hex]) => ( +
+ + + {room} {hex} + +
+ ))} +
+ + slate 9 — different hex per room. + +
+
+
+ + Which label on blue 9? + +
+ + +
+ + Max-ratio picks black (6.43 > 3.26); APCA picks white (|64.6| ≥ + 60, black’s 44.9 fails). Radix ships white — a meter + choice baked into every solid button. + +
+
+
+ ) +} diff --git a/src/components/playgrounds/ramp-vs-categorical.tsx b/src/components/playgrounds/ramp-vs-categorical.tsx new file mode 100644 index 0000000..97b6d55 --- /dev/null +++ b/src/components/playgrounds/ramp-vs-categorical.tsx @@ -0,0 +1,109 @@ +import { useState } from 'react' +import { clampChroma, differenceEuclidean, formatHex } from 'culori' + +import { Demo } from '@/components/demo' +import { ToggleButton } from '@/ui/toggle-button' +import { ToggleButtonGroup } from '@/ui/toggle-button-group' + +const dEOK = differenceEuclidean('oklab') +const swatch = (l: number, c: number, h: number) => + formatHex(clampChroma({ mode: 'oklch', l, c, h }, 'oklch')) + +// One blue in four lightnesses — the sequential shape, misused as categorical. +const RAMP = [ + swatch(0.82, 0.06, 250), + swatch(0.64, 0.14, 250), + swatch(0.48, 0.15, 250), + swatch(0.32, 0.11, 250), +] +// Four hues at one lightness — the categorical shape (ch5's equal-lightness palette). +const CAT = [25, 140, 250, 320].map((h) => swatch(0.65, 0.11, h)) + +const HEIGHTS = [72, 44, 88, 58] +const LABELS = ['Search', 'Direct', 'Social', 'Email'] + +function minPair(p: string[]): number { + let m = Infinity + for (let i = 0; i < p.length; i++) { + const a = p[i] + if (!a) continue + for (let j = i + 1; j < p.length; j++) { + const b = p[j] + if (!b) continue + m = Math.min(m, dEOK(a, b)) + } + } + return Number.isFinite(m) ? m : 0 +} + +export function RampVsCategorical() { + const [src, setSrc] = useState<'ramp' | 'hues'>('ramp') + const palette = src === 'ramp' ? RAMP : CAT + const min = minPair(palette) + + return ( + + Same four unordered categories, two palettes. The accent ramp is one + hue in four lightnesses — it reads as an order the data + doesn’t have (“Email is last”), and its steps sit + close by design. Four hues at one lightness read as peers, several + times farther apart. The ramp only has one hue to give. + + } + > +
+ { + const next = [...keys][0] + if (next === 'ramp' || next === 'hues') setSrc(next) + }} + size="sm" + aria-label="Palette source" + className="max-w-full overflow-x-auto" + > + Accent ramp (3/5/7/9) + Four hues, one lightness + + +
+
+ {palette.map((hex, i) => ( +
+
+
+
+ + {LABELS[i]} + +
+ ))} +
+
+ + + min pairwise ΔEOK {min.toFixed(2)} —{' '} + {src === 'ramp' + ? 'one hue, four lightnesses: ordered-looking and close' + : 'four peers, comfortably apart'} + +
+ + ) +} diff --git a/src/components/playgrounds/relative-color-steps.tsx b/src/components/playgrounds/relative-color-steps.tsx new file mode 100644 index 0000000..5506304 --- /dev/null +++ b/src/components/playgrounds/relative-color-steps.tsx @@ -0,0 +1,133 @@ +import { useState } from 'react' +import { clampChroma, converter, formatHex } from 'culori' + +import { Demo } from '@/components/demo' +import { ToggleButton } from '@/ui/toggle-button' +import { ToggleButtonGroup } from '@/ui/toggle-button-group' + +type SeedKey = 'blue' | 'violet' | 'amber' + +const toOklch = converter('oklch') +const clamp01 = (x: number) => Math.min(1, Math.max(0, x)) + +const SEEDS: Record = { + blue: { label: 'Blue', hex: '#4992dd' }, + violet: { label: 'Violet', hex: '#6e56cf' }, + amber: { label: 'Amber', hex: '#f0bf46' }, +} + +const DERIVE = [ + { name: 'subtle-bg', expr: 'calc(l + 0.24) c h', dL: 0.24, cMul: 1 }, + { name: 'hover', expr: 'calc(l - 0.08) c h', dL: -0.08, cMul: 1 }, + { name: 'active', expr: 'calc(l - 0.16) c h', dL: -0.16, cMul: 1 }, +] + +// `from` extracts channels as numbers; for oklch, l resolves in the 0–1 range, +// so the delta is a genuine lightness offset. Chroma clamped back under the tent. +const rel = (hex: string, dL: number, cMul: number) => { + const c = toOklch(hex) + if (!c) return hex + return ( + formatHex( + clampChroma( + { + mode: 'oklch' as const, + l: clamp01((c.l ?? 0) + dL), + c: Math.max(0, (c.c ?? 0) * cMul), + h: c.h ?? 0, + }, + 'oklch', + ), + ) ?? hex + ) +} + +export function RelativeColorSteps() { + const [seed, setSeed] = useState('blue') + const base = SEEDS[seed].hex + + return ( + + One --seed, three steps derived by + pulling its channels apart. from{' '} + resolves each channel to a number — oklch{' '} + l in 0–1, so{' '} + calc(l - 0.08) darkens by exactly + 0.08. Repoint the seed and every step follows at parse time. Relative + color reached Baseline only in September 2024. + + } + > +
+
+ --seed → + { + const next = [...keys][0] + if (typeof next === 'string' && next in SEEDS) + setSeed(next as SeedKey) + }} + size="sm" + aria-label="Seed color" + className="max-w-full overflow-x-auto" + > + {(Object.keys(SEEDS) as SeedKey[]).map((k) => ( + + {SEEDS[k].label} + + ))} + +
+ +
+
+
+ + --accent + + + {base} (seed) + +
+ {DERIVE.map((d) => { + const hex = rel(base, d.dL, d.cMul) + return ( +
+
+ + {d.name} + + + {hex} + +
+ ) + })} +
+ +
+
+            {`--seed: ${base};\n` +
+              DERIVE.map(
+                (d) => `--accent-${d.name}: oklch(from var(--seed) ${d.expr});`,
+              ).join('\n')}
+          
+
+
+ + ) +} diff --git a/src/components/playgrounds/scheme-pair-deltas.tsx b/src/components/playgrounds/scheme-pair-deltas.tsx new file mode 100644 index 0000000..9e4eae3 --- /dev/null +++ b/src/components/playgrounds/scheme-pair-deltas.tsx @@ -0,0 +1,181 @@ +import { useState } from 'react' +import { clampChroma, converter, formatHex, wcagContrast } from 'culori' + +import { apcaLc } from '@/lib/apca' +import { cn } from '@/lib/utils' +import { Demo } from '@/components/demo' +import { ToggleButton } from '@/ui/toggle-button' +import { ToggleButtonGroup } from '@/ui/toggle-button-group' + +const toLab = converter('lab') +const toOklch = converter('oklch') + +// Tone kept exact (CIE L* == tone); hue/chroma are OKLCH stand-ins for CAM16. +function toneHex(hue: number, chroma: number, tone: number): string { + let lo = 0 + let hi = 1 + for (let i = 0; i < 22; i++) { + const mid = (lo + hi) / 2 + const c = clampChroma( + { mode: 'oklch' as const, l: mid, c: chroma, h: hue }, + 'oklch', + ) + if ((toLab(c)?.l ?? 0) < tone) lo = mid + else hi = mid + } + return formatHex( + clampChroma( + { mode: 'oklch' as const, l: (lo + hi) / 2, c: chroma, h: hue }, + 'oklch', + ), + ) +} + +type Mode = 'light' | 'dark' + +// Baseline scheme Tone assignments, verbatim from material-color-utilities +// scheme.ts. The TONE numbers and deltas are the point, and those are exact; +// the swatches are OKLCH approximations of the tonal palette. +const SEED = '#6750A4' +const NEUTRAL_C = 0.008 + +const PAIRS: { + label: string + pal: 'accent' | 'neutral' + base: Record + on: Record +}[] = [ + { + label: 'primary → on-primary', + pal: 'accent', + base: { light: 40, dark: 80 }, + on: { light: 100, dark: 20 }, + }, + { + label: 'container → on-container', + pal: 'accent', + base: { light: 90, dark: 30 }, + on: { light: 10, dark: 90 }, + }, + { + label: 'surface → on-surface', + pal: 'neutral', + base: { light: 99, dark: 10 }, + on: { light: 10, dark: 90 }, + }, + { + label: 'surface-var → on-surface-var', + pal: 'neutral', + base: { light: 90, dark: 30 }, + on: { light: 30, dark: 80 }, + }, +] + +export function SchemePairDeltas() { + const [mode, setMode] = useState('light') + + const seed = toOklch(SEED) + const aH = seed?.h ?? 300 + const aC = seed?.c ?? 0.13 + + const rows = PAIRS.map((p) => { + const chroma = p.pal === 'accent' ? aC : NEUTRAL_C + const baseTone = p.base[mode] + const onTone = p.on[mode] + const baseHex = toneHex(aH, chroma, baseTone) + const onHex = toneHex(aH, chroma, onTone) + const delta = Math.abs(baseTone - onTone) + const w = wcagContrast(onHex, baseHex) + const lc = apcaLc(onHex, baseHex) + return { ...p, baseTone, onTone, baseHex, onHex, delta, w, lc } + }) + + return ( + + The Tone assignments are read straight from Material’s{' '} + scheme.ts. Every{' '} + on- pair sits a Tone gap of 50 or + more from its surface, so every pair clears its meter before a hex is + computed. Flip the mode: the palette is invariant, only the Tone the + role reads changes — chapter 17’s re-pick. + + } + > +
+ { + const next = [...keys][0] + if (next === 'light' || next === 'dark') setMode(next) + }} + size="sm" + aria-label="Scheme" + className="max-w-full overflow-x-auto" + > + Light + Dark + + +
+ + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + ))} + +
pairtonesΔswatchWCAGAPCA
{r.label} + {r.baseTone} / {r.onTone} + {r.delta} + + + + + = 4.5 ? 'text-fg-success' : 'text-fg-warning', + )} + > + {r.w.toFixed(2)}:1 + = 60 + ? 'text-fg-success' + : 'text-fg-warning', + )} + > + Lc {r.lc.toFixed(1)} +
+
+
+
+ ) +} diff --git a/src/components/playgrounds/sequential-vs-rainbow.tsx b/src/components/playgrounds/sequential-vs-rainbow.tsx new file mode 100644 index 0000000..1e6362e --- /dev/null +++ b/src/components/playgrounds/sequential-vs-rainbow.tsx @@ -0,0 +1,147 @@ +import { useState } from 'react' +import { converter } from 'culori' + +import { Demo } from '@/components/demo' +import { ToggleButton } from '@/ui/toggle-button' +import { ToggleButtonGroup } from '@/ui/toggle-button-group' + +type Colormap = 'viridis' | 'jet' + +const toOklch = converter('oklch') + +// Real reference colormaps, sampled to 9 stops. +// Viridis: matplotlib's default since 2.0, monotonic luminance. bids.github.io/colormap. +const VIRIDIS = [ + '#440154', + '#472d7b', + '#3b528b', + '#2c728e', + '#21918c', + '#28ae80', + '#5ec962', + '#addc30', + '#fde725', +] +// Jet: the classic rainbow — dark ends, bright middle, non-monotonic luminance. +const JET = [ + '#00007f', + '#0000ff', + '#007fff', + '#00ffff', + '#7fff7f', + '#ffff00', + '#ff7f00', + '#ff0000', + '#7f0000', +] + +const lOf = (hex: string) => toOklch(hex)?.l ?? 0 + +const W = 520 +const H = 120 +const PAD = { l: 8, r: 8, t: 10, b: 10 } +const px = (i: number, n: number) => PAD.l + (i / (n - 1)) * (W - PAD.l - PAD.r) +const py = (l: number) => PAD.t + (1 - l) * (H - PAD.t - PAD.b) + +function monotonic(ls: number[]): boolean { + return ls.every((l, i) => i === 0 || l > (ls[i - 1] ?? -1)) +} + +export function SequentialVsRainbow() { + const [map, setMap] = useState('viridis') + const stops = map === 'viridis' ? VIRIDIS : JET + const ls = stops.map(lOf) + const path = ls + .map( + (l, i) => + `${i === 0 ? 'M' : 'L'}${px(i, ls.length).toFixed(1)},${py(l).toFixed(1)}`, + ) + .join(' ') + const ok = monotonic(ls) + + return ( + + The band is the colormap; the line is its true OKLCH lightness, low + index to high. Viridis climbs monotonically — equal data steps + read as equal steps. Jet dips and spikes: the bright middle bands + invent boundaries where the data is flat, the dark ends hide real + gradients. This is why viridis replaced jet as matplotlib’s + default in 2.0. + + } + > +
+ { + const next = [...keys][0] + if (next === 'viridis' || next === 'jet') setMap(next) + }} + size="sm" + aria-label="Colormap" + className="max-w-full overflow-x-auto" + > + Viridis (right) + Jet / rainbow (wrong) + + +
+ {stops.map((hex, i) => ( +
+ ))} +
+ + + + + {ls.map((l, i) => ( + + ))} + + + + + lightness {ok ? 'monotonic ✓' : 'non-monotonic ✕'} + + + {' '} + · span {(Math.max(...ls) - Math.min(...ls)).toFixed(2)} L + + +
+ + ) +} diff --git a/src/components/playgrounds/small-mark-area.tsx b/src/components/playgrounds/small-mark-area.tsx new file mode 100644 index 0000000..5a16541 --- /dev/null +++ b/src/components/playgrounds/small-mark-area.tsx @@ -0,0 +1,86 @@ +import { differenceEuclidean } from 'culori' + +import { Demo } from '@/components/demo' + +const dEOK = differenceEuclidean('oklab') + +// Two distinct categorical series colors — easy apart as fills, closer as thin marks. +const A = '#3b6fc9' +const B = '#1f9e8f' + +const W = 260 +const H = 96 +const lineA = 'M8,74 L48,40 L88,58 L128,24 L168,50 L208,30 L252,44' +const lineB = 'M8,58 L48,66 L88,30 L128,52 L168,28 L208,60 L252,22' +const dotsA = [ + [8, 74], + [48, 40], + [88, 58], + [128, 24], + [168, 50], + [208, 30], + [252, 44], +] +const dotsB = [ + [8, 58], + [48, 66], + [88, 30], + [128, 52], + [168, 28], + [208, 60], + [252, 22], +] + +export function SmallMarkArea() { + const de = dEOK(A, B) + return ( + + The same two colors, ΔEOK {de.toFixed(2)} apart. As large fills they + separate instantly. As 1.5px lines and small dots the mark carries far + less area, so each color reads paler and closer to its neighbor + — the small-area effect. A separation that clears the + glance-apart bound for a bar can still fail for a scatter dot; small + marks need more. + + } + > +
+
+ Large fills +
+
+
+
+
+
+ 1.5px lines & dots +
+ + + + {dotsA.map(([x, y], i) => ( + + ))} + {dotsB.map(([x, y], i) => ( + + ))} + +
+
+
+ + ) +} diff --git a/src/components/playgrounds/state-status-lab.tsx b/src/components/playgrounds/state-status-lab.tsx new file mode 100644 index 0000000..7b24784 --- /dev/null +++ b/src/components/playgrounds/state-status-lab.tsx @@ -0,0 +1,303 @@ +import type * as React from 'react' +import { useState } from 'react' +import { converter, formatHex, wcagContrast } from 'culori' + +import { apcaLc } from '@/lib/apca' +import { Playground } from '@/components/playground' +import { ToggleButton } from '@/ui/toggle-button' +import { ToggleButtonGroup } from '@/ui/toggle-button-group' + +const toRgb = converter('rgb') +const toOklch = converter('oklch') + +function over(fg: string, bg: string, a: number): string { + const f = toRgb(fg) + const b = toRgb(bg) + if (!f || !b) return bg + return ( + formatHex({ + mode: 'rgb', + r: f.r * a + b.r * (1 - a), + g: f.g * a + b.g * (1 - a), + b: f.b * a + b.b * (1 - a), + }) ?? bg + ) +} + +const CARD = '#f6f5f1' // neutral-100 +const PANEL = '#b0dcff' // accent-200 — a colored callout +const RING = '#4992dd' // border-focus = accent-500 + +// State-layer opacities: Material 3 hover 0.08, pressed 0.12; disabled content +// 0.38. The overlay color is the role's on-color (black for neutral, the +// family solid for status). +const OP = { hover: 0.08, active: 0.12 } as const +const DISABLED = 0.38 + +type Role = 'neutral' | 'success' | 'warning' | 'danger' | 'info' +type State = 'rest' | 'hover' | 'active' | 'focus' | 'disabled' +type Mech = 'solid' | 'alpha' + +// overlay = state-layer color; text = the role's text partner (family-800). +const ROLES: Record = { + neutral: { label: 'Neutral', overlay: '#000000', text: '#1d1d1a' }, + success: { label: 'Success', overlay: '#00ad47', text: '#005912' }, + warning: { label: 'Warning', overlay: '#b78600', text: '#5e4100' }, + danger: { label: 'Danger', overlay: '#f34847', text: '#880010' }, + info: { label: 'Info', overlay: '#438aff', text: '#0b4092' }, +} + +const oklch = (hex: string) => { + const c = toOklch(hex) + return { l: c?.l ?? 0, c: c?.c ?? 0 } +} + +interface Resolved { + fill: string // the painted surface behind the label + text: string // label color + ring: boolean +} + +// The whole point: `solid` freezes the state layer against the card, then reuses +// that frozen hex everywhere. `alpha` recomposites the overlay per surface. +function resolve( + role: Role, + state: State, + mech: Mech, + surface: string, +): Resolved { + const { overlay, text } = ROLES[role] + if (state === 'rest' || state === 'focus') { + return { fill: surface, text, ring: state === 'focus' } + } + if (state === 'disabled') { + return { fill: surface, text: over(text, surface, DISABLED), ring: false } + } + const op = OP[state] + const fill = + mech === 'alpha' ? over(overlay, surface, op) : over(overlay, CARD, op) + return { fill, text, ring: false } +} + +const REST_TEXT = '#64635e' // neutral-600 + +function Control({ r, surface }: { r: Resolved; surface: string }) { + return ( +
+ + Rename + + + Duplicate + +
+ ) +} + +function Readout({ label, r }: { label: string; r: Resolved }) { + const f = oklch(r.fill) + const w = wcagContrast(r.text, r.fill) + const lc = apcaLc(r.text, r.fill) + return ( + + {label} + + + {r.fill} + + L {f.l.toFixed(3)} + C {f.c.toFixed(3)} + + {w.toFixed(2)}:1 · Lc {lc.toFixed(0)} + + + ) +} + +export function StateStatusLab() { + const [role, setRole] = useState('neutral') + const [state, setState] = useState('hover') + const [mech, setMech] = useState('alpha') + + const isLayer = state === 'hover' || state === 'active' + const card = resolve(role, state, mech, CARD) + const panel = resolve(role, state, mech, PANEL) + + // Both mechanisms on the panel, always, so the verdict is stable. + const layerState = isLayer ? state : 'hover' + const solidPanel = oklch(resolve(role, layerState, 'solid', PANEL).fill) + const alphaPanel = oklch(resolve(role, layerState, 'alpha', PANEL).fill) + + let verdict: React.ReactNode + if (isLayer) { + verdict = ( + <> + On the tinted panel the alpha overlay keeps chroma C{' '} + {alphaPanel.c.toFixed(3)} (stays tinted); the solid step collapses to C{' '} + {solidPanel.c.toFixed(3)} — a value frozen from the card. The solid hex + fits one surface; the alpha token composes over both. + + ) + } else if (state === 'focus') { + verdict = ( + <> + Focus is its own token, mechanism-independent: the ring is border-focus + ({RING}) with a 2px offset. Against the adjacent surface it’s{' '} + {wcagContrast(RING, CARD).toFixed(2)}:1 on the card and{' '} + {wcagContrast(RING, PANEL).toFixed(2)}:1 on the panel — both under WCAG + 1.4.11’s 3:1, so this token needs raising or a two-tone ring; the + offset gap only helps perceptually. + + ) + } else if (state === 'disabled') { + verdict = ( + <> + Disabled reduces the label to {DISABLED * 100}% opacity — reduced + contrast on purpose. WCAG exempts it from the 4.5:1 floor, but it must + still read as a control: {wcagContrast(card.text, card.fill).toFixed(2)} + :1 here. + + ) + } else { + verdict = <>Rest is the surface itself — no state layer, no cost. + } + + return ( + { + setRole('neutral') + setState('hover') + setMech('alpha') + }} + > +
+
+
+ role + { + const next = [...keys][0] + if (typeof next === 'string' && next in ROLES) + setRole(next as Role) + }} + size="sm" + aria-label="Role" + className="max-w-full overflow-x-auto" + > + {(Object.keys(ROLES) as Role[]).map((k) => ( + + {ROLES[k].label} + + ))} + +
+
+ state + { + const next = [...keys][0] + if ( + next === 'rest' || + next === 'hover' || + next === 'active' || + next === 'focus' || + next === 'disabled' + ) + setState(next) + }} + size="sm" + aria-label="State" + className="max-w-full overflow-x-auto" + > + Rest + Hover + Active + Focus + Disabled + +
+
+ mechanism + { + const next = [...keys][0] + if (next === 'solid' || next === 'alpha') setMech(next) + }} + size="sm" + aria-label="Mechanism" + isDisabled={!isLayer} + className="max-w-full overflow-x-auto" + > + Solid step + Alpha overlay + +
+
+ +
+
+ Plain card + +
+
+ Tinted panel + +
+
+ +
+ + + + + + + + + + + + + + +
surfacefillLClabel
+
+ +

+ {verdict} +

+
+
+ ) +} diff --git a/src/components/playgrounds/status-needs-a-second-channel.tsx b/src/components/playgrounds/status-needs-a-second-channel.tsx new file mode 100644 index 0000000..23d2b8e --- /dev/null +++ b/src/components/playgrounds/status-needs-a-second-channel.tsx @@ -0,0 +1,120 @@ +import { useState } from 'react' +import { + converter, + differenceEuclidean, + filterDeficiencyDeuter, + formatHex, +} from 'culori' +import { + CheckCircleIcon, + InfoIcon, + TriangleAlertIcon, + XCircleIcon, +} from 'lucide-react' + +import { Demo } from '@/components/demo' +import { Switch } from '@/ui/switch' + +const toRgb = converter('rgb') +const dEok = differenceEuclidean('oklab') +const deutan = filterDeficiencyDeuter(1) + +const shown = (hex: string, on: boolean) => { + if (!on) return hex + const rgb = toRgb(hex) + return rgb ? (formatHex(deutan(rgb)) ?? hex) : hex +} + +// dotUI status solids (family-500). Meaning carried by hue alone. +const STATUS = [ + { id: 'success', label: 'Paid', solid: '#00ad47', icon: CheckCircleIcon }, + { + id: 'warning', + label: 'Pending', + solid: '#b78600', + icon: TriangleAlertIcon, + }, + { id: 'danger', label: 'Failed', solid: '#f34847', icon: XCircleIcon }, + { id: 'info', label: 'Info', solid: '#438aff', icon: InfoIcon }, +] + +function worstPair(on: boolean) { + let worst = Infinity + let names = '' + for (const [i, a] of STATUS.entries()) + for (const b of STATUS.slice(i + 1)) { + const de = dEok(shown(a.solid, on), shown(b.solid, on)) + if (de < worst) { + worst = de + names = `${a.label}/${b.label}` + } + } + return { de: worst, names } +} + +export function StatusNeedsASecondChannel() { + const [sim, setSim] = useState(true) + const w = worstPair(sim) + + return ( + + Under deuteranopia — the dichromatic end of red-green color vision + deficiency, which in some form affects roughly one in twelve men — the + success green, warning amber, and danger red slide into one band of + olive: closest pair{' '} + + {w.names} at ΔEok {w.de.toFixed(2)} + + . Only blue holds. The top row encodes status in hue alone and + collapses; the bottom row keeps the same fills but adds an icon and a + word, so it still reads with the color removed. This is chapter + 9’s rule as a hard constraint on the status families: never let + hue be the only channel. + + } + > +
+
+ Hue alone +
+ {STATUS.map((s) => ( + + — + + ))} +
+
+
+ Hue + icon + word +
+ {STATUS.map((s) => { + const Icon = s.icon + return ( + + + {s.label} + + ) + })} +
+
+ + Simulate deuteranopia + +
+
+ ) +} diff --git a/src/components/playgrounds/status-soft-and-solid.tsx b/src/components/playgrounds/status-soft-and-solid.tsx new file mode 100644 index 0000000..b91a1ae --- /dev/null +++ b/src/components/playgrounds/status-soft-and-solid.tsx @@ -0,0 +1,131 @@ +import { wcagContrast } from 'culori' + +import { apcaLc } from '@/lib/apca' +import { Demo } from '@/components/demo' + +// dotUI status kit (styles.css): solid = family-500, soft surface = family-100 +// (info: family-50), soft text = fg-family = family-800. +const FAMILIES = [ + { + name: 'success', + solid: '#00ad47', + soft: '#b0ffbf', + text: '#005912', + fg: 'text-fg-success', + }, + { + name: 'warning', + solid: '#b78600', + soft: '#ffe69b', + text: '#5e4100', + fg: 'text-fg-warning', + }, + { + name: 'danger', + solid: '#f34847', + soft: '#ffdcd7', + text: '#880010', + fg: 'text-fg-danger', + }, + { + name: 'info', + solid: '#438aff', + soft: '#ecf9ff', + text: '#0b4092', + fg: 'text-fg-info', + }, +] as const + +function Meter({ fg, bg }: { fg: string; bg: string }) { + const w = wcagContrast(fg, bg) + const lc = apcaLc(fg, bg) + const passW = w >= 4.5 + const passA = Math.abs(lc) >= 60 + return ( + + + {w.toFixed(2)}:1 {passW ? '✓' : '✕'} + {' '} + + Lc {Math.abs(lc).toFixed(0)} {passA ? '✓' : '⚠'} + + + ) +} + +export function StatusSoftAndSolid() { + return ( + + Each status family owes the full kit from chapter 17 — a soft surface, + a solid, and their text partners, on both meters. The{' '} + soft style (family-800 text on + the family-muted surface) clears both meters for all four, + comfortably. The solid style at + step 500 is chapter 8’s orange-button fight, four times over: a + black label passes WCAG (5.8–7.1:1) but fails APCA (Lc + 42–49, under the 60 body floor), while a white label passes APCA + (Lc 61–68) but fails WCAG (2.97–3.60:1). At step 500{' '} + neither label passes both — a solid that carries a label + belongs at step 600–700 (danger-600 + white clears 4.87:1, Lc + 78). Status doesn’t get to skip the pairing promise for being + “just” a warning. + + } + > +
+ + + + + + + + + + + {FAMILIES.map((f) => ( + + + + + + + ))} + +
familysoft · fg-800 on mutedsolid · black labelsolid · white label
{f.name} +
+ + Soft + + +
+
+
+ + Solid + + +
+
+
+ + Solid + + +
+
+
+
+ ) +} diff --git a/src/components/playgrounds/tailwind-cross-hue-lightness.tsx b/src/components/playgrounds/tailwind-cross-hue-lightness.tsx new file mode 100644 index 0000000..e7607da --- /dev/null +++ b/src/components/playgrounds/tailwind-cross-hue-lightness.tsx @@ -0,0 +1,75 @@ +import { converter, formatHex } from 'culori' + +import { Demo } from '@/components/demo' + +const toOklch = converter('oklch') + +// Tailwind v4 default theme, step 500 across five families (theme.css, verbatim oklch). +const FIVES = [ + { name: 'blue-500', str: 'oklch(62.3% 0.214 259.815)' }, + { name: 'red-500', str: 'oklch(63.7% 0.237 25.331)' }, + { name: 'sky-500', str: 'oklch(68.5% 0.169 237.323)' }, + { name: 'green-500', str: 'oklch(72.3% 0.219 149.579)' }, + { name: 'amber-500', str: 'oklch(76.9% 0.188 70.08)' }, +] + +const LO = 0.6 +const HI = 0.8 +const pct = (l: number) => Math.max(0, Math.min(1, (l - LO) / (HI - LO))) * 100 + +export function TailwindCrossHueLightness() { + const rows = FIVES.map((f) => { + const c = toOklch(f.str) + return { ...f, l: c?.l ?? 0, hex: formatHex(f.str) ?? '#000000' } + }) + const ls = rows.map((r) => r.l) + const spread = Math.max(...ls) - Math.min(...ls) + const blueL = rows[0]?.l ?? 0.623 + + return ( + + Every swatch is someone’s step{' '} + 500, yet the lightnesses fan out + across ΔL {spread.toFixed(3)}. The dashed line marks{' '} + blue-500;{' '} + amber-500 rides 0.15 above it + because the gamut tent (chapter 6) forbids a dark vivid amber, so it + stays light to stay saturated — chapter 13’s “no + dark vivid yellow,” one hue over. v4 kept v3’s per-hue + balance (chapter 11), so the 500 row is a same-as-v3 row, not + an iso-lightness row: the number never promised one lightness per + step, and it doesn’t deliver one. + + } + > +
+ {rows.map((r) => ( +
+ + + {r.name} + +
+ + +
+ + L {r.l.toFixed(3)} + +
+ ))} +
+
+ ) +} diff --git a/src/components/playgrounds/tailwind-gamut-headroom.tsx b/src/components/playgrounds/tailwind-gamut-headroom.tsx new file mode 100644 index 0000000..cf92fae --- /dev/null +++ b/src/components/playgrounds/tailwind-gamut-headroom.tsx @@ -0,0 +1,106 @@ +import { useState } from 'react' +import { + clampChroma, + converter, + differenceEuclidean, + formatHex, + inGamut, +} from 'culori' + +import { Demo } from '@/components/demo' +import { ToggleButton } from '@/ui/toggle-button' +import { ToggleButtonGroup } from '@/ui/toggle-button-group' + +const toOklch = converter('oklch') +const dEOK = differenceEuclidean('oklab') +const inSrgb = inGamut('rgb') + +// Tailwind v4 default theme, verbatim oklch (theme.css) — three vivid mid steps. +const PICKS = [ + { key: 'amber', label: 'amber-500', str: 'oklch(76.9% 0.188 70.08)' }, + { key: 'blue', label: 'blue-500', str: 'oklch(62.3% 0.214 259.815)' }, + { key: 'red', label: 'red-600', str: 'oklch(57.7% 0.245 27.325)' }, +] as const + +type Key = (typeof PICKS)[number]['key'] + +export function TailwindGamutHeadroom() { + const [key, setKey] = useState('amber') + const pick = PICKS.find((p) => p.key === key) ?? PICKS[0] + + const clamped = clampChroma(pick.str, 'oklch') ?? pick.str // maps chroma into sRGB + const de = inSrgb(pick.str) ? 0 : dEOK(pick.str, clamped) + const cP3 = toOklch(pick.str)?.c ?? 0 + const cSrgb = toOklch(clamped)?.c ?? 0 + const srgbHex = formatHex(clamped) ?? '#000000' + + return ( + + Left is the color as authored, at the P3 chroma ceiling; right is the + same color gamut-mapped back to sRGB (chapter 6’s{' '} + clampChroma). On a P3 display the + left is visibly more saturated; on an sRGB display both render the + same, which is the honest point — the headroom exists only where + the hardware can show it. The chroma given up costs ΔEOK{' '} + {de.toFixed(3)} + {de >= 0.02 + ? ', past chapter 14’s 0.02 just-noticeable difference' + : ' — real, but under chapter 14’s 0.02 JND'} + . + + } + > +
+ { + const next = [...keys][0] + if (typeof next === 'string') setKey(next as Key) + }} + size="sm" + aria-label="Palette step" + className="max-w-full overflow-x-auto" + > + {PICKS.map((p) => ( + + {p.label} + + ))} + + +
+
+ + + P3, as authored · C {cP3.toFixed(3)} + +
+
+ + + sRGB, clamped · C {cSrgb.toFixed(3)} + +
+
+ + + {pick.label}: chroma {cP3.toFixed(3)} → {cSrgb.toFixed(3)} on the way + to sRGB · ΔEOK {de.toFixed(3)} + +
+
+ ) +} diff --git a/src/components/playgrounds/tailwind-palette-inspector.tsx b/src/components/playgrounds/tailwind-palette-inspector.tsx new file mode 100644 index 0000000..520cde7 --- /dev/null +++ b/src/components/playgrounds/tailwind-palette-inspector.tsx @@ -0,0 +1,293 @@ +import { useState } from 'react' +import { converter, formatHex, inGamut, wcagContrast } from 'culori' + +import { apcaLc } from '@/lib/apca' +import { cn } from '@/lib/utils' +import { Playground } from '@/components/playground' +import { ToggleButton } from '@/ui/toggle-button' +import { ToggleButtonGroup } from '@/ui/toggle-button-group' + +const toOklch = converter('oklch') +const inSrgb = inGamut('rgb') + +const STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] + +// Tailwind CSS v4 default theme, verbatim oklch() from packages/tailwindcss/theme.css. +const RAMPS = { + blue: [ + 'oklch(97% 0.014 254.604)', + 'oklch(93.2% 0.032 255.585)', + 'oklch(88.2% 0.059 254.128)', + 'oklch(80.9% 0.105 251.813)', + 'oklch(70.7% 0.165 254.624)', + 'oklch(62.3% 0.214 259.815)', + 'oklch(54.6% 0.245 262.881)', + 'oklch(48.8% 0.243 264.376)', + 'oklch(42.4% 0.199 265.638)', + 'oklch(37.9% 0.146 265.522)', + 'oklch(28.2% 0.091 267.935)', + ], + red: [ + 'oklch(97.1% 0.013 17.38)', + 'oklch(93.6% 0.032 17.717)', + 'oklch(88.5% 0.062 18.334)', + 'oklch(80.8% 0.114 19.571)', + 'oklch(70.4% 0.191 22.216)', + 'oklch(63.7% 0.237 25.331)', + 'oklch(57.7% 0.245 27.325)', + 'oklch(50.5% 0.213 27.518)', + 'oklch(44.4% 0.177 26.899)', + 'oklch(39.6% 0.141 25.723)', + 'oklch(25.8% 0.092 26.042)', + ], + amber: [ + 'oklch(98.7% 0.022 95.277)', + 'oklch(96.2% 0.059 95.617)', + 'oklch(92.4% 0.12 95.746)', + 'oklch(87.9% 0.169 91.605)', + 'oklch(82.8% 0.189 84.429)', + 'oklch(76.9% 0.188 70.08)', + 'oklch(66.6% 0.179 58.318)', + 'oklch(55.5% 0.163 48.998)', + 'oklch(47.3% 0.137 46.201)', + 'oklch(41.4% 0.112 45.904)', + 'oklch(27.9% 0.077 45.635)', + ], + slate: [ + 'oklch(98.4% 0.003 247.858)', + 'oklch(96.8% 0.007 247.896)', + 'oklch(92.9% 0.013 255.508)', + 'oklch(86.9% 0.022 252.894)', + 'oklch(70.4% 0.04 256.788)', + 'oklch(55.4% 0.046 257.417)', + 'oklch(44.6% 0.043 257.281)', + 'oklch(37.2% 0.044 257.287)', + 'oklch(27.9% 0.041 260.031)', + 'oklch(20.8% 0.042 265.755)', + 'oklch(12.9% 0.042 264.695)', + ], +} as const + +type Family = keyof typeof RAMPS +const FAMILY_LABELS: Record = { + blue: 'Blue', + red: 'Red', + amber: 'Amber', + slate: 'Slate', +} + +const hexOf = (s: string) => formatHex(s) ?? '#000000' + +export function TailwindPaletteInspector() { + const [family, setFamily] = useState('blue') + const [bgIdx, setBgIdx] = useState(5) + const [textSel, setTextSel] = useState('white') + + const ramp = RAMPS[family] + const bgStr = ramp[bgIdx] ?? ramp[5] ?? '#000000' + const bg = hexOf(bgStr) + const fg = + textSel === 'white' + ? '#ffffff' + : textSel === 'black' + ? '#000000' + : hexOf(ramp[Number(textSel)] ?? bgStr) + + const w = wcagContrast(fg, bg) + const lc = apcaLc(fg, bg) + const passW = w >= 4.5 + const passA = Math.abs(lc) >= 60 + + const oklch = toOklch(bgStr) + const bgSrgb = inSrgb(bgStr) + + const bgClass = `bg-${family}-${STEPS[bgIdx]}` + const textClass = + textSel === 'white' + ? 'text-white' + : textSel === 'black' + ? 'text-black' + : `text-${family}-${STEPS[Number(textSel)]}` + + const verdict = + passW && passA + ? 'Both meters clear — a safe pair, by luck not by promise.' + : !passW && !passA + ? 'Both meters fail — unreadable, and Tailwind shipped it without a word.' + : `The meters split: WCAG ${passW ? 'passes' : 'fails'} at ${w.toFixed(2)}:1, APCA ${passA ? 'passes' : 'fails'} at Lc ${lc.toFixed(1)}.` + + return ( + { + setFamily('blue') + setBgIdx(5) + setTextSel('white') + }} + > +
+
+ family + { + const next = [...keys][0] + if (typeof next === 'string' && next in RAMPS) + setFamily(next as Family) + }} + size="sm" + aria-label="Color family" + className="max-w-full overflow-x-auto" + > + {(Object.keys(RAMPS) as Family[]).map((k) => ( + + {FAMILY_LABELS[k]} + + ))} + +
+ +
+ background step +
+ {ramp.map((s, i) => ( + + ))} +
+
+ +
+ text color +
+ {ramp.map((s, i) => ( + + ))} + {(['white', 'black'] as const).map((k) => ( + + ))} +
+
+ +
+
+
+ + The quick brown fox + + + jumps over the lazy dog — can you read this line? + +
+ + {`

`} + +

+ +
+ + Pairing meters — WCAG ≥ 4.5 floor · |Lc| ≥ 60 target + +
+
+ WCAG + + {w.toFixed(2)}:1 {passW ? '✓' : '✕'} + +
+
+ APCA + + Lc {lc.toFixed(1)} {passA ? '✓' : '⚠'} + +
+
+ bg oklch + + {oklch + ? `${oklch.l.toFixed(3)} ${(oklch.c ?? 0).toFixed(3)} ${(oklch.h ?? 0).toFixed(0)}` + : '—'} + +
+
+ bg gamut + + {bgSrgb ? 'sRGB' : 'P3 headroom'} + +
+
+ + {verdict} + + + Valid Tailwind. Compiles. Ships. No tier here holds a position on + the pair — that’s chapter 17’s missing floor. + +
+
+
+
+ ) +} diff --git a/src/components/playgrounds/tailwind-theme-output.tsx b/src/components/playgrounds/tailwind-theme-output.tsx new file mode 100644 index 0000000..a80e0ef --- /dev/null +++ b/src/components/playgrounds/tailwind-theme-output.tsx @@ -0,0 +1,58 @@ +import { Demo } from '@/components/demo' + +// Verified example from the Tailwind v4 theme docs: one @theme definition emits +// both the :root custom property AND a family of utilities. +const MINT = 'oklch(0.72 0.11 178)' + +export function TailwindThemeOutput() { + return ( + + One @theme definition, two outputs: + a :root custom property{' '} + and a family of utilities. That is why the token table is an{' '} + @theme block, not a plain{' '} + :root one — chapter + 17’s bg-accent falls out of{' '} + --color-accent for free. + + } + > +
+
+
+ you write +
+
{`@theme {
+  --color-mint-500: oklch(0.72 0.11 178);
+}`}
+
+
+
+ Tailwind emits +
+
{`:root {
+  --color-mint-500: oklch(0.72 0.11 178);
+}
+.bg-mint-500   { background-color: var(--color-mint-500) }
+.text-mint-500 { color: var(--color-mint-500) }
+.fill-mint-500 { fill: var(--color-mint-500) }`}
+
+
+
+
+ + .bg-mint-500 + + + rendered live through the custom property + +
+
+
+ ) +} diff --git a/src/components/playgrounds/tailwind-theme-to-utility.tsx b/src/components/playgrounds/tailwind-theme-to-utility.tsx new file mode 100644 index 0000000..917ca6c --- /dev/null +++ b/src/components/playgrounds/tailwind-theme-to-utility.tsx @@ -0,0 +1,109 @@ +import { useState } from 'react' +import { formatHex } from 'culori' + +import { Demo } from '@/components/demo' +import { ToggleButton } from '@/ui/toggle-button' +import { ToggleButtonGroup } from '@/ui/toggle-button-group' + +const SEEDS = { + avocado: 'oklch(0.84 0.18 117)', + coral: 'oklch(0.70 0.18 25)', + iris: 'oklch(0.62 0.2 285)', +} as const + +type Seed = keyof typeof SEEDS + +export function TailwindThemeToUtility() { + const [seed, setSeed] = useState('avocado') + const value = SEEDS[seed] + const hex = formatHex(value) ?? '#000000' + const varName = `--color-${seed}-500` + + return ( + + One declaration, two outputs:{' '} + @theme emits a real CSS custom + property and generates every color utility from it — + chapter 20’s delivery mechanism, shipped. The palette + isn’t a config object anymore; it’s{' '} + --color-* variables you can read at + runtime in plain CSS, and exactly the substrate a semantic alias like{' '} + --color-accent would point at. + + } + > +
+ { + const next = [...keys][0] + if (typeof next === 'string' && next in SEEDS) setSeed(next as Seed) + }} + size="sm" + aria-label="Custom color added to the theme" + className="max-w-full overflow-x-auto" + > + {(Object.keys(SEEDS) as Seed[]).map((k) => ( + + {k} + + ))} + + +
+
+ @theme { +
+ + {varName}: {value}; + +
+ } +
+
↓ emits
+
+ :root {{' '} + + {varName}: {value}; + {' '} + } +
+
↓ generates
+
+ + .bg-{seed}-500 { background-color: var({varName}) } + + + .text-{seed}-500 { color: var({varName}) } + + + .border-{seed}-500 { border-color: var({varName}) } + +
+
+ +
+ + .border / .text-{seed}-500 + + + .bg-{seed}-500 + + + {hex} + +
+
+
+ ) +} diff --git a/src/components/playgrounds/token-count-lineup.tsx b/src/components/playgrounds/token-count-lineup.tsx new file mode 100644 index 0000000..497187b --- /dev/null +++ b/src/components/playgrounds/token-count-lineup.tsx @@ -0,0 +1,69 @@ +import { Demo } from '@/components/demo' + +// Counts verified against each system's source (July 2026): shadcn theming docs +// :root block; MaterialDynamicColors in material-color-utilities; dotUI theme.css. +const SYSTEMS = [ + { + name: 'Radix Colors', + count: 12, + unit: 'primitives per scale', + note: 'no shipped semantic tier — aliasing is documented homework', + }, + { + name: 'shadcn/ui', + count: 18, + unit: 'core tokens', + note: '31 with chart and sidebar tokens', + }, + { + name: 'Material 3', + count: 53, + unit: 'color roles', + note: 'plus 6 palette key colors, in the reference implementation', + }, + { + name: 'dotUI', + count: 83, + unit: 'semantic tokens', + note: '77 semantic + 6 component-tier', + }, +] + +const MAX = 83 + +export function TokenCountLineup() { + return ( + + Four shipped answers to “how many names.” The numbers + aren’t comparable one-to-one — Radix counts primitives and + leaves the semantic tier to you; the other three count the public API. + The pattern that is comparable: every system’s core — the + tokens a component author reaches for daily — converges near + twenty. + + } + > +
+ {SYSTEMS.map((s) => ( +
+
+ {s.name} + + {s.count} {s.unit} + +
+
+
+
+ {s.note} +
+ ))} +
+ + ) +} diff --git a/src/components/playgrounds/token-wiring-playground.tsx b/src/components/playgrounds/token-wiring-playground.tsx new file mode 100644 index 0000000..a6b911f --- /dev/null +++ b/src/components/playgrounds/token-wiring-playground.tsx @@ -0,0 +1,442 @@ +import { useState, type MouseEvent } from 'react' +import { wcagContrast } from 'culori' + +import { apcaLc } from '@/lib/apca' +import { cn } from '@/lib/utils' +import { Playground } from '@/components/playground' +import { ToggleButton } from '@/ui/toggle-button' +import { ToggleButtonGroup } from '@/ui/toggle-button-group' + +// dotUI primitive ramps (registry base/colors.css), oklch → hex via culori, +// renamed by hue — the primitive tier has no opinions. +const STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] +const RAMPS = { + blue: [ + '#e8faff', + '#d0edff', + '#b0dcff', + '#8fcbff', + '#69b0fb', + '#4992dd', + '#347bc2', + '#1a5c9b', + '#0f487b', + '#0a345b', + '#05203a', + ], + green: [ + '#d6ffde', + '#b0ffbf', + '#8bf3a2', + '#67e587', + '#36cb66', + '#00ad47', + '#009432', + '#00711e', + '#005912', + '#004208', + '#002904', + ], + red: [ + '#fff0ed', + '#ffdcd7', + '#ffc2bb', + '#ffa79e', + '#ff766e', + '#f34847', + '#d62f33', + '#ab091b', + '#880010', + '#65010a', + '#410205', + ], + amber: [ + '#fff5c1', + '#ffe69b', + '#fcd271', + '#f0bf46', + '#d7a200', + '#b78600', + '#9c7000', + '#775400', + '#5e4100', + '#462e00', + '#2d1b00', + ], +} as const + +type RampKey = keyof typeof RAMPS +type OnAccent = 'auto' | 'white' | 'black' +type ElementId = + | 'page' + | 'card' + | 'heading' + | 'byline' + | 'badge' + | 'button' + | 'link' + +const GRAY = { + bg: '#fafafa', + card: '#f7f7f7', + border: '#dbdbdb', + fgMuted: '#626262', + fg: '#070707', +} + +const at = (ramp: RampKey, idx: number) => RAMPS[ramp][idx] ?? '#000000' + +interface Wire { + slot: string + semantic: string + primitive: string + value: string +} + +const RAMP_LABELS: Record = { + blue: 'Blue', + green: 'Green', + red: 'Red', + amber: 'Amber', +} + +export function TokenWiringPlayground() { + const [ramp, setRamp] = useState('blue') + const [stepIdx, setStepIdx] = useState(5) + const [onAccent, setOnAccent] = useState('auto') + const [focused, setFocused] = useState('button') + + const accent = at(ramp, stepIdx) + const accentHover = at(ramp, Math.min(stepIdx + 1, 10)) + const accentMuted = at(ramp, 1) + const fgAccent = at(ramp, 8) + const autoPick = + wcagContrast('#000000', accent) > wcagContrast('#ffffff', accent) + ? 'black' + : 'white' + const labelPick = onAccent === 'auto' ? autoPick : onAccent + const label = labelPick === 'black' ? '#000000' : '#ffffff' + const step = STEPS[stepIdx] ?? 500 + + const wires: Record = { + page: [ + { + slot: 'page surface', + semantic: 'bg', + primitive: '--gray-50', + value: GRAY.bg, + }, + ], + card: [ + { + slot: 'card surface', + semantic: 'card', + primitive: '--gray-100', + value: GRAY.card, + }, + { + slot: 'card edge', + semantic: 'border', + primitive: '--gray-300', + value: GRAY.border, + }, + ], + heading: [ + { + slot: 'heading text', + semantic: 'fg', + primitive: '--gray-950', + value: GRAY.fg, + }, + ], + byline: [ + { + slot: 'byline text', + semantic: 'fg-muted', + primitive: '--gray-600', + value: GRAY.fgMuted, + }, + ], + badge: [ + { + slot: 'badge surface', + semantic: 'accent-muted', + primitive: `--${ramp}-100`, + value: accentMuted, + }, + { + slot: 'badge text', + semantic: 'fg-accent', + primitive: `--${ramp}-800`, + value: fgAccent, + }, + ], + button: [ + { + slot: 'button surface', + semantic: 'accent', + primitive: `--${ramp}-${step}`, + value: accent, + }, + { + slot: 'button label', + semantic: 'fg-on-accent', + primitive: onAccent === 'auto' ? `auto → ${autoPick}` : labelPick, + value: label, + }, + ], + link: [ + { + slot: 'link text', + semantic: 'fg-accent', + primitive: `--${ramp}-800`, + value: fgAccent, + }, + ], + } + + const promises = [ + { name: 'fg on card', fgc: GRAY.fg, bgc: GRAY.card }, + { name: 'fg-muted on card', fgc: GRAY.fgMuted, bgc: GRAY.card }, + { name: 'fg-accent on card', fgc: fgAccent, bgc: GRAY.card }, + { name: 'fg-accent on accent-muted', fgc: fgAccent, bgc: accentMuted }, + { name: 'fg-on-accent on accent', fgc: label, bgc: accent }, + { name: 'fg-on-accent on accent-hover', fgc: label, bgc: accentHover }, + ].map((p) => { + const w = wcagContrast(p.fgc, p.bgc) + const lc = apcaLc(p.fgc, p.bgc) + return { ...p, w, lc, passW: w >= 4.5, passA: Math.abs(lc) >= 60 } + }) + + const bothPass = promises.filter((p) => p.passW && p.passA).length + const wcagOnly = promises.filter((p) => p.passW && !p.passA).length + const broken = promises.filter((p) => !p.passW).length + + const focus = (id: ElementId) => ({ + onMouseEnter: () => setFocused(id), + onClick: (e: MouseEvent) => { + e.stopPropagation() + setFocused(id) + }, + }) + const ring = (id: ElementId) => + focused === id ? 'ring-2 ring-fg/50 ring-offset-1' : '' + + return ( + { + setRamp('blue') + setStepIdx(5) + setOnAccent('auto') + setFocused('button') + }} + > +
+
+
+ accent → + { + const next = [...keys][0] + if (typeof next === 'string' && next in RAMPS) + setRamp(next as RampKey) + }} + size="sm" + aria-label="Primitive ramp the accent alias points to" + className="max-w-full overflow-x-auto" + > + {(Object.keys(RAMPS) as RampKey[]).map((k) => ( + + {RAMP_LABELS[k]} + + ))} + +
+
+ solid step + { + const next = Number([...keys][0]) + if (next >= 4 && next <= 7) setStepIdx(next) + }} + size="sm" + aria-label="Ramp step for the accent solid" + className="max-w-full overflow-x-auto" + > + 400 + 500 + 600 + 700 + +
+
+ fg-on-accent + { + const next = [...keys][0] + if (next === 'auto' || next === 'white' || next === 'black') + setOnAccent(next) + }} + size="sm" + aria-label="Button label pairing policy" + className="max-w-full overflow-x-auto" + > + Auto + White + Black + +
+
+ +
+
+ + Hover any part to read its wiring + +
+
+
+ + Quarterly review + + + In progress + +
+ + Sarah · 2h ago + +
+ + Reply + + + Open thread + +
+
+
+
+ {(wires[focused] ?? []).map((w) => ( +
+ {w.slot} + + {w.semantic} + + {w.primitive} + + + {w.value} +
+ ))} +
+
+ +
+ + Pairing checker — WCAG ≥ 4.5 floor · |Lc| ≥ 60 target + +
+ + + + + + + + + + {promises.map((p) => ( + + + + + + ))} + +
promiseWCAGAPCA
{p.name} + {p.w.toFixed(2)}:1 {p.passW ? '✓' : '✕'} + + Lc {p.lc.toFixed(1)} {p.passA ? '✓' : '⚠'} +
+
+ + {bothPass} of {promises.length} promises hold on both meters + {wcagOnly > 0 && <> · {wcagOnly} pass the WCAG floor only} + {broken > 0 && <> · {broken} below the legal floor} + +
+
+
+
+ ) +} diff --git a/src/components/playgrounds/tonal-palette-role-lab.tsx b/src/components/playgrounds/tonal-palette-role-lab.tsx new file mode 100644 index 0000000..1f87d4a --- /dev/null +++ b/src/components/playgrounds/tonal-palette-role-lab.tsx @@ -0,0 +1,335 @@ +import { useMemo, useState } from 'react' +import { clampChroma, converter, formatHex, wcagContrast } from 'culori' + +import { apcaLc } from '@/lib/apca' +import { cn } from '@/lib/utils' +import { Playground } from '@/components/playground' +import { ToggleButton } from '@/ui/toggle-button' +import { ToggleButtonGroup } from '@/ui/toggle-button-group' + +const toLab = converter('lab') +const toOklch = converter('oklch') + +// HONEST APPROXIMATION: Tone is kept EXACT — every swatch is solved so its CIE +// L* equals the requested Tone (the axis that carries the contrast argument). +// Hue and chroma are OKLCH stand-ins for CAM16. Models Material's policy, not +// the HCT space. See the chapter. +function toneColorOf(hue: number, chroma: number, tone: number) { + let lo = 0 + let hi = 1 + for (let i = 0; i < 22; i++) { + const mid = (lo + hi) / 2 + const c = clampChroma( + { mode: 'oklch' as const, l: mid, c: chroma, h: hue }, + 'oklch', + ) + if ((toLab(c)?.l ?? 0) < tone) lo = mid + else hi = mid + } + return clampChroma( + { mode: 'oklch' as const, l: (lo + hi) / 2, c: chroma, h: hue }, + 'oklch', + ) +} +const hexOf = (hue: number, chroma: number, tone: number) => + formatHex(toneColorOf(hue, chroma, tone)) + +type Scheme = 'light' | 'dark' +type Level = 'standard' | 'medium' | 'high' + +const SEEDS = [ + { id: 'violet', label: 'M3 Violet', hex: '#6750A4' }, + { id: 'blue', label: 'Google Blue', hex: '#0b57d0' }, + { id: 'red', label: 'Error Red', hex: '#b3261e' }, + { id: 'neutral', label: 'Near-neutral', hex: '#5b6066' }, +] as const +type SeedId = (typeof SEEDS)[number]['id'] + +// contrastLevel is numeric in Material (-1..1). Widening each pair's tones by k +// per side is a faithful approximation of the per-role ContrastCurve. +const LEVEL_K: Record = { standard: 0, medium: 3, high: 6 } +const PALETTE_TONES = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 99, 100] +const NEUTRAL_C = 0.01 + +// Baseline scheme.ts Tone picks. +const PAIRS: { + label: string + pal: 'accent' | 'neutral' + base: Record + on: Record +}[] = [ + { + label: 'primary / on-primary', + pal: 'accent', + base: { light: 40, dark: 80 }, + on: { light: 100, dark: 20 }, + }, + { + label: 'container / on-container', + pal: 'accent', + base: { light: 90, dark: 30 }, + on: { light: 10, dark: 90 }, + }, + { + label: 'surface / on-surface', + pal: 'neutral', + base: { light: 99, dark: 10 }, + on: { light: 10, dark: 90 }, + }, + { + label: 'surface-var / on-surface-var', + pal: 'neutral', + base: { light: 90, dark: 30 }, + on: { light: 30, dark: 80 }, + }, +] + +function widen(base: number, on: number, k: number): [number, number] { + if (on > base) return [Math.max(0, base - k), Math.min(100, on + k)] + return [Math.min(100, base + k), Math.max(0, on - k)] +} + +export function TonalPaletteRoleLab() { + const [seedId, setSeedId] = useState('violet') + const [scheme, setScheme] = useState('light') + const [level, setLevel] = useState('standard') + + const seedHex = SEEDS.find((s) => s.id === seedId)?.hex ?? '#6750A4' + const k = LEVEL_K[level] + + const { aH, aC } = useMemo(() => { + const o = toOklch(seedHex) + return { aH: o?.h ?? 300, aC: o?.c ?? 0.12 } + }, [seedHex]) + + const chromaOf = (pal: 'accent' | 'neutral') => + pal === 'accent' ? aC : NEUTRAL_C + + const pairs = PAIRS.map((p) => { + const [baseTone, onTone] = widen(p.base[scheme], p.on[scheme], k) + const c = chromaOf(p.pal) + const baseHex = hexOf(aH, c, baseTone) + const onHex = hexOf(aH, c, onTone) + const delta = Math.abs(baseTone - onTone) + const w = wcagContrast(onHex, baseHex) + const lc = apcaLc(onHex, baseHex) + return { + ...p, + baseHex, + onHex, + delta, + w, + lc, + pass: w >= 4.5 && Math.abs(lc) >= 60, + } + }) + + const passing = pairs.filter((p) => p.pass).length + + // Resolved roles for the mini card. + const primary = pairs[0]?.baseHex ?? '#000' + const onPrimary = pairs[0]?.onHex ?? '#fff' + const container = pairs[1]?.baseHex ?? '#000' + const onContainer = pairs[1]?.onHex ?? '#fff' + const surface = pairs[2]?.baseHex ?? '#fff' + const onSurface = pairs[2]?.onHex ?? '#000' + + // Nominal accent tones the primary + container roles read (for the strip). + const usedTones = new Set([ + PAIRS[0]?.base[scheme] ?? -1, + PAIRS[0]?.on[scheme] ?? -1, + PAIRS[1]?.base[scheme] ?? -1, + PAIRS[1]?.on[scheme] ?? -1, + ]) + + return ( + { + setSeedId('violet') + setScheme('light') + setLevel('standard') + }} + > +
+
+
+ seed + { + const next = [...keys][0] + if ( + typeof next === 'string' && + SEEDS.some((s) => s.id === next) + ) + setSeedId(next as SeedId) + }} + size="sm" + aria-label="Seed color" + className="max-w-full overflow-x-auto" + > + {SEEDS.map((s) => ( + + {s.label} + + ))} + +
+
+ scheme + { + const next = [...keys][0] + if (next === 'light' || next === 'dark') setScheme(next) + }} + size="sm" + aria-label="Scheme" + className="max-w-full overflow-x-auto" + > + Light + Dark + +
+
+ contrast + { + const next = [...keys][0] + if (next === 'standard' || next === 'medium' || next === 'high') + setLevel(next) + }} + size="sm" + aria-label="Contrast level" + className="max-w-full overflow-x-auto" + > + Standard + Medium + High + +
+
+ +
+ + Accent tonal palette — one hue, one chroma, Tone 0→100. Ringed cells + are the tones the primary and container roles read. + +
+ {PALETTE_TONES.map((t) => ( +
+
+ + {t} + +
+ ))} +
+
+ +
+
+ Roles, painted +
+ + Quarterly review + +
+ + In progress + + + Awaiting review from Sarah + +
+ + Reply + +
+
+ +
+ + Pairs — delta drives contrast, on both meters + +
+ + + + + + + + + + + {pairs.map((p) => ( + + + + + + + ))} + +
pairΔWCAGAPCA
{p.label}{p.delta}= 4.5 ? 'text-fg-success' : 'text-fg-warning', + )} + > + {p.w.toFixed(2)}:1 + = 60 + ? 'text-fg-success' + : 'text-fg-warning', + )} + > + Lc {p.lc.toFixed(1)} +
+
+ + {passing} of {pairs.length} pairs clear both meters — constructed + by Tone delta, not one of them audited. + +
+
+
+ + ) +} diff --git a/src/components/playgrounds/tonal-palette-strip.tsx b/src/components/playgrounds/tonal-palette-strip.tsx new file mode 100644 index 0000000..30aae64 --- /dev/null +++ b/src/components/playgrounds/tonal-palette-strip.tsx @@ -0,0 +1,91 @@ +import { clampChroma, converter, formatHex } from 'culori' + +import { Demo } from '@/components/demo' + +const toLab = converter('lab') +const toOklch = converter('oklch') + +// Tone kept EXACT: solve OKLCH L so the sRGB result's CIE L* equals the tone. +// Hue/chroma are OKLCH stand-ins for CAM16 — the point is that dropping chroma +// on the same hue turns the accent palette into a warm-gray ramp. +function hexOf(hue: number, chroma: number, tone: number): string { + let lo = 0 + let hi = 1 + for (let i = 0; i < 22; i++) { + const mid = (lo + hi) / 2 + const c = clampChroma( + { mode: 'oklch' as const, l: mid, c: chroma, h: hue }, + 'oklch', + ) + if ((toLab(c)?.l ?? 0) < tone) lo = mid + else hi = mid + } + return formatHex( + clampChroma( + { mode: 'oklch' as const, l: (lo + hi) / 2, c: chroma, h: hue }, + 'oklch', + ), + ) +} + +const SEED = '#6750A4' // M3 baseline violet +const TONES = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 99, 100] +const NEUTRAL_C = 0.01 + +export function TonalPaletteStrip() { + const seed = toOklch(SEED) + const h = seed?.h ?? 300 + const accentC = seed?.c ?? 0.13 + + const rows = [ + { label: 'accent', chroma: accentC }, + { label: 'neutral', chroma: NEUTRAL_C }, + ] + + return ( + + One tonal palette is one hue and one chroma with Tone swept + 0→100. The top row holds the seed’s chroma; the bottom row + drops chroma to {NEUTRAL_C} at the same hue — a low-chroma tonal + palette is a warm-gray ramp (chapter 15), the same object + with chroma turned down. Tone is exact: each swatch is solved so its + CIE L* equals its label. + + } + > +
+ {rows.map((row) => ( +
+ + {row.label} + +
+ {TONES.map((t) => ( +
+ ))} +
+
+ ))} +
+ +
+ {TONES.map((t) => ( + + {t} + + ))} +
+
+
+ + ) +} diff --git a/src/components/playgrounds/tone-is-contrast.tsx b/src/components/playgrounds/tone-is-contrast.tsx new file mode 100644 index 0000000..7d086f7 --- /dev/null +++ b/src/components/playgrounds/tone-is-contrast.tsx @@ -0,0 +1,108 @@ +import { useState } from 'react' +import { clampChroma, converter, formatHex, wcagContrast } from 'culori' + +import { apcaLc } from '@/lib/apca' +import { cn } from '@/lib/utils' +import { Demo } from '@/components/demo' +import { ToggleButton } from '@/ui/toggle-button' +import { ToggleButtonGroup } from '@/ui/toggle-button-group' + +const toLab = converter('lab') + +// Tone is kept EXACT: solve OKLCH L so the sRGB result's CIE L* equals the +// requested tone (0-100). Hue/chroma are OKLCH stand-ins for CAM16 — a neutral +// swatch here isolates the tone gap from both. +function toneHex(hue: number, chroma: number, tone: number): string { + let lo = 0 + let hi = 1 + for (let i = 0; i < 22; i++) { + const mid = (lo + hi) / 2 + const c = clampChroma( + { mode: 'oklch' as const, l: mid, c: chroma, h: hue }, + 'oklch', + ) + if ((toLab(c)?.l ?? 0) < tone) lo = mid + else hi = mid + } + return formatHex( + clampChroma( + { mode: 'oklch' as const, l: (lo + hi) / 2, c: chroma, h: hue }, + 'oklch', + ), + ) +} + +const SURFACE_TONE = 90 +const TEXT_TONES = [70, 60, 50, 40, 30] + +export function ToneIsContrast() { + const [tone, setTone] = useState(50) + + const surface = toneHex(0, 0, SURFACE_TONE) + const text = toneHex(0, 0, tone) + const gap = SURFACE_TONE - tone + const w = wcagContrast(text, surface) + const lc = apcaLc(text, surface) + const passW = w >= 4.5 + const passA = Math.abs(lc) >= 60 + + return ( + + Two neutral tones, the surface held at Tone {SURFACE_TONE}. Widen the + gap and both meters climb together — contrast is a function of + the Tone delta and nothing else. Near a gap of 40 the pair clears 3:1; + near 50 it clears 4.5:1 (approximate). No hue, no chroma, no audit. + + } + > +
+
+ text Tone + { + const next = Number([...keys][0]) + if (TEXT_TONES.includes(next)) setTone(next) + }} + size="sm" + aria-label="Text tone against a Tone-90 surface" + className="max-w-full overflow-x-auto" + > + {TEXT_TONES.map((t) => ( + + {t} + + ))} + +
+ +
+ + Tone {tone} on Tone {SURFACE_TONE} + +
+ + + Tone delta {gap} · WCAG{' '} + + {w.toFixed(2)}:1 {passW ? '✓' : w >= 3 ? '(3:1)' : '✕'} + {' '} + · APCA{' '} + + Lc {lc.toFixed(1)} {passA ? '✓' : '⚠'} + + +
+
+ ) +} diff --git a/src/components/playgrounds/tone-versus-oklch-l.tsx b/src/components/playgrounds/tone-versus-oklch-l.tsx new file mode 100644 index 0000000..140472e --- /dev/null +++ b/src/components/playgrounds/tone-versus-oklch-l.tsx @@ -0,0 +1,70 @@ +import { converter, formatHex, wcagContrast } from 'culori' + +import { Demo } from '@/components/demo' + +const toLab = converter('lab') + +// Tone is CIE L* (0-100). This chapter's engine substitutes OKLCH L (x100). +// Both are monotonic in luminance, so both rank contrast the same way — but +// they are different rulers, and this table is the size of the gap. +const oklchGray = (t: number) => + formatHex({ mode: 'oklch' as const, l: t / 100, c: 0, h: 0 }) + +const ROWS = [20, 30, 40, 50, 60, 70, 80] + +export function ToneVersusOklchL() { + const rows = ROWS.map((t) => { + const hex = oklchGray(t) + const lstar = toLab(hex)?.l ?? 0 + return { t, hex, lstar, over: t - lstar, w: wcagContrast(hex, '#ffffff') } + }) + const maxOver = Math.max(...rows.map((r) => r.over)) + const l50 = rows.find((r) => r.t === 50)?.lstar ?? 42 + + return ( + + OKLCH L runs above true Tone (L*) by up to {maxOver.toFixed(0)} points + through the dark and mid range — an OKLCH-L 50 gray measures + Tone {l50.toFixed(0)}, not 50. Both order contrast identically, so + either works as the lever; they just number the axis differently. That + is why Material’s L*-based tone deltas cannot be pasted into an + OKLCH-L engine as constants. + + } + > +
+ + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + ))} + +
swatchOKLCH L ×100Tone (L*)over L*on white
+ + {r.t}{r.lstar.toFixed(1)} + +{r.over.toFixed(1)} + {r.w.toFixed(2)}:1
+
+
+ ) +} diff --git a/src/config/curriculum.ts b/src/config/curriculum.ts index 70ed4b6..3253ae3 100644 --- a/src/config/curriculum.ts +++ b/src/config/curriculum.ts @@ -127,25 +127,25 @@ export const curriculum: Part[] = [ slug: 'semantic-tokens', number: 17, title: 'Semantic tokens: tiers, naming, pairing', - status: 'planned', + status: 'published', }, { slug: 'states-alpha-status', number: 18, title: 'States, alpha, and status colors', - status: 'planned', + status: 'published', }, { slug: 'data-viz-palettes', number: 19, title: 'Color for data visualization', - status: 'planned', + status: 'published', }, { slug: 'shipping-color', number: 20, title: 'Shipping color in CSS', - status: 'planned', + status: 'published', }, ], }, @@ -158,31 +158,31 @@ export const curriculum: Part[] = [ slug: 'radix-colors', number: 21, title: 'Radix Colors', - status: 'planned', + status: 'published', }, { slug: 'tailwind', number: 22, title: 'Tailwind CSS', - status: 'planned', + status: 'published', }, { slug: 'material-hct', number: 23, title: 'Material 3 & HCT', - status: 'planned', + status: 'published', }, { slug: 'spectrum-leonardo', number: 24, title: 'Adobe Spectrum & Leonardo', - status: 'planned', + status: 'published', }, { slug: 'geist', number: 25, title: 'Vercel Geist', - status: 'planned', + status: 'published', }, ], }, diff --git a/vite.config.ts b/vite.config.ts index efa28b9..c8c19fc 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -19,6 +19,8 @@ export default defineConfig({ 'react-aria', 'react-aria-components/ToggleButton', 'react-aria-components/ToggleButtonGroup', + 'react-aria-components/Switch', + 'react-aria-components/Label', ], }, resolve: {