Skip to content

feat: versioned colour models, v2 (Tailwind v4 fit) as the new default - #8

Merged
nekomeowww merged 4 commits into
mainfrom
feat/fitted-model
Oct 27, 2026
Merged

nekomeowww merged 4 commits into
mainfrom
feat/fitted-model

Conversation

@nekomeowww

@nekomeowww nekomeowww commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Summary

Blue / cyan shades looked heavy and dull. The cause was in how shades are derived: each shade has a fixed OKLCH lightness, chroma follows 0.18 + 0.04 · cos(hue), and anything past the sRGB gamut is channel-clipped. Cyan / sky only reach high chroma at high lightness, so ~49% of hues at shade 500 were out of sRGB and clipped into darker colours.

This PR introduces versioned colour models and makes v2 the default:

  • v1 is the model of chromatic 1.x. model: 'v1' keeps those colours.
  • v2 varies lightness, chroma and a small hue drift smoothly with hue. They are fitted by least squares to the Tailwind CSS v4 palette.
  • Versions are frozen once released. A different fit ships as v3, so colours never change under an existing version.

How v2 is fitted:

  • Chroma is fitted in two stages: first the share of the sRGB max that Tailwind uses (smooth, 3 harmonics), then share × exact sRGB max refitted per shade with 12 harmonics. On the uniform 1° grid of the second stage, the least-squares solution is the truncated DFT.
  • This keeps cyan / sky 400–500 vivid, e.g. hue 222.69° shade 400 goes from #23c8ec to #00caf0 (Tailwind ≈ #00c7fb).
  • Around yellow (105° ± 40°) the fit targets 82% / 95% of sRGB instead of Tailwind, so light yellows do not turn neon.
  • ΔE (OKLab × 100) against Tailwind v4: RMS 1.63, leave-one-family-out RMS 2.11. v1 is at 5.80.

Changes

  • @proj-airi/chromatic: fittedOklch, MODEL_V2 (in model-v2.ts), and a model: 'v1' | 'v2' option on chromaticColorFrom / chromaticPaletteFrom / themeFrom / colorBy / chromaticFrom. The runtime still depends only on culori.
  • @proj-airi/unocss-preset-chromatic: a model option. With v2, the preflight emits per colour a shared cos(k·h) / sin(k·h) basis and per-shade lightness / chroma / hue-drift custom properties. Baked colours use the core values. Modifiers (~, *) keep working. The preset now depends on @proj-airi/chromatic.
  • scripts/fitted/: the Tailwind v4 data, the fit, and the generator.
    • Gamut and ΔE use culori (clampChroma, differenceEuclidean('oklab')). culori is added as a root dev dependency for the scripts.
    • pnpm generate:fitted writes model-v2.ts; --check verifies it is up to date.

Breaking changes

  • The default model is now v2, so every colour changes slightly. Shades 50 / 100 are no longer mixed with white.
  • v2 does not emit --chromatic-chroma / --chromatic-chroma-<shade>. Code that reads them directly should switch to utilities with ~ / * modifiers, or to chromaticColorFrom(hue, { shade, saturation, brightness }). It can also pass model: 'v1'.
  • Suggest releasing as 2.0.0.

Notes

  • With v2, lightness and chroma are computed from --chromatic-hue on :root. A subtree override of --chromatic-hue rotates the hue but keeps the root's lightness and chroma, which is what v1 already did for chroma.

  • CSS size per colour: ~17 KB raw / 2.4 KB gzip.

  • Style recalc per hue change with 5000 elements (median of 5):

    v1 v2
    2 colours, Chrome desktop 6.7 ms 10.1 ms
    2 colours, iOS Simulator Safari 8 ms 9 ms
    2 colours, Android emulator Chrome 2.2 ms 2.5 ms
    14 colours, Android emulator Chrome 2.5 ms 9.9 ms

    Simulators run on the host CPU. Real devices are not measured yet.

  • Registering the variables with @property helped on WebKit but not on Blink, so it is not used.

  • Longest substituted utility colour is 2.3 KB, ~900× below Chrome's ~2 MB var() substitution limit.

Test plan

  • pnpm install --frozen-lockfile, pnpm lint, pnpm typecheck, pnpm build
  • pnpm test:run: 26 tests pass.
    • v1 tests are unchanged and pinned to model: 'v1'.
    • New tests evaluate the generated preflight CSS and compare it with fittedOklch for 3 colours × 5 hues × 11 shades.
  • pnpm generate:fitted --check
  • Rendered in Chrome, iOS Simulator Safari and Android emulator Chrome. Computed colours match the core values (max RGB difference 0).
  • Measure style recalc on a real phone.

🤖 Generated with Claude Code

Base automatically changed from chore/deps-for-fitted-model to main October 27, 2026 21:05
nekomeowww and others added 3 commits October 28, 2026 05:06
`fitted` varies lightness, chroma and a small hue drift smoothly with hue,
fitted by least squares to the Tailwind CSS v4 palette. Around yellow it
targets a share of the sRGB gamut instead, so light shades do not turn
neon. Chroma is fitted as a share of sRGB and then refitted against the
exact gamut, which keeps cyan / sky 400–500 vivid.

`chromaticColorFrom`, `chromaticPaletteFrom`, `themeFrom`, `colorBy` and
`chromaticFrom` accept `model`; `classic` keeps the previous colours.
The fit and the Tailwind v4 data live in `scripts/fitted/`; coefficients
are generated with `pnpm generate:fitted` (`--check` to verify them).

BREAKING CHANGE: the default model is now `fitted`. Pass
`model: 'classic'` to keep the previous colours.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With `model: 'fitted'` (now the default) the preflight emits, per colour,
the Fourier basis of its hue and the lightness / chroma / hue drift of
each shade as custom properties; baked colours use the values of
`@proj-airi/chromatic`, which the preset now depends on. A test
evaluates the generated CSS and compares it with `fittedOklch`.

BREAKING CHANGE: the default model is now `fitted`, which does not emit
`--chromatic-chroma` / `--chromatic-chroma-<shade>`. Pass
`model: 'classic'` to keep the previous colours and variables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@netlify

netlify Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for proj-airi-chromatic ready!

Name Link
🔨 Latest commit 026d03a
🔍 Latest deploy log https://app.netlify.com/projects/proj-airi-chromatic/deploys/6ae115076283b8cf3ba06dce
😎 Deploy Preview https://deploy-preview-8--proj-airi-chromatic.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

- `model: 'classic' | 'fitted'` becomes `'v1' | 'v2'` (default `v2`).
  Released versions are frozen; a different fit ships as a new version,
  so colours never change under an existing one. `FITTED_MODEL` becomes
  `MODEL_V2` in `model-v2.ts`.
- The fit uses culori's `clampChroma` and `differenceEuclidean('oklab')`
  instead of hand-written OKLab maths (culori is now a root dev
  dependency for `scripts/`).
- The second chroma stage samples a uniform 1° grid, where the least
  squares Fourier fit is the truncated DFT, so it no longer solves a
  linear system. ΔE against Tailwind is unchanged (RMS 1.63, CV 2.11).

BREAKING CHANGE: `model` takes `'v1'` (colours of chromatic 1.x) or
`'v2'` (default).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nekomeowww nekomeowww changed the title feat: fitted colour model (Tailwind v4 fit) as the new default feat: versioned colour models, v2 (Tailwind v4 fit) as the new default Oct 27, 2026
@nekomeowww
nekomeowww merged commit 6b9890f into main Oct 27, 2026
7 checks passed
@nekomeowww
nekomeowww deleted the feat/fitted-model branch October 27, 2026 21:39
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