Skip to content

3.0 prototype: a small colour engine that works anywhere - #6

Open
odama626 wants to merge 31 commits into
mainfrom
v3
Open

odama626 wants to merge 31 commits into
mainfrom
v3

Conversation

@odama626

Copy link
Copy Markdown
Member

The nine-step scale is replaced by four inputs, --primary, --secondary, --neutral and --error, and fourteen colours derived from them on every element. Declaring the derived colours on * is what lets an input set on any element re-theme its subtree: a custom property declared once at the root is substituted there, so a descendant changing an input never reached it.

Colours keep the input's own hue and chroma and pin only the lightness, so a brand colour survives; chroma is capped per role and scheme to stay in gamut, using 2.x's stops so a vivid brand looks the same. Text is solid, surfaces and rules are a tinted mid-tone at partial opacity. Disabling is one declaration, --chroma: 0, instead of re-binding 29 variables per selector.

--line moves from 0.695 to 0.62 (light) and 0.54 (dark): the 2.x value failed WCAG 1.4.11 at about 2.7:1 for every brand tested. Measured across ten brands in both schemes, every text pair clears 4.5:1 and --line clears 3:1.

index.css: 60.2 kB to 38.4 kB raw, 7.3 kB to 6.3 kB gzip, 477 custom-property declarations to 95.

@pkg-pr-new

pkg-pr-new Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@sparkstone/css@6

commit: 33099dc

The nine-step scale is replaced by four inputs, --primary, --secondary, --neutral
and --error, and fourteen colours derived from them on every element. Declaring
the derived colours on * is what lets an input set on any element re-theme its
subtree: a custom property declared once at the root is substituted there, so a
descendant changing an input never reached it.

Colours keep the input's own hue and chroma and pin only the lightness, so a
brand colour survives; chroma is capped per role and scheme to stay in gamut,
using 2.x's stops so a vivid brand looks the same. Text is solid, surfaces and
rules are a tinted mid-tone at partial opacity. Disabling is one declaration,
--chroma: 0, instead of re-binding 29 variables per selector.

--line moves from 0.695 to 0.62 (light) and 0.54 (dark): the 2.x value failed
WCAG 1.4.11 at about 2.7:1 for every brand tested. Measured across ten brands in
both schemes, every text pair clears 4.5:1 and --line clears 3:1.

index.css: 60.2 kB to 38.4 kB raw, 7.3 kB to 6.3 kB gzip, 477 custom-property
declarations to 95.
@github-actions

github-actions Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

📚 Docs preview: https://sparkstonepdx-css-pr-6.surge.sh

Built from 33099dc.

odama626 and others added 26 commits September 21, 2026 14:16
The pickers drive the swatches, so they now sit right under the inputs they set.
Seeding them from the page also broke visibly there: --link's computed value is
still a relative-colour expression, which the canvas conversion can't parse, so
the Secondary picker showed black. They now seed from an element's resolved
colour, read back as a pixel.
The core .skeleton turned its shimmer off and went transparent unless the
element carried Kobalte's data-animate and data-visible, so class and classless
skeletons never animated and showed nothing. That state moves into the Kobalte
layer, keyed on Kobalte's root, role="group"; the core class always animates,
still off under prefers-reduced-motion.
Declaring the derived colours on * made every element a re-derivation point.
That blocked overriding a derived colour for a subtree, since each descendant
re-derived its own, and made restyles 34% slower than 2.x. Colours are now
derived at the root, on [data-color-scheme], and on .retheme, as sparkcss did;
components that set an input themselves (error fields, error alerts, every
disabled state) @extend .retheme, so they share one rule.

The derived colours are registered with @Property as <color>. Unregistered, a
custom property inherits as text, so every use re-evaluated the whole nested
relative-colour expression; registered, it resolves once and inherits as a
colour. Restyles are now 8% faster than 2.x.

--primary is never declared by the library; the derivation reads
var(--primary, var(--color, rebeccapurple)), so either name works on any
boundary, not only at the root. A boundary also re-applies text colour, so a
disabled element's own text greys.
The text colour a boundary re-applies lived in its own .retheme rule, which
@extend also appended every boundary selector to, so the 31-selector list was
emitted twice. It now sits in the one rule with the derived colours.
Every component set its own --chroma: 0 or --primary: var(--error) and made
itself a boundary, so the boundary rule carried 33 selectors. Now two rules
cover everything, however it says so: :disabled, [data-disabled],
[aria-disabled="true"] and .disabled for disabled; [aria-invalid="true"] and
.error for invalid. Component state classes (.tab-disabled, .menu-disabled,
.btn-disabled, .input-error, .alert-error, .text-error) @extend those.

