Skip to content

feat: fitted colour model (Tailwind v4 fit) as the new default, plus chroma lab - #6

Closed
nekomeowww wants to merge 5 commits into
mainfrom
feat/fitted-color-model
Closed

nekomeowww wants to merge 5 commits into
mainfrom
feat/fitted-color-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 adds a fitted colour model and makes it the default (model: 'classic' keeps the previous colours):

  • Lightness, chroma and a small hue drift vary smoothly with hue. They are fitted by least squares to the Tailwind CSS v4 palette.
  • 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. 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. The classic model is at 5.80.

Packages

  • @proj-airi/chromatic: fittedOklch, FITTED_MODEL, and a model option on chromaticColorFrom / chromaticPaletteFrom / themeFrom / colorBy / chromaticFrom. Coefficients are generated by pnpm generate:fitted from the playground fit. --check verifies they are up to date.
  • @proj-airi/unocss-preset-chromatic: a model option. With fitted, 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.
  • Playground: vue-router with the docs at / and a "chroma lab" at /lab (lazy loaded; three.js views only animate while on screen). The lab has chroma / lightness by hue and shade, residual heatmaps, a 3D surface, side-by-side palettes, ΔE against Tailwind with cross-validation, a live fit with controls, and TS / CSS export. Sliders are copied from moeru-ai/airi packages/ui (range / field-range, MIT, attributed in the files).

Breaking changes

  • The default model is now fitted, so every colour changes slightly. Shades 50 / 100 are no longer mixed with white.
  • fitted 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: 'classic'.
  • Suggest releasing as 2.0.0.

Notes

  • With fitted, 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 classic 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):

    classic fitted
    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 lint, pnpm typecheck, pnpm build
  • pnpm test:run: 26 tests pass.
    • Classic tests are unchanged and pinned to model: 'classic'.
    • 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

nekomeowww and others added 4 commits October 28, 2026 02:42
Visualizes how chroma and lightness are distributed over hue and shade:
chroma / lightness by hue and by shade against the sRGB and P3 gamut,
residual heatmaps, a 3D surface, side-by-side palettes, and ΔE against
the Tailwind CSS v4 palette with leave-one-family-out cross-validation.

Includes the least-squares fit to Tailwind v4 the `fitted` model is
generated from, with live controls, and copies the range / field-range
components of moeru-ai/airi packages/ui for the controls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`fitted` varies lightness, chroma and a small hue drift smoothly with hue,
fitted 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.
Coefficients are generated with `pnpm generate:fitted` (`--check` to
verify they are up to date).

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`. 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 b1d23bc
🔍 Latest deploy log https://app.netlify.com/projects/proj-airi-chromatic/deploys/6ae10de1cb6d7ffdfcf404c6
😎 Deploy Preview https://deploy-preview-6--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.

Adds vue-router: `/` is the docs page, `/lab` the chroma lab, both lazy
loaded so the docs no longer pay for fitting, heatmaps and the 3D
surface. The theme hue is shared through `useThemeHue()` and survives
route changes. The three.js views only animate while on screen. Adds a
Netlify `_redirects` fallback so `/lab` works on reload.

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

Copy link
Copy Markdown
Member Author

Split into a stack for easier review:

  1. chore(deps): add vue-router to the playground #7 chore(deps): add vue-router to the playground
  2. feat: versioned colour models, v2 (Tailwind v4 fit) as the new default #8 feat: fitted colour model (Tailwind v4 fit) as the new default (stacked on chore(deps): add vue-router to the playground #7)
  3. feat(playground): chroma lab on its own route #9 feat(playground): chroma lab on its own route (stacked on feat: versioned colour models, v2 (Tailwind v4 fit) as the new default #8)

The final tree of #9 is identical to this PR plus the route split and moving the fit to scripts/fitted/.

@nekomeowww nekomeowww closed this Oct 27, 2026
@nekomeowww
nekomeowww deleted the feat/fitted-color-model branch October 27, 2026 21:42
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