diff --git a/.gitignore b/.gitignore index 4cad133b..fc09e3fd 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,10 @@ packages/theming/tests/e2e/*.css.map # Agents and related files .opencode .pi + +# Copied in by `npm run prepack` and removed again by `postpack` +packages/theming/README.md +packages/theming/LICENSE + +# Scratch space +_to_delete/ diff --git a/biome.json b/biome.json index f8668ff7..a7375b8d 100644 --- a/biome.json +++ b/biome.json @@ -4,6 +4,7 @@ "includes": [ "packages/theming/**/*.{ts,js,mjs,cjs}", "packages/mcp/src/**/*.ts", + "packages/preview/**/*.ts", "!!**/dist" ] }, @@ -37,7 +38,9 @@ "noUnusedPrivateClassMembers": "off", "useImportExtensions": { "level": "error", - "options": { "forceJsExtensions": true } + "options": { + "forceJsExtensions": true + } } }, "style": { @@ -76,6 +79,16 @@ } } }, + { + "includes": ["packages/preview/src/**/*.ts"], + "linter": { + "rules": { + "suspicious": { + "noConsole": "off" + } + } + } + }, { "includes": ["packages/mcp/src/knowledge/**/*.ts"], "linter": { diff --git a/openspec/changes/archive/2026-09-17-preview-app-sections/.openspec.yaml b/openspec/changes/archive/2026-09-17-preview-app-sections/.openspec.yaml new file mode 100644 index 00000000..f0807784 --- /dev/null +++ b/openspec/changes/archive/2026-09-17-preview-app-sections/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-16 diff --git a/openspec/changes/archive/2026-09-17-preview-app-sections/blog-draft.md b/openspec/changes/archive/2026-09-17-preview-app-sections/blog-draft.md new file mode 100644 index 00000000..f1f526fa --- /dev/null +++ b/openspec/changes/archive/2026-09-17-preview-app-sections/blog-draft.md @@ -0,0 +1,135 @@ +# Ten shades, one seed, and a guarantee + +*Draft. The `` elements are the live demos — each one is a custom element from the preview bundle, and everything it shows is computed in the page, not a screenshot.* + +--- + +Here is an uncomfortable fact about the palette Ignite UI has shipped for years. + +Take its light Material theme. Take any of its colour families — primary, secondary, info, success, warn, error. Take shade 50 and shade 500, or 100 and 600, or any pair five steps apart. Check the contrast. + +All thirty pairs fail WCAG AA. + +Not *some*. Thirty of thirty. And Bootstrap and Fluent, built the same way, fail thirty of thirty too. If you have ever put shade 100 behind shade 600 and assumed the numbers were doing some work for you, they were not. + +We rebuilt the shade generator. The new one fails none of them — not on these palettes, and not on the 1,080 seeds we threw at it. This post is about how, and more importantly about how you can check rather than believe. Every demo below runs the real thing. + +## Pick a palette and look + + + +Three counters at the top, all computed live from the shades on screen: + +- **AA pairs that fail.** Any two shades 500 apart. Zero now, thirty before. +- **Duplicate shades.** Two tokens resolving to the same colour, so a border quietly vanishes into its fill. +- **Shades outside sRGB.** Colours no display can show, which the browser clips back to something it can — twenty of Material's sixty, fifteen of Bootstrap's and Fluent's. + +Both sides start from exactly the same seeds. We read them out of the shipped palette rather than retyping them, so the comparison has nowhere to hide. + +## Why a multiplier table cannot work + +The old generator did what most of them do: take the seed in HSL, multiply saturation and lightness by a fixed table, ten entries in, ten entries out. It is simple, it is fast, and it is wrong for a reason worth understanding. + +sRGB is not a box. It is a lumpy solid, and its shape changes with hue. The widest point of a hue — its **cusp**, where that hue holds the most colour before the display gives up — moves enormously as you go around the wheel. Across the spectrum the cusp varies by **more than twofold in chroma** and about **twofold in lightness**, and it never sits at either end of the lightness axis. + +So a multiplier that produces a pleasant ramp for blue produces a washed-out one for yellow and an impossible one for magenta. "Impossible" is literal: the generator asks for a colour outside sRGB, and the browser clips it. Two shades that asked to be different land on the same clipped edge and become the same colour. + +The new generator inverts the problem. Instead of multiplying the seed and hoping, it decides **what contrast each shade should have** and then finds the colour that hits it — working in OKLCH, cutting against the actual gamut boundary for that specific hue, and tapering chroma toward both ends so 50 reads as a tint and 900 as a near-neutral rather than as mud. + +The guarantee falls out of the construction: any two shades 500 apart clear AA, because the contrast targets were chosen so they would. + +## The receipts + + + +Drag the hue slider. That is 360 hues at three saturations — **1,080 seeds**, both generators, every ramp precomputed so you can sweep it in real time. + +The totals: + +| | legacy | fitted | +|---|---|---| +| AA pairs that fail | 5,182 of 5,400 | **0** | +| Seeds with a clean ramp | 0 of 1,080 | **1,080** | +| Shades outside sRGB | 1,800 of 10,800 | **0** | + +Zero seeds out of 1,080 produce a fully clean ramp under the old generator. Not a bad average — *none*. + +One honest note on that gamut figure, because it is easy to overstate: all 1,800 clipped shades come from the 85%-saturation row. At 60% and 35% there are none. Clipping is a saturated-seed problem, not a universal one. The AA failures are universal. + +The panel underneath is a slice through sRGB at whatever hue you have selected. The lit region is every colour that hue can produce; the hollow rings are shades the old table asked for and could not have, with a leader to where they actually landed. Sweep the hue and watch the region change shape — that changing shape is the whole argument against a fixed table, drawn. + +## Surface was never a ramp + +The old palette gave `surface` ten numbered shades, exactly like a colour family. It is not a colour family. It is the page, plus the layers that sit on it. + +On a white page that fell apart completely. Material's light surface ramp has **five duplicate shades** and fails **all five** AA pairs — because a generator asked to make ten distinct steps away from `#ffffff` simply runs out of room and returns white, repeatedly. + +Surface now has five named roles instead: `base`, `sunken`, `raised`, `overlay`, `container`. A role says what it is for, which means a component can ask for the right one rather than guessing that 200 is probably a card. And when a role has nowhere to go — `raised` on a pure-white page — it resolves onto the background deliberately, and the elevation shadow carries the depth instead of a colour difference that was never going to be visible. + + + +The mock on the left is built entirely from those roles, text included. Switch the theme and watch it hold together in both. + +The grayscale below it changed too: it is anchored to the page rather than to white, so shade 50 always sits nearest the background whichever theme you are in. No more inverting your mental model between light and dark. + +## The knobs + +Here is the part that actually differentiates this from a generator you cannot argue with. + +A **scale** decides where the ten shades sit between the lightest and the darkest. It has two parts: + +- **`range`** — the contrast of shade 50 and shade 900. `1.182` to `18.232` by default, measured against the anchor. +- **`curve`** — an optional cubic-bezier that redistributes the steps in between. No curve means evenly spaced by contrast. + +That is it. Two numbers and four control points, and they are yours. + + + +Four scales ship. `even` is the default and gives maximum separation. `material` reproduces the rhythm our grayscale has always had. `tailwind` matches Tailwind v4's slate, `carbon` matches IBM Carbon's gray 10–100 — both included because "make our palette feel like theirs" is a real request and a scale is the honest way to answer it. + +Drag the two handles on the chart and watch the ramp below redraw. Bunch the light end and watch the guarantee ladder go red as pairs drop under 4.5:1. That is the trade a scale makes, made visible. + +Two things this demo will tell you that a marketing page would not: + +**The default gray scale knowingly costs you two pairs.** Select `material` and the record reads 3 of 5. The gray family defaults to that scale because the familiar rhythm was worth more to us than two pairs on a family that is mostly used for text and borders. It is a choice, it is documented, and `even` undoes it in one line. + +**The guarantee is conditional on the range being reachable.** Switch the subject to a neutral on a dark page and push the range to its maximum. It fails — not because the generator broke, but because white on a `#222` page tops out around 15.6:1 and you asked for 18.2:1. The demo reports each subject's ceiling for exactly this reason. A guarantee that quietly degraded instead of telling you would be worse than no guarantee. + +## What you write + +```scss +$palette: palette( + $primary: #0099ff, + $secondary: #df1b74, + $gray: #000000, + $surface: #ffffff +); +``` + +Same call, same seeds, better shades. If you want a different rhythm: + +```scss +$palette: palette( + $primary: #0099ff, + $surface: #ffffff, + $scales: ('gray': 'carbon') +); +``` + +Or hand it a range and a curve directly: + +```scss +$scales: ('primary': (range: 1.1 14, curve: 0.5 0 0.8 0.8)) +``` + +**One migration note, stated plainly.** Surface no longer emits ten numbered shades, and components that still reference `--ig-surface-500` will need either the named roles or an alias onto `base`. That is the one breaking edge in this change, and it is the price of surface finally meaning something. + +## Why the demos and not screenshots + +Every number in this post is computed in your browser, from the same Sass that ships. The contrast ratios are measured, not asserted. The gamut cross-section is the real boundary for the hue you selected. The failing pairs go red because they failed, not because a designer coloured them red. + +We think that is the right way to make a claim about colour. If we have got something wrong, the demo above will show you before we do. + +--- + +*`igniteui-theming` is open source. The preview app these demos come from lives in the repository, and every figure here is covered by a test.* diff --git a/openspec/changes/archive/2026-09-17-preview-app-sections/design.md b/openspec/changes/archive/2026-09-17-preview-app-sections/design.md new file mode 100644 index 00000000..03697fcc --- /dev/null +++ b/openspec/changes/archive/2026-09-17-preview-app-sections/design.md @@ -0,0 +1,241 @@ +## Context + +`packages/preview` is 1,272 lines with two devDependencies and no client JavaScript. Its page is generated at build time by a Vite plugin that fills placeholders in `index.html` with markup built from compiled palettes. That is a good shape for a report and the wrong shape for everything we now want the package to do. + +The constraint that drives most of the decisions below: **the generator lives in Sass and only in Sass.** Shades cannot be computed in the browser. Anything interactive has to be served from data compiled at build time, which makes the data layer the load-bearing piece rather than the rendering layer. + +## Goals / Non-Goals + +**Goals:** + +- One application that teaches the framework by letting someone operate it, not a gallery of separate pages +- Adding a pillar costs a folder and a registry entry, never a shell edit +- Every demo looks and behaves like part of one product, because the shared pieces make consistency the path of least resistance +- Regressions in the claims the app makes fail the test suite, not the reader's attention +- Demos remain embeddable outside this app — in the sassdoc output, a marketing page, or a sandbox + +**Non-Goals:** + +- Porting the shade generator to TypeScript. The lookup strategy in Decision 8 removes the need, and a second implementation of the algorithm would be a correctness liability +- Implementing the typography, elevations, sizing, spacing or roundness sections. This change establishes the structure that will hold them +- Publishing this package or any component from it +- Server-side rendering or hydration. The app is a local and internal tool + +## Decisions + +### 1) Lit as the rendering layer + +Adopt `lit` for demos and the shell. + +Rationale: the package acquires a client runtime for the first time here, so the question is not whether to leave vanilla but what the new runtime should be. Lit is already the org's component technology (`igniteui-webcomponents`), so it is existing team knowledge. Its output is custom elements, which makes a demo portable into the sassdoc site or any other page without build coupling — that portability is what allows the demos to be reused rather than reimplemented. + +Alternatives considered: + +- **Imperative DOM with a tagged-template helper**: this is what the prototypes used. It works at this size, but its full-rebuild pattern (`replaceChildren()` on every state change) destroys DOM identity, which already produced two defects during prototyping: a pointer capture lost when an SVG re-rendered mid-drag, and keyboard focus needing manual restoration after each update. Both are the class of problem a diffing renderer removes. +- **React or Svelte**: neither offers anything Lit does not for this use, and both are unfamiliar in this repository. + +### 2) Composition over inheritance: the frame belongs to the shell + +A demo is a custom element with no base class and no required methods. The shell renders the surrounding frame — title and the one-line statement of what the demo teaches — from the registry metadata it already holds. Recurring behaviour is offered as elements a demo may use: `` for generated Sass with its copy control, `` for a pass/fail readout, `` for applying a compiled palette. + +Rationale: an earlier draft of this design fixed a four-part anatomy — controls, stage, verdict, generated Sass — in an abstract base class. That anatomy was generalised from three demos of a single pillar, and color is the pillar least like the others. Contrast is objectively measurable, so a verdict is natural there; a type scale's quality signals are line length and minimum size, which are guidelines rather than thresholds, and elevation's real question is whether one level reads as above another, which is perceptual. Sizing, spacing and roundness are multipliers whose stage is a real component being resized and which have no honest number at all. A contract that presumes a verdict, or presumes that a stage is a visualisation rather than a specimen, would have to be worked around by the second pillar to arrive. + +Moving the frame into the shell also keeps demos portable. A demo placed in the documentation site or a post should not carry this application's chrome with it, which a base class rendering that chrome would force it to do. + +Alternatives considered: + +- **A base class with optional hooks**: still presumes a rendering shape, and still couples every demo to the application it happens to live in. +- **No shared elements at all**: consistency degrades and each pillar reinvents the copy affordance and the palette scope. + +Trade-off: consistency across pillars becomes a convention upheld in review rather than a constraint enforced by the type system. For an application with a small number of authors that is the cheaper error — an inconsistent demo is a review comment, whereas a wrong abstraction spread across six pillars is a migration. + +### 3) Build-time data providers behind virtual modules + +A provider declares an id, the Sass paths that invalidate it, and a build function. A Vite plugin registers them and serves each as `virtual:data/`. + +```ts +interface DataProvider { + id: string; + deps: string[]; + build(): Promise; +} +``` + +Rationale: every pillar needs Sass compiled to JSON, and without a shared contract each will grow its own script — which is exactly how `previewShades.mjs` became unmaintainable. One mechanism, declared dependencies, uniform loading. + +### 4) On-disk caching keyed on inputs + +Provider output is cached under the package's build directory, keyed on a hash of its `deps` contents plus the provider module's own source. + +Rationale: the color scale tables take roughly 40 seconds of Sass to compile. Uncached, that lands on every `npm run preview`. Hashing the provider source as well as its inputs means editing the provider invalidates its own cache, which a naive mtime check would miss. CI is unaffected either way, since it never builds this package. + +### 5) Per-demo lazy loading of both code and data + +Sections are code-split, and a demo's data module is dynamically imported when the demo first renders. + +Rationale: the color tables are roughly 300 KB. Someone reading about spacing should not download them. This is nearly free to set up now and awkward to retrofit once several pillars exist. + +### 6) The static report becomes a demo; its assertions become tests + +The generator comparison is ported to ``. The AA-pair, duplicate-shade and out-of-gamut counts it displayed are asserted in the vitest project instead. + +Rationale: the page's regression value was always conditional on someone looking at it. Assertions belong where they fail the build. What remains — seeing thirty palettes side by side — is a legitimate demo, so the visuals stay and the checking moves. + +### 7) Hash routing, no router dependency + +`#/color/scales` selects section and demo. Unknown routes fall back to the first section. + +Rationale: deep links make demos citable from the docs and from a post. A hash router for a flat two-level structure is a few lines; a routing library is not warranted. + +### 8) Lookup tables generated through a degenerate range + +For the scale editor, `shades()` is compiled at N contrast targets using a `range` whose endpoints are equal. Every shade then solves to the same contrast while keeping its own position-dependent chroma taper, producing an exact `(position, target) -> color` table the editor reads. + +Rationale: a shade's color depends only on its position and its contrast target, so the table is complete in structure; sampling makes it approximate only in resolution. Across every subject and all four shipped presets, 78% of shades match the generator bit for bit, 99% are within one step per channel and none is more than two — far below a perceptible difference, and asserted in `scales.spec.ts` so it cannot drift unnoticed. This is what makes live `range` and `curve` manipulation possible without a second implementation of the generator. + +Alternatives considered: + +- **Port the generator to TypeScript**: enables arbitrary seeds entered at runtime, at the cost of two implementations that must be kept in agreement. Deferred until a feature actually requires it. +- **Bake only the four presets**: no editor, and the knobs stay abstract. + +### 9) The sweep covers hue and saturation, not hue alone + +Seeds are `hsl(h, s%, 50%)` at three saturations rather than one. + +Rationale: a hue-only sweep tests a single slice of seed space, and the obvious objection to any result from it is that maximally saturated seeds are unrepresentative. Widening the grid answers that before it is raised, and the answer is not uniform: the AA result holds everywhere — no seed at any of the three saturations clears all five pairs under `legacy`, against every seed clearing them under `fitted` — while the out-of-gamut result turns out to be specific to saturated seeds, appearing at 85% and not at all at 60% or 35%. Reporting both accurately is worth more than a larger headline. + +Known gap: the grid fixes lightness at 50%, so near-white and near-black seeds are not represented, and those are exactly where duplicate shades come from. The generator comparison covers that end with named seeds. Neither view should be presented as exhaustive on its own. + +### 10) App chrome styled from the library's own neutrals + +The shell takes its surface, text and line colors from the `gray` family and surface roles the library generates. + +Rationale: dogfooding, and a constraint rather than a preference — chrome carrying its own hue would contaminate how a reader perceives the swatches under test. The gray family is both the honest demonstration and the correct neutral. Demo content applies its scoped palettes through a wrapper element rather than ancestor classes, so a demo stays self-contained when embedded elsewhere. Custom properties inherit through shadow boundaries, so `var(--ig-*)` resolves inside components without further work. + +### 11) The build is served, not opened + +Code splitting produces several chunks, so the output is no longer a single file that works from `file://`. `npm run preview` runs the dev server and `vite preview` serves a build. + +Rationale: the previous report was a single self-contained page precisely so it could be emailed and double-clicked. An application with lazily loaded sections cannot be that, and should not pretend to be. What is lost is a convenience the report had; what is gained is that a reader never downloads a pillar they did not open. + +### 12) A palette travels with the markup that uses it + +`` sets one scope's `--ig-*` declarations on itself rather than the document carrying a stylesheet of scoped rules. It is a plain custom element with no render method, so the children written by whoever used it survive. + +Rationale: a demo that depends on a stylesheet another part of the app injected only works inside that app, which defeats the portability the whole composition approach is for. Setting the properties on the element means the palette is part of the markup, and custom properties inherit through shadow boundaries, so children resolve them wherever the element is mounted. + +It also removed a duplicate: the provider had been carrying both the stylesheet text and the same declarations parsed per scope. Dropping the stylesheet took the color chunk from 197 KB back to 111 KB. + +### 13) Every SVG fragment uses Lit's `svg` tag + +Any template inserted inside an `` is built with `svg` rather than `html`. + +Rationale: Lit parses each template independently, and `html` parses in the HTML namespace. A nested `html` fragment holding `` or `` therefore produces HTML elements with those names, which render nothing and cannot take focus. The failure is quiet — the markup is present, the selectors match, and the plot is simply empty — so it is worth stating as a rule rather than rediscovering. + +### 14) Straight-line curve handles sit at the identity control points + +When `curve` is null the editor still shows two handles, placed at 1/3 and 2/3. + +Rationale: a cubic bezier with control points at 1/3 and 2/3 reduces exactly to `t`, so the handles can be visible and grabbable while the scale is still a straight line, and the ramp does not shift when they appear. Rendering handles only once a preset had been chosen left the editor with nothing to drag on first load. + +### 15) A section is one page, with its controls pinned + +A section renders all of its views stacked on a single page, under a sticky bar holding the controls they share. The in-section nav scrolls rather than swaps. + +Rationale: tabs implied the views were independent subjects, and the seed picker had to be repeated in each one. Once the whole section reads one selection, tabs actively hide the payoff — the reader changes a palette and cannot see the other three views respond. Stacking costs nothing at load: an `IntersectionObserver` imports each view as it nears the viewport, so the code-splitting from Decision 5 still holds, and the first paint still carries one view's chunk. + +### 16) Section state lives in a controller, not in the shell + +The color section owns a small store and a `ReactiveController` over it. The shell knows only that a section may declare a persistent controls element; it never reads or writes what that element controls. + +Rationale: the pillars will not agree on what "the controls" are — typography has no seed, elevations have no theme — so a state shape in the shell would be a color-shaped shape imposed on everything else. A controller also makes a view independent of where it is mounted, which keeps Decision 2's promise that a view is a plain custom element. The selection is mirrored into the hash as a query string so a link carries it, using `replaceState` rather than assignment: it is a selection, not a navigation, and a `hashchange` would send the shell scrolling. + +### 17) The demos' seeds are the shipped palettes, read out of the library + +Every seed on the page comes from `$--palette`, read at build time rather than transcribed, and the fitted side is regenerated from exactly those seeds. The scale editor derives its subjects the same way, deduplicated by `(family, seed, surface)`. + +Rationale: a comparison between generators is only honest if both start from the same input, and a reader trusts a palette they recognise more than nine seeds chosen to make a point. Reading the seeds also means the page cannot drift from the library: change a preset's seed and the demo changes with it. The earlier `color.palettes` provider, with its hand-picked adversarial seeds, is retired — the hue sweep already covers the adversarial case across 1080 seeds, and with far better standing. + +### 18) Sass color keywords are normalised where they are emitted + +A provider that interpolates a resolved color emits it through a helper that rewrites a legacy-rgb color as `rgba(...)`, leaving every other value alone. + +Rationale: Sass serialises a color to its shortest form, so a shade that happens to equal `#ffffff` interpolates as `white` and the Node-side parser throws. Teaching the parser 148 color keywords would put a table in the browser bundle to work around a serialisation detail. The one value that must not be rewritten is a legacy `hsl()`: its saturation routinely exceeds 100%, and asking for its red channel would both fail and discard the out-of-gamut position the sweep exists to show. + +### 19) The app's own controls are the library's components + +The pickers, the range controls and the action buttons are `igc-button-group`, `igc-toggle-button`, `igc-slider` and `igc-button` from `igniteui-webcomponents`, themed by the palette `shell/theme.scss` puts on `:root`. + +Rationale: an app that exists to show off a theming framework should be built from the components that framework themes. It is also a live check that the fitted generator produces a usable theme for real components rather than only for swatches — the shipped CSS reads `--ig-*` at runtime, so no rebuild of the component library is involved and what paints the controls is this branch's generator. + +What the token audit found, per component's compiled CSS: + +| component | palette families it reads | usable under a fitted palette | +| --- | --- | --- | +| `igc-button` | none — typography and sizing only | anywhere | +| `igc-button-group` | `gray`, `primary` | yes | +| `igc-slider` | `gray`, `primary`, `secondary`, `surface` | needs numbered surface tokens | +| `igc-color-picker` | `gray`, `primary`, `error`, `surface` | needs numbered surface tokens | + +Two consequences. The controls sit outside every ``, so they take the document theme and a demo scope cannot repaint them — the same reason `chrome()` resolves to concrete colors. And `--ig-surface-500`, which the shipped components reference 488 times, no longer exists under the fitted generator, which is Decision 20. + +Trade-offs: the package's `exports` map publishes only `.`, `./themes/*.css` and `./extras`, so there is no subpath to import four components from and the whole library comes with them — about 59 KB gzipped, loaded with the section's controls. And the components are compiled against the theming version pinned at their publish time, so their *structure* is a release behind this branch even though their *colors* are current. + +### 20) Numbered surface tokens are aliased onto the named roles + +`theme.scss` emits `--ig-surface-50` through `--ig-surface-900`, all resolving to `surface.base`. + +Rationale: the fitted generator replaced the numbered surface ramp with five named roles, and `igc-slider` — like every shipped component — still asks for `--ig-surface-500`. Aliasing every numbered token to `base` is the model rather than a fudge: under the fitted generator the page is one color, and depth is carried by the roles and the elevation shadow, which is what the Neutrals view says in prose. The library has the same decision to make for real consumers, and this is the concrete proposal. + +### 21) The segmented control is themed to the chrome neutrals + +`igc-button-group` is given the chrome's own colors through its public `--ig-button-group-item-*` tokens. + +Rationale: at its defaults the group paints itself in `primary`, and a blue control pinned above the swatches under test changes how those swatches read — the objection Decision 10 already makes about chrome carrying a hue of its own. Overriding published tokens is still using the component as a consumer would, so the dogfooding claim survives. The one action button keeps its `primary` tint, because it is the only control on the page that does something rather than selecting something. + +### 22) The hue slider stays bespoke + +Sweep's hue control remains a native `input[type=range]`. + +Rationale: its track is the hue wheel. `igc-slider` publishes only flat `--ig-slider-*-color` tokens with no hook for a gradient, so adopting it would mean overriding shadow parts to draw the one thing that makes the control legible — more coupling than the bespoke input costs. Everything else about it, including keyboard operation, already works. + +### 23) The component family is `indigo`, chosen independently of the palette + +`configureTheme('indigo', variant)` in `elements/ignite.ts`, with `shell/theme.scss` taking `indigo` for its typography and elevation presets. + +Rationale: family and palette are separate axes and the app is better for showing that. The family decides the components' structure — their density, shapes and the type and elevation scales they assume — while the colors still come from this branch's generator, so switching it changes how the controls are built without touching what the views are about. Both sides name the same family so a component never assumes a type scale the document does not emit. + +The variant is taken from `prefers-color-scheme`, not from the section's theme picker. They mean different things: the picker chooses which shipped palette the views render, while the variant is the scheme the reader's own chrome is drawn in. Conflating them would repaint the page every time someone compared a light palette against a dark one. + +Two visible consequences, both left alone on purpose: the controls are denser than under `material` (28px against 38px), and they stopped being uppercased, because uppercasing was Material's button convention rather than anything this app asked for. Overriding either would be reintroducing a bespoke control through the back door. + +### 24) The curve chart's canvas follows the handle bound, not the plot + +The viewBox is derived from how far a control point may be pulled — `PY(1 + OVER) - PAD` to `PY(-OVER) + PAD` — rather than from the plotted range. + +Rationale: overshoot is the interesting part of a curve, since it is what bunches one end, so the bound is deliberately generous at ±0.4 beyond the range. But the canvas used to stop at the plot, which meant a handle pulled past it was drawn outside the viewBox: still live, still holding its value, and with nothing on screen left to grab it by. Deriving one from the other makes that unrepresentable rather than merely fixed. The cost is vertical headroom that sits empty while the curve is tame, and a dashed line closes the range so that space reads as overshoot rather than padding. + +The handles also carry a transparent target wider than their mark and are painted after the shade dots. Ten dots sit on the same curve, and a handle that had drifted under one was both invisible and unhittable. + +### 25) The shades are a contrast probe, not a scoreboard + +Clicking a shade pins its *number*. Every numbered strip on the page then measures its own shades against its own shade of that number, reporting the ratio and the best WCAG grade it earns. The score tiles, the per-row readout and the failing-pair dots that preceded this are all gone. + +Rationale: each of those answered a question the library has rather than one a reader has. "Do any two shades 500 apart miss AA" is the generator's internal promise; a developer asks "can I put 600 on 100", about an arbitrary pair. The dots were also, by construction, only ever going to appear on `legacy` — permanently empty on the row people would actually build from, which made them evidence dressed as a tool. + +Pinning the number rather than the swatch is what makes it an argument. Pin `100` and the two rows answer at once: `legacy` says 2.3:1 and fails, `fitted` says 4.5:1 and clears. That is the guarantee, performed by the reader in two clicks instead of asserted by a badge. Accent rows are untouched, since their keys are their own. + +The pin is section state, not per-view, because it is one key space: a reader who pins 100 has asked the same question of every strip that has a 100. It stays out of the hash — a palette is a selection worth linking to, an inspection is not. + +Mechanics worth recording. The swatches are native ` + + + Ignite UI Theming + + + + + + + + + diff --git a/packages/preview/package.json b/packages/preview/package.json new file mode 100644 index 00000000..a82fb7b4 --- /dev/null +++ b/packages/preview/package.json @@ -0,0 +1,24 @@ +{ + "name": "@igniteui-theming/preview", + "private": true, + "version": "0.0.0", + "description": "Interactive demos of the theming system, one section per pillar, built from the library itself.", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json", + "lint": "stylelint \"./src/**/*.scss\" && npm run typecheck" + }, + "devDependencies": { + "happy-dom": "^20.14.5", + "igniteui-webcomponents": "^7.3.2", + "lit": "^3.2.1", + "sass-embedded": "~1.92.1", + "shiki": "^4.4.3", + "stylelint": "^17.5.0", + "stylelint-config-standard-scss": "^17.0.0" + } +} diff --git a/packages/preview/src/color.spec.ts b/packages/preview/src/color.spec.ts new file mode 100644 index 00000000..b8f10f67 --- /dev/null +++ b/packages/preview/src/color.spec.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from "vitest"; +import { + composite, + contrast, + displayable, + failingPairs, + gamutCusp, + hex, + inSrgb, + maxChroma, + oklchToLinear, + parse, + parseRaw, + type Rgb, + readableOn, + rgbToOklch, + shadePairs, + toRgb, +} from "./color.js"; + +describe("parse", () => { + it("reads the literals Sass emits", () => { + expect(parse("#0099ff")).toEqual([0, 153, 255, 1]); + expect(parse("#fff")).toEqual([255, 255, 255, 1]); + expect(parse("rgb(226, 237, 251)")).toEqual([226, 237, 251, 1]); + expect(parse("rgba(255, 255, 255, 0.03)")).toEqual([255, 255, 255, 0.03]); + expect(parse("hsl(0, 0%, 98%)")).toEqual([250, 250, 250, 1]); + }); + + it("clips out-of-range saturation at the channel, not at the input", () => { + // The legacy generator emits calc(s * 1.26) against a fully saturated seed. A browser + // computes with s = 126% and clips the resulting channels; clamping s to 100% first + // would yield rgb(0, 136, 227) and overstate the contrast. + expect(parse("hsl(204, 126%, 44.5%)")).toEqual([0, 142, 255, 1]); + }); + + it("resolves lightness above 100% to white, the way color(srgb 1.78 ...) paints", () => { + expect(parse("hsl(0, 0%, 174%)")).toEqual([255, 255, 255, 1]); + }); + + it("rejects anything it cannot resolve", () => { + expect(() => parse("var(--ig-primary-500)")).toThrow(/unsupported/); + }); + + it("drops alpha for callers that only measure", () => { + expect(toRgb("rgba(1, 2, 3, 0.5)")).toEqual([1, 2, 3]); + }); +}); + +describe("parseRaw", () => { + it("keeps a request the display cannot satisfy", () => { + // The legacy generator asks for this when it multiplies a near-white seed. + const [r, g, b] = parseRaw("hsl(0, 0%, 174%)"); + expect(r).toBeGreaterThan(255); + expect(g).toBeGreaterThan(255); + expect(b).toBeGreaterThan(255); + expect(displayable([r, g, b])).toBe(false); + }); + + it("agrees with parse when the color is reachable", () => { + expect(parseRaw("rgb(226, 237, 251)")).toEqual(parse("rgb(226, 237, 251)")); + }); +}); + +describe("composite", () => { + it("flattens a translucent color onto its background", () => { + expect(composite([255, 255, 255, 0.03], [26, 26, 36])).toEqual([ + 33, 33, 43, + ]); + }); + + it("leaves an opaque color alone", () => { + expect(composite([12, 34, 56, 1], [255, 255, 255])).toEqual([12, 34, 56]); + }); +}); + +describe("contrast", () => { + it("matches the WCAG reference ratios", () => { + expect(contrast([0, 0, 0], [255, 255, 255])).toBeCloseTo(21, 5); + expect(contrast([255, 255, 255], [255, 255, 255])).toBeCloseTo(1, 5); + expect(contrast([0, 153, 255], [255, 255, 255])).toBeCloseTo(3.0, 2); + }); + + it("is symmetric", () => { + expect(contrast([12, 34, 56], [200, 210, 220])).toBeCloseTo( + contrast([200, 210, 220], [12, 34, 56]), + 10, + ); + }); +}); + +describe("shade pairs", () => { + const gray = (v: number): Rgb => [v, v, v]; + /** White to black in ten even steps: every pair five apart clears AA comfortably. */ + const wide = Array.from({ length: 10 }, (_, i) => + gray(Math.round(255 - (i * 255) / 9)), + ); + /** Ten grays between 200 and 100: nothing five apart reaches 4.5:1. */ + const narrow = Array.from({ length: 10 }, (_, i) => + gray(Math.round(200 - (i * 100) / 9)), + ); + + it("pairs each shade with the one five steps darker", () => { + expect(shadePairs(wide).map((p) => [p.i, p.j])).toEqual([ + [0, 5], + [1, 6], + [2, 7], + [3, 8], + [4, 9], + ]); + }); + + it("counts the pairs under AA", () => { + expect(failingPairs(wide)).toBe(0); + expect(failingPairs(narrow)).toBe(5); + // Five whites over three blacks and two light grays: only the last two pairs fail. + const mixed = [ + ...Array(5).fill(gray(255)), + ...Array(3).fill(gray(0)), + gray(200), + gray(200), + ]; + expect(failingPairs(mixed)).toBe(2); + }); +}); + +describe("readableOn", () => { + it("picks the ink that contrasts more with the color itself", () => { + expect(readableOn([255, 255, 255])).toBe("#111"); + expect(readableOn([0, 0, 0])).toBe("#fff"); + expect(readableOn([0, 153, 255])).toBe("#111"); + }); +}); + +describe("hex", () => { + it("pads single-digit channels", () => { + expect(hex([0, 9, 255])).toBe("#0009ff"); + }); +}); + +describe("OKLCH", () => { + it("round-trips a mid-tone", () => { + const [L, C, h] = rgbToOklch([9, 109, 183]); + const linear = oklchToLinear(L, C, h); + const back = linear.map((v) => + Math.round( + 255 * (v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055), + ), + ); + expect(back).toEqual([9, 109, 183]); + }); + + it("reports black and white as achromatic", () => { + expect(rgbToOklch([0, 0, 0])[1]).toBeCloseTo(0, 6); + expect(rgbToOklch([255, 255, 255])[1]).toBeCloseTo(0, 6); + expect(rgbToOklch([255, 255, 255])[0]).toBeCloseTo(1, 3); + }); + + it("places sRGB blue near its documented position", () => { + const [L, C] = rgbToOklch([0, 0, 255]); + expect(L).toBeCloseTo(0.452, 2); + expect(C).toBeCloseTo(0.313, 2); + }); +}); + +describe("maxChroma", () => { + it("collapses to near zero at white", () => { + expect(maxChroma(1, 250)).toBeLessThan(0.005); + }); + + it("stays inside the gamut and is maximal there", () => { + const hue = 250; + const L = 0.6; + const c = maxChroma(L, hue); + expect(c).toBeGreaterThan(0.1); + expect(inSrgb(oklchToLinear(L, c, hue))).toBe(true); + expect(inSrgb(oklchToLinear(L, c + 0.01, hue))).toBe(false); + }); + + it("does not bulge at the dark end", () => { + // A tolerance applied to linear channels rather than encoded ones admits chroma + // around 0.12 at near-black, which would widen the bottom of the gamut solid. + expect(maxChroma(0.001, 250)).toBeLessThan(0.06); + }); +}); + +describe("the gamut cusp across hue", () => { + const peaks = Array.from({ length: 72 }, (_, i) => gamutCusp(i * 5)); + const span = (values: number[]) => Math.max(...values) / Math.min(...values); + + it("varies more than twofold in chroma, which is why one multiplier table cannot serve every hue", () => { + expect(span(peaks.map((p) => p.C))).toBeGreaterThan(2); + }); + + it("varies about twofold in lightness", () => { + expect(span(peaks.map((p) => p.L))).toBeGreaterThan(1.9); + }); + + it("never peaks at an extreme of the lightness axis", () => { + for (const peak of peaks) { + expect(peak.L).toBeGreaterThan(0.3); + expect(peak.L).toBeLessThan(0.99); + } + }); +}); diff --git a/packages/preview/src/color.ts b/packages/preview/src/color.ts new file mode 100644 index 00000000..6513de39 --- /dev/null +++ b/packages/preview/src/color.ts @@ -0,0 +1,271 @@ +/** Color math shared by the build and the views. Pure functions over sRGB tuples. */ + +export type Rgb = [number, number, number]; +export type Rgba = [number, number, number, number]; + +/** WCAG AA for normal text. Every "pair" claim in the app is measured against it. */ +export const AA = 4.5; + +/** Shades this far apart in a ten-step ramp are the pairs the generator guarantees. */ +export const PAIR_DISTANCE = 5; + +const clamp = (v: number) => Math.min(255, Math.max(0, v)); + +const alpha = (value: string | undefined) => + value === undefined ? 1 : Number(value); + +/** + * CSS Color 4 hsl-to-rgb. Saturation and lightness are used as given, including the + * out-of-range values the legacy generator produces (`calc(s * 1.26)`), so the channels + * can land outside sRGB and get clipped exactly where a browser clips them. + */ +const hslToRgb = (h: number, s: number, l: number, clip = true): Rgb => { + const k = (n: number) => (((n + h / 30) % 12) + 12) % 12; + const a = s * Math.min(l, 1 - l); + const f = (n: number) => + l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1)); + + return [f(0), f(8), f(4)].map((v) => + clip ? Math.round(clamp(v * 255)) : v * 255, + ) as Rgb; +}; + +const read = (value: string, clip: boolean): Rgba => { + const hsl = value.match( + /^hsla?\(\s*([-\d.]+)[,\s]+([\d.]+)%[,\s]+([\d.]+)%\s*(?:[,/]\s*([\d.]+))?/, + ); + + if (hsl) { + const [h, s, l] = hsl.slice(1, 4).map(Number); + return [...hslToRgb(h, s / 100, l / 100, clip), alpha(hsl[4])]; + } + + const rgb = value.match( + /^rgba?\(\s*([-\d.]+)[,\s]+([-\d.]+)[,\s]+([-\d.]+)\s*(?:[,/]\s*([\d.]+))?/, + ); + + if (rgb) { + const [r, g, b] = rgb.slice(1, 4).map(Number); + return clip + ? [clamp(r), clamp(g), clamp(b), alpha(rgb[4])] + : [r, g, b, alpha(rgb[4])]; + } + + const hex = value.trim().match(/^#([0-9a-f]{3,8})$/i); + + if (hex) { + const d = + hex[1].length < 6 ? [...hex[1]].map((c) => c + c).join("") : hex[1]; + const n = [0, 2, 4, 6].map((i) => + Number.parseInt(d.slice(i, i + 2) || "ff", 16), + ); + return [n[0], n[1], n[2], n[3] / 255]; + } + + throw new Error(`unsupported color literal: ${value}`); +}; + +/** Resolves a Sass-emitted color literal to clipped sRGB. */ +export const parse = (value: string): Rgba => read(value, true); + +/** + * Resolves a literal without clipping, so a request the display cannot satisfy keeps + * the position it asked for. The legacy generator emits these routinely. + */ +export const parseRaw = (value: string): Rgba => read(value, false); + +/** The opaque channels of a literal. Most measurements do not care about alpha. */ +export const toRgb = (value: string): Rgb => { + const [r, g, b] = parse(value); + return [r, g, b]; +}; + +/** Flattens a translucent color onto the background it is painted over. */ +export const composite = ([r, g, b, a]: Rgba, over: Rgb): Rgb => + [r, g, b].map((c, i) => Math.round(a * c + (1 - a) * over[i])) as Rgb; + +const channel = (v: number) => { + const s = v / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; +}; + +export const luminance = ([r, g, b]: Rgb) => + 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); + +/** WCAG 2.x contrast ratio. */ +export const contrast = (a: Rgb, b: Rgb) => { + const x = luminance(a) + 0.05; + const y = luminance(b) + 0.05; + return Math.max(x, y) / Math.min(x, y); +}; + +export interface ShadePair { + /** Index of the lighter shade in the ramp. */ + i: number; + /** Index of the darker shade, `PAIR_DISTANCE` steps on. */ + j: number; + contrast: number; +} + +/** Every pair of shades `PAIR_DISTANCE` apart, with the contrast between them. */ +export const shadePairs = (ramp: Rgb[]): ShadePair[] => { + const pairs: ShadePair[] = []; + + for (let i = 0; i + PAIR_DISTANCE < ramp.length; i++) { + const j = i + PAIR_DISTANCE; + pairs.push({ i, j, contrast: contrast(ramp[i], ramp[j]) }); + } + + return pairs; +}; + +/** How many of a ramp's pairs fall short of AA. */ +export const failingPairs = (ramp: Rgb[]) => + shadePairs(ramp).filter((pair) => pair.contrast < AA).length; + +/** + * Black or white, whichever reads better *on this color*. Choosing it from the color's + * contrast with some other anchor gets it backwards as soon as the anchor is dark. + */ +export const readableOn = (color: Rgb) => + contrast(color, [255, 255, 255]) >= contrast(color, [0, 0, 0]) + ? "#fff" + : "#111"; + +export const hex = ([r, g, b]: Rgb) => + `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")}`; + +/** Sign-preserving sRGB transfer, defined outside [0,1] so an unreachable request stays measurable. */ +const toLinear = (v: number) => { + const s = Math.abs(v) / 255; + const lin = s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + return Math.sign(v) * lin; +}; + +/** Linear sRGB channel to its 0–255 encoding, clipped. */ +export const encode = (v: number) => { + const c = v <= 0 ? 0 : v >= 1 ? 1 : v; + return ( + (255 * (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055)) | 0 + ); +}; + +/** sRGB to OKLCH, after Ottosson. Hue is degrees. */ +export const rgbToOklch = ([R, G, B]: Rgb): [number, number, number] => { + const r = toLinear(R); + const g = toLinear(G); + const b = toLinear(B); + const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); + const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); + const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); + const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s; + const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s; + const Bb = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s; + + return [ + L, + Math.hypot(A, Bb), + ((Math.atan2(Bb, A) * 180) / Math.PI + 360) % 360, + ]; +}; + +/** + * OKLab (a, b) to linear sRGB. Split out from `oklchToLinear` so a caller that holds + * the hue fixed — the gamut plot paints thousands of pixels at one hue — can convert + * the angle once instead of per pixel. + */ +export const oklabToLinear = ( + L: number, + a: number, + b: number, +): [number, number, number] => { + const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3; + const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3; + const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3; + + return [ + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s, + ]; +}; + +/** OKLCH to linear sRGB. Channels outside [0,1] mean the color is outside the gamut. */ +export const oklchToLinear = ( + L: number, + C: number, + hDeg: number, +): [number, number, number] => { + const h = (hDeg * Math.PI) / 180; + return oklabToLinear(L, C * Math.cos(h), C * Math.sin(h)); +}; + +/** + * The linear values that encode to -0.001 and 1.001. + * + * The tolerance belongs on the *encoded* channels: near black every linear channel is + * close to zero, so a slack of 0.001 applied there would admit colors far outside the + * gamut and bulge the dark end of the solid. Converting the two bounds once, rather than + * encoding every channel of every sample, makes the test three `pow` calls cheaper — + * which matters because the gamut plot runs it per pixel. + */ +export const GAMUT_LOW = -0.001 / 12.92; +export const GAMUT_HIGH = ((1.001 + 0.055) / 1.055) ** 2.4; + +/** + * Whether an unclipped 0–255 triple is displayable, to the same one-part-in-a-thousand + * tolerance `GAMUT_LOW`/`GAMUT_HIGH` use. `parseRaw` keeps the position a legacy shade + * asked for, and this is what says the display cannot honour it. + */ +export const displayable = ([r, g, b]: Rgb) => + r >= -0.255 && + r <= 255.255 && + g >= -0.255 && + g <= 255.255 && + b >= -0.255 && + b <= 255.255; + +/** Whether a linear triple is displayable. */ +export const inSrgb = ([r, g, b]: [number, number, number]) => + r >= GAMUT_LOW && + r <= GAMUT_HIGH && + g >= GAMUT_LOW && + g <= GAMUT_HIGH && + b >= GAMUT_LOW && + b <= GAMUT_HIGH; + +/** Chroma past which no sRGB color exists at any hue. Bounds the searches below. */ +export const CHROMA_CEILING = 0.37; + +/** Largest chroma this hue can hold at this lightness, by bisection. */ +export const maxChroma = (L: number, hue: number, ceiling = CHROMA_CEILING) => { + let lo = 0; + let hi = ceiling; + + for (let i = 0; i < 18; i++) { + const mid = (lo + hi) / 2; + if (inSrgb(oklchToLinear(L, mid, hue))) lo = mid; + else hi = mid; + } + + return lo; +}; + +export interface Cusp { + hue: number; + L: number; + C: number; +} + +/** The most saturated color a hue can reach in sRGB, and the lightness it sits at. */ +export const gamutCusp = (hue: number): Cusp => { + let best: Cusp = { hue, L: 0, C: 0 }; + + for (let i = 1; i < 100; i++) { + const L = i / 100; + const C = maxChroma(L, hue); + if (C > best.C) best = { hue, L, C }; + } + + return best; +}; diff --git a/packages/preview/src/data/cache.spec.ts b/packages/preview/src/data/cache.spec.ts new file mode 100644 index 00000000..45d88a3a --- /dev/null +++ b/packages/preview/src/data/cache.spec.ts @@ -0,0 +1,91 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { fingerprint, resolve } from "./cache.js"; +import type { DataProvider } from "./provider.js"; +import { get, register, reset } from "./registry.js"; + +const dir = mkdtempSync(path.join(tmpdir(), "ig-preview-")); +const cacheDir = mkdtempSync(path.join(tmpdir(), "ig-preview-cache-")); +const dep = path.join(dir, "input.scss"); +const self = path.join(dir, "provider.ts"); + +const provider = (id: string, value: string): DataProvider => ({ + id, + module: pathToFileURL(self).href, + deps: [dep], + build: () => value, +}); + +beforeEach(() => { + reset(); + writeFileSync(dep, "$a: 1;"); + writeFileSync(self, "export const p = 1;"); +}); + +afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + rmSync(cacheDir, { recursive: true, force: true }); +}); + +describe("fingerprint", () => { + it("is stable while inputs and provider source are unchanged", () => { + expect(fingerprint(provider("t", "x"))).toBe( + fingerprint(provider("t", "x")), + ); + }); + + it("changes when a declared dependency changes", () => { + const before = fingerprint(provider("t", "x")); + writeFileSync(dep, "$a: 2;"); + expect(fingerprint(provider("t", "x"))).not.toBe(before); + }); + + it("changes when the provider's own source changes", () => { + const before = fingerprint(provider("t", "x")); + writeFileSync(self, "export const p = 2;"); + expect(fingerprint(provider("t", "x"))).not.toBe(before); + }); +}); + +describe("resolve", () => { + it("builds on a miss and reads from disk on the next call", async () => { + const hits: boolean[] = []; + const onHit = (hit: boolean) => hits.push(hit); + + await resolve(provider("hit", "first"), { dir: cacheDir, onHit }); + await resolve(provider("hit", "first"), { dir: cacheDir, onHit }); + + expect(hits).toEqual([false, true]); + }); + + it("rebuilds when a dependency changed, even though the id is the same", async () => { + await resolve(provider("dep", "first"), { dir: cacheDir }); + writeFileSync(dep, "$a: 3;"); + + const hits: boolean[] = []; + const value = await resolve(provider("dep", "second"), { + dir: cacheDir, + onHit: (hit) => hits.push(hit), + }); + + expect(hits).toEqual([false]); + expect(value).toBe("second"); + }); +}); + +describe("registry", () => { + it("rejects two providers claiming the same id", () => { + register(provider("clash", "a")); + expect(() => + register({ ...provider("clash", "b"), module: "file:///elsewhere.ts" }), + ).toThrow(/Duplicate data provider id "clash"/); + }); + + it("names the registered ids when one is missing", () => { + register(provider("color.palettes", "a")); + expect(() => get("color.nope")).toThrow(/Registered: color\.palettes/); + }); +}); diff --git a/packages/preview/src/data/cache.ts b/packages/preview/src/data/cache.ts new file mode 100644 index 00000000..d015dfad --- /dev/null +++ b/packages/preview/src/data/cache.ts @@ -0,0 +1,85 @@ +/** + * Caches provider output on disk so a rebuild does not recompile Sass that has not + * changed. The key covers the provider's declared inputs *and* its own source: editing + * the provider changes what it produces from identical inputs, which an mtime check on + * `deps` alone would miss. + */ +import { createHash } from "node:crypto"; +import { + mkdirSync, + readdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { DataProvider } from "./provider.js"; + +const CACHE_DIR = fileURLToPath( + new URL("../../node_modules/.cache/ig-preview/", import.meta.url), +); + +const sassFilesUnder = (dir: string): string[] => + readdirSync(dir, { withFileTypes: true }) + .flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return sassFilesUnder(full); + return entry.name.endsWith(".scss") ? [full] : []; + }) + .sort(); + +const filesFor = (dep: string): string[] => { + const target = dep.startsWith("file:") ? fileURLToPath(dep) : dep; + return statSync(target).isDirectory() ? sassFilesUnder(target) : [target]; +}; + +/** Hash of everything that can change a provider's output. */ +export const fingerprint = (provider: DataProvider): string => { + const hash = createHash("sha256"); + hash.update(provider.id); + hash.update(readFileSync(fileURLToPath(provider.module))); + + for (const dep of provider.deps) { + for (const file of filesFor(dep)) { + hash.update(file); + hash.update(readFileSync(file)); + } + } + + return hash.digest("hex").slice(0, 16); +}; + +export interface ResolveOptions { + /** Skip the cache entirely. Used by tests. */ + fresh?: boolean; + /** Reports whether the value came from disk. Used by tests. */ + onHit?: (hit: boolean) => void; + /** Where entries live. Tests point this at a temporary directory. */ + dir?: string; +} + +export const resolve = async ( + provider: DataProvider, + options: ResolveOptions = {}, +): Promise => { + const dir = options.dir ?? CACHE_DIR; + const file = path.join(dir, `${provider.id}.${fingerprint(provider)}.json`); + + if (!options.fresh) { + try { + const cached = JSON.parse(readFileSync(file, "utf8")) as T; + options.onHit?.(true); + return cached; + } catch { + // A miss, an unreadable entry and a corrupt one are all just "build it". + } + } + + options.onHit?.(false); + const built = await provider.build(); + mkdirSync(dir, { recursive: true }); + writeFileSync(file, JSON.stringify(built), "utf8"); + + return built; +}; diff --git a/packages/preview/src/data/color/index.ts b/packages/preview/src/data/color/index.ts new file mode 100644 index 00000000..f4741e97 --- /dev/null +++ b/packages/preview/src/data/color/index.ts @@ -0,0 +1,8 @@ +/** Importing this module registers every data provider the color section needs. */ +import "./presets.js"; +import "./scales.js"; +import "./sweep.js"; + +export { presetData } from "./presets.js"; +export { scaleData } from "./scales.js"; +export { sweepData } from "./sweep.js"; diff --git a/packages/preview/src/data/color/presets.ts b/packages/preview/src/data/color/presets.ts new file mode 100644 index 00000000..6f337246 --- /dev/null +++ b/packages/preview/src/data/color/presets.ts @@ -0,0 +1,156 @@ +/** + * The palettes the library ships, each one twice: as it ships today, and regenerated by + * the fitted generator from the seeds it already records. + * + * The comparison is only honest if both sides start from the same place, so the seeds + * are read out of the shipped palette rather than transcribed — what ships is what the + * legacy side shows, and the fitted side starts from exactly those seeds. + */ +import { + ACCENTS, + FAMILIES, + type Family, + PRESETS, + type PresetKey, + ROLES, + SHADES, + THEMES, + type Theme, +} from "../../variants.js"; +import { defineProvider } from "../provider.js"; +import { register } from "../registry.js"; +import { COLOR_SASS, compile, HEX_FN, HEX_USE } from "./sass-util.js"; + +export interface PresetSide { + /** `--ig-*` declarations, ready to apply to a subtree. */ + vars: Record; + /** Resolved literals keyed `family-variant`, for measuring. */ + values: Record; +} + +export interface Preset { + key: PresetKey; + label: string; + theme: Theme; + /** What the shipped palette records as each family's seed. */ + seeds: Record; + legacy: PresetSide; + fitted: PresetSide; +} + +export interface PresetData { + presets: Preset[]; + /** Sass warnings raised while compiling, reported by the plugin on every serve. */ + warnings: string[]; +} + +type Generator = "legacy" | "fitted"; + +/** The variants a family exposes. Surface has roles under fitted and numbers under legacy. */ +const variants = (family: Family, generator: Generator): readonly string[] => { + if (family !== "surface") return [...SHADES, ...ACCENTS]; + return generator === "fitted" ? ROLES : SHADES; +}; + +/** One rule per preset and side, plus the seeds: `.p-light-material-l { --ig-...: ...; }` */ +const probe = (key: PresetKey, theme: Theme) => { + const cls = `${theme}-${key}`; + const shipped = `$${theme}-${key}-palette`; + const sides: [Generator, string][] = [ + ["legacy", shipped], + ["fitted", `_fit(${shipped})`], + ]; + + return [ + // `_fit()` builds a whole palette, so it is bound once per rule. Calling it inside + // each lookup would regenerate it sixty times over. + ...sides.map( + ([generator, palette]) => + `.p-${cls}-${generator} { $s: ${palette}; @include palette($s); }`, + ), + `seed-${cls} { ${FAMILIES.map((f) => `${f}: '#{_hex(_seed(${shipped}, "${f}"))}';`).join(" ")} }`, + ...sides.map(([generator, palette]) => { + const rows = FAMILIES.flatMap((family) => + variants(family, generator).map( + (v) => + ` ${family}-${v}: '#{_lit(map.get(map.get($s, "${family}"), "${v}-raw"))}';`, + ), + ); + return `val-${cls}-${generator} {\n $s: ${palette};\n${rows.join("\n")}\n}`; + }), + ].join("\n"); +}; + +const PRELUDE = + `@use 'sass:map';\n@use 'sass/color' as *;\n@use 'sass/color/presets' as *;\n` + + HEX_USE + + `@function _seed($p, $f) { @return map.get(map.get($p, $f), 'seed'); }\n` + + HEX_FN + + "@function _fit($p) {\n @return palette(\n" + + FAMILIES.map((f) => ` $${f}: _seed($p, '${f}')`).join(",\n") + + "\n );\n}\n"; + +/** Custom property declarations of every rule with this class. */ +const declarations = (css: string, selector: string) => { + const out: Record = {}; + + for (const [, name, body] of css.matchAll(/\.([\w-]+)\s*\{([^}]*)\}/g)) { + if (name !== selector) continue; + + for (const [, prop, value] of body.matchAll(/(--[\w-]+):\s*([^;]+);/g)) { + out[prop] = value.trim(); + } + } + + return out; +}; + +/** The quoted `name: "value"` pairs of a probe rule. */ +const quoted = (css: string, selector: string) => { + const rule = css.match(new RegExp(`${selector}\\s*\\{([^}]*)\\}`)); + if (!rule) throw new Error(`presets: missing ${selector}`); + + return Object.fromEntries( + [...rule[1].matchAll(/([\w-]+):\s*"([^"]*)"/g)].map(([, name, value]) => [ + name, + value, + ]), + ) as Record; +}; + +export const presetData = defineProvider({ + id: "color.presets", + module: import.meta.url, + deps: [COLOR_SASS], + build() { + const selections = PRESETS.flatMap((preset) => + THEMES.map((theme) => ({ ...preset, theme })), + ); + const { css, warnings } = compile( + PRELUDE + selections.map((s) => probe(s.key, s.theme)).join("\n"), + ); + + const side = (cls: string, generator: Generator): PresetSide => ({ + vars: declarations(css, `p-${cls}-${generator}`), + values: quoted(css, `val-${cls}-${generator}`), + }); + + return { + warnings, + presets: selections.map(({ key, label, theme }) => { + const cls = `${theme}-${key}`; + + return { + key, + label, + theme, + seeds: quoted(css, `seed-${cls}`), + legacy: side(cls, "legacy"), + fitted: side(cls, "fitted"), + }; + }), + }; + }, +}); + +register(presetData); diff --git a/packages/preview/src/data/color/sass-util.ts b/packages/preview/src/data/color/sass-util.ts new file mode 100644 index 00000000..b62156ea --- /dev/null +++ b/packages/preview/src/data/color/sass-util.ts @@ -0,0 +1,75 @@ +/** + * What every color provider needs to compile a probe stylesheet against the library. + */ +import { fileURLToPath } from "node:url"; +import * as sass from "sass-embedded"; +import { SHADES } from "../../variants.js"; + +/** The theming package, which the probes `@use` by path. */ +export const THEMING = new URL("../../../../theming/", import.meta.url); + +/** The Sass sources whose contents change what a color provider produces. */ +export const COLOR_SASS = fileURLToPath(new URL("sass/color/", THEMING)); + +/** + * Sass serialises a color to its shortest form, so a seed written as `white` or `red` + * interpolates as that keyword rather than as a hex literal. Emitting through + * `ie-hex-str` — the one built-in that always produces `#AARRGGBB` — keeps every seed in + * a single shape the Node side can parse. + * + * Split in two because `@use` is only legal at the top of a stylesheet, above the + * function definitions the callers interleave with their own. + */ +export const HEX_USE = `@use 'sass:color';\n@use 'sass:meta';\n@use 'sass:string';\n`; + +export const HEX_FN = + `@function _hex($c) { @return '#' + string.slice(color.ie-hex-str($c), 4); }\n` + + // A resolved shade is usually a relative-color string the browser finishes, but a + // plain color comes back a color — and so gets the same keyword treatment. `rgba()` + // rather than `_hex()` because a shade may carry alpha. + // + // Only legacy-rgb colors are rewritten. A legacy `hsl()` is left exactly as written: + // its saturation routinely exceeds 100%, and asking for a red channel would both fail + // and throw away the out-of-gamut position the reader is here to see. + "@function _lit($v) {\n" + + ` @if meta.type-of($v) != 'color' { @return $v; }\n` + + ` @if color.space($v) != 'rgb' { @return $v; }\n` + + ` @return 'rgba(' + color.channel($v, 'red') + ',' + color.channel($v, 'green')\n` + + ` + ',' + color.channel($v, 'blue') + ',' + color.alpha($v) + ')';\n` + + "}\n"; + +/** The ten raw shade literals of a compiled family, `|`-separated, through `_lit`. */ +export const shadeList = (name: string) => + SHADES.map((shade) => `#{_lit(map.get(${name}, "${shade}-raw"))}`).join("|"); + +export interface CompileOptions { + /** Warnings matching this are expected by the probe and not worth reporting. */ + expected?: RegExp; +} + +/** + * Compiles a probe stylesheet with the theming package on the load path. + * + * Sass warnings come back with the CSS so a provider can carry them in its output: the + * plugin reports them whenever it serves the module, cached or not, which is how + * "shade 900 wanted 16.1:1 but its hue only reaches 15.2:1" reaches the terminal on + * every run and not only the one that compiled it. + */ +export const compile = (source: string, { expected }: CompileOptions = {}) => { + const warnings = new Set(); + + const { css } = sass.compileString(source, { + loadPaths: [fileURLToPath(THEMING)], + logger: { + warn(message, options) { + const where = options.span ? ` (${options.span.text})` : ""; + warnings.add(`${message}${where}`); + }, + }, + }); + + return { + css, + warnings: [...warnings].filter((warning) => !expected?.test(warning)), + }; +}; diff --git a/packages/preview/src/data/color/scales.spec.ts b/packages/preview/src/data/color/scales.spec.ts new file mode 100644 index 00000000..025808e2 --- /dev/null +++ b/packages/preview/src/data/color/scales.spec.ts @@ -0,0 +1,145 @@ +/** + * The scale table is a lookup standing in for the generator, so the thing worth testing + * is that it agrees with the generator. If this drifts, the editor lies. + */ +import { describe, expect, it } from "vitest"; +import { AA, contrast, toRgb } from "../../color.js"; +import { + rampFromTable, + SCALE_PRESETS, + type ScalePreset, +} from "../../scale-math.js"; +import { SHADES, type Theme } from "../../variants.js"; +import { resolve } from "../cache.js"; +import { compile } from "./sass-util.js"; +import { type ScaleSubject, scaleData } from "./scales.js"; + +/** What the generator itself produces for a subject under a shipped scale. */ +const rampFromSass = (subject: ScaleSubject, preset: ScalePreset) => { + const surface = + subject.family === "gray" ? `, $surface: ${subject.surface}` : ""; + const { css } = compile( + `@use 'sass:map';\n@use 'sass/color' as *;\n@use 'sass/color/types' as types;\n` + + `o { $s: shades('${subject.family}', ${subject.seed}, types.$INumericShades${surface}, $scale: '${preset.name}');\n` + + SHADES.map((v) => ` v${v}: '#{map.get($s, "${v}-raw")}';`).join("\n") + + "\n}", + ); + + return [...css.matchAll(/v\d+:\s*"([^"]+)"/g)].map(([, literal]) => + toRgb(literal), + ); +}; + +const rampFromData = (subject: ScaleSubject, preset: ScalePreset) => + rampFromTable(subject.table, preset).map((h) => toRgb(`#${h}`)); + +/** How many of the five "500 apart" pairs clear AA, under one preset, for one subject. */ +const record = (subject: ScaleSubject, name: string) => { + const preset = SCALE_PRESETS.find((p) => p.name === name); + if (!preset) throw new Error(`no preset ${name}`); + + const ramp = rampFromData(subject, preset); + let clear = 0; + + for (let i = 0; i + 5 < ramp.length; i++) { + if (contrast(ramp[i], ramp[i + 5]) >= AA) clear++; + } + + return clear; +}; + +// The real cache: the provider compiles thousands of Sass rules and the result is keyed +// by its inputs, so a warm run here is the same data the dev server serves. +const data = await resolve(scaleData); + +/** + * Subjects are derived from the shipped palettes, so they are addressed by what they + * are rather than by a key that moves when a preset's seeds change. + */ +const neutral = (theme: Theme) => { + const found = data.subjects.find( + (s) => s.family === "gray" && s.presets.includes(`material-${theme}`), + ); + if (!found) throw new Error(`no material ${theme} neutral`); + + return found; +}; + +describe("the scale lookup table", () => { + for (const subject of data.subjects) { + for (const preset of SCALE_PRESETS) { + it(`reproduces ${preset.name} on ${subject.key}`, () => { + const got = rampFromData(subject, preset); + const want = rampFromSass(subject, preset); + const worst = Math.max( + ...got.map((c, i) => + Math.max(...c.map((v, ch) => Math.abs(v - want[i][ch]))), + ), + ); + + // Sampling 400 targets geometrically leaves adjacent entries about half a + // percent of contrast apart, so a shade can land one step either side. Across + // all five subjects and four presets: 78% exact, 99% within one, never above two. + expect(worst).toBeLessThanOrEqual(2); + }); + } + } + + it("reports a ceiling a subject cannot exceed", () => { + for (const subject of data.subjects) { + const anchor = toRgb(subject.anchor); + const { samples, rows } = subject.table; + const reached = rows.flatMap((row) => + Array.from({ length: samples }, (_, k) => + contrast(toRgb(`#${row.slice(k * 6, k * 6 + 6)}`), anchor), + ), + ); + + expect(Math.max(...reached)).toBeLessThanOrEqual(subject.ceiling + 0.001); + } + }); + + it("anchors a neutral family to its page rather than to white", () => { + const dark = neutral("dark"); + expect(dark.anchor).not.toBe("#ffffff"); + // White on a dark page cannot reach the 21:1 a white page allows. + expect(dark.ceiling).toBeLessThan(18); + }); +}); + +describe("the AA record each preset carries", () => { + // `$scales` documents these against the grayscale the presets were fitted to. + it("matches the documented figures on the neutral family", () => { + const light = neutral("light"); + + expect(record(light, "even")).toBe(5); + expect(record(light, "material")).toBe(3); + expect(record(light, "tailwind")).toBe(5); + expect(record(light, "carbon")).toBe(5); + }); + + it("is stable across subjects that can reach the range", () => { + const reachable = data.subjects.filter((s) => s.ceiling >= 16.1); + const records = reachable.map((s) => record(s, "material")); + + expect(reachable.length).toBeGreaterThan(1); + expect(new Set(records).size).toBe(1); + }); + + it("degrades when the range asks for more contrast than the subject has", () => { + // `even` reaches for 18.232:1. A neutral on a dark page tops out below 17, so the + // ramp compresses at the dark end and a pair drops under AA. The guarantee is + // conditional on the range being reachable, and the ceiling is what tells you. + const dark = neutral("dark"); + + expect(dark.ceiling).toBeLessThan(18.232); + expect(record(dark, "even")).toBeLessThan(5); + }); + + it("holds for the straight line wherever the range is reachable", () => { + for (const subject of data.subjects) { + if (subject.ceiling < 18.232) continue; + expect(record(subject, "even")).toBe(5); + } + }); +}); diff --git a/packages/preview/src/data/color/scales.ts b/packages/preview/src/data/color/scales.ts new file mode 100644 index 00000000..18d89398 --- /dev/null +++ b/packages/preview/src/data/color/scales.ts @@ -0,0 +1,237 @@ +/** + * An exact `(position, contrast target) -> color` table per subject, so `range` and + * `curve` can be manipulated live without reimplementing the generator. + * + * The trick is a degenerate range: compiling `shades()` with `range: X X` pins every + * shade to the same contrast target while each keeps its own position-dependent chroma + * taper. Sweeping X across the reachable span therefore yields the whole table, and a + * scale is then a lookup rather than a computation. + * + * Reproduces every shipped preset closely rather than exactly: 78% of shades match the + * generator's output bit for bit, 99% are within one step per channel, and none is more + * than two — well under a perceptible difference, and guarded by `scales.spec.ts`. + * + * Gray is included twice, on a light and a dark page, because a neutral family is + * anchored to the background rather than to white — which is the whole reason the + * `material` scale exists, and it cannot be shown on a chromatic family. + */ +import { contrast, hex, toRgb } from "../../color.js"; +import type { ScaleTable } from "../../scale-math.js"; +import { PRESETS, SHADES, THEMES, type Theme } from "../../variants.js"; +import { defineProvider } from "../provider.js"; +import { register } from "../registry.js"; +import { + COLOR_SASS, + compile, + HEX_FN, + HEX_USE, + shadeList, +} from "./sass-util.js"; + +/** How many contrast targets are sampled, geometrically, across the span below. */ +export const SAMPLES = 400; +export const SPAN: [number, number] = [1, 21]; + +export interface ScaleSubject { + key: string; + label: string; + /** Which `-` selections this subject serves. */ + presets: string[]; + /** What the reader is looking at, in one line. */ + note: string; + family: "primary" | "gray"; + seed: string; + surface: string; + /** What contrast is measured against: white for a chromatic family, the page for gray. */ + anchor: string; + table: ScaleTable; + /** The highest contrast this subject can actually reach against its anchor. */ + ceiling: number; +} + +type Definition = Omit; + +export interface ScaleData { + subjects: ScaleSubject[]; + /** Sass warnings raised while compiling, reported by the plugin on every serve. */ + warnings: string[]; +} + +interface Seeds { + name: string; + theme: Theme; + primary: string; + gray: string; + surface: string; +} + +/** Read from the shipped palettes so the editor's subjects are the ones the picker offers. */ +const readSeeds = (): Seeds[] => { + const rules = PRESETS.flatMap(({ key }) => + THEMES.map( + (theme) => + `s-${theme}-${key} { ` + + (["primary", "gray", "surface"] as const) + .map( + (f) => + `${f}: '#{_hex(map.get(map.get($${theme}-${key}-palette, "${f}"), "seed"))}';`, + ) + .join(" ") + + " }", + ), + ); + const { css } = compile( + `@use 'sass:map';\n@use 'sass/color/presets' as *;\n${HEX_USE}${HEX_FN}${rules.join("\n")}`, + ); + + return [...css.matchAll(/s-(\w+)-(\w+)\s*\{([^}]*)\}/g)].map( + ([, theme, name, body]) => + ({ + name, + theme, + ...Object.fromEntries( + [...body.matchAll(/([\w-]+):\s*"([^"]+)"/g)].map(([, key, value]) => [ + key, + value, + ]), + ), + }) as Seeds, + ); +}; + +const capitalise = (name: string) => name[0].toUpperCase() + name.slice(1); + +/** + * One subject per distinct (family, seed, surface). Several presets share a surface, and + * a chromatic family is anchored to white whatever the page is, so the list collapses + * well below one entry per preset. + */ +const definitions = (): Definition[] => { + const seen = new Map(); + + for (const entry of readSeeds()) { + const candidates: Definition[] = [ + { + key: `${entry.name}-primary`, + label: `${capitalise(entry.name)} primary`, + note: "A color family, measured against white.", + family: "primary", + seed: entry.primary, + surface: "#ffffff", + anchor: "#ffffff", + presets: [], + }, + { + key: `${entry.name}-${entry.theme}-gray`, + label: `Gray on a ${entry.theme} page`, + note: "Measured against the page instead of white. This is the family the material scale was tuned for.", + family: "gray", + seed: entry.gray, + surface: entry.surface, + anchor: entry.surface, + presets: [], + }, + ]; + + for (const subject of candidates) { + const id = `${subject.family}|${subject.seed}|${subject.surface}`; + const tag = `${entry.name}-${entry.theme}`; + const existing = seen.get(id); + + if (existing) existing.presets.push(tag); + else seen.set(id, { ...subject, presets: [tag] }); + } + } + + return [...seen.values()]; +}; + +/** The contrast targets sampled, geometrically spaced so each step is a constant ratio. */ +const sampled = () => + Array.from( + { length: SAMPLES }, + (_, k) => SPAN[0] * (SPAN[1] / SPAN[0]) ** (k / (SAMPLES - 1)), + ); + +/** One rule per target: every shade pinned to the same contrast. */ +const probe = (index: number, subject: Definition, target: number) => { + const surface = + subject.family === "gray" ? `, $surface: ${subject.surface}` : ""; + const range = `${target.toFixed(5)} ${target.toFixed(5)}`; + + return ( + `o${index} { $s: shades('${subject.family}', ${subject.seed}, types.$INumericShades` + + `${surface}, $scale: (range: ${range}, curve: null)); v: '${shadeList("$s")}'; }` + ); +}; + +export const scaleData = defineProvider({ + id: "color.scales", + module: import.meta.url, + deps: [COLOR_SASS], + build() { + const subjects = definitions(); + const targets = sampled(); + const rules = subjects.flatMap((subject, s) => + targets.map((target, k) => probe(s * SAMPLES + k, subject, target)), + ); + const { css, warnings } = compile( + `@use 'sass:map';\n@use 'sass/color' as *;\n@use 'sass/color/types' as types;\n` + + HEX_USE + + HEX_FN + + rules.join("\n"), + { + // Sampling deliberately runs past what a subject can reach, so the generator's + // "wanted X but only reaches Y" is the probe working, not a problem. `ceiling` + // carries the same information, per subject. + expected: /wanted [\d.]+:1 but its hue only reaches/, + }, + ); + + const payloads = new Map(); + for (const [, index, payload] of css.matchAll( + /o(\d+)\s*\{\s*v:\s*"([^"]+)"/g, + )) { + payloads.set(Number(index), payload.split("|")); + } + + return { + warnings, + subjects: subjects.map((subject, s) => { + const rows = SHADES.map(() => [] as string[]); + + for (let k = 0; k < SAMPLES; k++) { + const literals = payloads.get(s * SAMPLES + k); + if (!literals) { + throw new Error( + `scales: missing rule for ${subject.key} sample ${k}`, + ); + } + + literals.forEach((literal, position) => { + rows[position].push(hex(toRgb(literal)).slice(1)); + }); + } + + const anchor = toRgb(subject.anchor); + const ceiling = Math.max( + ...rows.flatMap((row) => + row.map((h) => contrast(toRgb(`#${h}`), anchor)), + ), + ); + + return { + ...subject, + ceiling: Number(ceiling.toFixed(3)), + table: { + samples: SAMPLES, + span: SPAN, + rows: rows.map((row) => row.join("")), + }, + }; + }), + }; + }, +}); + +register(scaleData); diff --git a/packages/preview/src/data/color/sweep.spec.ts b/packages/preview/src/data/color/sweep.spec.ts new file mode 100644 index 00000000..3d82f64e --- /dev/null +++ b/packages/preview/src/data/color/sweep.spec.ts @@ -0,0 +1,86 @@ +/** + * The claims the color section makes, asserted rather than displayed. A scoreboard on a + * page only catches a regression when somebody opens the page. + */ +import { describe, expect, it } from "vitest"; +import { AA, contrast, PAIR_DISTANCE } from "../../color.js"; +import { clampedCount, rampRgb } from "../../ramp.js"; +import { resolve } from "../cache.js"; +import { sweepData } from "./sweep.js"; + +// The real cache, for the same reason as `scales.spec.ts`. +const data = await resolve(sweepData); + +describe("the fitted generator, across every seed in the sweep", () => { + it("covers the whole hue circle at more than one saturation", () => { + expect(data.rows.length).toBeGreaterThan(1); + expect(data.totals.seeds).toBe(data.rows.length * (360 / data.hueStep)); + }); + + it("clears AA for every pair five shades apart", () => { + expect(data.totals.fittedFails).toBe(0); + expect(data.totals.fittedClean).toBe(data.totals.seeds); + }); + + it("never asks for a color outside sRGB", () => { + expect(data.totals.fittedOog).toBe(0); + }); + + it("never resolves two shades to the same color", () => { + expect(data.totals.fittedDup).toBe(0); + }); + + it("holds at every saturation, not only the vivid one", () => { + for (const row of data.rows) { + for (const ramp of row.fitted) { + expect(clampedCount(ramp)).toBe(0); + const rgb = rampRgb(ramp); + for (let i = 0; i + PAIR_DISTANCE < rgb.length; i++) { + expect( + contrast(rgb[i], rgb[i + PAIR_DISTANCE]), + ).toBeGreaterThanOrEqual(AA); + } + } + } + }); +}); + +describe("the legacy generator, for comparison", () => { + it("fails AA pairs at every seed in the grid", () => { + expect(data.totals.legacyClean).toBe(0); + expect(data.totals.legacyFails).toBeGreaterThan(data.totals.pairs * 0.9); + }); + + it("emits shades outside sRGB, but only at high saturation", () => { + expect(data.totals.legacyOog).toBeGreaterThan(0); + + const bySaturation = data.rows.map((row) => ({ + saturation: row.saturation, + oog: row.legacy.reduce((sum, ramp) => sum + clampedCount(ramp), 0), + })); + const worst = bySaturation.reduce((a, b) => (a.oog > b.oog ? a : b)); + + // The out-of-gamut problem belongs to saturated seeds. Reporting it as a property of + // every legacy ramp would overstate it, which is why the grid has a saturation axis. + expect(worst.saturation).toBe(Math.max(...data.saturations)); + expect(bySaturation.filter((r) => r.oog === 0).length).toBeGreaterThan(0); + }); + + it("records where a clamped shade asked to be", () => { + const withAsk = data.rows + .flatMap((row) => row.legacy) + .filter((ramp) => ramp.mask !== 0); + expect(withAsk.length).toBeGreaterThan(0); + for (const ramp of withAsk) { + expect(ramp.ask.length).toBe(clampedCount(ramp) * 3); + } + }); +}); + +describe("the grid's own limits", () => { + it("does not claim to cover extreme lightness", () => { + // Every seed sits at 50% lightness, so near-white and near-black seeds — where + // duplicate shades come from — are the comparison demo's job, not the sweep's. + expect(data.totals.legacyDup).toBe(0); + }); +}); diff --git a/packages/preview/src/data/color/sweep.ts b/packages/preview/src/data/color/sweep.ts new file mode 100644 index 00000000..f3ac1e62 --- /dev/null +++ b/packages/preview/src/data/color/sweep.ts @@ -0,0 +1,186 @@ +/** + * Every hue at several saturations, through both generators. + * + * Sweeping hue alone would only test one slice of seed space, and the most obvious + * objection to the result is that maximally saturated seeds are unrepresentative. The + * saturation axis is here to answer that rather than to be pretty. + */ +import { + displayable, + failingPairs, + hex, + PAIR_DISTANCE, + parseRaw, + toRgb, +} from "../../color.js"; +import { SHADES } from "../../variants.js"; +import { defineProvider } from "../provider.js"; +import { register } from "../registry.js"; +import { + COLOR_SASS, + compile, + HEX_FN, + HEX_USE, + shadeList, +} from "./sass-util.js"; + +/** Muted, mid and vivid. Brand colors land across this span. */ +export const SATURATIONS = [35, 60, 85]; +export const HUE_STEP = 1; + +export interface SweepRamp { + /** Ten shades, concatenated six-digit hex, as the display will paint them. */ + hex: string; + /** Bit per shade: the generator asked for a color outside sRGB. */ + mask: number; + /** Unclipped `r, g, b` of the masked shades only — where each one asked to be. */ + ask: number[]; +} + +export interface SweepRow { + saturation: number; + /** Six-digit hex per hue step. */ + seeds: string[]; + legacy: SweepRamp[]; + fitted: SweepRamp[]; +} + +export interface SweepTotals { + seeds: number; + pairs: number; + shades: number; + legacyFails: number; + fittedFails: number; + legacyOog: number; + fittedOog: number; + legacyDup: number; + fittedDup: number; + /** Seeds where all five pairs clear AA. */ + legacyClean: number; + fittedClean: number; +} + +export interface SweepData { + saturations: number[]; + hueStep: number; + rows: SweepRow[]; + totals: SweepTotals; + /** Sass warnings raised while compiling, reported by the plugin on every serve. */ + warnings: string[]; +} + +const hues = () => { + const out: number[] = []; + for (let hue = 0; hue < 360; hue += HUE_STEP) out.push(hue); + return out; +}; + +/** A seed per rule, both generators run on it, the results joined by `#`. */ +const probe = (index: number, seed: string) => + `o${index} { $s: ${seed}; ` + + `$l: shades('primary', $s, types.$INumericShades, $generator: 'legacy'); ` + + `$f: shades('primary', $s, types.$INumericShades); ` + + `v: '#{$s}#${shadeList("$l")}#${shadeList("$f")}'; }`; + +/** Reads a ramp's literals into what the browser paints and what the generator asked for. */ +const measure = (literals: string[]) => { + const clipped = literals.map(toRgb); + const ask: number[] = []; + let mask = 0; + + literals.forEach((literal, index) => { + const [r, g, b] = parseRaw(literal); + if (displayable([r, g, b])) return; + mask |= 1 << index; + ask.push(r, g, b); + }); + + return { + ramp: { hex: clipped.map((c) => hex(c).slice(1)).join(""), mask, ask }, + fails: failingPairs(clipped), + oog: ask.length / 3, + dup: clipped.length - new Set(clipped.map(String)).size, + }; +}; + +export const sweepData = defineProvider({ + id: "color.sweep", + module: import.meta.url, + deps: [COLOR_SASS], + build() { + const seeds = SATURATIONS.flatMap((saturation) => + hues().map((hue) => ({ + saturation, + seed: `hsl(${hue}, ${saturation}%, 50%)`, + })), + ); + const { css, warnings } = compile( + `@use 'sass:map';\n@use 'sass/color' as *;\n@use 'sass/color/types' as types;\n` + + HEX_USE + + HEX_FN + + seeds.map((s, i) => probe(i, s.seed)).join("\n"), + ); + + const payloads = new Map(); + for (const [, index, payload] of css.matchAll( + /o(\d+)\s*\{\s*v:\s*"([^"]+)"/g, + )) { + payloads.set(Number(index), payload.split("#")); + } + + const totals: SweepTotals = { + seeds: 0, + pairs: 0, + shades: 0, + legacyFails: 0, + fittedFails: 0, + legacyOog: 0, + fittedOog: 0, + legacyDup: 0, + fittedDup: 0, + legacyClean: 0, + fittedClean: 0, + }; + const rows: SweepRow[] = SATURATIONS.map((saturation) => ({ + saturation, + seeds: [], + legacy: [], + fitted: [], + })); + + seeds.forEach(({ saturation }, index) => { + const parts = payloads.get(index); + if (!parts) throw new Error(`sweep: missing rule ${index}`); + + const row = rows[SATURATIONS.indexOf(saturation)]; + const legacy = measure(parts[1].split("|")); + const fitted = measure(parts[2].split("|")); + + row.seeds.push(hex(toRgb(parts[0])).slice(1)); + row.legacy.push(legacy.ramp); + row.fitted.push(fitted.ramp); + + totals.seeds++; + totals.pairs += SHADES.length - PAIR_DISTANCE; + totals.shades += SHADES.length; + totals.legacyFails += legacy.fails; + totals.fittedFails += fitted.fails; + totals.legacyOog += legacy.oog; + totals.fittedOog += fitted.oog; + totals.legacyDup += legacy.dup; + totals.fittedDup += fitted.dup; + if (!legacy.fails) totals.legacyClean++; + if (!fitted.fails) totals.fittedClean++; + }); + + return { + saturations: SATURATIONS, + hueStep: HUE_STEP, + rows, + totals, + warnings, + }; + }, +}); + +register(sweepData); diff --git a/packages/preview/src/data/plugin.ts b/packages/preview/src/data/plugin.ts new file mode 100644 index 00000000..ea665fb5 --- /dev/null +++ b/packages/preview/src/data/plugin.ts @@ -0,0 +1,35 @@ +import type { Plugin } from "vite"; +import { resolve } from "./cache.js"; +import "./color/index.js"; +import { get } from "./registry.js"; + +const PREFIX = "virtual:data/"; + +/** Provider output that carries the Sass warnings its build raised. */ +const warningsOf = (data: unknown): string[] => + typeof data === "object" && data !== null && "warnings" in data + ? (data.warnings as string[]) + : []; + +/** Serves each registered provider as `virtual:data/`, built once and cached. */ +export const dataProviders = (): Plugin => ({ + name: "preview-data-providers", + resolveId(id) { + return id.startsWith(PREFIX) ? `\0${id}` : null; + }, + async load(id) { + if (!id.startsWith(`\0${PREFIX}`)) return null; + + const providerId = id.slice(PREFIX.length + 1); + const data = await resolve(get(providerId)); + + // Reported here rather than at compile time, so a build served from cache says the + // same thing as the build that produced it. + for (const warning of warningsOf(data)) + this.warn(`${providerId}: ${warning}`); + + // `JSON.parse` of a string beats an object literal of the same size: the literal is + // parsed as source, which for a few hundred kilobytes is the bulk of a tab switch. + return `export default JSON.parse(${JSON.stringify(JSON.stringify(data))});`; + }, +}); diff --git a/packages/preview/src/data/provider.ts b/packages/preview/src/data/provider.ts new file mode 100644 index 00000000..33cbb64b --- /dev/null +++ b/packages/preview/src/data/provider.ts @@ -0,0 +1,19 @@ +/** + * A dataset the preview derives from Sass at build time. + * + * Every pillar of the theming system needs something compiled — palettes, type scales, + * shadow maps — and each one declares it the same way so no section has to invent its + * own build step. + */ +export interface DataProvider { + /** Unique, dot-separated: `color.sweep`. Also the `virtual:data/` specifier. */ + id: string; + /** `import.meta.url` of the declaring module, so editing it invalidates its cache. */ + module: string; + /** Sass files or directories whose contents change the output. Directories are walked for `.scss`. */ + deps: string[]; + build(): Promise | T; +} + +export const defineProvider = (provider: DataProvider): DataProvider => + provider; diff --git a/packages/preview/src/data/registry.ts b/packages/preview/src/data/registry.ts new file mode 100644 index 00000000..55e24314 --- /dev/null +++ b/packages/preview/src/data/registry.ts @@ -0,0 +1,30 @@ +import type { DataProvider } from "./provider.js"; + +const providers = new Map(); + +/** Registers a provider. IDs are unique across the whole app. */ +export const register = (provider: DataProvider): void => { + const clash = providers.get(provider.id); + + if (clash && clash.module !== provider.module) { + throw new Error( + `Duplicate data provider id "${provider.id}", declared by ${clash.module} and ${provider.module}.`, + ); + } + + providers.set(provider.id, provider); +}; + +export const get = (id: string): DataProvider => { + const provider = providers.get(id); + + if (!provider) { + const known = [...providers.keys()].sort().join(", ") || "none"; + throw new Error(`Unknown data provider "${id}". Registered: ${known}.`); + } + + return provider; +}; + +/** Test seam. */ +export const reset = (): void => providers.clear(); diff --git a/packages/preview/src/define.ts b/packages/preview/src/define.ts new file mode 100644 index 00000000..4126667c --- /dev/null +++ b/packages/preview/src/define.ts @@ -0,0 +1,11 @@ +/** + * Registers a custom element unless that name is already taken. + * + * `customElements.define` throws on a repeat, and an uncaught throw aborts the rest of + * the module — which is how a second copy of a shared element silently stops a whole view + * from ever being defined. Two views bundled separately and landing on the same page is + * enough to cause it. + */ +export const define = (tag: string, element: CustomElementConstructor) => { + if (!customElements.get(tag)) customElements.define(tag, element); +}; diff --git a/packages/preview/src/elements/code-block.ts b/packages/preview/src/elements/code-block.ts new file mode 100644 index 00000000..196e37e4 --- /dev/null +++ b/packages/preview/src/elements/code-block.ts @@ -0,0 +1,181 @@ +import { css, html, LitElement, type PropertyValues } from "lit"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import { define } from "../define.js"; + +type State = "idle" | "copied" | "selected"; + +/** + * The Sass that reproduces what a demo is showing, with a control that copies it. + * + * Where the clipboard is unavailable — an insecure origin, a browser that refuses + * without a gesture it recognises, a denied permission — the text is selected instead + * and the control says which keys to press. A copy button that silently does nothing is + * worse than one that admits it cannot. + */ +export class CodeBlock extends LitElement { + static properties = { + code: { type: String }, + label: { type: String }, + state: { state: true }, + highlighted: { state: true }, + }; + + static styles = css` + :host { + display: block; + } + + header { + display: flex; + align-items: center; + gap: var(--space-3, 12px); + margin-block-end: var(--space-2, 8px); + } + + .label { + font-family: var(--mono, ui-monospace, monospace); + font-size: var(--text-xs, 11px); + font-weight: 500; + letter-spacing: 0.11em; + text-transform: uppercase; + color: var(--muted, currentColor); + } + + button { + margin-inline-start: auto; + font-family: var(--mono, ui-monospace, monospace); + font-size: var(--text-xs, 11px); + font-weight: 500; + letter-spacing: 0.07em; + text-transform: uppercase; + color: inherit; + background: transparent; + border: 1px solid var(--line, currentColor); + border-radius: var(--radius-sm, 3px); + padding: 5px 11px; + cursor: pointer; + } + + button:hover { + border-color: var(--muted, currentColor); + } + + button:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; + } + + /* + * The theme paints the text; the block's shape is ours. The background is the + * theme's own page color, so the plain-text moment before highlighting matches. + */ + pre { + margin: 0; + padding: var(--space-4, 16px); + background: #0d1117; + border-radius: var(--radius-sm, 3px); + overflow-x: auto; + font-family: var(--mono, ui-monospace, monospace); + font-size: var(--text-sm, 12px); + line-height: 1.65; + } + + pre:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; + } + `; + + declare code: string; + declare label: string; + declare state: State; + /** `code` as highlighted markup, or null until the highlighter has run. */ + declare highlighted: string | null; + + constructor() { + super(); + this.code = ""; + this.label = "Sass"; + this.state = "idle"; + this.highlighted = null; + } + + /** + * Plain text renders at once and the highlighted markup replaces it when ready. A + * result is dropped if the code moved on while it was being produced. + * + * The highlighter is imported here rather than at the top so its grammar, theme and + * engine are a chunk of their own, fetched the first time a code block appears. + */ + willUpdate(changed: PropertyValues) { + if (!changed.has("code")) return; + + const code = this.code; + this.highlighted = null; + import("./highlight.js") + .then(({ highlightSass }) => highlightSass(code)) + .then((markup) => { + if (this.code === code) this.highlighted = markup; + }); + } + + private get shortcut() { + return navigator.platform?.startsWith("Mac") ? "⌘C" : "Ctrl+C"; + } + + private select() { + const pre = this.renderRoot.querySelector("pre"); + const selection = getSelection(); + + if (!pre || !selection) return; + + const range = document.createRange(); + range.selectNodeContents(pre); + selection.removeAllRanges(); + selection.addRange(range); + } + + private async copy() { + try { + await navigator.clipboard.writeText(this.code); + this.state = "copied"; + } catch { + this.select(); + this.state = "selected"; + } + + setTimeout(() => { + this.state = "idle"; + }, 2000); + } + + private get action() { + if (this.state === "copied") return "Copied"; + if (this.state === "selected") return `Press ${this.shortcut}`; + return "Copy"; + } + + render() { + return html` +
+ ${this.label} + +
+
+ ${ + this.highlighted === null + ? html`
${this.code}
` + : unsafeHTML(this.highlighted) + } +
+ `; + } +} + +define("ig-code-block", CodeBlock); + +declare global { + interface HTMLElementTagNameMap { + "ig-code-block": CodeBlock; + } +} diff --git a/packages/preview/src/elements/curve-editor.ts b/packages/preview/src/elements/curve-editor.ts new file mode 100644 index 00000000..69dafbac --- /dev/null +++ b/packages/preview/src/elements/curve-editor.ts @@ -0,0 +1,274 @@ +import { css, html, LitElement, svg } from "lit"; +import { define } from "../define.js"; +import { type Curve, ease, IDENTITY_CURVE } from "../scale-math.js"; + +/** A shade drawn on the curve: where it sits, where the curve put it, what it looks like. */ +export interface CurvePoint { + x: number; + t: number; + hex: string; +} + +type Handle = "p1" | "p2"; + +const WIDTH = 900; +const TOP = 16; +const BOTTOM = 176; +const LEFT = WIDTH * 0.05; +const SPAN = WIDTH * 0.9; +const px = (t: number) => LEFT + t * SPAN; +const py = (t: number) => BOTTOM - t * (BOTTOM - TOP); + +/** + * How far past the range a control point may be pulled. Overshoot is the interesting + * part of a curve — it is what bunches one end — so the bound is generous. + */ +const OVERSHOOT = 0.4; +/** Half the handle mark plus its ring, so one pulled to the limit still draws whole. */ +const PAD = 12; +/** The viewBox follows the overshoot bound, so a handle at the limit stays on screen to grab. */ +const VIEW_TOP = py(1 + OVERSHOOT) - PAD; +const VIEW_HEIGHT = py(-OVERSHOOT) + PAD - VIEW_TOP; +const CURVE_SAMPLES = 120; + +const clamp = (value: number, low: number, high: number) => + Math.min(high, Math.max(low, value)); + +/** + * The easing curve, made grabbable. Controlled: the host owns `curve` and hears + * `curve-change` when a handle is dragged or nudged. + */ +export class CurveEditor extends LitElement { + static properties = { + curve: { attribute: false }, + points: { attribute: false }, + low: { type: String }, + high: { type: String }, + }; + + static styles = css` + :host { + display: block; + color: var(--plot-ink, currentColor); + } + + svg { + display: block; + width: 100%; + height: auto; + touch-action: none; + } + + .tick { + font-family: var(--mono, ui-monospace, monospace); + font-size: 10px; + fill: currentColor; + opacity: 0.8; + } + + .axis { + letter-spacing: 0.08em; + opacity: 0.6; + } + + .handle { + cursor: grab; + } + + .handle:active { + cursor: grabbing; + } + + /* The transparent target takes the pointer; the marks are decoration. */ + .handle circle { + pointer-events: all; + } + + .handle rect { + pointer-events: none; + } + + .handle rect[tabindex] { + pointer-events: all; + } + + .handle rect:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; + } + `; + + declare curve: Curve | null; + declare points: CurvePoint[]; + declare low: string; + declare high: string; + + private dragging: Handle | null = null; + + constructor() { + super(); + this.curve = null; + this.points = []; + this.low = ""; + this.high = ""; + } + + disconnectedCallback() { + this.release(); + super.disconnectedCallback(); + } + + private get handles(): Curve { + return this.curve ?? IDENTITY_CURVE; + } + + private change(next: Curve) { + this.dispatchEvent( + new CustomEvent("curve-change", { detail: next, bubbles: true }), + ); + } + + private move(which: Handle, x: number, y: number) { + const next = [...this.handles] as Curve; + const at = which === "p1" ? 0 : 2; + + next[at] = clamp(x, 0, 1); + next[at + 1] = clamp(y, -OVERSHOOT, 1 + OVERSHOOT); + this.change(next); + } + + private grab(event: PointerEvent, which: Handle) { + event.preventDefault(); + this.dragging = which; + addEventListener("pointermove", this.onDrag); + addEventListener("pointerup", this.release); + addEventListener("pointercancel", this.release); + // Grabbing with the pointer also takes focus, so the arrow keys carry on from + // wherever the drag ended rather than from whichever handle was last tabbed to. + (event.currentTarget as SVGGElement) + .querySelector("[tabindex]") + ?.focus(); + } + + private onDrag = (event: PointerEvent) => { + const which = this.dragging; + const canvas = this.renderRoot.querySelector("svg"); + if (!which || !canvas) return; + + event.preventDefault(); + const box = canvas.getBoundingClientRect(); + const x = (((event.clientX - box.left) / box.width) * WIDTH - LEFT) / SPAN; + const local = + VIEW_TOP + ((event.clientY - box.top) / box.height) * VIEW_HEIGHT; + const y = (BOTTOM - local) / (BOTTOM - TOP); + + this.move(which, x, y); + }; + + private release = () => { + this.dragging = null; + removeEventListener("pointermove", this.onDrag); + removeEventListener("pointerup", this.release); + removeEventListener("pointercancel", this.release); + }; + + private nudge(event: KeyboardEvent, which: Handle) { + const step = event.shiftKey ? 0.1 : 0.02; + const moves: Record = { + ArrowLeft: [-step, 0], + ArrowRight: [step, 0], + ArrowUp: [0, step], + ArrowDown: [0, -step], + }; + const move = moves[event.key]; + if (!move) return; + + event.preventDefault(); + const at = which === "p1" ? 0 : 2; + this.move( + which, + this.handles[at] + move[0], + this.handles[at + 1] + move[1], + ); + } + + private handle(which: Handle) { + const [x, y] = + which === "p1" ? this.handles.slice(0, 2) : this.handles.slice(2); + const [anchorX, anchorY] = which === "p1" ? [0, 0] : [1, 1]; + const cx = px(x); + const cy = py(y); + + return svg` + + this.grab(event, which)}> + + + + + this.nudge(event, which)} + /> + + `; + } + + private path() { + return Array.from({ length: CURVE_SAMPLES + 1 }, (_, k) => { + const x = k / CURVE_SAMPLES; + return `${k ? "L" : "M"}${px(x).toFixed(2)} ${py(ease(x, this.curve)).toFixed(2)}`; + }).join(" "); + } + + render() { + return html` + + + + + + ${this.high} + ${this.low} + CONTRAST + ${this.points.map( + ( + p, + ) => svg``, + )} + + ${this.points.map( + (p) => svg``, + )} + ${this.handle("p1")} + ${this.handle("p2")} + 50 + 900 + + `; + } +} + +define("ig-curve-editor", CurveEditor); + +declare global { + interface HTMLElementTagNameMap { + "ig-curve-editor": CurveEditor; + } + + interface HTMLElementEventMap { + "curve-change": CustomEvent; + } +} diff --git a/packages/preview/src/elements/gamut-plot.ts b/packages/preview/src/elements/gamut-plot.ts new file mode 100644 index 00000000..d4ad0109 --- /dev/null +++ b/packages/preview/src/elements/gamut-plot.ts @@ -0,0 +1,267 @@ +import { css, html, LitElement, type SVGTemplateResult, svg } from "lit"; +import { + CHROMA_CEILING, + type Cusp, + encode, + GAMUT_HIGH, + GAMUT_LOW, + gamutCusp, + oklabToLinear, + rgbToOklch, + toRgb, +} from "../color.js"; +import type { SweepRamp } from "../data/color/sweep.js"; +import { define } from "../define.js"; +import { isClamped, shadeHex, shadeRgb } from "../ramp.js"; +import { SHADES } from "../variants.js"; + +/** The plot's coordinate space. The canvas and the SVG over it share it exactly. */ +const PLOT = { + width: 400, + height: 300, + left: 46, + right: 384, + top: 16, + bottom: 260, +}; +const px = (C: number) => + PLOT.left + (C / CHROMA_CEILING) * (PLOT.right - PLOT.left); +const py = (L: number) => PLOT.bottom - L * (PLOT.bottom - PLOT.top); + +/** + * A slice through sRGB at one hue, with both generators' shades placed on it. + * + * The lit region is every color the hue can produce. Painted per pixel on a canvas at + * one device pixel per CSS pixel: the field is a smooth gradient the browser scales up + * cleanly, and everything that needs to stay crisp — axes, ticks, marks — is SVG over it. + */ +export class GamutPlot extends LitElement { + static properties = { + seed: { type: String }, + legacy: { attribute: false }, + fitted: { attribute: false }, + }; + + static styles = css` + :host { + display: block; + color: var(--plot-ink, currentColor); + } + + .plot { + position: relative; + aspect-ratio: ${PLOT.width} / ${PLOT.height}; + border: 1px solid var(--line); + border-radius: var(--radius-sm, 3px); + background: var(--panel); + overflow: hidden; + } + + canvas, + svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + } + + .tick { + font-family: var(--mono, ui-monospace, monospace); + font-size: 9px; + fill: currentColor; + opacity: 0.8; + } + + .axis { + letter-spacing: 0.08em; + opacity: 0.6; + } + + .legend { + display: flex; + flex-wrap: wrap; + gap: var(--space-4, 16px); + margin-block-start: var(--space-3, 12px); + font-family: var(--mono, ui-monospace, monospace); + font-size: var(--text-xs, 11px); + color: var(--muted); + } + + .legend span { + display: inline-flex; + align-items: center; + gap: var(--space-2, 8px); + } + + .mark { + flex: none; + width: 9px; + height: 9px; + border: 1.5px solid currentColor; + background: currentColor; + } + + .mark.is-fitted, + .mark.is-asked { + border-radius: 50%; + } + + .mark.is-asked { + background: transparent; + } + `; + + declare seed: string; + declare legacy: SweepRamp; + declare fitted: SweepRamp; + + private cusps = new Map(); + private paintedHue = Number.NaN; + + constructor() { + super(); + this.seed = "#000000"; + this.legacy = { hex: "", mask: 0, ask: [] }; + this.fitted = { hex: "", mask: 0, ask: [] }; + } + + /** Memoised: finding the cusp is 100 bisections, and it is read several times a render. */ + private get cusp(): Cusp { + const cached = this.cusps.get(this.seed); + if (cached) return cached; + + const cusp = gamutCusp(rgbToOklch(toRgb(this.seed))[2]); + this.cusps.set(this.seed, cusp); + return cusp; + } + + updated() { + this.paint(); + } + + /** Only the hue moves the field, so a render for any other reason reuses the canvas. */ + private paint() { + const canvas = this.renderRoot.querySelector("canvas"); + const context = canvas?.getContext("2d"); + const { hue } = this.cusp; + if (!canvas || !context || hue === this.paintedHue) return; + + const { width, height, left, right, top, bottom } = PLOT; + canvas.width = width; + canvas.height = height; + + const image = context.createImageData(width, height); + const radians = (hue * Math.PI) / 180; + const cos = Math.cos(radians); + const sin = Math.sin(radians); + + for (let y = top; y <= bottom; y++) { + const L = (bottom - y) / (bottom - top); + + for (let x = left; x <= right; x++) { + const C = ((x - left) / (right - left)) * CHROMA_CEILING; + const [r, g, b] = oklabToLinear(L, C * cos, C * sin); + + if (r < GAMUT_LOW || r > GAMUT_HIGH) continue; + if (g < GAMUT_LOW || g > GAMUT_HIGH) continue; + if (b < GAMUT_LOW || b > GAMUT_HIGH) continue; + + const i = (y * width + x) * 4; + image.data[i] = encode(r); + image.data[i + 1] = encode(g); + image.data[i + 2] = encode(b); + image.data[i + 3] = 255; + } + } + + context.putImageData(image, 0, 0); + this.paintedHue = hue; + } + + /** Where each clamped legacy shade asked to be, with a leader to where it landed. */ + private asked(): SVGTemplateResult[] { + const marks: SVGTemplateResult[] = []; + let n = 0; + + SHADES.forEach((_, i) => { + if (!isClamped(this.legacy, i)) return; + + const ask = this.legacy.ask; + const [askL, askC] = rgbToOklch([ + ask[n * 3], + ask[n * 3 + 1], + ask[n * 3 + 2], + ]); + const [landedL, landedC] = rgbToOklch(shadeRgb(this.legacy, i)); + const ax = Math.min(px(Math.min(askC, CHROMA_CEILING)), PLOT.right); + const ay = py(Math.max(0, Math.min(1, askL))); + n++; + + marks.push(svg` + + + `); + }); + + return marks; + } + + private marks(ramp: SweepRamp, shape: "square" | "circle") { + return SHADES.map((_, i) => { + const [L, C] = rgbToOklch(shadeRgb(ramp, i)); + const fill = shadeHex(ramp, i); + + return shape === "square" + ? svg`` + : svg``; + }); + } + + render() { + const cusp = this.cusp; + const { left, right, top, bottom } = PLOT; + + return html` +
+ + + + + ${[0, 0.25, 0.5, 0.75, 1].map( + (L) => + svg`${L.toFixed(2)}`, + )} + ${[0, 0.1, 0.2, 0.3].map( + (C) => + svg`${C.toFixed(1)}`, + )} + CHROMA + LIGHTNESS + + cusp ${cusp.C.toFixed(2)} + ${this.asked()} + ${this.marks(this.legacy, "square")} + ${this.marks(this.fitted, "circle")} + +
+