19 boundary selectors, one --chroma: 0 in the build. All 15 disabled and error
states compute as before, except that any element Kobalte marks data-disabled
now greys in the class build too.
The field, field-block, field-text, field-height and field-icon blocks were
included into every control, so the same declarations were emitted up to seven
times. They are now placeholder selectors that each control @extends, which
groups the controls onto one rule per block. Declared before the controls, so a
control's own declarations still override them.

Every form control and its pseudo-elements compute identically before and after,
in both builds (250 styles compared). index.css: 39.8 kB to 37.5 kB raw.
Translucent surfaces made every contrast ratio depend on what was underneath.
The docs' alpha badge passed on its own and failed at 4.34:1 on the current
page's highlighted sidebar row, where two translucent layers stacked. Surfaces,
rules, the placeholder and the docs badge are now color-mix()ed into --bg:
solid, so each pairing has one ratio that can be checked once. Mixing with the
page colour rather than black or white keeps the scheme flip for free.

The badge now measures 5.58:1 (light) and 5.46 (dark) wherever it sits. A card
over an image is opaque again, and nested surfaces no longer stack.

Also: the tooltip was a mid-tone with light text and measured 3.47:1 at worst;
it is now a true inverse, --fg on --bg, at 18.37.
The placeholder was --fg-muted mixed 50% into the page, 2.86:1 at worst. 70% is
the smallest mix that clears 4.5:1 for every brand tested in both schemes
(lowest 4.78), and still reads lighter than an entered value.
The 1.x classless build hung an absolutely positioned, full-height SVG off every
element inside a <details>: the space in 'details ::after' made it a descendant
selector. It drew nothing visible, but the overflow stretched the page, so a
classless collapse was taller than the class and Kobalte ones.
The box fades in from scale(1.25) over 550ms, off under
prefers-reduced-motion; the dialog clips horizontal overflow so the scaled box
never scrolls sideways. The animation sits on .dialog-box itself: a closed
dialog's contents are not rendered, and an animation restarts whenever its
element starts rendering, so it replays on every open without a dialog[open]
ancestor, and the classless dialog > article binding picks it up via @extend.
Toggles, checkbox ticks, radio dots and the tab and segmented indicators each
animated at their own speed (150, 200 or 250ms). They now all read
--duration-change, 200ms, the value 1.x used for the tick and radio dot.

The dialog's open animation reads a new --duration-enter, 400ms, down from
550ms: Material 3's duration for an element entering the screen. Loops (the
spinner and skeleton) keep their own durations.
All behind prefers-reduced-motion: no-preference, all on the two duration
tokens, none depending on a component library:

- dialogs fade their blur and backdrop in, and fade out on close, kept in the top
  layer with transition-behavior: allow-discrete
- dropdowns, popovers and tooltips scale in from 0.96 as they start rendering
- buttons ease colour changes and press to scale(0.98); menu rows, tabs and
  collapse titles ease their hover
- toast messages rise into the stack
- a native <details> collapse animates its height via ::details-content and
  interpolate-size, where supported

Verified in Chromium 141: the dialog closes through opacity 0.42, 0.13, 0.02
before hiding; panels and toasts animate on mount; a collapse passes 49px on its
way to 120px. With reduced motion, none of it runs.
The box eases to its checked fill, and the tick pops in from scale(0.4) with a
slight overshoot (peaking at about 1.05) before settling at its normal size.
Behind prefers-reduced-motion: no-preference, on --duration-change; with
reduced motion the tick keeps its 1.x fade only. A checked box at rest is
identical to before.
A pressed .btn now scales to 1.02, rising to meet the click, instead of 0.98.
The press used the default ease, which starts slowly: 25ms after the click the
button had covered 7% of its grow. On cubic-bezier(0.2, 0, 0, 1) it has covered
41%, so the press answers the click at once and lands softly.
Page buttons were full-size .btn elements, 55px tall, so single-digit pages were
tall, narrow rectangles (46 by 55). Inside .pagination they are now 2.25rem tall
with a minimum width to match, so a number is square (38 by 36) and Previous and
Next keep comfortable padding. The size is --pagination-size.
Each item is a <label> wrapping a radio, so in the classless build (and the
Kobalte layer on top of it) the label rules laid it out as a form row: a 6px
bottom margin, and the hidden radio put back in the flow as a 20px box that
pushed the text right. Items were 28px wider than in the class build.

The segmented control now outranks those rules for its own items, so all three
builds measure the same (44, 64 and 89px by 35). The docs' frame runtime also
places the Kobalte demo's indicator on the checked item, as Kobalte does,
instead of a hard-coded width.
…d panel

Menu rows now respond to the standard state signals as well as the classes: a
row is highlighted on :focus or data-highlighted (Kobalte, Radix and Ark all set
it), and selected on aria-selected or aria-checked. Before, a Kobalte select on
the class build showed no highlight and no selection, and no class could fix it,
since no headless library exposes which row is highlighted.