+ legacy + fitted + asked for, then clamped +

+ `; + } +} + +define("ig-gamut-plot", GamutPlot); + +declare global { + interface HTMLElementTagNameMap { + "ig-gamut-plot": GamutPlot; + } +} diff --git a/packages/preview/src/elements/highlight.spec.ts b/packages/preview/src/elements/highlight.spec.ts new file mode 100644 index 00000000..0fa9bf0c --- /dev/null +++ b/packages/preview/src/elements/highlight.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { highlightSass } from "./highlight.js"; + +const SNIPPET = `$palette: palette( + $primary: #0099ff, + $scales: ('gray': (range: 1.1 18.1, curve: null)) +);`; + +describe("highlightSass", () => { + it("returns a pre painted in the theme, one line per source line", async () => { + const html = await highlightSass(SNIPPET); + + expect(html).toMatch(/^
 {
+    const html = await highlightSass(SNIPPET);
+    const colors = new Set(html.match(/color:#[0-9a-f]{6}/gi));
+
+    expect(html).toMatch(/color:#[0-9a-f]{6}[^>]*>\s*\$primary/i);
+    expect(colors.size).toBeGreaterThan(2);
+  });
+
+  it("escapes markup in the source", async () => {
+    expect(await highlightSass("$x: '';")).not.toContain("");
+  });
+});
diff --git a/packages/preview/src/elements/highlight.ts b/packages/preview/src/elements/highlight.ts
new file mode 100644
index 00000000..583bd4b7
--- /dev/null
+++ b/packages/preview/src/elements/highlight.ts
@@ -0,0 +1,31 @@
+/**
+ * Syntax highlighting for the Sass snippets, through Shiki's fine-grained core: one
+ * grammar, one theme, and the JavaScript regex engine rather than the WebAssembly one,
+ * so the whole thing is a few tens of kilobytes rather than a megabyte.
+ *
+ * The one theme is dark in both chrome schemes. A code block is a panel of its own, and
+ * a dark panel on a light page reads as "code" the way a terminal does.
+ */
+import scss from "@shikijs/langs/scss";
+import theme from "@shikijs/themes/github-dark-default";
+import { createHighlighterCore, type HighlighterCore } from "shiki/core";
+import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
+
+const THEME_NAME = "github-dark-default";
+
+let pending: Promise | undefined;
+
+/** Built once, on first use, and shared by every code block on the page. */
+const highlighter = () => {
+  pending ??= createHighlighterCore({
+    langs: [scss],
+    themes: [theme],
+    engine: createJavaScriptRegexEngine(),
+  });
+
+  return pending;
+};
+
+/** Sass source as a highlighted `
`, painted in the theme's own colors. */
+export const highlightSass = async (code: string) =>
+  (await highlighter()).codeToHtml(code, { lang: "scss", theme: THEME_NAME });
diff --git a/packages/preview/src/elements/ignite.ts b/packages/preview/src/elements/ignite.ts
new file mode 100644
index 00000000..99b341ed
--- /dev/null
+++ b/packages/preview/src/elements/ignite.ts
@@ -0,0 +1,60 @@
+/**
+ * The shipped components this app uses for its own controls.
+ *
+ * Registered here rather than at each call site so the set is visible in one place, and
+ * so a view that imports it cannot forget one and render an inert custom element.
+ *
+ * It has to come through the package barrel: `exports` only publishes `.`, `./themes/*`
+ * and `./extras`, so there is no subpath to import five components from. That is what
+ * the ~59 KB gzipped chunk buys — the whole library, for a button, a button group, a
+ * slider and a tooltip.
+ *
+ * They are themed by `shell/theme.scss`, which emits this branch's palette onto `:root` —
+ * the components read `--ig-*` at runtime, so the controls in this app are painted by the
+ * generator the app is about.
+ */
+import {
+  configureTheme,
+  defineComponents,
+  IgcButtonComponent,
+  IgcButtonGroupComponent,
+  IgcSliderComponent,
+  IgcToggleButtonComponent,
+  IgcTooltipComponent,
+} from "igniteui-webcomponents";
+
+/**
+ * The family decides the components' structure — their shapes, densities and the type and
+ * elevation scales they assume. `shell/theme.scss` picks the same one for its Sass
+ * presets, so the two stay in step.
+ */
+const THEME = "indigo";
+
+const prefersDark = matchMedia("(prefers-color-scheme: dark)");
+
+/**
+ * The scheme the chrome is drawn in: an explicit `data-theme` on the root wins, then the
+ * system preference. The same two sources, in the same order, that `shell/theme.scss`
+ * reads — a control themed for the other scheme is invisible on the page.
+ */
+const scheme = () => {
+  const forced = document.documentElement.dataset.theme;
+  if (forced === "light" || forced === "dark") return forced;
+  return prefersDark.matches ? "dark" : "light";
+};
+
+const applyTheme = () => configureTheme(THEME, scheme());
+
+applyTheme();
+prefersDark.addEventListener("change", applyTheme);
+new MutationObserver(applyTheme).observe(document.documentElement, {
+  attributeFilter: ["data-theme"],
+});
+
+defineComponents(
+  IgcButtonComponent,
+  IgcButtonGroupComponent,
+  IgcSliderComponent,
+  IgcToggleButtonComponent,
+  IgcTooltipComponent,
+);
diff --git a/packages/preview/src/elements/index.ts b/packages/preview/src/elements/index.ts
new file mode 100644
index 00000000..ded5bf3a
--- /dev/null
+++ b/packages/preview/src/elements/index.ts
@@ -0,0 +1,10 @@
+/**
+ * Registers every element a view may compose. Importing this module is all a view has to
+ * do; the elements are addressed by tag, so nothing is exported.
+ */
+import "./code-block.js";
+import "./curve-editor.js";
+import "./gamut-plot.js";
+import "./ignite.js";
+import "./palette-scope.js";
+import "./verdict.js";
diff --git a/packages/preview/src/elements/palette-scope.spec.ts b/packages/preview/src/elements/palette-scope.spec.ts
new file mode 100644
index 00000000..8e7438f9
--- /dev/null
+++ b/packages/preview/src/elements/palette-scope.spec.ts
@@ -0,0 +1,34 @@
+// @vitest-environment happy-dom
+import { describe, expect, it } from "vitest";
+import { define } from "../define.js";
+import { PaletteScope } from "./palette-scope.js";
+
+describe("ig-palette-scope", () => {
+  const scope = () => document.createElement("ig-palette-scope");
+
+  it("applies its palette as inline custom properties", () => {
+    const element = scope();
+    element.vars = { "--ig-primary-500": "#09f" };
+
+    expect(element.style.getPropertyValue("--ig-primary-500")).toBe("#09f");
+  });
+
+  it("removes properties the next palette no longer carries", () => {
+    const element = scope();
+    element.vars = { "--ig-primary-500": "#09f", "--ig-gray-500": "#888" };
+    element.vars = { "--ig-primary-500": "#f90" };
+
+    expect(element.style.getPropertyValue("--ig-primary-500")).toBe("#f90");
+    expect(element.style.getPropertyValue("--ig-gray-500")).toBe("");
+    expect(element.vars).toEqual({ "--ig-primary-500": "#f90" });
+  });
+});
+
+describe("define", () => {
+  it("keeps the first definition rather than throwing on a repeat", () => {
+    class Other extends HTMLElement {}
+
+    expect(() => define("ig-palette-scope", Other)).not.toThrow();
+    expect(customElements.get("ig-palette-scope")).toBe(PaletteScope);
+  });
+});
diff --git a/packages/preview/src/elements/palette-scope.ts b/packages/preview/src/elements/palette-scope.ts
new file mode 100644
index 00000000..8010e05b
--- /dev/null
+++ b/packages/preview/src/elements/palette-scope.ts
@@ -0,0 +1,38 @@
+/**
+ * Applies one compiled palette to its own subtree.
+ *
+ * A demo that relied on a stylesheet somebody else added to the document would only work
+ * inside this application. Setting the declarations on the element itself means the
+ * palette travels with the markup, and custom properties inherit through shadow
+ * boundaries, so children resolve `var(--ig-*)` wherever they are.
+ *
+ * It deliberately does not render: children belong to whoever wrote the template.
+ */
+import { define } from "../define.js";
+export class PaletteScope extends HTMLElement {
+  #vars: Record = {};
+
+  set vars(next: Record | undefined) {
+    for (const name of Object.keys(this.#vars)) {
+      if (!next || !(name in next)) this.style.removeProperty(name);
+    }
+
+    this.#vars = next ?? {};
+
+    for (const [name, value] of Object.entries(this.#vars)) {
+      this.style.setProperty(name, value);
+    }
+  }
+
+  get vars() {
+    return this.#vars;
+  }
+}
+
+define("ig-palette-scope", PaletteScope);
+
+declare global {
+  interface HTMLElementTagNameMap {
+    "ig-palette-scope": PaletteScope;
+  }
+}
diff --git a/packages/preview/src/elements/verdict.ts b/packages/preview/src/elements/verdict.ts
new file mode 100644
index 00000000..0ff450fe
--- /dev/null
+++ b/packages/preview/src/elements/verdict.ts
@@ -0,0 +1,55 @@
+import { css, html, LitElement } from "lit";
+import { define } from "../define.js";
+
+export interface Finding {
+  text: string;
+  ok: boolean;
+}
+
+/**
+ * A pass or fail readout. Offered, not imposed: a demo whose subject has no honest
+ * measure simply does not use one.
+ */
+export class Verdict extends LitElement {
+  static properties = { findings: { attribute: false } };
+
+  static styles = css`
+    :host {
+      display: flex;
+      flex-direction: column;
+      gap: 2px;
+      font-family: var(--mono, ui-monospace, monospace);
+      font-size: var(--text-xs, 11px);
+    }
+
+    .ok {
+      color: var(--pass, currentColor);
+    }
+
+    .no {
+      color: var(--fail, currentColor);
+    }
+  `;
+
+  declare findings: Finding[];
+
+  constructor() {
+    super();
+    this.findings = [];
+  }
+
+  render() {
+    return this.findings.map(
+      (finding) =>
+        html`${finding.text}`,
+    );
+  }
+}
+
+define("ig-verdict", Verdict);
+
+declare global {
+  interface HTMLElementTagNameMap {
+    "ig-verdict": Verdict;
+  }
+}
diff --git a/packages/preview/src/main.ts b/packages/preview/src/main.ts
new file mode 100644
index 00000000..328928ff
--- /dev/null
+++ b/packages/preview/src/main.ts
@@ -0,0 +1 @@
+import "./shell/shell.js";
diff --git a/packages/preview/src/preset-model.spec.ts b/packages/preview/src/preset-model.spec.ts
new file mode 100644
index 00000000..d1910d9b
--- /dev/null
+++ b/packages/preview/src/preset-model.spec.ts
@@ -0,0 +1,133 @@
+import { describe, expect, it } from "vitest";
+import type { Preset, PresetSide } from "./data/color/presets.js";
+import { buildPreset, markTwins, type Swatch } from "./preset-model.js";
+import { ACCENTS, FAMILIES, type Family, ROLES, SHADES } from "./variants.js";
+
+/** A gray ramp from white to black, the same on every family. */
+const ramp = (i: number) => {
+  const v = Math.round(255 - (i * 255) / 9);
+  return `rgb(${v}, ${v}, ${v})`;
+};
+
+/** A preset whose every shade is set by one function, so a test can shape it. */
+const side = (literal: (family: Family, key: string) => string): PresetSide => {
+  const values: Record = {};
+
+  for (const family of FAMILIES) {
+    SHADES.forEach((key, i) => {
+      values[`${family}-${key}`] = literal(family, key) ?? ramp(i);
+    });
+    for (const key of ACCENTS)
+      values[`${family}-${key}`] = literal(family, key);
+    for (const key of ROLES) values[`${family}-${key}`] = literal(family, key);
+  }
+
+  return { vars: { "--ig-primary-500": "#09f" }, values };
+};
+
+const preset = (literal: (family: Family, key: string) => string): Preset => ({
+  key: "material",
+  label: "Material",
+  theme: "light",
+  seeds: {
+    primary: "#0099ff",
+    secondary: "#df1b74",
+    gray: "#333333",
+    surface: "#ffffff",
+    info: "#1377d5",
+    success: "#4eb862",
+    warn: "#faa419",
+    error: "#ff134a",
+  },
+  legacy: side(literal),
+  fitted: side(literal),
+});
+
+const grayscale = (_: Family, key: string) => {
+  const i = SHADES.indexOf(key as (typeof SHADES)[number]);
+  return i >= 0 ? ramp(i) : "rgb(128, 128, 128)";
+};
+
+describe("buildPreset", () => {
+  const model = buildPreset(preset(grayscale));
+
+  it("measures chromatic families against white, whatever the page", () => {
+    const primary = model.families[0].shades[0];
+    expect(primary.swatches[0].against).toBe("white");
+    expect(primary.swatches[0].ratio).toBeCloseTo(1, 5);
+    expect(primary.swatches[9].ratio).toBeCloseTo(21, 3);
+  });
+
+  it("measures the neutrals against the page", () => {
+    const gray = model.surface.grays[0];
+    expect(gray.swatches[0].against).toBe("the page #ffffff");
+  });
+
+  it("only lets the numbered ramps take part in the probe", () => {
+    const [family] = model.families;
+    expect(family.shades.every((row) => row.probeable)).toBe(true);
+    expect(family.accents.every((row) => !row.probeable)).toBe(true);
+  });
+
+  it("names the token each swatch paints from", () => {
+    expect(model.families[0].shades[0].swatches[4].token).toBe(
+      "--ig-primary-400",
+    );
+  });
+
+  it("flags a shade the generator asked for outside sRGB", () => {
+    const clipped = buildPreset(
+      preset((family, key) =>
+        family === "primary" && key === "50"
+          ? "hsl(0, 0%, 174%)"
+          : grayscale(family, key),
+      ),
+    );
+    const [first, second] = clipped.families[0].shades[0].swatches;
+
+    expect(first.clipped).toBe(true);
+    expect(first.hex).toBe("#ffffff");
+    expect(second.clipped).toBe(false);
+  });
+
+  it("reports a role that resolved onto the page as collapsed", () => {
+    const flat = buildPreset(
+      preset((family, key) =>
+        family === "surface" && key === "raised"
+          ? "#ffffff"
+          : grayscale(family, key),
+      ),
+    );
+    const raised = flat.surface.roles.find((r) => r.key === "raised");
+    const sunken = flat.surface.roles.find((r) => r.key === "sunken");
+
+    expect(raised?.collapsed).toBe(true);
+    expect(sunken?.collapsed).toBe(false);
+  });
+});
+
+describe("markTwins", () => {
+  const swatch = (key: string, hex: string): Swatch => ({
+    key,
+    hex,
+    token: "",
+    rgb: [0, 0, 0],
+    ratio: 1,
+    against: "",
+    clipped: false,
+    same: [],
+  });
+
+  it("lists, on each shade, every other shade with the same color", () => {
+    const swatches = [
+      swatch("50", "#fff"),
+      swatch("100", "#fff"),
+      swatch("200", "#eee"),
+    ];
+    markTwins(swatches);
+
+    expect(swatches[0].same).toEqual(["100"]);
+    expect(swatches[1].same).toEqual(["50"]);
+    expect(swatches[2].same).toEqual([]);
+  });
+});
diff --git a/packages/preview/src/preset-model.ts b/packages/preview/src/preset-model.ts
new file mode 100644
index 00000000..326d4154
--- /dev/null
+++ b/packages/preview/src/preset-model.ts
@@ -0,0 +1,250 @@
+/** Turns a compiled preset into the view model the color views render. */
+
+import {
+  composite,
+  contrast,
+  displayable,
+  hex,
+  parse,
+  parseRaw,
+  type Rgb,
+  toRgb,
+} from "./color.js";
+import type { Preset, PresetSide } from "./data/color/presets.js";
+import {
+  ACCENTS,
+  type Family,
+  ROLES,
+  type RoleKey,
+  SHADES,
+  type Theme,
+} from "./variants.js";
+
+/** Below this the shade is indistinguishable from the surface it sits on. */
+const SAME = 1.005;
+
+/**
+ * The families that get the full ten shades plus accents. `gray` and `surface` are
+ * shown on their own terms, because both are about the page rather than about a color.
+ */
+export const CHROMATIC: { key: Family; label: string }[] = [
+  { key: "primary", label: "Primary" },
+  { key: "secondary", label: "Secondary" },
+  { key: "info", label: "Info" },
+  { key: "success", label: "Success" },
+  { key: "warn", label: "Warn" },
+  { key: "error", label: "Error" },
+];
+
+export interface Swatch {
+  key: string;
+  token: string;
+  rgb: Rgb;
+  ratio: number;
+  hex: string;
+  against: string;
+  /** The shade asked for a color outside sRGB, and the browser clipped it to fit. */
+  clipped: boolean;
+  /** Keys of other shades in the same row that resolve to this exact color. */
+  same: string[];
+}
+
+export interface Row {
+  /** The custom properties this row paints from, applied to its own subtree. */
+  vars: Record;
+  label: string;
+  swatches: Swatch[];
+  /** Whether a pinned shade number applies here. Accents run on their own keys. */
+  probeable: boolean;
+}
+
+export interface Role {
+  key: RoleKey;
+  /** Contrast against the page. */
+  away: number;
+  /** The role had no room and resolved onto the page. */
+  collapsed: boolean;
+}
+
+export interface FamilyBlock {
+  key: Family;
+  label: string;
+  seed: string;
+  shades: Row[];
+  accents: Row[];
+}
+
+export interface SurfaceBlock {
+  /** The page the preset paints on, and what grays are measured against. */
+  bg: string;
+  seed: string;
+  fittedVars: Record;
+  legacySurface: Row;
+  roles: Role[];
+  grays: Row[];
+}
+
+export interface PresetModel {
+  key: string;
+  label: string;
+  theme: Theme;
+  bg: string;
+  families: FamilyBlock[];
+  surface: SurfaceBlock;
+}
+
+interface RowSpec {
+  side: PresetSide;
+  label: string;
+  family: Family;
+  keys: readonly string[];
+  against: Rgb;
+  againstLabel: string;
+  /** Whether the row takes part in the probe, which only the numbered ramps do. */
+  probeable?: boolean;
+}
+
+const swatch = (
+  family: Family,
+  key: string,
+  literal: string,
+  against: Rgb,
+  label: string,
+): Swatch => {
+  const color = composite(parse(literal), against);
+
+  return {
+    key,
+    token: `--ig-${family}-${key}`,
+    rgb: color,
+    ratio: contrast(color, against),
+    hex: hex(color),
+    against: label,
+    clipped: !displayable(toRgbRaw(literal)),
+    same: [],
+  };
+};
+
+const toRgbRaw = (literal: string): Rgb => {
+  const [r, g, b] = parseRaw(literal);
+  return [r, g, b];
+};
+
+/**
+ * Marks every shade that resolves to the same color as another in its row.
+ *
+ * Two tokens with one color is the quiet failure: a border drawn in 600 over a fill of
+ * 700 simply is not there, and nothing in the markup says so. It happens when a ramp is
+ * asked for ten steps in a direction that has fewer than ten left.
+ */
+export const markTwins = (swatches: Swatch[]) => {
+  const byColor = new Map();
+
+  for (const entry of swatches) {
+    const group = byColor.get(entry.hex) ?? [];
+    group.push(entry);
+    byColor.set(entry.hex, group);
+  }
+
+  for (const group of byColor.values()) {
+    if (group.length < 2) continue;
+
+    for (const entry of group) {
+      entry.same = group.filter((o) => o !== entry).map((o) => o.key);
+    }
+  }
+};
+
+const row = ({
+  side,
+  label,
+  family,
+  keys,
+  against,
+  againstLabel,
+  probeable = false,
+}: RowSpec): Row => {
+  const swatches = keys.map((key) =>
+    swatch(family, key, side.values[`${family}-${key}`], against, againstLabel),
+  );
+
+  markTwins(swatches);
+
+  return { vars: side.vars, label, swatches, probeable };
+};
+
+const WHITE: Rgb = [255, 255, 255];
+
+export const buildPreset = (preset: Preset): PresetModel => {
+  const bg = preset.seeds.surface;
+  const page = toRgb(bg);
+
+  // Chromatic families are cut against white whatever the page is, so that is what
+  // their numbers are measured against — the page would be a different claim.
+  const chromatic = (
+    side: PresetSide,
+    label: string,
+    family: Family,
+    keys: readonly string[],
+    probeable: boolean,
+  ) =>
+    row({
+      side,
+      label,
+      family,
+      keys,
+      against: WHITE,
+      againstLabel: "white",
+      probeable,
+    });
+
+  const onPage = (side: PresetSide, label: string, family: Family) =>
+    row({
+      side,
+      label,
+      family,
+      keys: SHADES,
+      against: page,
+      againstLabel: `the page ${bg}`,
+      probeable: true,
+    });
+
+  const families = CHROMATIC.map(({ key, label }) => ({
+    key,
+    label,
+    seed: preset.seeds[key],
+    shades: [
+      chromatic(preset.legacy, "legacy", key, SHADES, true),
+      chromatic(preset.fitted, "fitted", key, SHADES, true),
+    ],
+    accents: [
+      chromatic(preset.legacy, "legacy accent", key, ACCENTS, false),
+      chromatic(preset.fitted, "fitted accent", key, ACCENTS, false),
+    ],
+  }));
+
+  const roles = ROLES.map((key): Role => {
+    const literal = preset.fitted.values[`surface-${key}`];
+    const away = contrast(composite(parse(literal), page), page);
+    return { key, away, collapsed: away < SAME };
+  });
+
+  return {
+    key: preset.key,
+    label: preset.label,
+    theme: preset.theme,
+    bg,
+    families,
+    surface: {
+      bg,
+      seed: preset.seeds.gray,
+      fittedVars: preset.fitted.vars,
+      legacySurface: onPage(preset.legacy, "legacy", "surface"),
+      roles,
+      grays: [
+        onPage(preset.legacy, "legacy", "gray"),
+        onPage(preset.fitted, "fitted", "gray"),
+      ],
+    },
+  };
+};
diff --git a/packages/preview/src/ramp.spec.ts b/packages/preview/src/ramp.spec.ts
new file mode 100644
index 00000000..eaa5f69f
--- /dev/null
+++ b/packages/preview/src/ramp.spec.ts
@@ -0,0 +1,91 @@
+import { describe, expect, it } from "vitest";
+import type { SweepRamp } from "./data/color/sweep.js";
+import {
+  clampedCount,
+  duplicateCount,
+  findings,
+  isClamped,
+  shadeHex,
+} from "./ramp.js";
+
+/** Ten grays from white to black, packed the way the provider packs them. */
+const grays = Array.from({ length: 10 }, (_, i) => {
+  const v = Math.round(255 - (i * 255) / 9)
+    .toString(16)
+    .padStart(2, "0");
+  return `${v}${v}${v}`;
+});
+
+const ramp = (extra: Partial = {}): SweepRamp => ({
+  hex: grays.join(""),
+  mask: 0,
+  ask: [],
+  ...extra,
+});
+
+describe("reading a packed ramp", () => {
+  it("addresses shades by index", () => {
+    expect(shadeHex(ramp(), 0)).toBe("#ffffff");
+    expect(shadeHex(ramp(), 9)).toBe("#000000");
+  });
+
+  it("reads the clamped bits", () => {
+    const clamped = ramp({ mask: 0b1000000001 });
+    expect(isClamped(clamped, 0)).toBe(true);
+    expect(isClamped(clamped, 1)).toBe(false);
+    expect(isClamped(clamped, 9)).toBe(true);
+    expect(clampedCount(clamped)).toBe(2);
+  });
+
+  it("counts shades that resolve to the same color", () => {
+    const twins = ramp({
+      hex: `ffffff${grays.slice(1).join("")}`.replace(
+        /^ffffff.{6}/,
+        "ffffffffffff",
+      ),
+    });
+    expect(duplicateCount(twins)).toBe(1);
+  });
+});
+
+describe("findings", () => {
+  it("leads with the AA record", () => {
+    expect(findings(ramp())[0]).toEqual({
+      text: "all 5 pairs pass AA",
+      ok: true,
+    });
+
+    // Ten grays between 200 and 100: nothing five apart reaches 4.5:1.
+    const narrow = Array.from({ length: 10 }, (_, i) => {
+      const v = Math.round(200 - (i * 100) / 9)
+        .toString(16)
+        .padStart(2, "0");
+      return `${v}${v}${v}`;
+    });
+    expect(findings(ramp({ hex: narrow.join("") }))[0]).toEqual({
+      text: "5 of 5 pairs fail AA",
+      ok: false,
+    });
+  });
+
+  it("reports a clean ramp as clean", () => {
+    // Black-and-white alternation: every pair five apart is 21:1.
+    const stark = ramp({
+      hex: Array.from({ length: 10 }, (_, i) =>
+        i < 5 ? "ffffff" : "000000",
+      ).join(""),
+    });
+    expect(findings(stark).map((f) => f.text)).toEqual([
+      "all 5 pairs pass AA",
+      "nothing clamped",
+      "8 shades share a color",
+    ]);
+  });
+
+  it("always says whether anything was clamped", () => {
+    expect(findings(ramp()).map((f) => f.text)).toContain("nothing clamped");
+    expect(findings(ramp({ mask: 0b11 })).map((f) => f.text)).toContain(
+      "2 shades clamped",
+    );
+  });
+});
diff --git a/packages/preview/src/ramp.ts b/packages/preview/src/ramp.ts
new file mode 100644
index 00000000..79a5c6af
--- /dev/null
+++ b/packages/preview/src/ramp.ts
@@ -0,0 +1,53 @@
+/** Reading the packed ramps the sweep provider emits. Pure, shared by the view and its tests. */
+import { failingPairs, PAIR_DISTANCE, type Rgb, toRgb } from "./color.js";
+import type { SweepRamp } from "./data/color/sweep.js";
+import type { Finding } from "./elements/verdict.js";
+import { SHADES } from "./variants.js";
+
+/** The six-digit hex of shade `i`, with its `#`. */
+export const shadeHex = (ramp: SweepRamp, i: number) =>
+  `#${ramp.hex.slice(i * 6, i * 6 + 6)}`;
+
+export const shadeRgb = (ramp: SweepRamp, i: number): Rgb =>
+  toRgb(shadeHex(ramp, i));
+
+export const rampRgb = (ramp: SweepRamp): Rgb[] =>
+  SHADES.map((_, i) => shadeRgb(ramp, i));
+
+/** Whether the generator asked for shade `i` outside sRGB. */
+export const isClamped = (ramp: SweepRamp, i: number) =>
+  ((ramp.mask >> i) & 1) === 1;
+
+export const clampedCount = (ramp: SweepRamp) =>
+  SHADES.filter((_, i) => isClamped(ramp, i)).length;
+
+export const duplicateCount = (ramp: SweepRamp) => {
+  const shades = rampRgb(ramp);
+  return shades.length - new Set(shades.map(String)).size;
+};
+
+/** What the verdict beside a ramp says. */
+export const findings = (ramp: SweepRamp): Finding[] => {
+  const shades = rampRgb(ramp);
+  const pairs = shades.length - PAIR_DISTANCE;
+  const fails = failingPairs(shades);
+  const clamped = clampedCount(ramp);
+  const dup = duplicateCount(ramp);
+  const out: Finding[] = [
+    {
+      text: fails
+        ? `${fails} of ${pairs} pairs fail AA`
+        : `all ${pairs} pairs pass AA`,
+      ok: fails === 0,
+    },
+  ];
+
+  out.push(
+    clamped
+      ? { text: `${clamped} shade${clamped > 1 ? "s" : ""} clamped`, ok: false }
+      : { text: "nothing clamped", ok: true },
+  );
+  if (dup) out.push({ text: `${dup} shades share a color`, ok: false });
+
+  return out;
+};
diff --git a/packages/preview/src/sass-code.spec.ts b/packages/preview/src/sass-code.spec.ts
new file mode 100644
index 00000000..aaa9f5c6
--- /dev/null
+++ b/packages/preview/src/sass-code.spec.ts
@@ -0,0 +1,112 @@
+import { describe, expect, it } from "vitest";
+import {
+  call,
+  entries,
+  list,
+  map,
+  num,
+  quoted,
+  raw,
+  statement,
+} from "./sass-code.js";
+
+describe("sass-code", () => {
+  it("keeps a short map on one line", () => {
+    expect(
+      statement(
+        "scale",
+        map([
+          ["range", list(1.182, 18.232)],
+          ["curve", raw("null")],
+        ]),
+      ),
+    ).toBe("$scale: (range: 1.182 18.232, curve: null);");
+  });
+
+  it("breaks a call that will not fit on one line", () => {
+    const code = statement(
+      "palette",
+      call("palette", [
+        ["$primary", raw("#0099ff")],
+        ["$surface", raw("#fff")],
+        [
+          "$scales",
+          map([
+            [
+              "'gray'",
+              map([
+                ["range", list(1.1, 18.1)],
+                ["curve", raw("null")],
+              ]),
+            ],
+          ]),
+        ],
+      ]),
+    );
+
+    expect(code).toBe(
+      `$palette: palette(
+    $primary: #0099ff,
+    $surface: #fff,
+    $scales: ('gray': (range: 1.1 18.1, curve: null))
+);`,
+    );
+  });
+
+  it("breaks a call that is merely long", () => {
+    const code = statement(
+      "palette",
+      call("palette", [
+        ["$primary", raw("#0099ff")],
+        ["$secondary", raw("#df1b74")],
+        ["$surface", raw("#1a1a24")],
+        ["$gray", raw("#333333")],
+      ]),
+    );
+
+    expect(code.split("\n")).toHaveLength(6);
+    expect(code).toContain("\n    $gray: #333333\n");
+  });
+
+  it("keeps the trailing comma in a map and drops it in an argument list", () => {
+    const code = statement(
+      "p",
+      call("palette", [["$scales", map([["'gray'", quoted("carbon")]])]]),
+    );
+    expect(code).toBe("$p: palette($scales: ('gray': 'carbon'));");
+  });
+
+  it("ignores extra arguments, so `map(num)` cannot pass an index as the precision", () => {
+    expect(
+      [0.53, 0, 0.825].map(num).map((v) => (v as { text: string }).text),
+    ).toEqual(["0.53", "0", "0.825"]);
+  });
+
+  it("trims a number the way a person writes it", () => {
+    expect(statement("x", list(num(1.04), num(16.1)))).toBe("$x: 1.04 16.1;");
+    expect(statement("y", num(0))).toBe("$y: 0;");
+    expect(statement("z", num(0.5296))).toBe("$z: 0.53;");
+  });
+
+  it("quotes only what asked to be quoted", () => {
+    expect(
+      statement(
+        "x",
+        map([
+          ["a", quoted("gray")],
+          ["b", raw("gray")],
+        ]),
+      ),
+    ).toBe("$x: (a: 'gray', b: gray);");
+  });
+
+  it("drops absent entries so callers do not branch", () => {
+    const args = entries({
+      $primary: raw("#09f"),
+      $gray: undefined,
+      $surface: raw("#fff"),
+    });
+
+    expect(args.map(([key]) => key)).toEqual(["$primary", "$surface"]);
+  });
+});
diff --git a/packages/preview/src/sass-code.ts b/packages/preview/src/sass-code.ts
new file mode 100644
index 00000000..6c2e4e14
--- /dev/null
+++ b/packages/preview/src/sass-code.ts
@@ -0,0 +1,102 @@
+/**
+ * Builds the Sass a demo shows, as a value rather than a string.
+ *
+ * The snippets are small but they nest, and every branch — a family that needs a
+ * `$gray` argument, a curve that is `null` rather than four numbers — used to be another
+ * ternary inside a template literal. Composing values and formatting once keeps the
+ * shape of the call readable and makes the output testable.
+ */
+export type Value =
+  | { kind: "raw"; text: string }
+  | { kind: "quoted"; text: string }
+  | { kind: "list"; items: Value[] }
+  | { kind: "map"; entries: Entry[] }
+  | { kind: "call"; name: string; args: Entry[] };
+
+export type Entry = [string, Value];
+
+/**
+ * A number, trimmed the way a person writes it: `1.04`, not `1.040`.
+ *
+ * Takes no precision argument on purpose. With one it is `Array.map`-shaped, and
+ * `values.map(num)` then passes the index as the precision — which reads fine and
+ * silently rounds the first element to zero decimals.
+ */
+export const num = (value: number): Value =>
+  raw(String(Number(value.toFixed(3))));
+
+/** A color, a number, an identifier, `null` — anything Sass reads literally. */
+export const raw = (text: string | number): Value => ({
+  kind: "raw",
+  text: String(text),
+});
+
+/** A single-quoted string, as scale and family names are written. */
+export const quoted = (text: string): Value => ({ kind: "quoted", text });
+
+/** Space separated, the way a `range` is written. */
+export const list = (...items: (string | number | Value)[]): Value => ({
+  kind: "list",
+  items: items.map((item) => (typeof item === "object" ? item : raw(item))),
+});
+
+export const map = (entries: Entry[]): Value => ({ kind: "map", entries });
+
+export const call = (name: string, args: Entry[]): Value => ({
+  kind: "call",
+  name,
+  args,
+});
+
+/** Entries whose value is absent are dropped, so a caller can compose without branching. */
+export const entries = (source: Record): Entry[] =>
+  Object.entries(source).filter(
+    (entry): entry is Entry => entry[1] !== undefined,
+  );
+
+const WIDTH = 64;
+
+const inline = (value: Value): string => {
+  switch (value.kind) {
+    case "raw":
+      return value.text;
+    case "quoted":
+      return `'${value.text}'`;
+    case "list":
+      return value.items.map(inline).join(" ");
+    case "map":
+      return `(${value.entries.map(([key, item]) => `${key}: ${inline(item)}`).join(", ")})`;
+    case "call":
+      return `${value.name}(${value.args.map(([key, item]) => `${key}: ${inline(item)}`).join(", ")})`;
+  }
+};
+
+/** Broken across lines only when the flat form will not fit. */
+const format = (value: Value, depth: number): string => {
+  if (value.kind !== "map" && value.kind !== "call") return inline(value);
+
+  const flat = inline(value);
+
+  if (flat.length + depth * 4 <= WIDTH) return flat;
+
+  const group = value.kind === "map" ? value.entries : value.args;
+  const pad = "    ".repeat(depth + 1);
+  // A map keeps its trailing comma, the way they are usually written; an argument list
+  // does not, because nobody writes one that way.
+  const last = value.kind === "map" ? "," : "";
+  const body = group
+    .map(
+      ([key, item], index) =>
+        `${pad}${key}: ${format(item, depth + 1)}${index === group.length - 1 ? last : ","}`,
+    )
+    .join("\n");
+  const close = "    ".repeat(depth);
+
+  return value.kind === "map"
+    ? `(\n${body}\n${close})`
+    : `${value.name}(\n${body}\n${close})`;
+};
+
+/** A complete statement: `$name: ;` */
+export const statement = (name: string, value: Value) =>
+  `$${name}: ${format(value, 0)};`;
diff --git a/packages/preview/src/scale-math.spec.ts b/packages/preview/src/scale-math.spec.ts
new file mode 100644
index 00000000..e70d7d41
--- /dev/null
+++ b/packages/preview/src/scale-math.spec.ts
@@ -0,0 +1,95 @@
+import { describe, expect, it } from "vitest";
+import {
+  ease,
+  IDENTITY_CURVE,
+  lookup,
+  rampFromTable,
+  SCALE_PRESETS,
+  type ScaleTable,
+  sameScale,
+  targets,
+} from "./scale-math.js";
+
+describe("ease", () => {
+  it("is the identity without a curve, and with the identity curve", () => {
+    for (const x of [0, 0.25, 0.5, 0.75, 1]) {
+      expect(ease(x, null)).toBe(x);
+      expect(ease(x, IDENTITY_CURVE)).toBeCloseTo(x, 6);
+    }
+  });
+
+  it("pins both ends whatever the curve", () => {
+    for (const { curve } of SCALE_PRESETS) {
+      expect(ease(0, curve)).toBeCloseTo(0, 6);
+      expect(ease(1, curve)).toBeCloseTo(1, 6);
+    }
+  });
+
+  it("bunches the light end under the material curve", () => {
+    const material = SCALE_PRESETS.find((p) => p.name === "material")?.curve;
+    // A slow start: the first third of the positions covers well under a third of the range.
+    expect(ease(1 / 3, material ?? null)).toBeLessThan(0.15);
+  });
+});
+
+describe("targets", () => {
+  it("runs from the low end of the range to the high end", () => {
+    const [first, ...rest] = targets({ range: [1.2, 18], curve: null });
+    expect(first).toBeCloseTo(1.2, 6);
+    expect(rest.at(-1)).toBeCloseTo(18, 6);
+    expect(rest).toHaveLength(9);
+  });
+
+  it("spaces a straight line geometrically", () => {
+    const t = targets({ range: [1, 16], curve: null });
+    const ratios = t.slice(1).map((v, i) => v / t[i]);
+    for (const r of ratios) expect(r).toBeCloseTo(ratios[0], 6);
+  });
+});
+
+describe("lookup", () => {
+  // Three samples per row, each a distinct hex, so the index chosen is visible.
+  const table: ScaleTable = {
+    samples: 3,
+    span: [1, 4],
+    rows: ["aaaaaabbbbbbcccccc", "111111222222333333"],
+  };
+
+  it("picks the sample nearest the requested contrast", () => {
+    expect(lookup(table, 0, 1)).toBe("aaaaaa");
+    expect(lookup(table, 0, 2)).toBe("bbbbbb");
+    expect(lookup(table, 0, 4)).toBe("cccccc");
+    expect(lookup(table, 1, 2.1)).toBe("222222");
+  });
+
+  it("clamps a request outside the span to the nearest end", () => {
+    expect(lookup(table, 0, 0.5)).toBe("aaaaaa");
+    expect(lookup(table, 0, 40)).toBe("cccccc");
+  });
+
+  it("reads one hex per shade position", () => {
+    expect(
+      rampFromTable(
+        { ...table, rows: Array(10).fill(table.rows[0]) },
+        {
+          range: [1, 4],
+          curve: null,
+        },
+      ),
+    ).toHaveLength(10);
+  });
+});
+
+describe("sameScale", () => {
+  it("compares the range and the curve by value", () => {
+    const [even, material] = SCALE_PRESETS;
+    expect(sameScale(even, { range: [1.182, 18.232], curve: null })).toBe(true);
+    expect(
+      sameScale(material, {
+        ...material,
+        curve: [...(material.curve ?? [0, 0, 0, 0])],
+      }),
+    ).toBe(true);
+    expect(sameScale(even, material)).toBe(false);
+  });
+});
diff --git a/packages/preview/src/scale-math.ts b/packages/preview/src/scale-math.ts
new file mode 100644
index 00000000..2dc27a4f
--- /dev/null
+++ b/packages/preview/src/scale-math.ts
@@ -0,0 +1,127 @@
+/**
+ * How a scale places ten shades between two contrast targets. This is the arithmetic
+ * `_generator.scss` performs, kept in one place so the editor, the lookup table and the
+ * tests that check the table against Sass all agree on it.
+ */
+
+/** Cubic bezier control points, `(x1, y1, x2, y2)`, as the Sass `curve` option takes them. */
+export type Curve = [number, number, number, number];
+
+export interface Scale {
+  /** Contrast of shade 50 and shade 900 against the anchor. */
+  range: [number, number];
+  /** How positions are eased between them. `null` is a straight line. */
+  curve: Curve | null;
+}
+
+export interface ScalePreset extends Scale {
+  name: string;
+  /** What the preset is for, in one line. */
+  why: string;
+}
+
+/** The scales the library ships, with the figures `_scales.scss` documents. */
+export const SCALE_PRESETS: ScalePreset[] = [
+  {
+    name: "even",
+    range: [1.182, 18.232],
+    curve: null,
+    why: "Spread as evenly as possible. The default.",
+  },
+  {
+    name: "material",
+    range: [1.04, 16.1],
+    curve: [0.53, 0, 0.825, 0.785],
+    why: "The grayscale we have always shipped.",
+  },
+  {
+    name: "tailwind",
+    range: [1.04, 17.76],
+    curve: [0.615, 0.07, 0.225, 0.43],
+    why: "The slate scale from Tailwind v4.",
+  },
+  {
+    name: "carbon",
+    range: [1.1, 18.1],
+    curve: [0.525, 0.295, 0.655, 0.87],
+    why: "IBM Carbon's gray, 10 through 100.",
+  },
+];
+
+/**
+ * Control points for a straight line. A cubic bezier with its handles at 1/3 and 2/3
+ * reduces exactly to `t`, so an editor can show and drag them while `curve` is still
+ * null without the rendered ramp shifting.
+ */
+export const IDENTITY_CURVE: Curve = [1 / 3, 1 / 3, 2 / 3, 2 / 3];
+
+/** One axis of a cubic bezier from (0,0) to (1,1). */
+export const bezier = (t: number, p1: number, p2: number) => {
+  const u = 1 - t;
+  return 3 * u * u * t * p1 + 3 * u * t * t * p2 + t ** 3;
+};
+
+/** The same easing `_ease()` performs in the generator: solve x for t, then read y. */
+export const ease = (x: number, curve: Curve | null) => {
+  if (!curve) return x;
+
+  let lo = 0;
+  let hi = 1;
+
+  for (let i = 0; i < 24; i++) {
+    const mid = (lo + hi) / 2;
+    if (bezier(mid, curve[0], curve[2]) < x) lo = mid;
+    else hi = mid;
+  }
+
+  return bezier((lo + hi) / 2, curve[1], curve[3]);
+};
+
+/** Where a shade sits on the 0–1 axis, before easing. */
+export const position = (index: number, count = 10) => index / (count - 1);
+
+/** The contrast a shade at eased position `t` aims for: geometric between the two ends. */
+export const target = ([lo, hi]: [number, number], t: number) =>
+  lo * (hi / lo) ** t;
+
+/** The ten contrast targets a scale asks for, in shade order. */
+export const targets = ({ range, curve }: Scale, count = 10) =>
+  Array.from({ length: count }, (_, i) =>
+    target(range, ease(position(i, count), curve)),
+  );
+
+/**
+ * A `(position, contrast target) -> color` table sampled geometrically across a span.
+ * Each row is one shade position; each entry is a six-digit hex, concatenated.
+ */
+export interface ScaleTable {
+  samples: number;
+  span: [number, number];
+  /** One string per shade position. */
+  rows: string[];
+}
+
+/** The hex at `position` nearest to `contrast`, by index into the sampled span. */
+export const lookup = (
+  table: ScaleTable,
+  position: number,
+  contrast: number,
+) => {
+  const { samples, span, rows } = table;
+  const fraction = Math.log(contrast / span[0]) / Math.log(span[1] / span[0]);
+  const k = Math.min(
+    samples - 1,
+    Math.max(0, Math.round(fraction * (samples - 1))),
+  );
+
+  return rows[position].slice(k * 6, k * 6 + 6);
+};
+
+/** The ten hexes a scale produces from a table. */
+export const rampFromTable = (table: ScaleTable, scale: Scale) =>
+  targets(scale).map((contrast, i) => lookup(table, i, contrast));
+
+export const sameScale = (a: Scale, b: Scale) =>
+  a.range[0] === b.range[0] &&
+  a.range[1] === b.range[1] &&
+  String(a.curve) === String(b.curve);
diff --git a/packages/preview/src/sections/color/controls.ts b/packages/preview/src/sections/color/controls.ts
new file mode 100644
index 00000000..9c72b579
--- /dev/null
+++ b/packages/preview/src/sections/color/controls.ts
@@ -0,0 +1,76 @@
+import "../../elements/index.js";
+import { html, LitElement } from "lit";
+import { define } from "../../define.js";
+import { isPresetKey, isTheme, PRESETS, THEMES } from "../../variants.js";
+import { ColorStateController, setColorState } from "./state.js";
+
+/** `igcSelect` carries the chosen button's value; an empty group emits undefined. */
+type Select = CustomEvent;
+
+/**
+ * The section's one input. Every view below reads what this writes, so a reader changes a
+ * palette once and watches the shades, the surface roles and the scale editor all move
+ * together.
+ *
+ * Built from the library's own button groups: this app demonstrates a theming framework,
+ * so its controls should be the components that framework themes.
+ */
+export class ColorControls extends LitElement {
+  private state = new ColorStateController(this);
+
+  createRenderRoot() {
+    return this;
+  }
+
+  render() {
+    const { preset, theme } = this.state.value;
+
+    return html`
+      
+ Palette + { + if (isPresetKey(detail)) setColorState({ preset: detail }); + }} + > + ${PRESETS.map( + (entry) => html` + + ${entry.label} + + `, + )} + +
+ +
+ Theme + { + if (isTheme(detail)) setColorState({ theme: detail }); + }} + > + ${THEMES.map( + (entry) => html` + + ${entry === "light" ? "Light" : "Dark"} + + `, + )} + +
+ `; + } +} + +define("ig-color-controls", ColorControls); + +declare global { + interface HTMLElementTagNameMap { + "ig-color-controls": ColorControls; + } +} diff --git a/packages/preview/src/sections/color/grade.spec.ts b/packages/preview/src/sections/color/grade.spec.ts new file mode 100644 index 00000000..81444217 --- /dev/null +++ b/packages/preview/src/sections/color/grade.spec.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import type { Swatch } from "../../preset-model.js"; +import { describe as describeSwatch, grade, listKeys, ratio } from "./grade.js"; + +const swatch = (extra: Partial = {}): Swatch => ({ + key: "500", + token: "--ig-primary-500", + rgb: [0, 153, 255], + ratio: 3.02, + hex: "#0099ff", + against: "white", + clipped: false, + same: [], + ...extra, +}); + +describe("grade", () => { + it("awards the best threshold a ratio clears", () => { + expect(grade(7)).toBe("AAA"); + expect(grade(4.5)).toBe("AA"); + expect(grade(3.2)).toBe("UI"); + expect(grade(2.9)).toBeNull(); + }); + + it("prints a ratio to one decimal", () => { + expect(ratio(4.456)).toBe("4.5:1"); + }); +}); + +describe("listKeys", () => { + it("reads as a sentence", () => { + expect(listKeys(["50"])).toBe("50"); + expect(listKeys(["50", "100"])).toBe("50 and 100"); + expect(listKeys(["50", "100", "200"])).toBe("50, 100 and 200"); + }); +}); + +describe("describe", () => { + it("names the token, the color and the ratio against the row's anchor", () => { + expect(describeSwatch(swatch(), null, 3.02)).toBe( + "--ig-primary-500 #0099ff 3.02:1 against white", + ); + }); + + it("grades the ratio when a reference is pinned", () => { + expect(describeSwatch(swatch(), "100", 4.8)).toContain( + "4.80:1 against 100 · AA", + ); + expect(describeSwatch(swatch(), "100", 1.2)).toContain( + "below 3:1, no grade", + ); + }); + + it("says what is wrong with the shade", () => { + const text = describeSwatch( + swatch({ same: ["600"], clipped: true }), + null, + 1, + ); + expect(text).toContain("same color as 600"); + expect(text).toContain("clipped"); + }); +}); diff --git a/packages/preview/src/sections/color/grade.ts b/packages/preview/src/sections/color/grade.ts new file mode 100644 index 00000000..086fe9c2 --- /dev/null +++ b/packages/preview/src/sections/color/grade.ts @@ -0,0 +1,42 @@ +/** How a measured contrast is reported to the reader. Pure, so it is easy to test. */ +import type { Swatch } from "../../preset-model.js"; + +/** WCAG thresholds, coarsest last, so the first match is the best grade a ratio earns. */ +const GRADES: [number, string][] = [ + [7, "AAA"], + [4.5, "AA"], + [3, "UI"], +]; + +/** The best grade a ratio earns, or null when it clears none of them. */ +export const grade = (value: number) => + GRADES.find(([min]) => value >= min)?.[1] ?? null; + +export const ratio = (value: number) => `${value.toFixed(1)}:1`; + +/** `50, 100 and 200` */ +export const listKeys = (keys: string[]) => + keys.length > 1 + ? `${keys.slice(0, -1).join(", ")} and ${keys.at(-1)}` + : keys[0]; + +/** The tooltip for a swatch: token, hex, the measured ratio, and whatever is wrong with it. */ +export const describe = ( + swatch: Swatch, + against: string | null, + value: number, +) => { + const notes = [`${swatch.token} ${swatch.hex}`]; + + notes.push( + against + ? `${value.toFixed(2)}:1 against ${against} · ${grade(value) ?? "below 3:1, no grade"}` + : `${value.toFixed(2)}:1 against ${swatch.against}`, + ); + + if (swatch.same.length) notes.push(`same color as ${listKeys(swatch.same)}`); + if (swatch.clipped) + notes.push("asked for a color the screen cannot show, so it was clipped"); + + return notes.join(" "); +}; diff --git a/packages/preview/src/sections/color/index.ts b/packages/preview/src/sections/color/index.ts new file mode 100644 index 00000000..bf5a6b93 --- /dev/null +++ b/packages/preview/src/sections/color/index.ts @@ -0,0 +1,46 @@ +import type { SectionDef } from "../types.js"; + +export const color: SectionDef = { + id: "color", + title: "Color", + blurb: + "Give the generator one color and it makes ten shades of it. Pick one of the palettes that ship with the library and see how its seed colors play out in every demo below.", + controls: { + tag: "ig-color-controls", + load: () => import("./controls.js"), + }, + views: [ + { + id: "shades", + title: "Shades", + teaches: + "What changes when each shade is cut to suit its own hue, instead of every color going through the same multiplication table.", + tag: "ig-view-shades", + load: () => import("./shades.js"), + }, + { + id: "neutrals", + title: "Neutrals", + teaches: + "Why the surface family became five named roles instead of ten numbered shades, and what a grayscale gains from being anchored to the page.", + tag: "ig-view-neutrals", + load: () => import("./neutrals.js"), + }, + { + id: "scales", + title: "Scales", + teaches: + "How a scale decides where the ten shades land, and what it costs you when the light end gets crowded.", + tag: "ig-view-scales", + load: () => import("./scales.js"), + }, + { + id: "sweep", + title: "Sweep", + teaches: + "The proof: 1,080 seed colors run through both generators, and why the range of colors a screen can show matters.", + tag: "ig-view-sweep", + load: () => import("./sweep.js"), + }, + ], +}; diff --git a/packages/preview/src/sections/color/model.ts b/packages/preview/src/sections/color/model.ts new file mode 100644 index 00000000..e5944f50 --- /dev/null +++ b/packages/preview/src/sections/color/model.ts @@ -0,0 +1,20 @@ +import data from "virtual:data/color.presets"; +import { buildPreset, type PresetModel } from "../../preset-model.js"; +import type { ColorState } from "./state.js"; + +/** Built once per preset and kept, so flipping back and forth is a lookup. */ +const cache = new Map(); + +export const modelFor = ({ preset, theme }: ColorState): PresetModel => { + const id = `${preset}-${theme}`; + const hit = cache.get(id); + if (hit) return hit; + + const record = + data.presets.find((p) => p.key === preset && p.theme === theme) ?? + data.presets[0]; + const built = buildPreset(record); + cache.set(id, built); + + return built; +}; diff --git a/packages/preview/src/sections/color/neutrals.ts b/packages/preview/src/sections/color/neutrals.ts new file mode 100644 index 00000000..a0e203a8 --- /dev/null +++ b/packages/preview/src/sections/color/neutrals.ts @@ -0,0 +1,117 @@ +import "../../elements/index.js"; +import { html, LitElement } from "lit"; +import { define } from "../../define.js"; +import type { Role, SurfaceBlock } from "../../preset-model.js"; +import { modelFor } from "./model.js"; +import { ColorStateController, setColorState } from "./state.js"; +import { legend, type Probe, strip } from "./strip.js"; + +/** A page built from nothing but the five surface roles, text included. */ +const mock = (surface: SurfaceBlock) => html` + +
+
sunken, a well cut into the page
+
+ raiseda card resting on the page +
+
+
+
+ overlaya menu floating above everything +
+
+`; + +const role = (surface: SurfaceBlock, r: Role) => html` +
+ + + +
+ ${r.key} + + ${r.collapsed ? "same as the page" : `${r.away.toFixed(2)}:1 from the page`} + +
+
+`; + +/** + * Surface stopped being ten numbered shades, and gray stopped being anchored to white. + * Both are about the page rather than about a color, which is why they share a view. + */ +export class ViewNeutrals extends LitElement { + private state = new ColorStateController(this); + + private get probe(): Probe { + return { + pinned: this.state.value.pinned, + pin: (pinned) => setColorState({ pinned }), + }; + } + + createRenderRoot() { + return this; + } + + render() { + const model = modelFor(this.state.value); + const surface = model.surface; + + return html` +
+
+ +

${model.label} ${model.theme}

+ ${surface.bg} +
+
+ ${mock(surface)} +
+
${strip(surface.legacySurface, this.probe)}
+

+ Ten numbered shades, with nothing to say what each one is for. On a very + light or very dark page most of them end up the same color as the + background, because there is simply no room left to go lighter or darker. +

+ + ${surface.roles.map((r) => role(surface, r))} + +

+ Five roles, each named after its job. When a role has no room left it + deliberately settles onto the background, and a shadow carries the sense + of depth instead. The mock page on the left is built entirely from these + five roles, text included. +

+
+
+
+ + ${legend(this.probe)} + +

Grayscale

+

+ The grays are anchored to the page rather than to white, so shade 50 is always + the one closest to the background, in a light theme and a dark one alike. This + palette seeds them with ${surface.seed}. +

+
+
${surface.grays.map((row) => strip(row, this.probe))}
+

+ Both rows fail the same two pairs, and that is deliberate. The gray family uses + the material scale by default, which keeps the rhythm our grayscale + has always had at the cost of two AA pairs. The Scales demo below is where that + trade is made, and where you can undo it. +

+
+ `; + } +} + +define("ig-view-neutrals", ViewNeutrals); + +declare global { + interface HTMLElementTagNameMap { + "ig-view-neutrals": ViewNeutrals; + } +} diff --git a/packages/preview/src/sections/color/scales.ts b/packages/preview/src/sections/color/scales.ts new file mode 100644 index 00000000..74bf327a --- /dev/null +++ b/packages/preview/src/sections/color/scales.ts @@ -0,0 +1,397 @@ +import "../../elements/index.js"; +import data from "virtual:data/color.scales"; +import { html, LitElement } from "lit"; +import { + AA, + contrast, + PAIR_DISTANCE, + readableOn, + shadePairs, + toRgb, +} from "../../color.js"; +import type { ScaleSubject } from "../../data/color/scales.js"; +import { define } from "../../define.js"; +import type { CurvePoint } from "../../elements/curve-editor.js"; +import { + call, + entries, + list, + map, + num, + raw, + statement, +} from "../../sass-code.js"; +import { + type Curve, + ease, + lookup, + position, + rampFromTable, + SCALE_PRESETS, + type Scale, + type ScalePreset, + sameScale, + target, +} from "../../scale-math.js"; +import { SHADES } from "../../variants.js"; +import { ColorStateController } from "./state.js"; + +/** The two ends of the range cannot cross; this keeps them apart on the sliders. */ +const MIN_RANGE = 0.5; +/** Past the subject's ceiling by more than this, the dark end is visibly compressing. */ +const CEILING_SLACK = 0.05; + +interface Step { + key: string; + x: number; + t: number; + target: number; + hex: string; + reached: number; +} + +/** Range and curve, made editable. */ +export class ViewScales extends LitElement { + static properties = { + subject: { state: true }, + lo: { state: true }, + hi: { state: true }, + curve: { state: true }, + }; + + declare subject: string; + declare lo: number; + declare hi: number; + declare curve: Curve | null; + + private state = new ColorStateController(this); + + constructor() { + super(); + this.subject = ""; + this.apply(SCALE_PRESETS[0]); + } + + createRenderRoot() { + return this; + } + + /** + * The first render happens before `igc-slider` has upgraded, so Lit's `value` property + * lands while the element still holds default bounds and gets quantised against them. + * One re-render after the definition resolves is enough; `updated()` then asserts the + * real value. + */ + async firstUpdated() { + await customElements.whenDefined("igc-slider"); + this.requestUpdate(); + } + + /** + * Keeps each slider's thumb on the number its label reports. Both steps are 0.01, the + * precision the labels print at, so the two can always agree exactly. + */ + updated() { + const sliders = [ + ["scale-lo", this.lo], + ["scale-hi", this.hi], + ] as const; + + for (const [id, value] of sliders) { + const slider = this.querySelector( + `#${id}`, + ); + if (slider && slider.value !== value) slider.value = value; + } + } + + /** + * Only the subjects the selected palette actually has. The section's control bar owns + * the seed, so this view shows what that seed produces rather than a menu of seeds of + * its own. + */ + private get available(): ScaleSubject[] { + const { preset, theme } = this.state.value; + const tag = `${preset}-${theme}`; + const matching = data.subjects.filter((s) => s.presets.includes(tag)); + + return matching.length ? matching : data.subjects; + } + + /** Falls back rather than resetting, so switching palette keeps the reader's place. */ + private get current(): ScaleSubject { + const available = this.available; + return available.find((s) => s.key === this.subject) ?? available[0]; + } + + private get scale(): Scale { + return { range: [this.lo, this.hi], curve: this.curve }; + } + + private get ramp(): Step[] { + const subject = this.current; + const anchor = toRgb(subject.anchor); + + return SHADES.map((key, i) => { + const x = position(i); + const t = ease(x, this.curve); + const wanted = target(this.scale.range, t); + const hex = `#${lookup(subject.table, i, wanted)}`; + + return { + key, + x, + t, + target: wanted, + hex, + reached: contrast(toRgb(hex), anchor), + }; + }); + } + + private apply({ range, curve }: Scale) { + this.lo = range[0]; + this.hi = range[1]; + this.curve = curve ? ([...curve] as Curve) : null; + } + + private get code() { + const { family, seed, surface } = this.current; + const neutral = family === "gray"; + + return statement( + "palette", + call( + "palette", + entries({ + $primary: raw(neutral ? "#0099ff" : seed), + $secondary: raw("#df1b74"), + $surface: raw(neutral ? surface : "#fff"), + $gray: neutral ? raw(seed) : undefined, + $scales: map([ + [ + `'${family}'`, + map([ + ["range", list(num(this.lo), num(this.hi))], + [ + "curve", + this.curve ? list(...this.curve.map(num)) : raw("null"), + ], + ]), + ], + ]), + }), + ), + ); + } + + private preset(preset: ScalePreset) { + const hexes = rampFromTable(this.current.table, preset); + const failing = shadePairs(hexes.map((h) => toRgb(`#${h}`))).filter( + (pair) => pair.contrast < AA, + ).length; + const pairs = SHADES.length - PAIR_DISTANCE; + + return html` + + `; + } + + private subjects(subject: ScaleSubject) { + return html` +
+ Subject + ) => { + if (detail) this.subject = detail; + }} + > + ${this.available.map( + (entry) => html` + + ${entry.label} + + `, + )} + +
+

+ ${subject.note} Against ${subject.anchor} it tops out at + ${subject.ceiling.toFixed(2)}:1. +

+ `; + } + + private controls(subject: ScaleSubject) { + const overshoot = this.hi > subject.ceiling + CEILING_SLACK; + const curveLabel = this.curve + ? this.curve.map((v) => v.toFixed(2)).join(", ") + : "linear"; + + return html` +
+
+ + ) => { + this.lo = Math.min(detail, this.hi - MIN_RANGE); + }} + > +

How much shade 50 stands out from the anchor. At 1:1 it would be the anchor itself.

+
+
+ + ) => { + this.hi = Math.max(detail, this.lo + MIN_RANGE); + }} + > +

+ ${ + overshoot + ? html`That is more than this subject can reach (${subject.ceiling.toFixed(2)}:1). + The dark end gets squeezed and pairs start to fail.` + : "How much shade 900 stands out. Black on white is 21:1, the most contrast there is." + } +

+
+
+ Curve ${curveLabel} +

Drag the two handles on the chart, or nudge them with the arrow keys.

+ { + this.curve = null; + }}>Straight line +
+
+ `; + } + + /** Every guaranteed pair as a bar spanning the two shades, on a twenty-column grid. */ + private ladder(ramp: Step[]) { + return html` +
+ ${shadePairs(ramp.map((s) => toRgb(s.hex))).map((pair) => { + const ok = pair.contrast >= AA; + // Labels sit after the bar until the bar reaches the right edge, then before it. + const tail = pair.j >= SHADES.length - 3; + const bar = `${pair.i * 2 + 2} / ${pair.j * 2 + 2}`; + const label = tail + ? `1 / ${pair.i * 2 + 2}` + : `${pair.j * 2 + 2} / -1`; + + return html` +
+ + + ${ramp[pair.i].key}–${ramp[pair.j].key} ${pair.contrast.toFixed(1)}:1 + +
+ `; + })} +
+ `; + } + + private table(ramp: Step[]) { + return html` +
+ + + + + + ${ramp.map( + (s) => html` + + + + + + + + + `, + )} + +
ShadePositionAfter curveTargetReachedColor
${s.key}${s.x.toFixed(3)}${s.t.toFixed(3)}${s.target.toFixed(2)}:1 s.target * 0.05 ? "is-miss" : ""}> + ${s.reached.toFixed(2)}:1 + ${s.hex}
+
+ `; + } + + render() { + const subject = this.current; + const ramp = this.ramp; + const points: CurvePoint[] = ramp.map(({ x, t, hex }) => ({ x, t, hex })); + + return html` +
${SCALE_PRESETS.map((p) => this.preset(p))}
+ + ${this.subjects(subject)} + ${this.controls(subject)} + +
+ ) => { + this.curve = detail; + }} + > + +
+ ${ramp.map( + (s) => html` + + ${s.key} + ${s.reached.toFixed(1)}:1 + + `, + )} +
+ + ${this.ladder(ramp)} +
+ + ${this.table(ramp)} + + + `; + } +} + +define("ig-view-scales", ViewScales); + +declare global { + interface HTMLElementTagNameMap { + "ig-view-scales": ViewScales; + } +} diff --git a/packages/preview/src/sections/color/shades.ts b/packages/preview/src/sections/color/shades.ts new file mode 100644 index 00000000..cc1336f3 --- /dev/null +++ b/packages/preview/src/sections/color/shades.ts @@ -0,0 +1,78 @@ +import "../../elements/index.js"; +import { html, LitElement } from "lit"; +import { define } from "../../define.js"; +import type { FamilyBlock } from "../../preset-model.js"; +import { call, type Entry, raw, statement } from "../../sass-code.js"; +import { modelFor } from "./model.js"; +import { ColorStateController, setColorState } from "./state.js"; +import { legend, type Probe, strip } from "./strip.js"; + +const block = (family: FamilyBlock, probe: Probe) => html` +
+
+ +

${family.label}

+ ${family.seed} +
+
${family.shades.map((row) => strip(row, probe))}
+
${family.accents.map((row) => strip(row, probe))}
+
+`; + +/** + * The selected palette, as it ships and as the fitted generator rebuilds it from the + * very seeds the shipped palette records. Same input on both sides, which is the only + * way the comparison means anything. + */ +export class ViewShades extends LitElement { + private state = new ColorStateController(this); + + private get probe(): Probe { + return { + pinned: this.state.value.pinned, + pin: (pinned) => setColorState({ pinned }), + }; + } + + createRenderRoot() { + return this; + } + + render() { + const model = modelFor(this.state.value); + const args: Entry[] = [ + ...model.families.map( + (family): Entry => [`$${family.key}`, raw(family.seed.toLowerCase())], + ), + ["$gray", raw(model.surface.seed.toLowerCase())], + ["$surface", raw(model.bg.toLowerCase())], + ]; + + return html` +

+ The promise is simple: any two shades that are 500 apart, like 100 and 600, have + enough contrast to pass the WCAG AA standard for text. The fitted + generator is built to keep that promise. The legacy generator keeps + it only by luck, and often not at all. Hover over any swatch to see its token, + its measured contrast, and anything that went wrong with it. +

+ + ${legend(this.probe)} + + ${model.families.map((family) => block(family, this.probe))} + + + `; + } +} + +define("ig-view-shades", ViewShades); + +declare global { + interface HTMLElementTagNameMap { + "ig-view-shades": ViewShades; + } +} diff --git a/packages/preview/src/sections/color/state.spec.ts b/packages/preview/src/sections/color/state.spec.ts new file mode 100644 index 00000000..4c58d8cb --- /dev/null +++ b/packages/preview/src/sections/color/state.spec.ts @@ -0,0 +1,80 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from "vitest"; +import { + ColorStateController, + getColorState, + readColorState, + setColorState, + writeColorState, +} from "./state.js"; + +describe("readColorState", () => { + it("reads the selection from the hash query", () => { + expect(readColorState("#/color/shades?preset=fluent&theme=dark")).toEqual({ + preset: "fluent", + theme: "dark", + pinned: null, + }); + }); + + it("falls back to material light for anything it does not recognise", () => { + expect(readColorState("#/color?preset=nope&theme=sepia")).toMatchObject({ + preset: "material", + theme: "light", + }); + expect(readColorState("")).toMatchObject({ + preset: "material", + theme: "light", + }); + }); +}); + +describe("writeColorState", () => { + const state = { preset: "bootstrap", theme: "dark", pinned: "100" } as const; + + it("keeps the path and rewrites the query", () => { + expect(writeColorState("#/color/scales?preset=material", state)).toBe( + "#/color/scales?preset=bootstrap&theme=dark", + ); + }); + + it("leaves other parameters alone, and never writes the pin", () => { + expect(writeColorState("#/color?x=1", state)).toBe( + "#/color?x=1&preset=bootstrap&theme=dark", + ); + }); + + it("starts from the color section when the hash is empty", () => { + expect(writeColorState("", state)).toBe( + "#/color?preset=bootstrap&theme=dark", + ); + }); +}); + +describe("the store", () => { + it("updates the hash without navigating, and tells its hosts", () => { + let updates = 0; + const host = { + addController() {}, + removeController() {}, + requestUpdate: () => updates++, + updateComplete: Promise.resolve(true), + }; + const controller = new ColorStateController(host); + controller.hostConnected(); + + setColorState({ preset: "fluent" }); + + expect(getColorState().preset).toBe("fluent"); + expect(controller.value.preset).toBe("fluent"); + expect(location.hash).toContain("preset=fluent"); + expect(updates).toBe(1); + + setColorState({ preset: "fluent" }); + expect(updates).toBe(1); + + controller.hostDisconnected(); + setColorState({ pinned: "50" }); + expect(updates).toBe(1); + }); +}); diff --git a/packages/preview/src/sections/color/state.ts b/packages/preview/src/sections/color/state.ts new file mode 100644 index 00000000..c9496b11 --- /dev/null +++ b/packages/preview/src/sections/color/state.ts @@ -0,0 +1,109 @@ +import type { ReactiveController, ReactiveControllerHost } from "lit"; +import { + isPresetKey, + isTheme, + type PresetKey, + type Theme, +} from "../../variants.js"; + +/** + * What every view in the color section reads. One store rather than one control per + * view: the point of the section is that a single seed set flows through the shades, the + * surface roles and the scale editor at once, and a reader should be able to see that by + * moving one control. + */ +export interface ColorState { + /** A palette the library ships, by name. */ + preset: PresetKey; + /** Which variant of it — the two differ in more than their background. */ + theme: Theme; + /** + * The shade number every numbered strip measures against, or null. + * + * Section state rather than per-view: it is one key space, and a reader who pins 100 in + * one place has asked the same question of every strip that has a 100. It stays out of + * the hash — a palette is a selection worth linking to, an inspection is not. + */ + pinned: string | null; +} + +const FALLBACK: ColorState = { + preset: "material", + theme: "light", + pinned: null, +}; + +/** The selection lives in the hash, so a link carries it and a reload keeps it. */ +export const readColorState = (hash: string): ColorState => { + const query = new URLSearchParams(hash.split("?")[1] ?? ""); + const preset = query.get("preset"); + const theme = query.get("theme"); + + return { + preset: isPresetKey(preset) ? preset : FALLBACK.preset, + theme: isTheme(theme) ? theme : FALLBACK.theme, + pinned: null, + }; +}; + +/** The hash with the selection written into its query, path untouched. */ +export const writeColorState = (hash: string, state: ColorState) => { + const [path, query = ""] = hash.split("?"); + const params = new URLSearchParams(query); + + params.set("preset", state.preset); + params.set("theme", state.theme); + + return `${path || "#/color"}?${params}`; +}; + +let state = readColorState(location.hash); +const listeners = new Set<() => void>(); + +export const getColorState = (): ColorState => state; + +export const setColorState = (patch: Partial) => { + const next = { ...state, ...patch }; + + if ( + next.preset === state.preset && + next.theme === state.theme && + next.pinned === state.pinned + ) { + return; + } + + state = next; + + // `replaceState` rather than assigning the hash: the selection is not a navigation, + // and a `hashchange` here would send the shell scrolling to the view in the path. + history.replaceState(null, "", writeColorState(location.hash, next)); + + for (const listener of listeners) listener(); +}; + +/** + * Re-renders its host whenever the selection changes. A controller rather than an event + * on the shell so a view is independent of where it is mounted. + */ +export class ColorStateController implements ReactiveController { + private readonly host: ReactiveControllerHost; + private readonly listener = () => this.host.requestUpdate(); + + constructor(host: ReactiveControllerHost) { + this.host = host; + host.addController(this); + } + + get value(): ColorState { + return state; + } + + hostConnected() { + listeners.add(this.listener); + } + + hostDisconnected() { + listeners.delete(this.listener); + } +} diff --git a/packages/preview/src/sections/color/strip.ts b/packages/preview/src/sections/color/strip.ts new file mode 100644 index 00000000..3c058bd0 --- /dev/null +++ b/packages/preview/src/sections/color/strip.ts @@ -0,0 +1,178 @@ +/** A row of swatches the reader can pin one of, and the legend that explains it. */ +import { html, nothing } from "lit"; +import { contrast } from "../../color.js"; +import type { Row, Swatch } from "../../preset-model.js"; +import { describe, grade, listKeys, ratio } from "./grade.js"; + +/** + * A pinned shade number, and how to change it. + * + * It is a number rather than one swatch on purpose: pinning `100` makes every numbered + * row measure against its own `100`, so the legacy answer and the fitted answer appear + * side by side. The reader performs the comparison instead of being told it. + */ +export interface Probe { + pinned: string | null; + pin: (key: string | null) => void; +} + +/** + * One tooltip for the whole app, moved to whichever swatch is under the pointer or has + * focus. `igc-tooltip` takes a transient anchor, so a shared instance does the work that + * one instance per swatch would — and there are a hundred and twenty swatches on a page. + */ +type Tooltip = HTMLElement & { + show(target: Element): unknown; + hide(): unknown; +}; + +let tooltip: Tooltip | null = null; + +const sharedTooltip = (): Tooltip => { + if (!tooltip) { + tooltip = document.createElement("igc-tooltip") as Tooltip; + tooltip.setAttribute("placement", "bottom"); + tooltip.setAttribute("show-delay", "120"); + tooltip.setAttribute("hide-delay", "0"); + document.body.append(tooltip); + } + + return tooltip; +}; + +const swatch = ( + s: Swatch, + probe: Probe, + reference: Swatch | null, + focusable: string, +) => { + const isReference = reference?.key === s.key; + const measured = + reference && !isReference ? contrast(s.rgb, reference.rgb) : s.ratio; + const against = reference && !isReference ? reference.key : null; + const earned = against ? grade(measured) : null; + + const show = (event: Event) => { + const tip = sharedTooltip(); + tip.textContent = describe(s, against, measured); + tip.show(event.currentTarget as Element); + }; + const hide = () => sharedTooltip().hide(); + + return html` + + `; +}; + +/** Every swatch paints from the palette its own scope carries, not a document stylesheet. */ +export const strip = (row: Row, probe: Probe) => { + const reference = + row.probeable && probe.pinned + ? (row.swatches.find((s) => s.key === probe.pinned) ?? null) + : null; + const focusable = reference?.key ?? row.swatches[0].key; + + // Arrow keys move and pin in one go, which is what a radio group does. Escape clears, + // because a pinned shade is an inspection the reader needs a way out of. + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + probe.pin(null); + return; + } + + const keys = row.swatches.map((s) => s.key); + const from = keys.indexOf( + (event.target as HTMLElement).dataset.key ?? focusable, + ); + const moves: Record = { + ArrowLeft: from - 1, + ArrowRight: from + 1, + Home: 0, + End: keys.length - 1, + }; + const to = moves[event.key]; + if (to === undefined) return; + + event.preventDefault(); + const next = keys[Math.min(keys.length - 1, Math.max(0, to))]; + probe.pin(next); + + const group = event.currentTarget as HTMLElement; + requestAnimationFrame(() => + group + .querySelector(`[data-key="${next}"]`) + ?.focus({ preventScroll: true }), + ); + }; + + return html` + + ${row.label} +
+ ${row.swatches.map((s) => swatch(s, probe, reference, focusable))} +
+
+ `; +}; + +/** + * What the marks mean, and what the probe is currently doing. + * + * The line changes with the state rather than describing every state at once: before a + * shade is pinned the only thing worth saying is that you can pin one. + */ +export const legend = (probe: Probe) => html` +
+ ${ + probe.pinned + ? html` + Every shade is now measured against its own ${probe.pinned}. AAA needs + 7:1, AA needs 4.5:1, and 3:1 is enough for things like borders and icons. + + ` + : html`Click any shade to measure every other shade in its row against it. Right + now the ratios are against white, or against the page for grays.` + } + the same color as another shade in the row +
+`; diff --git a/packages/preview/src/sections/color/sweep.ts b/packages/preview/src/sections/color/sweep.ts new file mode 100644 index 00000000..b5ec13da --- /dev/null +++ b/packages/preview/src/sections/color/sweep.ts @@ -0,0 +1,188 @@ +import "../../elements/index.js"; +import data from "virtual:data/color.sweep"; +import { html, LitElement } from "lit"; +import { contrast, type Rgb, readableOn } from "../../color.js"; +import type { SweepRamp } from "../../data/color/sweep.js"; +import { define } from "../../define.js"; +import { findings, isClamped, shadeHex, shadeRgb } from "../../ramp.js"; +import { SHADES } from "../../variants.js"; + +const WHITE: Rgb = [255, 255, 255]; +/** Milliseconds per hue step while sweeping: a full circle in about sixteen seconds. */ +const SWEEP_INTERVAL = 45; + +/** Sweeps the seed across the whole hue circle, at more than one saturation. */ +export class ViewSweep extends LitElement { + static properties = { + hue: { state: true }, + row: { state: true }, + running: { state: true }, + }; + + declare hue: number; + /** Index into `data.rows`: which saturation. */ + declare row: number; + declare running: boolean; + + private timer = 0; + + constructor() { + super(); + this.hue = 204; + this.row = data.rows.length - 1; + this.running = false; + } + + createRenderRoot() { + return this; + } + + disconnectedCallback() { + this.stop(); + super.disconnectedCallback(); + } + + private get index() { + return ( + Math.round(this.hue / data.hueStep) % data.rows[this.row].seeds.length + ); + } + + private get seed() { + return `#${data.rows[this.row].seeds[this.index]}`; + } + + private stop = () => { + clearInterval(this.timer); + this.timer = 0; + this.running = false; + }; + + private toggle() { + if (this.timer) { + this.stop(); + return; + } + + this.running = true; + this.timer = window.setInterval(() => { + this.hue = (this.hue + data.hueStep) % 360; + }, SWEEP_INTERVAL); + } + + private strip(ramp: SweepRamp, label: string) { + return html` +
+ ${label} +
+ ${SHADES.map((key, i) => { + const rgb = shadeRgb(ramp, i); + const hex = shadeHex(ramp, i); + const cr = contrast(rgb, WHITE); + const clamped = isClamped(ramp, i); + + return html` + + ${key} + ${cr.toFixed(1)}:1 + + `; + })} +
+ +
+ `; + } + + render() { + const row = data.rows[this.row]; + const legacy = row.legacy[this.index]; + const fitted = row.fitted[this.index]; + const totals = data.totals; + const count = (n: number) => n.toLocaleString("en-US"); + + return html` +
+ + seed ${this.seed} + Saturation + ) => { + if (detail !== undefined) this.row = Number(detail); + }} + > + ${data.rows.map( + (entry, i) => html` + + ${entry.saturation}% + + `, + )} + + + ${this.running ? "Stop" : "Sweep"} + +
+ + + + { + this.hue = Number((event.target as HTMLInputElement).value); + }} /> + +
${this.strip(legacy, "legacy")}${this.strip(fitted, "fitted")}
+ +
+ Shades 50 to 900, each with its contrast against white + clamped: the generator asked for a color the screen cannot show +
+ +
+ +
+

+ This is a slice through every color your screen can show, at the current hue. + The lit shape is the whole range for this hue. Its widest point, the cusp, + moves a long way as you sweep: more than twofold in both how vivid and how + light a color can get. +

+

+ The hollow rings show where the legacy multiplier table asked a shade to be + when that was outside what a screen can show. The dotted line leads to where + the shade actually landed. When two shades get pushed to the same edge, two + tokens end up the same color. +

+

+ Across all ${count(totals.seeds)} seeds in this grid, + ${count(totals.legacyClean)} pass every AA pair with the legacy + generator, against ${count(totals.fittedClean)} with the fitted one. + Every shade that fell outside the screen's range came from the most vivid + row: ${count(totals.legacyOog)} of ${count(totals.shades)} shades, and none + below ${Math.max(...data.saturations)}% saturation. +

+
+
+ `; + } +} + +define("ig-view-sweep", ViewSweep); + +declare global { + interface HTMLElementTagNameMap { + "ig-view-sweep": ViewSweep; + } +} diff --git a/packages/preview/src/sections/index.spec.ts b/packages/preview/src/sections/index.spec.ts new file mode 100644 index 00000000..5b457ce9 --- /dev/null +++ b/packages/preview/src/sections/index.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { findSection, findView, SECTIONS } from "./index.js"; + +describe("the section registry", () => { + it("finds a section by id and falls back to the first", () => { + expect(findSection("color").id).toBe("color"); + expect(findSection("nope")).toBe(SECTIONS[0]); + expect(findSection(undefined)).toBe(SECTIONS[0]); + }); + + it("finds a view within a section", () => { + const color = findSection("color"); + expect(findView(color, "sweep")?.tag).toBe("ig-view-sweep"); + expect(findView(color, "nope")).toBeUndefined(); + }); + + it("gives every view a unique tag and id", () => { + for (const section of SECTIONS) { + const ids = section.views.map((v) => v.id); + const tags = section.views.map((v) => v.tag); + expect(new Set(ids).size).toBe(ids.length); + expect(new Set(tags).size).toBe(tags.length); + } + }); +}); diff --git a/packages/preview/src/sections/index.ts b/packages/preview/src/sections/index.ts new file mode 100644 index 00000000..9e6adf3b --- /dev/null +++ b/packages/preview/src/sections/index.ts @@ -0,0 +1,14 @@ +import { color } from "./color/index.js"; +import type { SectionDef } from "./types.js"; + +/** + * Every pillar of the theming system, in display order. Adding one is a folder and an + * entry here; the shell reads this and knows nothing else about them. + */ +export const SECTIONS: SectionDef[] = [color]; + +export const findSection = (id: string | undefined) => + SECTIONS.find((section) => section.id === id) ?? SECTIONS[0]; + +export const findView = (section: SectionDef, id: string | undefined) => + section.views.find((view) => view.id === id); diff --git a/packages/preview/src/sections/types.ts b/packages/preview/src/sections/types.ts new file mode 100644 index 00000000..ff90cb52 --- /dev/null +++ b/packages/preview/src/sections/types.ts @@ -0,0 +1,30 @@ +/** A view is a custom element. The shell supplies its frame from this metadata. */ +export interface ViewDef { + id: string; + title: string; + /** What a reader learns here, in one line. Rendered by the shell, not the view. */ + teaches: string; + /** Custom element tag the loader defines. */ + tag: string; + /** Imported as the view nears the viewport, so a section's code and data stay out of the initial load. */ + load: () => Promise; +} + +/** + * A persistent element pinned above a section's views. Every view in the section reads + * the same state it writes, which is what lets one control drive the whole page. + */ +export interface ControlsDef { + tag: string; + load: () => Promise; +} + +/** One pillar of the theming system. */ +export interface SectionDef { + id: string; + title: string; + blurb: string; + /** Optional: a pillar whose views share no state does not need one. */ + controls?: ControlsDef; + views: ViewDef[]; +} diff --git a/packages/preview/src/shell/shell.ts b/packages/preview/src/shell/shell.ts new file mode 100644 index 00000000..2409b78c --- /dev/null +++ b/packages/preview/src/shell/shell.ts @@ -0,0 +1,238 @@ +import { html, LitElement, nothing } from "lit"; +import { html as staticHtml, unsafeStatic } from "lit/static-html.js"; +import { define } from "../define.js"; +import { findSection, findView, SECTIONS } from "../sections/index.js"; +import type { SectionDef, ViewDef } from "../sections/types.js"; + +/** How far ahead of the viewport a view starts loading. */ +const LOOKAHEAD = "600px"; + +/** `#/
/?` — the query belongs to the section, not the shell. */ +const parseHash = (hash: string) => { + const [section, view] = hash.replace(/^#\/?/, "").split("?")[0].split("/"); + return { section: findSection(section), viewId: view }; +}; + +/** + * Navigation, routing and the frame around a section. + * + * A section renders as one page: its controls are pinned at the top and every view is + * stacked below them, so a control the reader moves is visibly the same control for all + * of them. Views still load one at a time — an observer imports each as it nears the + * viewport — so stacking costs nothing on first paint. + */ +export class PreviewShell extends LitElement { + static properties = { + section: { state: true }, + loaded: { state: true }, + controlsReady: { state: true }, + current: { state: true }, + }; + + declare section: SectionDef; + /** Tags whose module has finished loading. */ + declare loaded: Set; + declare controlsReady: boolean; + /** The view the reader is looking at, for the nav. */ + declare current: string; + + private approach?: IntersectionObserver; + private spy?: IntersectionObserver; + private bar?: ResizeObserver; + + constructor() { + super(); + this.section = parseHash(location.hash).section; + this.loaded = new Set(); + this.controlsReady = false; + this.current = this.section.views[0]?.id ?? ""; + } + + /** Light DOM: the app is styled by the library's own generated custom properties. */ + createRenderRoot() { + return this; + } + + connectedCallback() { + super.connectedCallback(); + addEventListener("hashchange", this.onHashChange); + this.approach = new IntersectionObserver(this.onApproach, { + rootMargin: `${LOOKAHEAD} 0px ${LOOKAHEAD} 0px`, + }); + this.spy = new IntersectionObserver(this.onSpy, { + rootMargin: "-20% 0px -70% 0px", + }); + this.loadControls(); + } + + disconnectedCallback() { + removeEventListener("hashchange", this.onHashChange); + this.approach?.disconnect(); + this.spy?.disconnect(); + this.bar?.disconnect(); + super.disconnectedCallback(); + } + + /** + * The pinned bar's height, as a custom property, so a view scrolled to by link stops + * below it rather than under it. Measured because the bar wraps on narrow screens. + */ + firstUpdated() { + const bar = this.querySelector(".control-bar"); + if (!bar) return; + + this.bar = new ResizeObserver(([entry]) => { + this.style.setProperty( + "--control-bar-height", + `${entry.contentRect.height}px`, + ); + }); + this.bar.observe(bar); + } + + private onHashChange = () => { + const { section, viewId } = parseHash(location.hash); + + if (section !== this.section) { + this.section = section; + this.controlsReady = false; + this.loadControls(); + } + + const view = findView(section, viewId); + if (view) this.updateComplete.then(() => this.reveal(view.id)); + }; + + private async loadControls() { + const { controls } = this.section; + if (!controls) return; + + await controls.load(); + if (this.section.controls === controls) this.controlsReady = true; + } + + private onApproach = (entries: IntersectionObserverEntry[]) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + + const view = findView( + this.section, + (entry.target as HTMLElement).dataset.view, + ); + if (!view || this.loaded.has(view.tag)) continue; + + this.approach?.unobserve(entry.target); + view.load().then(() => { + this.loaded = new Set(this.loaded).add(view.tag); + }); + } + }; + + /** + * The observer is only the trigger; which view is current is read from geometry. A + * batch of entries can be nothing but departures, which says where the reader left, + * not where they are. + */ + private onSpy = () => { + const frames = [...this.querySelectorAll("[data-view]")]; + if (!frames.length) return; + + const line = innerHeight * 0.25; + const passed = frames.filter( + (frame) => frame.getBoundingClientRect().top <= line, + ); + const current = (passed.at(-1) ?? frames[0]).dataset.view; + + if (current) this.current = current; + }; + + private reveal(id: string) { + this.querySelector(`[data-view="${id}"]`)?.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + } + + updated() { + for (const frame of this.querySelectorAll("[data-view]")) { + const tag = findView(this.section, frame.dataset.view)?.tag ?? ""; + if (!this.loaded.has(tag)) this.approach?.observe(frame); + this.spy?.observe(frame); + } + } + + private frame(view: ViewDef) { + return html` +
+
+

${view.title}

+

${view.teaches}

+
+ ${ + this.loaded.has(view.tag) + ? staticHtml`<${unsafeStatic(view.tag)}>` + : html`

Loading…

` + } +
+ `; + } + + render() { + const section = this.section; + const controls = section.controls; + + return html` +
+

Ignite UI Theming

+ +
+ +
+

${section.title}

+

${section.blurb}

+
+ +
+ ${ + controls && this.controlsReady + ? staticHtml`<${unsafeStatic(controls.tag)}>` + : nothing + } + ${ + section.views.length > 1 + ? html` + + ` + : nothing + } +
+ + ${section.views.map((view) => this.frame(view))} + `; + } +} + +define("ig-preview-shell", PreviewShell); + +declare global { + interface HTMLElementTagNameMap { + "ig-preview-shell": PreviewShell; + } +} diff --git a/packages/preview/src/shell/theme.scss b/packages/preview/src/shell/theme.scss new file mode 100644 index 00000000..375fcf78 --- /dev/null +++ b/packages/preview/src/shell/theme.scss @@ -0,0 +1,93 @@ +// The app is styled by the library it demonstrates. Chrome tokens resolve to concrete +// colors here rather than aliasing `--ig-*`, because a demo scope that redefines +// `--ig-gray-*` would otherwise repaint the chrome sitting inside it. Only the neutral +// and semantic families are used: chrome carrying a hue of its own would change how the +// swatches under test read. +@use "../../../theming/sass/color" as *; +@use "../../../theming/sass/elevations" as *; +@use "../../../theming/sass/elevations/presets" as elevation-presets; +@use "../../../theming/sass/themes" as themes; +@use "../../../theming/sass/typography" as *; +@use "../../../theming/sass/typography/presets" as type-presets; + +$light: palette($primary: #09f, $secondary: #df1b74, $surface: #f8f8fa, $gray: #333); +$dark: palette($primary: #09f, $secondary: #df1b74, $surface: #1a1a24, $gray: #333); + +@mixin chrome($palette, $semantic) { + // The page is the well and a card sits at the base level. Going the other way puts + // `raised` against a near-white page, where it has no room and collapses by design. + --ground: #{color($palette, "surface", "sunken")}; + --panel: #{color($palette, "surface", "base")}; + --raised: #{color($palette, "surface", "raised")}; + --overlay: #{color($palette, "surface", "overlay")}; + --ink: #{color($palette, "gray", 900)}; + --muted: #{color($palette, "gray", 600)}; + --line: #{color($palette, "gray", 200)}; + --line-soft: #{color($palette, "gray", 100)}; + --plot-ink: #{color($palette, "gray", 700)}; + --pass: #{color($palette, "success", $semantic)}; + --fail: #{color($palette, "error", $semantic)}; +} + +/* + * The shipped components read `--ig-*` at runtime, so supplying the branch's own palette + * here is what themes them — no rebuild of `igniteui-webcomponents`, and the controls in + * this app are painted by the generator the app is about. + * + * It goes on `:root` only. A demo scope redefining `--ig-gray-*` must not repaint a + * control sitting above it, which is also why `chrome()` resolves to concrete colors. + */ +@include themes.sizing; +@include themes.spacing; + +// `indigo` throughout: the Sass presets here and `configureTheme` in `elements/ignite.ts` +// pick the same family, so the components' structure and the type and elevation scales +// they assume stay in step. Only the colors come from this branch's generator. +@include typography($font-family: (ui-sans-serif, system-ui, sans-serif), $type-scale: type-presets.$indigo-type-scale); +@include elevations(elevation-presets.$indigo-elevations); + +:root { + color-scheme: light dark; + + // Semantic colors are chromatic, so they are anchored to white whatever the page is. + // The light theme needs a dark variant of them and the dark theme a light one. + @include chrome($light, 700); +} + +@include palette($light); + +/* + * `igc-slider` and `igc-color-picker` still ask for `--ig-surface-500`, which the fitted + * generator no longer emits — surface became five named roles. Every numbered token + * resolves to `base`, which is the model rather than a fudge: under the fitted generator + * the page is one color and depth is carried by the roles and the elevation shadow. The + * library has the same decision to make for real consumers. + */ +@mixin surface-compat($palette) { + @each $shade in (50, 100, 200, 300, 400, 500, 600, 700, 800, 900) { + --ig-surface-#{$shade}: #{color($palette, "surface", "base")}; + --ig-surface-#{$shade}-contrast: #{contrast-color($palette, "surface", "base")}; + } +} + +:root { + @include surface-compat($light); + + --ig-theme-variant: light; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + @include chrome($dark, 300); + @include surface-compat($dark); + + --ig-theme-variant: dark; + } +} + +:root[data-theme="dark"] { + @include chrome($dark, 300); + @include surface-compat($dark); + + --ig-theme-variant: dark; +} diff --git a/packages/preview/src/styles.scss b/packages/preview/src/styles.scss new file mode 100644 index 00000000..3ff53254 --- /dev/null +++ b/packages/preview/src/styles.scss @@ -0,0 +1,979 @@ +/* + * Type and layout. Every color comes from shell/theme.scss, generated by the library. + * + * Organised top to bottom: tokens, base elements, the primitives views compose + * (rows, strips, legends), the shell, then one block per view. + */ + +/* --- tokens --- */ +:root { + --sans: ui-sans-serif, system-ui, sans-serif; + --mono: ui-monospace, "SF Mono", menlo, monospace; + + /* + * The widest thing on the page is a ten-swatch strip with a label and a ratio in each + * swatch, which stops gaining from extra width at about this point. Past it the rows + * only get longer to scan, so on a wide display the page centres instead. + */ + --measure: 1280px; + + /* Type: nothing below 11px, so the smallest label is still a label. */ + --text-xs: 0.6875rem; + --text-sm: 0.75rem; + --text-md: 0.8125rem; + --text-base: 0.9375rem; + --text-lg: 1.05rem; + --text-xl: 1.2rem; + --text-2xl: clamp(1.7rem, 4vw, 2.3rem); + + /* Space, on a 4px grid. */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --space-10: 40px; + --space-12: 48px; + --radius-sm: 3px; + --radius-md: 6px; + --radius-lg: 10px; + --swatch-height: 46px; +} + +/* --- base --- */ +* { + box-sizing: border-box; +} + +body { + max-width: var(--measure); + margin: 0 auto; + padding: 0 var(--space-6) var(--space-12); + background: var(--ground); + color: var(--ink); + font: var(--text-base) / 1.55 var(--sans); +} + +h1, +h2, +h3 { + margin: 0; + letter-spacing: -0.01em; +} + +h3 { + font-size: var(--text-base); +} + +p { + margin: 0; +} + +code { + font: var(--text-sm) var(--mono); + color: var(--muted); +} + +article { + margin-block-start: var(--space-5); + padding-block-start: var(--space-4); + border-block-start: 1px solid var(--line); + + > header { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-2); + margin-block-end: var(--space-3); + } +} + +:focus-visible { + outline: 2px solid var(--ink); + outline-offset: 2px; +} + +/* For text that has to reach a screen reader but would be noise on screen. */ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +/* --- text roles --- */ +.sub, +.note, +.teaches, +.blurb { + color: var(--muted); +} + +.sub { + margin-block-end: var(--space-2); +} + +.note { + margin-block-start: var(--space-3); + font-size: var(--text-md); +} + +.group { + margin-block: var(--space-12) var(--space-2); + font-size: var(--text-lg); +} + +/* A small uppercase caption in the mono face. */ +.tag, +.who, +.picker-label, +.control-label { + font-family: var(--mono); + font-size: var(--text-xs); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.dot { + flex: none; + display: inline-block; + width: 20px; + height: 20px; + border: 1px solid var(--line); + border-radius: 5px; + vertical-align: middle; +} + +/* --- layout primitives --- */ +.stack { + display: flex; + flex-direction: column; + gap: 2px; + + & + & { + margin-block-start: var(--space-3); + } +} + +/* A labelled strip, with room for a verdict when the view supplies one. */ +.row { + display: grid; + grid-template-columns: 52px minmax(0, 1fr); + align-items: center; + gap: var(--space-3); + + &:has(ig-verdict) { + grid-template-columns: 52px minmax(0, 1fr) 150px; + } +} + +.who { + font-weight: 600; + text-align: end; +} + +.two-up { + display: grid; + grid-template-columns: minmax(280px, 340px) minmax(0, 1fr); + align-items: start; + gap: var(--space-6); + + &.is-plot { + grid-template-columns: minmax(0, 420px) minmax(0, 1fr); + gap: var(--space-8); + margin-block-start: var(--space-6); + } + + @media (width <= 860px) { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (width <= 640px) { + .row, + .row:has(ig-verdict) { + grid-template-columns: minmax(0, 1fr); + gap: var(--space-1); + } + + .who { + text-align: start; + } +} + +/* --- swatch strips --- */ +.strip { + display: flex; + border: 1px solid var(--line); + border-radius: 5px; + overflow: hidden; + + &.is-attached { + border-radius: 0; + border-block-start: 0; + } +} + +.swatch { + position: relative; + display: flex; + flex: 1 1 0; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1px; + min-width: 0; + height: var(--swatch-height); + padding: 0; + border: 0; + font: inherit; + cursor: pointer; + + /* The shade every other shade in the row is being measured against. */ + &.is-reference { + box-shadow: inset 0 0 0 2px currentcolor; + } + + /* + * Focus rings go inward. An outward ring on a swatch in a seamless run of ten + * overlaps its neighbours and gets clipped at the ends of the strip, which reads as + * a broken box rather than a focus indicator. Nested inside the reference ring so a + * focused reference shows both. + */ + &:focus-visible { + outline: 2px solid currentcolor; + outline-offset: -6px; + } + + /* + * A pairing that earns no grade keeps its ratio, quietly. With a shade pinned most + * pairings fail, and that is not news; what the reader is looking for is what they + * can use. + */ + &.is-short .swatch-ratio { + opacity: 0.5; + } + + /* The generator asked for a color outside sRGB and the browser clipped it. */ + &.is-clipped::after { + content: ""; + position: absolute; + inset-inline: 0; + inset-block-end: 0; + height: 3px; + background: repeating-linear-gradient(-45deg, currentcolor 0 2px, transparent 2px 4px); + opacity: 0.85; + } +} + +.swatch-key { + font: 600 var(--text-xs) / 1 var(--mono); +} + +.swatch-ratio { + display: flex; + align-items: center; + gap: var(--space-1); + font: var(--text-xs) / 1 var(--mono); + letter-spacing: -0.02em; + opacity: 0.72; +} + +/* The grade a pairing earns. Hairline in the swatch's own contrast color, so it stays legible on any shade. */ +.grade { + flex: none; + padding: 1px 3px 0; + border: 1px solid currentcolor; + border-radius: 2px; + font: 700 0.625rem / 1.35 var(--mono); + letter-spacing: 0.04em; +} + +/* Two tokens, one color. Opposite corner from the grade so a shade can carry both. */ +.twin { + position: absolute; + inset-block-end: 3px; + inset-inline-end: 5px; + font: 700 var(--text-xs) / 1 var(--mono); + opacity: 0.85; +} + +@media (width <= 640px) { + .swatch { + height: 36px; + } + + .swatch-ratio { + display: none; + } +} + +/* --- legends --- */ +.keyline { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-5); + + /* Held at the height of its tallest state, so pinning a shade does not shift the page. */ + min-height: 38px; + margin-block-start: var(--space-4); + padding-block-start: var(--space-3); + font-size: var(--text-sm); + color: var(--muted); + + b { + font-weight: 450; + color: var(--ink); + } +} + +.key-twin { + display: inline-block; + margin-inline-end: var(--space-2); + font: 700 var(--text-xs) / 1 var(--mono); + color: var(--ink); + vertical-align: middle; +} + +.key-hatch { + display: inline-block; + width: 15px; + height: 4px; + margin-inline-end: var(--space-1); + background: repeating-linear-gradient(-45deg, var(--ink) 0 2px, transparent 2px 4px); + vertical-align: middle; +} + +.clear { + height: 22px; + padding: 0 var(--space-2); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: transparent; + color: var(--muted); + font: var(--text-xs) / 1 var(--mono); + cursor: pointer; + + &:hover { + color: var(--ink); + border-color: var(--muted); + } +} + +/* --- shell --- */ +.masthead { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: var(--space-5); + padding-block-start: var(--space-6); + border-block-end: 1px solid var(--line); + + .eyebrow { + margin-block-end: var(--space-3); + } +} + +.sections, +.views { + display: flex; + gap: var(--space-1); + margin-inline-start: auto; + + a { + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + font-family: var(--mono); + font-size: var(--text-xs); + letter-spacing: 0.06em; + text-transform: uppercase; + text-decoration: none; + white-space: nowrap; + color: var(--muted); + + &:hover { + color: var(--ink); + background: var(--line-soft); + } + + &[aria-current] { + color: var(--ink); + box-shadow: inset 0 -2px 0 var(--ink); + } + } +} + +.section-intro { + padding-block-start: var(--space-8); + + h1 { + margin-block-end: var(--space-2); + font-size: var(--text-2xl); + letter-spacing: -0.02em; + } +} + +.blurb { + font-size: 1rem; +} + +/* + * Pinned rather than scrolled away: the whole point of one page is that the control the + * reader moves is visibly the same control for every view under it. + */ +.control-bar { + position: sticky; + inset-block-start: 0; + z-index: 5; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-2) var(--space-5); + margin-block-start: var(--space-5); + padding-block-start: var(--space-2); + border-block-end: 1px solid var(--line); + background: var(--ground); + + /* One row at any width: a wrapped four-item nav eats half a phone screen. */ + .views { + flex-basis: 100%; + flex-wrap: nowrap; + margin-inline-start: 0; + overflow-x: auto; + scrollbar-width: none; + } +} + +ig-color-controls { + display: contents; +} + +.picker { + display: flex; + align-items: center; + gap: var(--space-2); +} + +/* + * The library's segmented control, themed to the chrome neutrals through its own public + * tokens. Left at its defaults it paints itself in `primary`, and a blue control sitting + * above the swatches under test changes how those swatches read — the same reason + * `shell/theme.scss` keeps the chrome free of any hue of its own. + */ +igc-button-group { + --ig-button-group-elevation: none; + --ig-button-group-item-background: transparent; + --ig-button-group-item-text-color: var(--muted); + --ig-button-group-item-border-color: var(--line); + --ig-button-group-item-hover-background: var(--line-soft); + --ig-button-group-item-hover-text-color: var(--ink); + --ig-button-group-item-focused-background: var(--line-soft); + --ig-button-group-item-focused-text-color: var(--ink); + --ig-button-group-item-selected-background: var(--line-soft); + --ig-button-group-item-selected-text-color: var(--ink); + --ig-button-group-item-selected-border-color: var(--ink); + --ig-button-group-item-selected-hover-background: var(--line-soft); + --ig-button-group-item-selected-hover-text-color: var(--ink); +} + +/* A view's frame is an article too, but only frames after the first draw a rule. */ +.view-frame { + margin-block-start: var(--space-10); + padding-block-start: 0; + border-block-start: 0; + + /* Measured by the shell: the bar wraps to more rows as the screen narrows. */ + scroll-margin-block-start: calc(var(--control-bar-height, 64px) + var(--space-4)); + + & + & { + padding-block-start: var(--space-10); + border-block-start: 1px solid var(--line); + } + + > header { + display: block; + margin-block-end: var(--space-5); + } + + h2 { + margin-block-end: var(--space-1); + font-size: var(--text-xl); + } +} + +.teaches { + font-size: 0.875rem; +} + +.loading { + padding-block: var(--space-10); + font-family: var(--mono); + font-size: var(--text-md); + color: var(--muted); +} + +/* Shared elements are laid out by their host and carry their own internals. */ +ig-palette-scope { + display: block; +} + +ig-code-block { + display: block; + margin-block-start: var(--space-5); +} + +/* --- neutrals: the mock page and the surface roles --- */ +.mock { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding: var(--space-4); + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: var(--ig-surface-base); + color: var(--ig-surface-base-contrast); + + strong { + font-size: var(--text-md); + } + + span { + font-size: var(--text-sm); + } +} + +.mock-bar { + display: flex; + align-items: center; + gap: var(--space-2); + height: 22px; +} + +.mock-title { + width: 74px; + height: 7px; + border-radius: var(--space-1); + background: currentcolor; + opacity: 0.5; +} + +.mock-pill { + width: 56px; + height: 20px; + margin-inline-start: auto; + border-radius: 100px; + background: var(--ig-surface-container); +} + +.mock-well { + padding: var(--space-2) var(--space-3); + border-radius: 7px; + background: var(--ig-surface-sunken); + color: var(--ig-surface-sunken-contrast); + font: var(--text-sm) var(--mono); +} + +.mock-card, +.mock-pop { + display: flex; + flex-direction: column; + gap: var(--space-1); + padding: var(--space-3); + border-radius: var(--radius-md); + + span { + opacity: 0.72; + } +} + +.mock-card { + background: var(--ig-surface-raised); + color: var(--ig-surface-raised-contrast); + box-shadow: 0 1px 2px rgb(0 0 0 / 9%); +} + +.mock-pop { + margin-inline-start: var(--space-5); + background: var(--ig-surface-overlay); + color: var(--ig-surface-overlay-contrast); + box-shadow: 0 8px 22px -6px rgb(0 0 0 / 35%), 0 2px 5px rgb(0 0 0 / 16%); +} + +.mock-line { + height: 6px; + border-radius: 3px; + background: currentcolor; + opacity: 0.16; + + &.is-short { + width: 58%; + } +} + +.roles { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); + gap: var(--space-2); + margin-block-start: var(--space-4); +} + +.role { + display: flex; + align-items: center; + gap: var(--space-2); + + code { + display: block; + color: var(--ink); + } +} + +.role-swatch { + display: grid; + flex: none; + place-items: center; + width: 42px; + height: 30px; + border: 1px solid var(--line); + border-radius: 5px; + + > span { + display: block; + width: 26px; + height: 16px; + border-radius: var(--radius-sm); + } +} + +.role-distance { + display: block; + font: var(--text-xs) var(--mono); + color: var(--muted); +} + +/* --- sweep --- */ +.sweep-head { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-3); + margin-block-end: var(--space-4); +} + +.sweep-toggle { + margin-inline-start: auto; +} + +.seed-chip { + flex: none; + width: 30px; + height: 30px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); +} + +.seed-readout { + font-family: var(--mono); + font-size: var(--text-md); + font-variant-numeric: tabular-nums; + + span { + color: var(--muted); + } +} + +/* + * The one control that stays bespoke: its track is the hue wheel itself. Both engines' + * pseudo-elements are styled, because a range input with `appearance: none` and no + * track rule is invisible. + */ +.hue-input { + display: block; + width: 100%; + height: 26px; + margin-block-start: var(--space-2); + background: transparent; + appearance: none; + cursor: ew-resize; + + --hue-track: linear-gradient( + to right, + hsl(0deg 90% 50%), + hsl(60deg 90% 50%), + hsl(120deg 90% 50%), + hsl(180deg 90% 50%), + hsl(240deg 90% 50%), + hsl(300deg 90% 50%), + hsl(359deg 90% 50%) + ); + + &::-webkit-slider-runnable-track { + height: 26px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--hue-track); + } + + &::-moz-range-track { + height: 26px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--hue-track); + } + + &::-webkit-slider-thumb { + width: 10px; + height: 34px; + margin-block-start: -5px; + border: 2px solid var(--ink); + border-radius: 2px; + background: var(--panel); + appearance: none; + } + + &::-moz-range-thumb { + width: 10px; + height: 34px; + border: 2px solid var(--ink); + border-radius: 2px; + background: var(--panel); + } +} + +.strips { + display: flex; + flex-direction: column; + gap: var(--space-3); + margin-block-start: var(--space-5); +} + +/* --- scales: presets, controls, the editor and its readouts --- */ +.scale-presets { + display: flex; + flex-direction: column; + gap: 2px; + margin-block-end: var(--space-5); +} + +.scale-preset { + display: grid; + grid-template-columns: 128px minmax(0, 1fr) 118px; + align-items: center; + gap: var(--space-3); + width: 100%; + padding: var(--space-2) var(--space-3); + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: none; + color: inherit; + font: inherit; + text-align: start; + cursor: pointer; + + &:hover, + &[aria-current="true"] { + background: var(--line-soft); + } + + &[aria-current="true"] { + border-color: var(--muted); + } + + @media (width <= 620px) { + grid-template-columns: 104px minmax(0, 1fr); + + .preset-record { + grid-column: 2; + } + } +} + +.preset-name { + font: 600 var(--text-sm) var(--mono); +} + +.preset-why { + display: block; + margin-block-start: 2px; + font-size: var(--text-xs); + font-weight: 400; + line-height: 1.35; + color: var(--muted); +} + +.preset-mini { + display: flex; + height: 28px; + border: 1px solid var(--line); + border-radius: 2px; + overflow: hidden; + + span { + flex: 1; + } +} + +.preset-record { + font-family: var(--mono); + font-size: var(--text-xs); + text-align: end; +} + +.subjects { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-3); +} + +.subject-note { + margin-block: var(--space-2) var(--space-4); +} + +.controls { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: var(--space-4) var(--space-6); + padding-block-end: var(--space-4); + border-block-end: 1px solid var(--line); +} + +.control-label { + display: flex; + justify-content: space-between; + gap: var(--space-2); + margin-block-end: var(--space-2); + + b { + color: var(--ink); + font-variant-numeric: tabular-nums; + letter-spacing: 0; + } +} + +.control-hint { + margin-block-start: var(--space-2); + font-size: var(--text-sm); + line-height: 1.45; + color: var(--muted); + + &.is-over { + color: var(--fail); + } +} + +.control-action { + margin-block-start: var(--space-2); +} + +/* + * No top margin: the chart carries empty headroom above the plot for a handle pulled + * past the range, and that band is the gap between the controls and the chart. + */ +.stage { + width: 100%; +} + +/* Every guaranteed pair as a bar between its two shades, on the strip's own twenty columns. */ +.ladder { + display: flex; + flex-direction: column; + gap: 3px; + margin-block-start: var(--space-2); +} + +.ladder-row { + display: grid; + grid-template-columns: repeat(20, minmax(0, 1fr)); + align-items: center; + height: 16px; +} + +.ladder-bar { + height: 4px; + border-radius: 2px; +} + +.ladder-tag { + padding-inline-start: var(--space-2); + font-family: var(--mono); + font-size: var(--text-xs); + white-space: nowrap; + + &.is-before { + padding-inline: 0 var(--space-2); + text-align: end; + } +} + +.is-passing { + color: var(--pass); + + &.ladder-bar { + background: var(--pass); + } +} + +.is-failing { + color: var(--fail); + + &.ladder-bar { + background: var(--fail); + } +} + +.table-wrap { + margin-block-start: var(--space-6); + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; + font-family: var(--mono); + font-size: var(--text-sm); + font-variant-numeric: tabular-nums; +} + +th, +td { + padding-inline-start: var(--space-4); + text-align: end; + white-space: nowrap; + + &:first-child { + padding-inline-start: 0; + text-align: start; + } +} + +th { + padding-block-end: var(--space-2); + font-size: var(--text-xs); + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +td { + padding-block: var(--space-1); + border-block-start: 1px solid var(--line-soft); + + &.is-miss { + color: var(--fail); + } + + .dot { + width: 12px; + height: 12px; + margin-inline-end: var(--space-2); + border-radius: 2px; + } +} diff --git a/packages/preview/src/variants.ts b/packages/preview/src/variants.ts new file mode 100644 index 00000000..58806838 --- /dev/null +++ b/packages/preview/src/variants.ts @@ -0,0 +1,60 @@ +/** + * The names a palette is addressed by. A leaf module on purpose: both the Sass-compiling + * providers and the browser bundle need these, and a provider drags `sass-embedded` in + * with it. + */ +export const FAMILIES = [ + "primary", + "secondary", + "gray", + "surface", + "info", + "success", + "warn", + "error", +] as const; + +export const SHADES = [ + "50", + "100", + "200", + "300", + "400", + "500", + "600", + "700", + "800", + "900", +] as const; + +export const ACCENTS = ["A100", "A200", "A400", "A700"] as const; + +export const ROLES = [ + "base", + "sunken", + "raised", + "overlay", + "container", +] as const; + +export const THEMES = ["light", "dark"] as const; + +/** The palettes the library ships, in the order the picker offers them. */ +export const PRESETS = [ + { key: "material", label: "Material" }, + { key: "bootstrap", label: "Bootstrap" }, + { key: "fluent", label: "Fluent" }, +] as const; + +export type Family = (typeof FAMILIES)[number]; +export type Shade = (typeof SHADES)[number]; +export type Accent = (typeof ACCENTS)[number]; +export type RoleKey = (typeof ROLES)[number]; +export type Theme = (typeof THEMES)[number]; +export type PresetKey = (typeof PRESETS)[number]["key"]; + +export const isTheme = (value: unknown): value is Theme => + THEMES.includes(value as Theme); + +export const isPresetKey = (value: unknown): value is PresetKey => + PRESETS.some((preset) => preset.key === value); diff --git a/packages/preview/src/vite-env.d.ts b/packages/preview/src/vite-env.d.ts new file mode 100644 index 00000000..1e03fd07 --- /dev/null +++ b/packages/preview/src/vite-env.d.ts @@ -0,0 +1,16 @@ +/// + +declare module "virtual:data/color.sweep" { + const data: import("./data/color/sweep.js").SweepData; + export default data; +} + +declare module "virtual:data/color.scales" { + const data: import("./data/color/scales.js").ScaleData; + export default data; +} + +declare module "virtual:data/color.presets" { + const data: import("./data/color/presets.js").PresetData; + export default data; +} diff --git a/packages/preview/stylelint.config.mjs b/packages/preview/stylelint.config.mjs new file mode 100644 index 00000000..885c7060 --- /dev/null +++ b/packages/preview/stylelint.config.mjs @@ -0,0 +1,3 @@ +export default { + extends: ["stylelint-config-standard-scss"], +}; diff --git a/packages/preview/tsconfig.json b/packages/preview/tsconfig.json new file mode 100644 index 00000000..ecfa4a20 --- /dev/null +++ b/packages/preview/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "declaration": false, + "lib": ["ES2022", "DOM"] + }, + "include": ["src/**/*.ts", "vite.config.ts"] +} diff --git a/packages/preview/vite.config.ts b/packages/preview/vite.config.ts new file mode 100644 index 00000000..20e93f06 --- /dev/null +++ b/packages/preview/vite.config.ts @@ -0,0 +1,10 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; +import { dataProviders } from "./src/data/plugin.js"; + +export default defineConfig({ + root: fileURLToPath(new URL(".", import.meta.url)), + plugins: [dataProviders()], + base: "./", + build: { outDir: "dist", emptyOutDir: true }, +}); diff --git a/packages/preview/vitest.config.ts b/packages/preview/vitest.config.ts new file mode 100644 index 00000000..90824d85 --- /dev/null +++ b/packages/preview/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + name: "preview", + include: ["src/**/*.spec.ts"], + environment: "node", + testTimeout: 20000, + }, +}); diff --git a/packages/theming/sass/color/_config.scss b/packages/theming/sass/color/_config.scss new file mode 100644 index 00000000..efa0bd1f --- /dev/null +++ b/packages/theming/sass/color/_config.scss @@ -0,0 +1,95 @@ +//// +/// @package theming +/// @group Palettes +//// + +/// Which generator builds the shades. `fitted` cuts each scale to its own hue — +/// lightness from a contrast target, chroma from how much color that hue can actually +/// hold. `legacy` multiplies the seed by fixed saturation and lightness tables. +/// @type String +/// @access public +/// @example scss +/// @use 'igniteui-theming' with ($shade-generator: 'legacy'); +$shade-generator: 'fitted' !default; + +/// Ready-made scale shapes. +/// +/// `range` is the WCAG contrast the family spans, from shade 50 to shade 900, measured +/// against white — or against the background for `gray`. So `1.182 18.232` means the +/// lightest shade sits at 1.18:1 and the darkest at 18.23:1. +/// +/// `curve` is read exactly like a CSS `cubic-bezier()` timing function: x is how far +/// along the shades you are, y is how far into that contrast range. `null` is the +/// straight line, and an even spread is what makes any two shades 500 apart clear AA — +/// bunching the light end buys a familiar rhythm at the cost of that guarantee. +/// +/// Each preset was fitted to the published scale it names. Bracketed figures are the max +/// ΔE from that scale and how many of the five "500 apart" pairs still clear AA. +/// +/// - `even` — straight [5/5] +/// - `material` — this library's original grayscale [0.025, 3/5] +/// - `tailwind` — Tailwind v4 slate [0.013, 5/5] +/// - `carbon` — IBM Carbon gray 10–100 [0.007, 5/5] +/// +/// @type Map +/// @access public +/// @example scss - Name a preset +/// $p: palette($primary: #09f, $secondary: #f0f, $surface: #fff, $scales: ('gray': 'carbon')); +/// @example scss - Or write one inline +/// $p: palette(#09f, #f0f, #fff, $scales: ('gray': (range: 1.1 14, curve: 0.5 0 0.8 0.8))); +$scales: ( + 'even': ( + range: 1.182 18.232, + curve: null, + ), + 'material': ( + range: 1.04 16.1, + curve: 0.53 0 0.825 0.785, + ), + 'tailwind': ( + range: 1.04 17.76, + curve: 0.615 0.07 0.225 0.43, + ), + 'carbon': ( + range: 1.1 18.1, + curve: 0.525 0.295 0.655 0.87, + ), +) !default; + +/// Which scale each family uses. Unlisted families use `even`. +/// @type Map +/// @access public +$family-scales: ( + 'gray': 'material', +) !default; + +/// The layers a surface family generates. +/// The `toward` is the end of the range each moves to; +/// A layer with no room resolves onto the background. +/// The `step` is its contrast from the background. +/// @type Map +/// @access public +$surface-roles: ( + 'sunken': ( + toward: 'black', + step: 1.07, + ), + 'raised': ( + toward: 'white', + step: 1.08, + ), + 'overlay': ( + toward: 'white', + step: 1.18, + ), +) !default; + +/// Alpha of the translucent `container` layer — black over a light background, white over a dark one. +/// @type Number +/// @access public +$surface-container-alpha: 0.03 !default; + +/// Lower bound on the seed's saturation level, so a muted brand color still yields a usable ramp. +/// @type Number +/// @access public +$saturation-floor: 0.3 !default; diff --git a/packages/theming/sass/color/_contrast.scss b/packages/theming/sass/color/_contrast.scss new file mode 100644 index 00000000..d6b42fa6 --- /dev/null +++ b/packages/theming/sass/color/_contrast.scss @@ -0,0 +1,65 @@ +@use 'sass:math'; +@use 'sass:meta'; +@use 'sass:color'; +@use '../utils/math' as *; + +//// +/// WCAG relative luminance and contrast ratio. +/// Extracted from `functions` so the shade generators can use them without a cycle; +/// both are still exported from the `color` module exactly as before. +/// @package theming +//// + +/// Calculates the linear channel value for a given sRGB color. +/// @access private +/// @group Color +/// @param {Number} $value - The sRGB color +/// @returns {Number} The calculated linear channel value +@function _lcv($value) { + // stylelint-disable number-max-precision + @return if($value < 0.03928, math.div($value, 12.92), math.pow(math.div($value + 0.055, 1.055), 2.4)); +} + +/// Calculates the luminance for a given color. +/// @access public +/// @group Color +/// @param {Color} $color - The color to calculate luminance for. +/// +/// @example scss +/// $white: luminance(#fff); // 1 +/// $blue: luminance(#09f); // 0.3 +/// $black: luminance(#000); // 0 +/// +/// @returns {Number | String} The calculated luminance of a given color +/// @link https://www.w3.org/TR/WCAG20-TECHS/G17.html#G17-tests. WCAG 2.0 Contrast Ratio +@function luminance($color) { + @if meta.type-of($color) == 'color' { + $r: math.div(color.channel($color, 'red', $space: rgb), 255); + $g: math.div(color.channel($color, 'green', $space: rgb), 255); + $b: math.div(color.channel($color, 'blue', $space: rgb), 255); + + @return 0.2126 * _lcv($r) + 0.7152 * _lcv($g) + 0.0722 * _lcv($b); + } + + @return $color; +} + +/// Calculates the contrast ratio between two colors. +/// @access public +/// @group Color +/// @param {Color} $background - The background color. +/// @param {Color} $foreground - The foreground color. +/// +/// @example scss +/// $conrast: contrast(#09f, #000); // 7 +/// +/// @returns {Number} The contrast ratio between the background and foreground colors. +/// @require {function} luminance +/// @require {function} to-fixed +/// @link https://www.w3.org/TR/WCAG20-TECHS/G17.html#G17-tests WCAG 2.0 Contrast Ratio +@function contrast($background, $foreground) { + $backLum: luminance($background) + 0.05; + $foreLum: luminance($foreground) + 0.05; + + @return to-fixed(math.div(math.max($backLum, $foreLum), math.min($backLum, $foreLum))); +} diff --git a/packages/theming/sass/color/_functions.scss b/packages/theming/sass/color/_functions.scss index 2629106d..2b9a021f 100644 --- a/packages/theming/sass/color/_functions.scss +++ b/packages/theming/sass/color/_functions.scss @@ -5,9 +5,13 @@ @use 'sass:color'; @use 'sass:string'; @use 'charts'; +@use 'config'; +@use 'generator'; @use 'multipliers'; @use 'types'; +@use 'contrast' as *; @use '../utils/math' as *; +@forward 'contrast'; //// /// @package theming @@ -42,6 +46,8 @@ $_enhanced-accessibility: false; /// @param {Color} $warn [#faa419] - The warning color used throughout the application (optional). /// @param {Color} $error [#ff134a] - The error color used throughout the application (optional). /// @param {String} $variant [null] - Used internally (optional). +/// @param {String} $generator [$shade-generator] - Either `fitted` or `legacy` (optional). +/// @param {String | Map | List} $scales [null] - One scale for every family, or a map keyed by family name (optional). /// @requires {function} shades /// @returns {Map} A map consisting of color shades for the passed base colors (primary, secondary, gray, etc). /// @example scss @@ -54,6 +60,12 @@ $_enhanced-accessibility: false; /// /// // Include the generated palette colors as CSS variables. /// @include palette($my-palette); +/// @example scss - Keep the original multiplier-based shades +/// $my-palette: palette(rebeccapurple, orange, white, $generator: 'legacy'); +/// @example scss - Give the grayscale a different rhythm +/// $my-palette: palette(rebeccapurple, orange, white, $scales: ('gray': 'carbon')); +/// @see $shade-generator +/// @see $scales @function palette( $primary, $secondary, @@ -63,18 +75,57 @@ $_enhanced-accessibility: false; $success: #4eb862, $warn: #faa419, $error: #ff134a, - $variant: null + $variant: null, + $generator: config.$shade-generator, + $scales: null ) { $color-shades: types.$IColorShades; $gray-shades: types.$IGrayShades; - $primary-palette: shades('primary', $primary, $color-shades); - $secondary-palette: shades('secondary', $secondary, $color-shades); - $surface-palette: shades('surface', $surface, $color-shades); - $grayscale-palette: shades('gray', $gray, $gray-shades, $surface); - $info-palette: if($info, shades('info', $info, $color-shades), ()); - $success-palette: if($success, shades('success', $success, $color-shades), ()); - $warn-palette: if($warn, shades('warn', $warn, $color-shades), ()); - $error-palette: if($error, shades('error', $error, $color-shades), ()); + + // Legacy palettes keep numeric surface shades. + $surface-shades: if($generator == 'legacy', $color-shades, types.$ISurfaceRoles); + $primary-palette: shades( + 'primary', + $primary, + $color-shades, + $generator: $generator, + $scale: _scale-for('primary', $scales) + ); + $secondary-palette: shades( + 'secondary', + $secondary, + $color-shades, + $generator: $generator, + $scale: _scale-for('secondary', $scales) + ); + $surface-palette: shades( + 'surface', + $surface, + $surface-shades, + $generator: $generator, + $scale: _scale-for('surface', $scales) + ); + $grayscale-palette: shades('gray', $gray, $gray-shades, $surface, $generator, _scale-for('gray', $scales)); + $info-palette: if( + $info, + shades('info', $info, $color-shades, $generator: $generator, $scale: _scale-for('info', $scales)), + () + ); + $success-palette: if( + $success, + shades('success', $success, $color-shades, $generator: $generator, $scale: _scale-for('success', $scales)), + () + ); + $warn-palette: if( + $warn, + shades('warn', $warn, $color-shades, $generator: $generator, $scale: _scale-for('warn', $scales)), + () + ); + $error-palette: if( + $error, + shades('error', $error, $color-shades, $generator: $generator, $scale: _scale-for('error', $scales)), + () + ); @return ( 'primary': $primary-palette, @@ -91,6 +142,25 @@ $_enhanced-accessibility: false; ); } +/// Picks one family's scale out of the `$scales` argument, which may be a single spec +/// for every family or a map keyed by family name. +/// @access private +/// @group Palettes +/// @param {String} $name - The family to look up. +/// @param {String | Map | List} $scales - The `palette()` argument, or null. +/// @returns {String | Map | List} That family's scale, or null to let the generator decide. +@function _scale-for($name, $scales) { + @if not $scales { + @return null; + } + + @if meta.type-of($scales) == 'map' and not map.has-key($scales, range) and not map.has-key($scales, curve) { + @return map.get($scales, $name); + } + + @return $scales; +} + /// Generates color shades for a given color. /// @access public /// @group Palettes @@ -98,6 +168,8 @@ $_enhanced-accessibility: false; /// @param {Color} $color - The base color used to generate the shades. /// @param {List} $shades - The list of shade variants. /// @param {Color} $surface [null] - The surface color. Useful if generating shades of gray (optional). +/// @param {String} $generator [$shade-generator] - Either `fitted` or `legacy` (optional). +/// @param {String | Map | List} $scale [null] - A preset name, a `(range:, curve:)` map, or a curve (optional). /// @requires {function} adaptive-contrast /// @returns {Map} A map consisting of color shades and their respective contrast colors. /// @@ -110,23 +182,59 @@ $_enhanced-accessibility: false; /// #{$color-name}: shades($color-name, #a57865, $IColorShades), /// ) /// ); +/// @example scss - Pick a scale for this scale +/// $shades: shades('accent', #a57865, $IColorShades, $scale: 'carbon'); /// @see $IColorShades -@function shades($name, $color, $shades, $surface: null) { +/// @see $scales +@function shades($name, $color, $shades, $surface: null, $generator: config.$shade-generator, $scale: null) { + $entries: if( + $generator == 'legacy', + _legacy-entries($name, $color, $shades, $surface), + generator.family($name, $color, $shades, $surface, $scale) + ); $result: (); + @each $variant, $shade in $entries { + $result: map.set($result, $variant, map.get($shade, 'css')); + $result: map.set($result, '#{$variant}-contrast', adaptive-contrast(#{var(--ig-#{$name}-#{$variant})})); + $result: map.set($result, '#{$variant}-raw', map.get($shade, 'raw')); + } + + @return $result; +} + +/// Builds the multiplier-based shades, plus — for a surface — the same layer roles the +/// `fitted` generator emits. The roles are contrast steps off the background rather than +/// points on a multiplier ramp, so they mean the same thing under either generator, and +/// having them everywhere is what lets a schema say `('surface', 'base')` unconditionally. +/// @access private +/// @group Palettes +/// @param {String} $name - The name of the color. +/// @param {Color} $color - The base color used to generate the shades. +/// @param {List} $shades - The list of shade variants. +/// @param {Color} $surface [null] - The surface color, for the grayscale. +/// @requires {function} shade +/// @returns {Map} Each variant mapped to `(css:, raw:)`. +@function _legacy-entries($name, $color, $shades, $surface: null) { + $entries: (); + @each $variant in $shades { $shade: shade($name, $color, $variant, $surface); - $result: map.merge( - $result, + $entries: map.set( + $entries, + $variant, ( - $variant: map.get($shade, 'hsl'), - '#{$variant}-contrast': adaptive-contrast(#{var(--ig-#{$name}-#{$variant})}), - '#{$variant}-raw': map.get($shade, 'raw'), + css: map.get($shade, 'hsl'), + raw: map.get($shade, 'raw'), ) ); } - @return $result; + @if '#{$name}' == 'surface' { + @return map.merge($entries, generator.family('surface', $color, types.$ISurfaceRoles)); + } + + @return $entries; } /// Generates a color shade for a given base colors. @@ -249,7 +357,8 @@ $_enhanced-accessibility: false; /// .my-component-2 { /// background: color($my-palette, primary, 200, $opacity: 0.5); // rgba(136, 192, 229, 0.5) /// } -@function color($palette: null, $color: primary, $variant: 500, $opacity: null) { +@function color($palette: null, $color: primary, $variant: null, $opacity: null) { + $variant: if($variant, $variant, if('#{$color}' == 'surface', 'base', 500)); $s: #{var(--ig-#{$color}-#{$variant})}; $contrast: if(meta.type-of($variant) == string, string.index($variant, 'contrast'), false); $_alpha: if($opacity, $opacity, 1); @@ -260,7 +369,11 @@ $_enhanced-accessibility: false; $base: map.get($s, #{$variant}); $raw: map.get($s, #{$variant}-raw); - @return if($contrast, $_relative-color, if($raw, rgba($raw, $_alpha), _normalize-color($base))); + @return if( + $contrast, + $_relative-color, + if($raw, if($opacity, rgba($raw, $opacity), $raw), _normalize-color($base)) + ); } @return $_relative-color; @@ -281,7 +394,9 @@ $_enhanced-accessibility: false; /// background: color($color: 'primary', $variant: 200); /// color: contrast-color($color: 'primary', $variant: 200); /// } -@function contrast-color($palette: null, $color: primary, $variant: 500, $opacity: null) { +@function contrast-color($palette: null, $color: primary, $variant: null, $opacity: null) { + $variant: if($variant, $variant, if('#{$color}' == 'surface', 'base', 500)); + @return color($palette, $color, #{$variant}-contrast, $opacity); } @@ -403,60 +518,6 @@ $_enhanced-accessibility: false; ); } -/// Calculates the contrast ratio between two colors. -/// @access public -/// @group Color -/// @param {Color} $background - The background color. -/// @param {Color} $foreground - The foreground color. -/// -/// @example scss -/// $conrast: contrast(#09f, #000); // 7 -/// -/// @returns {Number} The contrast ratio between the background and foreground colors. -/// @require {function} luminance -/// @require {function} to-fixed -/// @link https://www.w3.org/TR/WCAG20-TECHS/G17.html#G17-tests WCAG 2.0 Contrast Ratio -@function contrast($background, $foreground) { - $backLum: luminance($background) + 0.05; - $foreLum: luminance($foreground) + 0.05; - - @return to-fixed(math.div(math.max($backLum, $foreLum), math.min($backLum, $foreLum))); -} - -/// Calculates the luminance for a given color. -/// @access public -/// @group Color -/// @param {Color} $color - The color to calculate luminance for. -/// -/// @example scss -/// $white: luminance(#fff); // 1 -/// $blue: luminance(#09f); // 0.3 -/// $black: luminance(#000); // 0 -/// -/// @returns {Number | String} The calculated luminance of a given color -/// @link https://www.w3.org/TR/WCAG20-TECHS/G17.html#G17-tests. WCAG 2.0 Contrast Ratio -@function luminance($color) { - @if meta.type-of($color) == 'color' { - $r: math.div(color.channel($color, 'red', $space: rgb), 255); - $g: math.div(color.channel($color, 'green', $space: rgb), 255); - $b: math.div(color.channel($color, 'blue', $space: rgb), 255); - - @return 0.2126 * _lcv($r) + 0.7152 * _lcv($g) + 0.0722 * _lcv($b); - } - - @return $color; -} - -/// Calculates the linear channel value for a given sRGB color. -/// @access private -/// @group Color -/// @param {Number} $value - The sRGB color -/// @returns {Number} The calculated linear channel value -@function _lcv($value) { - // stylelint-disable number-max-precision - @return if($value < 0.03928, math.div($value, 12.92), math.pow(math.div($value + 0.055, 1.055), 2.4)); -} - /// Returns a list of colors to be used as chart brushes. The return value depends on the value of enhanced-accessibility. /// @access public /// @group Palettes diff --git a/packages/theming/sass/color/_gamut.scss b/packages/theming/sass/color/_gamut.scss new file mode 100644 index 00000000..1869b0ce --- /dev/null +++ b/packages/theming/sass/color/_gamut.scss @@ -0,0 +1,136 @@ +@use 'sass:color'; +@use 'sass:map'; +@use 'sass:math'; +@use 'sass:list'; + +//// +/// sRGB gamut geometry in OKLCH. Chroma rises from 0 at black, peaks at the hue's cusp, +/// and falls back to 0 at white — a tent whose height these functions report. +/// @package theming +/// @group Palettes +/// @access private +//// + +/// Reads a color's OKLCH components as plain numbers. `color.channel()` returns units — +/// lightness as `70.8%`, hue as `314.6deg` — which break arithmetic silently. +/// @param {Color} $c - The color to read. +/// @returns {Map} Its `l`, `c` and `h`, unitless. +@function read($c) { + $ok: color.to-space($c, oklch); + + @return ( + l: math.div(color.channel($ok, 'lightness', $space: oklch), 100%), + c: color.channel($ok, 'chroma', $space: oklch), + h: math.div(color.channel($ok, 'hue', $space: oklch), 1deg) + ); +} + +/// Builds an OKLCH color from unitless components. +/// @param {Number} $l - Lightness, 0 to 1. +/// @param {Number} $c - Chroma. +/// @param {Number} $h - Hue, in degrees. +/// @returns {Color} The color, which may fall outside sRGB. +@function make($l, $c, $h) { + @return oklch($l $c $h); +} + +/// Whether an OKLCH color fits inside sRGB. +/// @param {Number} $l - Lightness, 0 to 1. +/// @param {Number} $c - Chroma. +/// @param {Number} $h - Hue, in degrees. +/// @returns {Boolean} True when the color is displayable. +@function in-srgb($l, $c, $h) { + @return color.is-in-gamut(make($l, $c, $h), srgb); +} + +/// The most chroma sRGB can hold at one lightness, found by bisection. +/// 18 rounds narrow the search from 0.45 to about 0.0000017. +/// @param {Number} $l - Lightness, 0 to 1. +/// @param {Number} $h - Hue, in degrees. +/// @returns {Number} The highest in-gamut chroma there. +@function max-chroma($l, $h) { + $lo: 0; + $hi: 0.45; + + @for $i from 0 to 18 { + $mid: math.div($lo + $hi, 2); + + @if in-srgb($l, $mid, $h) { + $lo: $mid; + } @else { + $hi: $mid; + } + } + + @return $lo; +} + +/// The peak of a hue's chroma curve. +/// The `max-chroma` rises then falls, so a ternary search is used. +/// @param {Number} $h - Hue, in degrees. +/// @returns {Map} The peak's `l` and `c`. +@function cusp($h) { + $lo: 0.02; + $hi: 0.995; + + @for $i from 0 to 26 { + $a: $lo + math.div($hi - $lo, 3); + $b: $hi - math.div($hi - $lo, 3); + + @if max-chroma($a, $h) < max-chroma($b, $h) { + $lo: $a; + } @else { + $hi: $b; + } + } + + $l: math.div($lo + $hi, 2); + + @return (l: $l, c: max-chroma($l, $h)); +} + +/// The cusp as the two slopes of a tent, which turns the chroma available at a given lightness into a single multiply. +/// @param {Number} $h - Hue, in degrees. +/// @returns {List} `(rising-slope, falling-slope)`. +@function tent($h) { + $k: cusp($h); + $lc: map.get($k, 'l'); + $cc: map.get($k, 'c'); + + @return (math.div($cc, $lc), math.div($cc, 1 - $lc)); +} + +/// A `min()` that curves, so the tent has no corner for a shade to land on. +/// @param {Number} $a - First value. +/// @param {Number} $b - Second value. +/// @param {Number} $p - Softness. Higher tracks the true minimum more closely. +/// @returns {Number} The softened minimum, or 0 if either value is non-positive. +@function soft-min($a, $b, $p) { + @if $a <= 0 or $b <= 0 { + @return 0; + } + + @return math.pow(math.div(1, math.div(1, math.pow($a, $p)) + math.div(1, math.pow($b, $p))), math.div(1, $p)); +} + +/// Converts a color to sRGB with integer channels. +/// @param {Color} $c - The color to convert. +/// @returns {Color} The color as `rgb()` with rounded channels. +@function to-srgb($c) { + $rgb: color.to-space($c, rgb); + + @return rgb( + math.round(color.channel($rgb, 'red', $space: rgb)), + math.round(color.channel($rgb, 'green', $space: rgb)), + math.round(color.channel($rgb, 'blue', $space: rgb)) + ); +} + +/// How much chroma a hue can hold at one lightness, read off its tent. +/// @param {Number} $l - Lightness, 0 to 1. +/// @param {List} $tent - The hue's tent, from `tent()`. +/// @param {Number} $p - Softness of the tent's peak. +/// @returns {Number} The chroma ceiling there. +@function ceiling($l, $tent, $p) { + @return soft-min($l * list.nth($tent, 1), (1 - $l) * list.nth($tent, 2), $p); +} diff --git a/packages/theming/sass/color/_generator.scss b/packages/theming/sass/color/_generator.scss new file mode 100644 index 00000000..e2aaca10 --- /dev/null +++ b/packages/theming/sass/color/_generator.scss @@ -0,0 +1,478 @@ +@use 'sass:color'; +@use 'sass:map'; +@use 'sass:math'; +@use 'sass:list'; +@use 'sass:meta'; +@use 'config'; +@use 'gamut'; +@use 'types'; +@use 'contrast' as *; + +//// +/// @package theming +/// @group Palettes +/// @access private +//// + +$_default-scale: 'even'; + +// Where the accent (A100-A700) shades sit, as contrast against white. +$_accent-targets: ( + 'A100': 1.4, + 'A200': 2.6, + 'A400': 4.2, + 'A700': 6.2, +); + +// Below this chroma a seed's hue is a rounding artifact. Surfaces are exempt. +$_neutral-threshold: 0.02; + +// Keeps a tinted gray a gray. +$_neutral-tint-cap: 0.12; + +// Chroma pulled back at the ends of a ramp, and the shape of that taper. +$_envelope-depth: 0.3; +$_envelope-power: 2.5; + +// Ceiling softness: 4 sits inside the sRGB boundary so shades survive gamut mapping; +// 8 tracks the real boundary, which is where the accent track rides. +$_ceiling-softness: 4; +$_accent-ceiling-softness: 8; + +// How far a solved shade may sit from its contrast target before that is worth reporting. +// The solver always returns its closest approach, so this only trips when a target is +// genuinely out of reach for the hue. +$_target-tolerance: 0.05; + +/// Evaluates one axis of a cubic Bézier whose outer control points are pinned at 0 and 1. +/// @param {Number} $t - Position along the curve, 0 to 1. +/// @param {Number} $p1 - First inner control point. +/// @param {Number} $p2 - Second inner control point. +/// @returns {Number} The value of that axis at `$t`. +@function _bezier($t, $p1, $p2) { + @return 3 * math.pow(1 - $t, 2) * $t * $p1 + 3 * (1 - $t) * $t * $t * $p2 + math.pow($t, 3); +} + +/// Reads a cubic Bézier the way CSS reads a timing function: solves x for t, then returns y. +/// @param {Number} $x - Progress along the x axis, 0 to 1. +/// @param {List} $curve - Control points `(x1, y1, x2, y2)`, or null for a straight line. +/// @returns {Number} The eased position, 0 to 1. +@function _ease($x, $curve) { + @if not $curve { + @return $x; + } + + $lo: 0; + $hi: 1; + + @for $i from 0 to 24 { + $mid: math.div($lo + $hi, 2); + + @if _bezier($mid, list.nth($curve, 1), list.nth($curve, 3)) < $x { + $lo: $mid; + } @else { + $hi: $mid; + } + } + + @return _bezier(math.div($lo + $hi, 2), list.nth($curve, 2), list.nth($curve, 4)); +} + +/// Resolves a scale argument into its `(range:, curve:)` pair. +/// @param {String} $name - The family name, used to look up a default in `$family-scales`. +/// @param {String | Map | List} $given - A preset name, a `(range:, curve:)` map, a bare curve, or null. +/// @returns {Map} The resolved scale. Falls back to `even` for both the scale and a missing range. +/// @throws Unknown scale "". +@function _scale($name, $given) { + $spec: $given; + + @if not $spec { + $spec: map.get(config.$family-scales, $name); + + @if not $spec { + $spec: $_default-scale; + } + } + + @if meta.type-of($spec) == 'string' { + $found: map.get(config.$scales, $spec); + + @if not $found { + @error 'Unknown scale "#{$spec}". Available: #{map.keys(config.$scales)}.'; + } + + @return $found; + } + + $default-range: map.get(config.$scales, $_default-scale, range); + + @if meta.type-of($spec) == 'list' and list.length($spec) == 4 { + @return (range: $default-range, curve: $spec); + } + + $range: map.get($spec, range); + + @return (range: if($range, $range, $default-range), curve: map.get($spec, curve)); +} + +/// The contrast target for one shade, placed along the scale by its curve. +/// The range is walked geometrically, so equal steps are equal contrast *ratios*. +/// @param {Map} $scale - A resolved `(range:, curve:)` pair. +/// @param {Number} $i - Zero-based index of the shade. +/// @param {Number} $n - How many shades the family has. +/// @returns {Number} The WCAG contrast this shade should reach against its anchor. +@function _target($scale, $i, $n) { + $range: map.get($scale, range); + $lo: list.nth($range, 1); + $hi: list.nth($range, 2); + $t: _ease(math.div($i, $n - 1), map.get($scale, curve)); + + @return $lo * math.pow(math.div($hi, $lo), $t); +} + +/// Tapers chroma toward both ends of a ramp, so 50 reads as a tint and 900 as a near-neutral. +/// @param {Number} $t - Position along the ramp, 0 to 1. +/// @returns {Number} A multiplier for the chroma ceiling at that position. +@function _envelope($t) { + @return 1 - $_envelope-depth * math.pow(math.abs(2 * $t - 1), $_envelope-power); +} + +/// Corrects the hue drift OKLCH shows through the blue-violet wedge. Linear fits against CAM16. +/// @param {Number} $h - The family's hue, in degrees. +/// @param {Number} $l - Lightness of the shade being built, 0 to 1. +/// @returns {Number} The hue to use at that lightness. Unchanged outside the wedge. +@function _hue-at($h, $l) { + @if $h >= 255 and $h <= 295 and $l > 0.55 { + @return $h + 33 * ($l - 0.55); + } + + @if (($h >= 235 and $h < 255) or ($h > 300 and $h <= 355)) and $l > 0.6 { + @return $h + 20 * ($l - 0.6); + } + + @return $h; +} + +/// The fixed part of a solve: what the shades of this family are being built against. +/// @param {Number} $h - The family's hue, in degrees. +/// @param {List} $tent - The hue's chroma tent, from `gamut.tent()`. +/// @param {Number} $anchor-y - Luminance of whatever the contrast is measured against. +/// @param {Boolean} $lighter - Whether the shades sit lighter than the anchor. +/// @returns {Map} A context for `_solve()`. +@function _context($h, $tent, $anchor-y, $lighter) { + @return (h: $h, tent: $tent, anchor-y: $anchor-y, lighter: $lighter); +} + +/// Finds the shade that hits a contrast target. Chroma adds light, so the lightness cannot be +/// computed directly; contrast is monotonic in lightness, so this bisects for it. +/// @param {Map} $ctx - The family context, from `_context()`. +/// @param {Number} $target - The WCAG contrast to reach. +/// @param {Map} $chroma - How to chroma the shade, plus the ceiling `softness` to use. +/// Either `(level:, at:, softness:)` to take that share of the ceiling with the envelope +/// applied at position `at`, or `(fixed:, softness:)` to hold chroma flat. +/// @returns {Map} The solved `color` and the contrast it actually `reached`. +@function _solve($ctx, $target, $chroma) { + $h: map.get($ctx, 'h'); + $tent: map.get($ctx, 'tent'); + $anchor-y: map.get($ctx, 'anchor-y'); + $lighter: map.get($ctx, 'lighter'); + $fixed: map.get($chroma, 'fixed'); + $softness: map.get($chroma, 'softness'); + $lo: 0.02; + $hi: 0.995; + $out: null; + $got: 1; + + @for $i from 0 to 20 { + $l: math.div($lo + $hi, 2); + $ceiling: gamut.ceiling($l, $tent, $softness); + $c: if( + $fixed != null, + math.min($fixed, $ceiling), + map.get($chroma, 'level') * _envelope(map.get($chroma, 'at')) * $ceiling + ); + $out: color.to-gamut(gamut.make($l, $c, _hue-at($h, $l)), srgb, local-minde); + $y: luminance($out); + $got: if($lighter, math.div($y + 0.05, $anchor-y + 0.05), math.div($anchor-y + 0.05, $y + 0.05)); + + @if math.abs($got - $target) < 0.005 { + @return (color: $out, reached: $got); + } + + @if if($lighter, $got < $target, $got > $target) { + $lo: $l; + } @else { + $hi: $l; + } + } + + @return (color: $out, reached: $got); +} + +/// Solves one shade and reports a target the hue cannot reach, rather than emitting the +/// near miss silently. +/// @param {String} $name - The family name, for the warning. +/// @param {String} $variant - The shade being built, for the warning. +/// @param {Map} $ctx - The family context, from `_context()`. +/// @param {Number} $target - The WCAG contrast to reach. +/// @param {Map} $chroma - The chroma spec, as `_solve()` takes it. +/// @returns {Color} The solved color. +@function _shade-at($name, $variant, $ctx, $target, $chroma) { + $solved: _solve($ctx, $target, $chroma); + $reached: map.get($solved, 'reached'); + + @if math.abs($reached - $target) > $target * $_target-tolerance { + @warn 'Shade "#{$name}-#{$variant}" wanted #{$target}:1 but its hue only reaches #{$reached}:1.'; + } + + @return map.get($solved, 'color'); +} + +/// How much of the chroma available at its own lightness the seed actually uses. +/// @param {Map} $read - The seed's OKLCH components, from `gamut.read()`. +/// @param {List} $tent - The hue's chroma tent, from `gamut.tent()`. +/// @param {Boolean} $chromatic - Whether the seed carries a usable hue. +/// @param {Boolean} $muted - Whether to cap the result at `$_neutral-tint-cap`. +/// @returns {Number} A saturation level, 0 to 1. +@function _level($read, $tent, $chromatic, $muted) { + @if not $chromatic { + @return 0; + } + + $s: math.div(map.get($read, 'c'), gamut.ceiling(map.get($read, 'l'), $tent, $_ceiling-softness)); + + @return if($muted, math.min($s, $_neutral-tint-cap), math.clamp(config.$saturation-floor, $s, 1)); +} + +/// Packs one finished color into the pair `shades()` expects. +/// @param {Color} $color - The solved color. +/// @returns {Map} The color as both `raw` and `css`, in sRGB with integer channels. +@function _entry($color) { + $srgb: gamut.to-srgb($color); + + @return (raw: $srgb, css: $srgb); +} + +/// Steps one surface layer away from its background. +/// @param {Map} $ctx - The surface context, from `_context()`, with `lighter` set for this role. +/// @param {Color} $anchor - The background the layer sits on. +/// @param {Number} $c - Chroma to hold the layer at. +/// @param {Number} $target - The WCAG contrast the layer should reach from the background. +/// @returns {Color} The layer, or the anchor unchanged when there is no room in that direction. +@function _layer($ctx, $anchor, $c, $target) { + $y: map.get($ctx, 'anchor-y'); + $lighter: map.get($ctx, 'lighter'); + $room: if($lighter, math.div(1.05, $y + 0.05), math.div($y + 0.05, 0.05)); + + @if $room < $target { + @return $anchor; + } + + $solved: _solve( + $ctx, + $target, + ( + fixed: $c, + softness: $_ceiling-softness, + ) + ); + + @return if(map.get($solved, 'reached') >= $target * 0.9, map.get($solved, 'color'), $anchor); +} + +/// Builds a surface family: a background plus the layers that sit on it. Layers hold the +/// background's chroma flat, since scaling it reads as a color shift rather than an elevation. +/// @param {Color} $seed - The background color. +/// @param {List} $variants - The roles to generate, in emit order. +/// @param {Number} $sh - The background's hue, in degrees. +/// @param {List} $tent - The hue's chroma tent, from `gamut.tent()`. +/// @param {Number} $c - The background's chroma. +/// @returns {Map} Each role mapped to `(raw:, css:)`. +/// @throws A surface family is built from roles, not numeric shades. +@function _surface($seed, $variants, $sh, $tent, $c) { + $by: luminance($seed); + $result: ( + 'base': _entry($seed), + 'seed': ( + raw: $seed, + css: $seed, + ), + ); + + @each $role, $spec in config.$surface-roles { + $ctx: _context($sh, $tent, $by, map.get($spec, toward) == 'white'); + $result: map.set($result, $role, _entry(_layer($ctx, $seed, $c, map.get($spec, step)))); + } + + $translucent: rgba(if($by > 0.5, #000, #fff), config.$surface-container-alpha); + $result: map.set( + $result, + 'container', + ( + raw: $translucent, + css: $translucent, + ) + ); + $ordered: (); + + @each $variant in $variants { + $v: '#{$variant}'; + + @if map.has-key($result, $v) { + $ordered: map.set($ordered, $v, map.get($result, $v)); + } + } + + @if not map.has-key($ordered, 'base') { + @error 'A surface family is built from roles, not numeric shades. Pass $ISurfaceRoles ' + + '(or a list containing "base") as its variants, got: #{$variants}.'; + } + + @return $ordered; +} + +/// Generates every shade of one color family in a single pass. +/// The gamut cusp is found once and shared by all shades; +/// @param {String} $name - The family name. +/// @param {Color} $seed - The seed color. +/// @param {List} $variants - The shade variants to generate. +/// @param {Color} $surface [null] - The surface color, for neutral families. +/// @param {String | Map | List} $scale [null] - A scale name, spec, or bare curve. +/// @returns {Map} Each variant mapped to `(raw:, css:)`. +/// @throws A "" family needs at least two numeric shades to span a scale. +@function family($name, $seed, $variants, $surface: null, $scale: null) { + $key: '#{$name}'; + $is-surface: $key == 'surface'; + $is-neutral: $key == 'gray'; + + @if $is-neutral and not $seed { + $seed: if(luminance($surface) > 0.5, #000, #fff); + } + + $read: gamut.read($seed); + $sh: map.get($read, 'h'); + + // Backgrounds are picked deliberately, and a warm vs cool dark sits below the + // threshold that rounds a near-gray seed to neutral. + $chromatic: if($is-surface, map.get($read, 'c') > 0, map.get($read, 'c') >= $_neutral-threshold); + $tent: if($chromatic, gamut.tent($sh), (0, 0)); + $s: _level($read, $tent, $chromatic, $is-neutral); + + @if $is-surface { + @return _surface($seed, $variants, $sh, $tent, map.get($read, 'c')); + } + + $anchor-y: if($is-neutral, luminance($surface), 1); + $ctx: _context($sh, $tent, $anchor-y, $is-neutral and $anchor-y <= 0.5); + $sc: _scale($key, $scale); + $result: (); + $i: 0; + $n: 0; + + @each $variant in $variants { + @if not map.has-key($_accent-targets, '#{$variant}') and '#{$variant}' != 'seed' { + $n: $n + 1; + } + } + + // The scale spans a range across `$n` shades, so a lone shade has nowhere to sit. + @if $n < 2 { + @error 'A "#{$key}" family needs at least two numeric shades to span a scale, got #{$n}.'; + } + + @each $variant in $variants { + $v: '#{$variant}'; + + @if $v == 'seed' { + $result: map.set( + $result, + $v, + ( + raw: $seed, + css: $seed, + ) + ); + } @else if map.has-key($_accent-targets, $v) { + $result: map.set( + $result, + $v, + _entry( + _shade-at( + $key, + $v, + $ctx, + map.get($_accent-targets, $v), + ( + level: if($chromatic, 1, 0), + at: 0.5, + softness: $_accent-ceiling-softness, + ) + ) + ) + ); + } @else { + $result: map.set( + $result, + $v, + _entry( + _shade-at( + $key, + $v, + $ctx, + _target($sc, $i, $n), + ( + level: $s, + at: math.div($i, $n - 1), + softness: $_ceiling-softness, + ) + ) + ) + ); + $i: $i + 1; + } + } + + @return $result; +} + +/// Reports which shade sits closest to the seed in lightness. Chroma and hue are not +/// compared: each shade carries the chroma its hue can hold at that lightness, so the +/// match is rarely the seed itself. `--ig-{name}-seed` always holds the original. +/// @access public +/// @group Palettes +/// @param {Map} $palette - A generated palette. +/// @param {String} $name ['primary'] - The color family to inspect. +/// @returns {String} The variant whose lightness is closest to the seed's, or null when +/// the family has no seed or no numeric shades. +/// @example scss +/// $where: seed-lands-on($my-palette, 'primary'); // '300' +@function seed-lands-on($palette, $name: 'primary') { + $family: map.get($palette, $name); + $seed: if($family, map.get($family, 'seed'), null); + + @if not $seed { + @return null; + } + + $target: map.get(gamut.read($seed), 'l'); + $best: null; + $best-d: null; + + // Only the numeric shades are candidates: `seed` is the input itself, the accents ride + // their own contrast track, and `-raw`/`-contrast` are other views of these same shades. + @each $variant in types.$INumericShades { + $raw: map.get($family, '#{$variant}-raw'); + + @if $raw { + $d: math.abs(map.get(gamut.read($raw), 'l') - $target); + + // Only null is falsy here — a distance of 0 is a real, and winning, value. + @if not $best-d or $d < $best-d { + $best-d: $d; + $best: $variant; + } + } + } + + @return $best; +} diff --git a/packages/theming/sass/color/_index.scss b/packages/theming/sass/color/_index.scss index 2dc0f8f8..f1eea95d 100644 --- a/packages/theming/sass/color/_index.scss +++ b/packages/theming/sass/color/_index.scss @@ -1,4 +1,6 @@ +@forward 'config'; @forward 'functions'; +@forward 'generator' show seed-lands-on; @forward 'mixins'; @forward 'types'; @forward 'multipliers'; diff --git a/packages/theming/sass/color/_types.scss b/packages/theming/sass/color/_types.scss index 14c672da..eef3729d 100644 --- a/packages/theming/sass/color/_types.scss +++ b/packages/theming/sass/color/_types.scss @@ -1,32 +1,65 @@ @use 'sass:map'; @use 'sass:list'; +@use 'config'; //// /// @package theming /// @group Palettes //// +/// The numeric shades every chromatic family generates. +/// @type List +/// @access public +$INumericShades: ('50', '100', '200', '300', '400', '500', '600', '700', '800', '900'); + +/// The accent shades a chromatic family adds on top of its numeric ones. +/// @type List +/// @access public +$IAccentShades: ('A100', 'A200', 'A400', 'A700'); + /// A list consisting of all generated gray shades /// @type Map /// @access public -$IGrayShades: ('50', '100', '200', '300', '400', '500', '600', '700', '800', '900', 'seed'); +/// @requires $INumericShades +$IGrayShades: list.join($INumericShades, ('seed')); /// A list consisting of all generated shades for palette colors /// @type Map /// @access public /// @requires $IGrayShades -$IColorShades: list.join($IGrayShades, ('A100', 'A200', 'A400', 'A700')); +/// @requires $IAccentShades +$IColorShades: list.join($IGrayShades, $IAccentShades); + +/// A background plus the layers that sit on it. A role with no room in its direction +/// resolves onto the background. Derived from `$surface-roles`, so configuring that +/// map is enough to add or drop a layer. +/// @type List +/// @access public +/// @requires $surface-roles +$ISurfaceRoles: list.join(('base'), list.join(map.keys(config.$surface-roles), ('container', 'seed'))); + +/// Every key a surface family can expose. Both generators emit the roles; `legacy` +/// additionally keeps the numeric shades it has always carried. `$IPaletteColors` +/// deliberately lists only the roles — those are what *every* palette has — so this is +/// for consumers that must cover a legacy palette's extras too. +/// @type List +/// @access public +/// @requires $ISurfaceRoles +/// @requires $INumericShades +/// @requires $IAccentShades +$ISurfaceShades: list.join($ISurfaceRoles, list.join($INumericShades, $IAccentShades)); /// All palette colors mapped with corresponding color shades /// @type Map /// @access public /// @requires $IColorShades /// @requires $IGrayShades +/// @requires $ISurfaceRoles $IPaletteColors: ( 'primary': $IColorShades, 'secondary': $IColorShades, 'gray': $IGrayShades, - 'surface': $IColorShades, + 'surface': $ISurfaceRoles, 'info': $IColorShades, 'success': $IColorShades, 'warn': $IColorShades, diff --git a/packages/theming/sass/color/presets/dark/_bootstrap.scss b/packages/theming/sass/color/presets/dark/_bootstrap.scss index 9818c8fb..23bb24b4 100644 --- a/packages/theming/sass/color/presets/dark/_bootstrap.scss +++ b/packages/theming/sass/color/presets/dark/_bootstrap.scss @@ -27,4 +27,5 @@ $palette: palette( $warn: #ffc107, $error: #dc3545, $variant: 'bootstrap', + $generator: 'legacy', ); diff --git a/packages/theming/sass/color/presets/dark/_extra.scss b/packages/theming/sass/color/presets/dark/_extra.scss index 9438f508..6b41018a 100644 --- a/packages/theming/sass/color/presets/dark/_extra.scss +++ b/packages/theming/sass/color/presets/dark/_extra.scss @@ -25,6 +25,7 @@ $green-palette: palette( $success: #4eb862, $warn: #fbb13c, $error: #ff134a, + $generator: 'legacy', ); /// Generates the dark purple palette. @@ -46,4 +47,5 @@ $purple-palette: palette( $success: #4eb862, $warn: #fbb13c, $error: #ff134a, + $generator: 'legacy', ); diff --git a/packages/theming/sass/color/presets/dark/_fluent.scss b/packages/theming/sass/color/presets/dark/_fluent.scss index 38eb57e1..48539302 100644 --- a/packages/theming/sass/color/presets/dark/_fluent.scss +++ b/packages/theming/sass/color/presets/dark/_fluent.scss @@ -26,6 +26,7 @@ $palette: palette( $warn: #797673, $error: #a80000, $variant: 'fluent', + $generator: 'legacy', ); /// Generates the dark fluent word palette. @@ -48,6 +49,7 @@ $word-palette: palette( $warn: #797673, $error: #a80000, $variant: 'fluent', + $generator: 'legacy', ); /// Generates the dark fluent excel palette. @@ -70,4 +72,5 @@ $excel-palette: palette( $warn: #797673, $error: #a80000, $variant: 'fluent', + $generator: 'legacy', ); diff --git a/packages/theming/sass/color/presets/dark/_indigo.scss b/packages/theming/sass/color/presets/dark/_indigo.scss index 714a12bb..7dda5d14 100644 --- a/packages/theming/sass/color/presets/dark/_indigo.scss +++ b/packages/theming/sass/color/presets/dark/_indigo.scss @@ -8,7 +8,7 @@ //// $color-shades: types.$IColorShades; -$surface-shades: shades('surface', #1e1f24, $color-shades); +$surface-shades: shades('surface', #1e1f24, $color-shades, $generator: 'legacy'); /// Generates the dark indigo palette. /// @type Map diff --git a/packages/theming/sass/color/presets/dark/_material.scss b/packages/theming/sass/color/presets/dark/_material.scss index c561e327..abc008c9 100644 --- a/packages/theming/sass/color/presets/dark/_material.scss +++ b/packages/theming/sass/color/presets/dark/_material.scss @@ -26,4 +26,5 @@ $palette: palette( $warn: #faa419, $error: #ff134a, $variant: 'material', + $generator: 'legacy', ); diff --git a/packages/theming/sass/color/presets/light/_bootstrap.scss b/packages/theming/sass/color/presets/light/_bootstrap.scss index 59f6c94d..1a145959 100644 --- a/packages/theming/sass/color/presets/light/_bootstrap.scss +++ b/packages/theming/sass/color/presets/light/_bootstrap.scss @@ -27,4 +27,5 @@ $palette: palette( $warn: #ffc107, $error: #dc3545, $variant: 'bootstrap', + $generator: 'legacy', ); diff --git a/packages/theming/sass/color/presets/light/_extra.scss b/packages/theming/sass/color/presets/light/_extra.scss index 12051a5a..292a4ff6 100644 --- a/packages/theming/sass/color/presets/light/_extra.scss +++ b/packages/theming/sass/color/presets/light/_extra.scss @@ -25,6 +25,7 @@ $green-palette: palette( $success: #4eb862, $warn: #fbb13c, $error: #ff134a, + $generator: 'legacy', ); /// Generates the light purple palette. @@ -46,4 +47,5 @@ $purple-palette: palette( $success: #4eb862, $warn: #fbb13c, $error: #ff134a, + $generator: 'legacy', ); diff --git a/packages/theming/sass/color/presets/light/_fluent.scss b/packages/theming/sass/color/presets/light/_fluent.scss index bf3305b5..36423736 100644 --- a/packages/theming/sass/color/presets/light/_fluent.scss +++ b/packages/theming/sass/color/presets/light/_fluent.scss @@ -26,6 +26,7 @@ $palette: palette( $warn: #797673, $error: #a80000, $variant: 'fluent', + $generator: 'legacy', ); /// Generates the light fluent word palette. @@ -48,6 +49,7 @@ $word-palette: palette( $warn: #797673, $error: #a80000, $variant: 'fluent', + $generator: 'legacy', ); /// Generates the dark green palette. @@ -70,4 +72,5 @@ $excel-palette: palette( $warn: #797673, $error: #a80000, $variant: 'fluent', + $generator: 'legacy', ); diff --git a/packages/theming/sass/color/presets/light/_indigo.scss b/packages/theming/sass/color/presets/light/_indigo.scss index 6ea4fecf..426af95c 100644 --- a/packages/theming/sass/color/presets/light/_indigo.scss +++ b/packages/theming/sass/color/presets/light/_indigo.scss @@ -8,7 +8,7 @@ //// $color-shades: types.$IColorShades; -$surface-shades: shades('surface', #f8f8fa, $color-shades); +$surface-shades: shades('surface', #f8f8fa, $color-shades, $generator: 'legacy'); /// Generates the light indigo palette. /// @type Map diff --git a/packages/theming/sass/color/presets/light/_material.scss b/packages/theming/sass/color/presets/light/_material.scss index 468fdfc7..4144e21d 100644 --- a/packages/theming/sass/color/presets/light/_material.scss +++ b/packages/theming/sass/color/presets/light/_material.scss @@ -26,4 +26,5 @@ $palette: palette( $warn: #faa419, $error: #ff134a, $variant: 'material', + $generator: 'legacy', ); diff --git a/packages/theming/sass/json/generators.scss b/packages/theming/sass/json/generators.scss index d24af732..6f2c6d65 100644 --- a/packages/theming/sass/json/generators.scss +++ b/packages/theming/sass/json/generators.scss @@ -22,6 +22,32 @@ palette { } } +/* +* @outputDir - /colors/meta +*/ +scales { + @each $name, $spec in $scales { + $range: map.get($spec, range); + $curve: map.get($spec, curve); + + > #{$name} { + --range: [#{list.nth($range, 1)}, #{list.nth($range, 2)}]; + --curve: [ + #{if( + $curve, + '#{list.nth($curve, 1)}, #{list.nth($curve, 2)}, #{list.nth($curve, 3)}, #{list.nth($curve, 4)}', + '' + )}]; + } + } +} + +family-scales { + @each $family, $name in $family-scales { + --#{$family}: #{$name}; + } +} + multipliers { @each $variant in ('color', 'grayscale') { $multipliers: meta.module-variables(multipliers); diff --git a/packages/theming/sass/tailwind/_mixins.scss b/packages/theming/sass/tailwind/_mixins.scss index 000794e0..8aa16910 100644 --- a/packages/theming/sass/tailwind/_mixins.scss +++ b/packages/theming/sass/tailwind/_mixins.scss @@ -7,7 +7,11 @@ $theme-schemas: () !default; @mixin generate-color-vars { - @each $colorName, $shades in $IPaletteColors { + // A `legacy` surface carries numeric shades on top of the roles every palette has, + // and this bridge is static, so it covers the union rather than picking a generator. + $families: map.set($IPaletteColors, 'surface', $ISurfaceShades); + + @each $colorName, $shades in $families { @each $shade in $shades { --color-#{$colorName}-#{$shade}: var(--ig-#{$colorName}-#{$shade}); --color-#{$colorName}-#{$shade}-contrast: var(--ig-#{$colorName}-#{$shade}-contrast); diff --git a/packages/theming/sass/themes/schemas/charts/light/_data-chart.scss b/packages/theming/sass/themes/schemas/charts/light/_data-chart.scss index c9e627e4..89ab16f7 100644 --- a/packages/theming/sass/themes/schemas/charts/light/_data-chart.scss +++ b/packages/theming/sass/themes/schemas/charts/light/_data-chart.scss @@ -28,7 +28,7 @@ $light-data-chart: ( /// @prop {List} marker-brushes [series] - Defines the palette from which automatically assigned marker brushes are selected. /// @prop {List} outlines [series] - Defines the palette from which automatically assigned series outline colors are selected. /// @prop {List} marker-outlines [series] - Defines the palette from which automatically assigned marker outlines are selected. -/// @prop {Color} plot-area-background [color: ('surface', 500)] - Sets the brush used as the background for the current Chart object's plot area. +/// @prop {Color} plot-area-background [color: ('surface', 'base')] - Sets the brush used as the background for the current Chart object's plot area. /// @prop {String} title-horizontal-alignment [null] - The horizontal alignment to use for the title. /// @prop {String} subtitle-horizontal-alignment [null] - The horizontal alignment to use for the subtitle. /// @prop {List} axis-label-margin [null] - Sets the margin (top, right, bottom, left) of labels on the both axes. @@ -74,7 +74,7 @@ $material-data-chart: extend( plot-area-background: ( color: ( 'surface', - 500, + 'base', ), ), title-horizontal-alignment: null, diff --git a/packages/theming/sass/themes/schemas/charts/light/_gauge.scss b/packages/theming/sass/themes/schemas/charts/light/_gauge.scss index 73048e8a..68a8ef02 100644 --- a/packages/theming/sass/themes/schemas/charts/light/_gauge.scss +++ b/packages/theming/sass/themes/schemas/charts/light/_gauge.scss @@ -35,13 +35,13 @@ $light-base-gauge: ( font-brush: ( contrast-color: ( 'surface', - 500, + 'base', ), ), minor-tick-brush: ( contrast-color: ( 'surface', - 500, + 'base', ), ), minor-tick-stroke-thickness: null, @@ -60,7 +60,7 @@ $light-base-gauge: ( tick-brush: ( contrast-color: ( 'surface', - 500, + 'base', ), ), tick-stroke-thickness: null, diff --git a/packages/theming/sass/themes/schemas/charts/light/_graph.scss b/packages/theming/sass/themes/schemas/charts/light/_graph.scss index d9186f09..24ac9977 100644 --- a/packages/theming/sass/themes/schemas/charts/light/_graph.scss +++ b/packages/theming/sass/themes/schemas/charts/light/_graph.scss @@ -53,13 +53,13 @@ $material-graph: extend( font-brush: ( contrast-color: ( 'surface', - 500, + 'base', ), ), minor-tick-brush: ( contrast-color: ( 'surface', - 500, + 'base', ), ), minor-tick-thickness: null, @@ -84,7 +84,7 @@ $material-graph: extend( tick-brush: ( contrast-color: ( 'surface', - 500, + 'base', ), ), tick-stroke-thickness: null, diff --git a/packages/theming/sass/themes/schemas/components/dark/_grid.scss b/packages/theming/sass/themes/schemas/components/dark/_grid.scss index 7cd2902c..80d2daf1 100644 --- a/packages/theming/sass/themes/schemas/components/dark/_grid.scss +++ b/packages/theming/sass/themes/schemas/components/dark/_grid.scss @@ -494,8 +494,8 @@ $dark-bootstrap-grid: extend( /// @prop {Map} expand-icon-hover-color [contrast-color: ('gray', 50)] - The grid row expand icon hover color. /// @prop {Map} active-expand-icon-color [contrast-color: ('gray', 50, .8)] - The drop area background on drop color. /// @prop {Map} drop-area-on-drop-background [color: ('gray', 100)] - The drop area background on drop color. -/// @prop {Map} header-background [color: ('surface', 500)] - The table header background color. -/// @prop {Map} content-background [color: ('surface', 500)] - The table body background color. +/// @prop {Map} header-background [color: ('surface', 'base')] - The table header background color. +/// @prop {Map} content-background [color: ('surface', 'base')] - The table body background color. /// @prop {Map} cell-editing-background [color: ('gray' 100)] - The background for the cell in editing mode. /// @prop {Map} cell-editing-foreground [contrast-color: ('gray', 50)] - The cell text color in edit mode /// @prop {Map} cell-edited-value-color [contrast-color: ('gray', 50)] - The color of cell edited value. @@ -518,7 +518,7 @@ $dark-indigo-grid: extend( content-background: ( color: ( 'surface', - 500, + 'base', ), ), @@ -763,7 +763,7 @@ $dark-indigo-grid: extend( header-background: ( color: ( 'surface', - 500, + 'base', ), ), diff --git a/packages/theming/sass/themes/schemas/components/light/_chat.scss b/packages/theming/sass/themes/schemas/components/light/_chat.scss index 00b1fb53..34bddf5b 100644 --- a/packages/theming/sass/themes/schemas/components/light/_chat.scss +++ b/packages/theming/sass/themes/schemas/components/light/_chat.scss @@ -9,9 +9,9 @@ /// Generates a light chat component schema. /// @type Map -/// @prop {Map} background [color: ('surface', 500)] - The background color of the chat component. -/// @prop {Map} header-background [color: ('surface', 500)] - The background color of the chat header. -/// @prop {Map} header-color [contrast-color: ('surface', 500)] - The text color of the chat header. +/// @prop {Map} background [color: ('surface', 'base')] - The background color of the chat component. +/// @prop {Map} header-background [color: ('surface', 'base')] - The background color of the chat header. +/// @prop {Map} header-color [contrast-color: ('surface', 'base')] - The text color of the chat header. /// @prop {Color} header-border [transparent] - The color used for the chat header border. /// @prop {Map} sent-message-background [color: ('gray', 200)] - The background color for sent messages. /// @prop {Map} sent-message-color [color: ('gray', 800)] - The text color for sent messages. @@ -32,19 +32,19 @@ $light-chat: ( background: ( color: ( 'surface', - 500, + 'base', ), ), header-background: ( color: ( 'surface', - 500, + 'base', ), ), header-color: ( contrast-color: ( 'surface', - 500, + 'base', ), ), header-border: ( diff --git a/packages/theming/sass/themes/schemas/components/light/_grid-excel-filtering.scss b/packages/theming/sass/themes/schemas/components/light/_grid-excel-filtering.scss index 225e38f7..80a29205 100644 --- a/packages/theming/sass/themes/schemas/components/light/_grid-excel-filtering.scss +++ b/packages/theming/sass/themes/schemas/components/light/_grid-excel-filtering.scss @@ -158,7 +158,7 @@ $bootstrap-excel-filtering: extend( /// Generates an indigo excel filtering schema. /// @prop {Map} background [contrast-color: ('gray', 900)] - The background contrast color for the excel filtering area. -/// @prop {Map} secondary-background [color: ('surface', 500)] - The secondary background color for the excel filtering area. +/// @prop {Map} secondary-background [color: ('surface', 'base')] - The secondary background color for the excel filtering area. /// @type Map /// @requires $light-excel-filtering $indigo-excel-filtering: extend( @@ -174,7 +174,7 @@ $indigo-excel-filtering: extend( secondary-background: ( color: ( 'surface', - 500, + 'base', ), ), diff --git a/packages/theming/sass/themes/schemas/components/light/_grid-toolbar.scss b/packages/theming/sass/themes/schemas/components/light/_grid-toolbar.scss index fc99c958..e22bf140 100644 --- a/packages/theming/sass/themes/schemas/components/light/_grid-toolbar.scss +++ b/packages/theming/sass/themes/schemas/components/light/_grid-toolbar.scss @@ -147,7 +147,7 @@ $bootstrap-grid-toolbar: extend( /// Generates an indigo grid-toolbar schema. /// @type Map -/// @prop {Color} background [color: ('surface', 500)] - The toolbar background color. +/// @prop {Color} background [color: ('surface', 'base')] - The toolbar background color. /// @prop {Map} border-color [color: ('gray', 400)] - The toolbar border-bottom color. /// @requires $light-grid-toolbar $indigo-grid-toolbar: extend( @@ -156,7 +156,7 @@ $indigo-grid-toolbar: extend( background: ( color: ( 'surface', - 500, + 'base', ), ), diff --git a/packages/theming/sass/themes/schemas/components/light/_grid.scss b/packages/theming/sass/themes/schemas/components/light/_grid.scss index 47d97d12..bcf275b1 100644 --- a/packages/theming/sass/themes/schemas/components/light/_grid.scss +++ b/packages/theming/sass/themes/schemas/components/light/_grid.scss @@ -79,7 +79,7 @@ /// @prop {Map} filtering-header-text-color [color: ('gray', 800)] - The text color of the filtered column header. /// @prop {Map} filtering-row-background [color: 'gray', 50)] - The background color of the filtering row. /// @prop {Map} filtering-row-text-color [color: ('gray', 800)] - The text color of the filtering row. -/// @prop {Map} filtering-dialog-background [color: ('surface', 500)] - The background color of the advanced filtering dialog. +/// @prop {Map} filtering-dialog-background [color: ('surface', 'base')] - The background color of the advanced filtering dialog. /// @prop {Map} excel-filtering-header-foreground [color: ('gray', 700)] - The text color of the header in the excel style filtering. /// @prop {Map} excel-filtering-subheader-foreground [color: ('gray', 700)] - The text color of the sorting and moving headers in the excel style filtering. /// @prop {Map} excel-filtering-actions-foreground [color: ('gray', 700)] - The text color of the excel style filtering options. @@ -605,7 +605,7 @@ $light-grid: extend( filtering-dialog-background: ( color: ( 'surface', - 500, + 'base', ), ), @@ -1243,7 +1243,7 @@ $bootstrap-grid: extend( /// @prop {Map} header-selected-text-color [color: 'gray', 900] - The table header text color when selected (ex. column selection). /// @prop {Map} header-border-color [color: ('gray', 400)] - The color used for header borders. /// @prop {Map} action-border-color [color: ('gray', 400)] - The color used for action borders. -/// @prop {Map} filtering-row-background [color: ('surface', 500)] - The background color of the filtering row. +/// @prop {Map} filtering-row-background [color: ('surface', 'base')] - The background color of the filtering row. /// @prop {Map} filtering-dialog-background [contrast-color: ('gray', 900)] - The background color of the advanced filtering dialog. /// @prop {Map} edited-row-indicator [color: ('primary', 400)] - The indicator's color of edited row. /// @prop {Map} cell-selected-background [color: ('primary', 50)] - The selected cell background color. @@ -1267,7 +1267,7 @@ $bootstrap-grid: extend( /// @prop {Map} edit-mode-color [color: ('primary', 400)] - The text color in edit mode. /// @prop {Map} drop-area-text-color [color: ('gray', 600)] - The drop area text color. /// @prop {Map} drop-area-icon-color [color: ('gray', 600)] - The drop area icon color. -/// @prop {Map} group-row-background [color: ('surface', 500)] - The grid group row background color. +/// @prop {Map} group-row-background [color: ('surface', 'base')] - The grid group row background color. /// @prop {Map} group-row-selected-background [color: ('gray', 100)] - The drop area background on drop color. /// @prop {Map} group-count-background [color: ('primary', 400)] - The grid group row cont badge background color. /// @prop {Map} group-count-text-color [contrast-color: ('primary', 900)] - The grid group row cont badge text color. @@ -1277,7 +1277,7 @@ $bootstrap-grid: extend( /// @prop {Map} cell-active-border-color [color: ('primary', 400)] - The active(focused) cell border color. /// @prop {Map} cell-editing-background [color: ('gray' 200)] - The background for the cell in editing mode. /// @prop {Map} row-selected-hover-text-color [color: ('gray', 900)] - The selected row hover text color. -/// @prop {Map} grouparea-background [color: ('surface', 500)] - The grid group area background color. +/// @prop {Map} grouparea-background [color: ('surface', 'base')] - The grid group area background color. /// @prop {Map} grouparea-color [color: ('gray', 500)] - The grid group area color. /// @prop {Map} drop-area-background [color: ('gray', 200)] - The drop area background color. /// @prop {Map} row-hover-text-color [color: ('gray', 900)] - The hover row text color. @@ -1371,7 +1371,7 @@ $indigo-grid: extend( filtering-row-background: ( color: ( 'surface', - 500, + 'base', ), ), @@ -1553,7 +1553,7 @@ $indigo-grid: extend( group-row-background: ( color: ( 'surface', - 500, + 'base', ), ), @@ -1619,7 +1619,7 @@ $indigo-grid: extend( grouparea-background: ( color: ( 'surface', - 500, + 'base', ), ), diff --git a/packages/theming/sass/themes/schemas/components/light/_navbar.scss b/packages/theming/sass/themes/schemas/components/light/_navbar.scss index eedcce23..ca4b2560 100644 --- a/packages/theming/sass/themes/schemas/components/light/_navbar.scss +++ b/packages/theming/sass/themes/schemas/components/light/_navbar.scss @@ -159,7 +159,7 @@ $bootstrap-navbar: extend( /// Generates an indigo navbar schema. /// @type Map -/// @prop {Map} background [color: ('surface', 500)] - The navbar background color. +/// @prop {Map} background [color: ('surface', 'base')] - The navbar background color. /// @prop {Map} text-color [color: ('gray', 800)] - The navbar text color. /// @prop {Map} idle-icon-color [color: ('gray', 600)] - The navbar idle icon color. /// @prop {Map} hover-icon-color [color: ('gray', 700)] - The navbar hover icon color. diff --git a/packages/theming/sass/themes/schemas/components/light/_pagination.scss b/packages/theming/sass/themes/schemas/components/light/_pagination.scss index a2f3c67f..8f8b5955 100644 --- a/packages/theming/sass/themes/schemas/components/light/_pagination.scss +++ b/packages/theming/sass/themes/schemas/components/light/_pagination.scss @@ -105,7 +105,7 @@ $bootstrap-pagination: extend( /// Generates an indigo pagination schema. /// @type Map -/// @prop {Map} background [color: ('surface', 500)] - The background color of the paging panel. +/// @prop {Map} background [color: ('surface', 'base')] - The background color of the paging panel. /// @prop {Map} border-color [color: ('gray', 400)] - The border color of the paging panel. /// @requires $light-pagination $indigo-pagination: extend( @@ -114,7 +114,7 @@ $indigo-pagination: extend( background: ( color: ( 'surface', - 500, + 'base', ), ), diff --git a/packages/theming/sass/themes/schemas/components/light/_query-builder.scss b/packages/theming/sass/themes/schemas/components/light/_query-builder.scss index c431230b..bc5bc548 100644 --- a/packages/theming/sass/themes/schemas/components/light/_query-builder.scss +++ b/packages/theming/sass/themes/schemas/components/light/_query-builder.scss @@ -11,8 +11,8 @@ /// Generates a base light query builder schema. /// @type Map -/// @prop {Map} background [color: ('surface', 500)] - The background color of the query builder. -/// @prop {Map} header-background [color: ('surface', 500)] - The background color of the query builder header. +/// @prop {Map} background [color: ('surface', 'base')] - The background color of the query builder. +/// @prop {Map} header-background [color: ('surface', 'base')] - The background color of the query builder header. /// @prop {Map} header-foreground [color: ('gray', 900)] - The foreground color of the query builder header. /// @prop {Color} header-border [transparent] - The border color of the query builder header. /// @prop {Map} label-foreground [color: ('gray', 700)] - The color for query builder labels "from" & "select". @@ -30,14 +30,14 @@ $light-query-builder: extend( background: ( color: ( 'surface', - 500, + 'base', ), ), header-background: ( color: ( 'surface', - 500, + 'base', ), ), diff --git a/packages/theming/sass/themes/schemas/components/light/_slider.scss b/packages/theming/sass/themes/schemas/components/light/_slider.scss index 5cf7869f..f982693c 100644 --- a/packages/theming/sass/themes/schemas/components/light/_slider.scss +++ b/packages/theming/sass/themes/schemas/components/light/_slider.scss @@ -170,12 +170,12 @@ $material-slider: extend( /// @type Map /// @prop {Map} track-color [color: ('gray', 800)] - The color of the track. /// @prop {Map} track-hover-color [color: ('primary', 500)] - The color of the track on hover. -/// @prop {Map} thumb-color [color: ('surface', 500)] - The color of the thumb. +/// @prop {Map} thumb-color [color: ('surface', 'base')] - The color of the thumb. /// @prop {Map} thumb-border-color [color: ('gray', 700)] - The thumb border color. /// @prop {Map} thumb-border-hover-color [color: ('gray', 700)] - The thumb border color when hovered. /// @prop {Map} thumb-border-focus-color [color: ('gray', 700)] - The thumb border color when focused. /// @prop {Map} thumb-focus-color [color: ('primary', 700)] - The focus outline color of the thumb. -/// @prop {Map} disabled-thumb-color [color: ('surface', 500)] - The thumb color when its disabled. +/// @prop {Map} disabled-thumb-color [color: ('surface', 'base')] - The thumb color when its disabled. /// @prop {Map} label-background-color [color: ('primary', 500)] - The background color of the bubble label. /// @prop {Map} base-track-color [color: ('gray', 600)] - The base background color of the track. /// @prop {Map} base-track-hover-color [color: ('gray', 600)] - The base background color of the track on hover. @@ -200,7 +200,7 @@ $fluent-slider: extend( thumb-color: ( color: ( 'surface', - 500, + 'base', ), ), thumb-border-color: ( @@ -230,7 +230,7 @@ $fluent-slider: extend( disabled-thumb-color: ( color: ( 'surface', - 500, + 'base', ), ), base-track-color: ( @@ -277,7 +277,7 @@ $fluent-slider: extend( /// @prop {Map} thumb-color [color: ('primary', 500)] - The color of the thumb. /// @prop {Map} thumb-border-color [color: ('primary', 500)] - The thumb border color. /// @prop {Map} thumb-border-hover-color [color: ('primary', 500)] - The thumb border color when hovered. -/// @prop {Map} thumb-border-focus-color [color: ('surface', 500)] - The thumb border color when focused. +/// @prop {Map} thumb-border-focus-color [color: ('surface', 'base')] - The thumb border color when focused. /// @prop {Map} thumb-focus-color [color: ('primary', 200)] - The focus outline color of the thumb. /// @prop {Map} disabled-thumb-color [color: ('gray', 400)] - The thumb color when its disabled. /// @prop {Map} disabled-base-track-color [color: ('gray', 200)] - The base background color of the track when is disabled. @@ -326,7 +326,7 @@ $bootstrap-slider: extend( thumb-border-focus-color: ( color: ( 'surface', - 500, + 'base', ), ), thumb-focus-color: ( diff --git a/packages/theming/sass/themes/schemas/components/light/_tile-manager.scss b/packages/theming/sass/themes/schemas/components/light/_tile-manager.scss index af60f683..bdf30b23 100644 --- a/packages/theming/sass/themes/schemas/components/light/_tile-manager.scss +++ b/packages/theming/sass/themes/schemas/components/light/_tile-manager.scss @@ -11,10 +11,10 @@ /// Generates a light base tile manager schema. /// @type Map /// @prop {Map} background [color: ('gray', 100)] - The background color of the tile manager component. -/// @prop {Map} tile-background [color: ('surface', 500)] - The background color of the tile component inside the tile manager. +/// @prop {Map} tile-background [color: ('surface', 'base')] - The background color of the tile component inside the tile manager. /// @prop {Map} title-color [color: ('gray', 900)] - The title color of the tile component. -/// @prop {Map} header-background [color: ('surface', 500)] - The background color of the tile header component. -/// @prop {Map} content-background [color: ('surface', 500)] - The background color of the tile component content. +/// @prop {Map} header-background [color: ('surface', 'base')] - The background color of the tile header component. +/// @prop {Map} content-background [color: ('surface', 'base')] - The background color of the tile component content. /// @prop {Map} content-color [color: ('gray', 700)] - The text color of the tile component content. /// @prop {Color} border-color [transparent] - The border color of the tile component. /// @prop {Map} hover-border-color [color: ('gray', 400)] - The border color of the tile component on hover. @@ -38,7 +38,7 @@ $light-tile-manager: extend( tile-background: ( color: ( 'surface', - 500, + 'base', ), ), @@ -52,14 +52,14 @@ $light-tile-manager: extend( header-background: ( color: ( 'surface', - 500, + 'base', ), ), content-background: ( color: ( 'surface', - 500, + 'base', ), ), @@ -207,7 +207,7 @@ $bootstrap-tile-manager: extend( /// Generates an indigo tile manager schema. /// @type Map -/// @prop {Map} background [color: ('surface', 500)] - The background color of the tile manager component. +/// @prop {Map} background [color: ('surface', 'base')] - The background color of the tile manager component. /// @prop {Map} tile-background [contrast-color: ('gray', 900)] - The background color of the tile component inside the tile manager. /// @prop {Map} title-color [color: ('gray', 800)] - The title color of the tile component. /// @prop {Map} header-background [contrast-color: ('gray', 900)] - The background color of the tile header component. @@ -224,7 +224,7 @@ $indigo-tile-manager: extend( background: ( color: ( 'surface', - 500, + 'base', ), ), diff --git a/packages/theming/schemas/index.mjs b/packages/theming/schemas/index.mjs index eeb8ea55..ce596786 100644 --- a/packages/theming/schemas/index.mjs +++ b/packages/theming/schemas/index.mjs @@ -33,6 +33,21 @@ export const PaletteMultipliersSchema = z.object({ export const PaletteMetaSchema = z.record(z.string(), z.array(z.string())); +/** + * A shade scale: the WCAG contrast `range` the family spans from shade 50 to 900, and the + * cubic-bezier `curve` that places each shade in it. An empty `curve` is the straight line. + */ +export const ShadeScalesSchema = z.record( + z.string(), + z.object({ + range: z.array(z.string()).length(2), + curve: z.array(z.string()), + }), +); + +/** Which scale each family uses by default. Unlisted families use `even`. */ +export const FamilyScalesSchema = z.record(z.string(), z.string()); + export const PalettesSchema = z.record( z.string(), z.object({ @@ -139,6 +154,14 @@ export const EXPORT_MAP = { exportName: "PaletteMeta", schema: PaletteMetaSchema, }, + "colors/meta/scales": { + exportName: "ShadeScales", + schema: ShadeScalesSchema, + }, + "colors/meta/family-scales": { + exportName: "FamilyScales", + schema: FamilyScalesSchema, + }, "colors/presets/palettes": { exportName: "Palettes", schema: PalettesSchema }, "colors/charts/brushes": { exportName: "ChartBrushes", diff --git a/packages/theming/tests/_color.spec.scss b/packages/theming/tests/_color.spec.scss index 7d3d0b5d..94c0fb55 100644 --- a/packages/theming/tests/_color.spec.scss +++ b/packages/theming/tests/_color.spec.scss @@ -5,6 +5,7 @@ @use 'sass:math'; @use 'sass:meta'; @use 'sass:color'; +@use 'sass:string'; @use 'sass-true' as *; @use 'color' as *; @use 'styles/mocks'; @@ -39,6 +40,18 @@ $_palette: palette( $error: $_error, $variant: 'material', ); +// The original multiplier-based generator, still reachable as an escape hatch. +$_legacy-palette: palette( + $primary: $_primary, + $secondary: $_secondary, + $surface: $_surface, + $success: $_success, + $info: $_info, + $warn: $_warn, + $error: $_error, + $variant: 'material', + $generator: 'legacy', +); @include describe('Color') { @include describe('base') { @@ -145,14 +158,14 @@ $_palette: palette( } @include it('should return a shade of type color string w/ palette and color as only arguments') { - $value: color($_palette, secondary); + $value: color($_legacy-palette, secondary); @include assert-equal($value, $_secondary); } @include it('should return a shade of type string w/ all arguments passed') { - $value-500: color($_palette, secondary, 500); - $value-800: color($_palette, secondary, 800); + $value-500: color($_legacy-palette, secondary, 500); + $value-800: color($_legacy-palette, secondary, 800); @include assert-equal($value-500, $_secondary); @include assert-unequal($value-500, $value-800); @@ -203,10 +216,10 @@ $_palette: palette( } @include it('should retrieve colors from a palette regadless of type of key') { - @include assert-true(color($_palette, primary, 500)); - @include assert-equal(color($_palette, primary, 500), $_primary); - @include assert-true(color($_palette, 'primary', '500')); - @include assert-equal(color($_palette, 'primary', '500'), $_primary); + @include assert-true(color($_legacy-palette, primary, 500)); + @include assert-equal(color($_legacy-palette, primary, 500), $_primary); + @include assert-true(color($_legacy-palette, 'primary', '500')); + @include assert-equal(color($_legacy-palette, 'primary', '500'), $_primary); @include assert-true(contrast-color($_palette, primary, 500)); @include assert-equal(contrast-color($_palette, primary, 500), var(--ig-primary-500-contrast)); @include assert-true(contrast-color($_palette, 'primary', '500')); @@ -358,6 +371,352 @@ $_palette: palette( } } + @include describe('fitted generator') { + $_numbered: ('50', '100', '200', '300', '400', '500', '600', '700', '800', '900'); + + @include it('keeps any two shades 500 apart at WCAG AA or better') { + @each $seed in (#09f, #df1b74, #faa419, #4eb862, #ffe9b0, #141225, #0f0, #a99bb0) { + $p: palette( + $primary: $seed, + $secondary: $seed, + $surface: #fff, + ); + + @for $i from 1 through 5 { + $lo: color($p, primary, list.nth($_numbered, $i)); + $hi: color($p, primary, list.nth($_numbered, $i + 5)); + + @include assert-true(contrast($lo, $hi) >= 4.5); + } + } + } + + @include it('keeps every shade visibly distinct, whatever the seed') { + // The original generator collapses here: a near-white seed made shades + // 50 through 300 all resolve to #fff. + @each $seed in (#ffe9b0, #141225, #0f0, #a99bb0, #8a8a8a) { + $p: palette( + $primary: $seed, + $secondary: $seed, + $surface: #fff, + ); + $seen: (); + + @each $v in $_numbered { + $c: color($p, primary, $v); + + @include assert-true(list.index($seen, $c) == null); + + $seen: list.append($seen, $c); + } + } + } + + @include it('generates a neutral ramp for a seed with no usable hue') { + $p: palette( + $primary: #8a8a8a, + $secondary: #09f, + $surface: #fff, + ); + + @each $v in $_numbered { + $c: color($p, primary, $v); + + @include assert-equal(color.channel($c, 'saturation', $space: hsl), 0%); + } + } + + @include it('emits concrete colors, not runtime expressions') { + $p: palette( + $primary: #09f, + $secondary: #09f, + $surface: #fff, + ); + + @each $v in $_numbered { + @include assert-equal(meta.type-of(map.get($p, 'primary', $v)), 'color'); + } + } + + @include it('keeps the surface seed on base, which is also its default variant') { + @each $bg in (#fff, #fcfcfd, #1a1a24, #000) { + $p: palette( + $primary: #09f, + $secondary: #09f, + $surface: $bg, + ); + + @include assert-equal(color($p, surface, 'base'), $bg); + // surface has no numeric shades, so a bare reference lands on base + @include assert-equal(color($p, surface), $bg); + // every other family still defaults to 500 + @include assert-equal(color($p, primary), color($p, primary, 500)); + } + } + + @include it('moves surface layers away from the background, or collapses them onto it') { + // sunken goes toward black and raised/overlay toward white, so a page at + // either extreme resolves the roles it has no room for onto itself — + // elevation is carried by shadow there, the way light themes really work. + $white: palette( + $primary: #09f, + $secondary: #09f, + $surface: #fff, + ); + $black: palette( + $primary: #09f, + $secondary: #09f, + $surface: #000, + ); + $mid: palette( + $primary: #09f, + $secondary: #09f, + $surface: #1a1a24, + ); + + // white page: nothing lighter exists + @include assert-equal(color($white, surface, 'raised'), #fff); + @include assert-equal(color($white, surface, 'overlay'), #fff); + @include assert-true(contrast(color($white, surface, 'sunken'), #fff) > 1.04); + + // black page: nothing darker exists + @include assert-equal(color($black, surface, 'sunken'), #000); + @include assert-true(contrast(color($black, surface, 'raised'), #000) > 1.04); + @include assert-true(contrast(color($black, surface, 'overlay'), #000) > 1.1); + + // a page with room either side gets every role distinct, and overlay + // always sits further out than raised + @include assert-true( + contrast(color($mid, surface, 'overlay'), #1a1a24) > + contrast(color($mid, surface, 'raised'), #1a1a24) + ); + @include assert-unequal(color($mid, surface, 'sunken'), color($mid, surface, 'base')); + } + + @include it('keeps translucent containers translucent') { + $p: palette( + $primary: #09f, + $secondary: #09f, + $surface: #fff, + ); + $dark: palette( + $primary: #09f, + $secondary: #09f, + $surface: #1a1a24, + ); + + @include assert-equal(color($p, surface, 'container'), rgba(#000, 0.03)); + @include assert-equal(color($dark, surface, 'container'), rgba(#fff, 0.03)); + } + + @include it('carries a tinted background through its layers') { + // A cool dark and a warm dark are a deliberate choice, and the tint sits + // well below the threshold that rounds a near-gray seed to neutral. + $p: palette( + $primary: #09f, + $secondary: #09f, + $surface: #1a1a24, + ); + $raised: color($p, surface, 'raised'); + + @include assert-true( + color.channel($raised, 'blue', $space: rgb) > color.channel($raised, 'red', $space: rgb) + 5 + ); + } + + @include it('accepts a scale as a preset name, a curve, or a full spec') { + $carbon: palette( + $primary: #09f, + $secondary: #09f, + $surface: #fff, + $scales: ( + 'gray': 'carbon', + ), + ); + $curve: palette( + $primary: #09f, + $secondary: #09f, + $surface: #fff, + $scales: ( + 'gray': ( + 0.53, + 0, + 0.825, + 0.785, + ), + ), + ); + $spec: palette( + $primary: #09f, + $secondary: #09f, + $surface: #fff, + $scales: ( + 'gray': ( + range: ( + 1.1, + 12, + ), + curve: null, + ), + ), + ); + $everything: palette( + $primary: #09f, + $secondary: #09f, + $surface: #fff, + $scales: 'tailwind', + ); + $direct: shades('brand', #09f, $IColorShades, $scale: 'carbon'); + + // carbon reproduces IBM's published grayscale + @include assert-true(contrast(color($carbon, gray, 50), #f4f4f4) < 1.02); + @include assert-true(contrast(color($carbon, gray, 900), #161616) < 1.02); + + // each form produces a different, valid scale + @include assert-unequal(color($carbon, gray, 300), color($curve, gray, 300)); + @include assert-unequal(color($carbon, gray, 300), color($spec, gray, 300)); + + // a bare name applies to every family, not only the neutrals + @include assert-unequal(color($everything, primary, 300), color($carbon, primary, 300)); + @include assert-equal(meta.type-of(map.get($direct, '300-raw')), 'color'); + } + + @include it('ships the documented scale presets') { + @each $name in ('even', 'material', 'tailwind', 'carbon') { + @include assert-true(map.has-key($scales, $name)); + } + } + + @include it('follows the grayscale rhythm the component schemas expect') { + $p: palette( + $primary: #09f, + $secondary: #09f, + $surface: #fff, + ); + // The eased scale reproduces the original grayscale to within ~1 JND, + // so existing schemas keep their intended weight. + $expected: ( + '100': #f5f5f5, + '400': #bdbdbd, + '700': #616161, + '900': #212121, + ); + + @each $v, $want in $expected { + @include assert-true(contrast(color($p, gray, $v), $want) < 1.06); + } + } + + @include it('flips the grayscale for a dark surface but not the color ramps') { + $light: palette( + $primary: #09f, + $secondary: #09f, + $surface: #fff, + $gray: #333, + ); + $dark: palette( + $primary: #09f, + $secondary: #09f, + $surface: #1a1a24, + $gray: #333, + ); + + // gray-50 always sits nearest the background + @include assert-true(contrast(color($light, gray, 50), #fff) < 1.2); + @include assert-true(contrast(color($dark, gray, 50), #1a1a24) < 1.2); + + // the color ramp does not move between themes + @each $v in $_numbered { + @include assert-equal(color($light, primary, $v), color($dark, primary, $v)); + } + } + + @include it('reports which shade the seed lands nearest') { + $light: palette( + $primary: #ffe9b0, + $secondary: #09f, + $surface: #fff, + ); + $dark: palette( + $primary: #141225, + $secondary: #09f, + $surface: #fff, + ); + + @include assert-equal(seed-lands-on($light, 'primary'), '50'); + @include assert-equal(seed-lands-on($dark, 'primary'), '900'); + } + + @include it('falls back to the original multipliers on request') { + $p: palette( + $primary: #09f, + $secondary: #97c, + $surface: #fff, + $generator: 'legacy', + ); + + @include assert-equal(color($p, primary, 500), #09f); + @include assert-equal( + map.get($p, 'primary', '600'), + #{hsl(from var(--ig-primary-500) h calc(s * 1.26) calc(l * 0.89))} + ); + } + } + + @include describe('surface roles across generators') { + $_light-fitted: palette($primary: #09f, $secondary: #09f, $surface: #f8f8fa); + $_light-legacy: palette( + $primary: #09f, + $secondary: #09f, + $surface: #f8f8fa, + $generator: 'legacy', + ); + + @include it('gives a legacy surface the same role vocabulary a fitted one has') { + // Roles are contrast steps off the background rather than points on a + // multiplier ramp, so they mean the same thing under either generator. + // That is what lets a component schema say ('surface', 'base') outright. + @each $role in $ISurfaceRoles { + @include assert-true(map.has-key($_light-fitted, 'surface', $role)); + @include assert-true(map.has-key($_light-legacy, 'surface', $role)); + @include assert-true(map.has-key($_light-legacy, 'surface', '#{$role}-raw')); + } + } + + @include it('keeps the numeric shades a legacy surface has always carried') { + @each $shade in $IColorShades { + @include assert-true(map.has-key($_light-legacy, 'surface', $shade)); + } + + // ...and a fitted surface deliberately has none of them + @include assert-false(map.has-key($_light-fitted, 'surface', '500')); + } + + @include it('resolves a bare surface reference under either generator') { + @include assert-equal(color($_light-fitted, 'surface'), color($_light-fitted, 'surface', 'base')); + @include assert-equal(color($_light-legacy, 'surface'), color($_light-legacy, 'surface', 'base')); + @include assert-equal(color($_light-legacy, 'surface', 'base'), #f8f8fa); + } + + @include it('derives $ISurfaceRoles from the $surface-roles config') { + // The two used to be maintained separately, so a role added to the public + // config was generated and then silently filtered back out. + @each $role, $spec in $surface-roles { + @include assert-true(list.index($ISurfaceRoles, $role) != null); + } + + @include assert-equal(list.length($ISurfaceRoles), list.length(map.keys($surface-roles)) + 3); + } + + @include it('covers both surface vocabularies in $ISurfaceShades') { + // The Tailwind bridge is static, so it has to name every key either + // generator can emit. + @each $key in list.join($ISurfaceRoles, $IColorShades) { + @include assert-true(list.index($ISurfaceShades, $key) != null); + } + } + } + @include it('should convert a color to a list of HSL values') { @include assert-equal(to-hsl(black), (0deg, 0%, 0%)); } diff --git a/packages/theming/tests/styles/_mocks.scss b/packages/theming/tests/styles/_mocks.scss index 40c4074b..685602a0 100644 --- a/packages/theming/tests/styles/_mocks.scss +++ b/packages/theming/tests/styles/_mocks.scss @@ -222,35 +222,17 @@ $handmade-palette: ( 'seed-contrast': white, ), 'surface': ( - '50': #fef7e2, - '50-contrast': black, - '100': #fdeab7, - '100-contrast': black, - '200': #fbdd89, - '200-contrast': black, - '300': #fad15c, - '300-contrast': black, - '400': #f9c63f, - '400-contrast': black, - '500': #f7bd32, - '500-contrast': white, - '600': #f6b02d, - '600-contrast': white, - '700': #f49e2a, - '700-contrast': white, - '800': #f38e28, - '800-contrast': white, - '900': #f38e28, - '900-contrast': white, - 'A100': #fbdd89, - 'A100-contrast': black, - 'A200': #f9c63f, - 'A200-contrast': black, - 'A400': #f6b02d, - 'A400-contrast': white, - 'A700': #f38e28, - 'A700-contrast': white, + 'base': #f7bd32, + 'base-contrast': black, + 'sunken': #eeb732, + 'sunken-contrast': black, + 'raised': #f9c657, + 'raised-contrast': black, + 'overlay': #fdd073, + 'overlay-contrast': black, + 'container': rgb(0 0 0 / 0.03), + 'container-contrast': black, 'seed': #f7bd32, 'seed-contrast': white, - ) + ), ); diff --git a/vitest.config.ts b/vitest.config.ts index eb73860b..375de0cc 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ projects: [ 'packages/theming', 'packages/mcp', + 'packages/preview', ], }, });