In the Kobalte layer the listbox no longer doubles as a panel. A real Kobalte
Select renders the panel as Select.Content around the listbox; binding
.dropdown-content to the listbox drew a second bordered panel inside it. The
select page's demo now mirrors the DOM Kobalte renders, and gains a class-build
example.
The field reserved 2.5rem on the right and the chevron sits 1.25rem to 2.25rem
in, so truncated text ran to within 4px of it. Selects now reserve 3rem, a 12px
gap. And .select truncates with an ellipsis on one line, as a native select
does; a <button> trigger, such as Kobalte's, wrapped onto a second line instead.

Date and time inputs share the icon rule but keep 2.5rem, since their invisible
picker button is positioned to it.
The boundary rule re-applies text colour, and components that set an input
@extend .retheme. In the classless build, the extended selector for a
.contrast button outranked the button's own colour rule, so it got dark text on
its grey fill in light mode, about 2.5:1. Wrapped in :where(), the rule has zero
specificity: any colour a component sets wins, and it only fills in where none
is. Found by the new cross-build tests.
tests/cross-build.spec.ts drives the real docs: every demo is screenshotted in
each build it shows (Class, Classless, Kobalte), in light and dark, and must
match the Class render pixel for pixel, apart from anti-aliasing. All renders
happen in one run on one machine, so there are no stored images and it gives
the same answer on any platform. A failure attaches both screenshots, a diff,
and the computed styles that differ.

By-design differences live in cross-build-exceptions.json with a reason; an
exception that stops happening fails too. The divider demo's text moved from
bare <p> to <div>, which the classless build styles as prose, so it compares
the divider alone.

tests/regression.visual.ts compares every render against stored screenshots,
meant to run in Playwright's Docker image (tests/README.md). pnpm test runs the
cross-build suite, and CI runs it on every pull request.
Selects reserve 2.25rem on the right with the chevron 0.75rem in, leaving 8px
between text and chevron. Date and time inputs keep their own spacing, since
their invisible picker button is positioned to it.
…by member

_input.scss built every control from five mixins wrapped in five placeholder
selectors, extended in a different combination per control, and some controls
then undid what they inherited. The radio's look lived in _label.scss and only
applied inside a label.

Now:
- _field.scss holds text, date and time inputs, selects and textareas. What
  controls share is written as grouped rules whose selector lists name their
  members, so finding a control in those lists tells you what it inherits; each
  control's own rule only adds or overrides. Measurements that have to stay in
  step, such as the icon inset and the gutter reserved for it, are named.
- _checkbox.scss holds the checkbox and the radio, which is drawn on .radio
  itself, so it looks the same outside a label.
- _file-input.scss, _color-input.scss and _range.scss hold one control each.

Also fixes a bug the old structure hid: the date icon padding came in through a
placeholder extend, which emitted it outside its @supports block, so Firefox
drew our calendar icon beside its own.

Every form control and pseudo-element computes identically to before in all
three builds and both schemes (2,466 styles compared), except a radio outside a
label, which is now drawn like one inside. The builds come out slightly smaller:
classless and Kobalte by 762 and 809 bytes raw. All 60 cross-build tests pass.
Rows were --padding / 3 by --padding / 2; they are now 8px by 16px, 42px tall.
Menubar triggers use .menu-item too, so they widen with them.
A "Composition" group in the docs: one page on how the pieces fit
together (who owns layout, surface, text, controls and colour, and
where spacing comes from), and two pages of composed sections modelled
on sparkstonepdx.com and lzr.life, each with notes on which piece does
which job. Class build only; no library CSS changed.
Rebuild the studio and community section pages from the actual
structure and copy of sparkstonepdx.com and lzr.life, and swap the
composition page's invented examples for real sections. Images are
placeholders; the studio sections follow the site's own CSS, the
community sections only its structure.
A zine has one fold, so trifold and quarters become a single choice;
color and the sided topics stay checkboxes.
Every component page now ends with a Composition section: one or two
generic patterns using the component with others, and notes on which
piece does which job. The general Composition page moves into Getting
started with generic copy, and the two site-specific section pages are
removed.
Simple components (kbd, progress, skeleton, loading, colour and file
inputs, textarea, checkbox, range, slider, toggle, tooltip, toast,
divider, breadcrumbs, pagination) lose their Composition section; their
useful patterns move to the component they combine with. The rest get
more varied examples: an account menu and shortcut menu on Dropdown, a
busy state and a toolbar on Button, tabs inside a card, counts on tabs,
a details table, advanced options in a collapse, a destructive confirm
dialog, and more.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant