Skip to content

website: shadcn tokens on Material Design 3 colour roles - #143

Merged
abernier merged 4 commits into
claude/replace-select-button-3c2216from
claude/material-theme-builder-setup-1c2f32
Jul 31, 2026
Merged

website: shadcn tokens on Material Design 3 colour roles#143
abernier merged 4 commits into
claude/replace-select-button-3c2216from
claude/material-theme-builder-setup-1c2f32

Conversation

@abernier

@abernier abernier commented Jul 31, 2026

Copy link
Copy Markdown
Member

Stacked on #142.

Puts the website's colour system on material-theme-builder. The three PRs below this one moved the nav onto shadcn components and shadcn tokens; the tokens themselves were still shadcn's stock neutral greys. They now come out of Material Design 3 — every one of them maps to an --md-sys-color-* role, per the shadcn mapping in the README.

One hex

const MCU_SOURCE = "#5de4c7";
const mcuCss = builder(MCU_SOURCE, { scheme: "tonalSpot" }).toCss();

Poimandres' signature mint. Surfaces, badges, the select popover, focus rings, the empty state — all of it falls out of those two lines in app/layout.tsx. Change them and the site retunes; contrast, core-colour overrides and customColors go in the same call.

Why the layout and not <Mcu>

layout.tsx is a server component, so builder(...).toCss() runs once at build time and the CSS ships inside the prerendered HTML — React hoists the <style> into <head> via href/precedence.

<Mcu> would put the same CSS in the page (it renders its <style> as of 2.2.0) but recomputes the palette on the client at hydration for a theme that never changes at runtime. This app is output: "export": the browser gets the whole page and paints it long before any JS runs, so anything that supplies the colours later gives you a frame with none of them at all.

That was the bug in the first two commits of this PR, and it's fixed upstream now:

  • abernier/material-theme-builder#155<Mcu> renders its <style> instead of injecting it from useInsertionEffect
  • #156"use client" off builder, so a server component can call it
  • v3.0.0 — React bindings moved to material-theme-builder/react, root left React-free. We only import builder from the root, so the migration was the version bump alone.

Verified on the built artefact

out/ served over http, both schemes, sampled at domcontentloaded — before hydration:

light dark
--md-sys-color-surface at first paint #f5fbf7 #0e1513
body background at first paint same same
<style> mutations caused by hydration 0 0
console warnings none none

--md-sys-color-surface: is present in out/index.html; the <style> sits in <head>, 19 398 chars, unescaped.

Client bundle

Unchanged: 313 214 bytes gzip, the same number as #142 before any of this. No md-sys-color, tonalSpot or mcu-styles string survives in the chunks.

That took a detour. On 2.2.0 the root entry re-exported the package's "use client" module, and Next registers every export of one it reaches — so Mcu and @material/material-color-utilities shipped to the browser (+32 kB gzip) even though nothing renders them. 3.0.0 puts the React bindings behind /react and leaves the root React-free, which removes it at the source.

Not in scope

The panels that were already hardcoded light stay hardcoded light: the nav's HIDE toggle (Nav.tsx), and Info / Dev on the demo page. They're @scoped inline CSS, so they'd need the same treatment #140 gave the shell before they can follow the palette.

Lockfile

material-theme-builder@3.0.0 plus its zod dependency; earlier in the branch pnpm add also deduped a few @babel/* transitives that had two copies pinned. minimumReleaseAgeExclude in pnpm-workspace.yaml is pnpm 11's supply-chain gate — 3.0.0 was published the same day, and the entry can be dropped once it has aged.

Checks

tsc --noEmit, next lint and next build (163 pages) clean, console clean. Exercised in both schemes: the demo list, the library dropdown, a demo page, and the pre-hydration paint on the real static artefact.

🤖 Generated with Claude Code

abernier and others added 4 commits July 31, 2026 17:49
Wires material-theme-builder into the root layout: `<Mcu>` generates the
m3 palette from one source hex, and every shadcn token in globals.css
now reads the matching `--md-sys-color-*` role.

The stock oklch neutrals stay as each `var()` fallback -- `<Mcu>` injects
its stylesheet on hydration, so those are what paints first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`<Mcu>` fills its <style> tag from useInsertionEffect, so on an
`output: "export"` site the first paint lands before a single colour
exists -- the prerendered HTML carried none of the `--md-sys-color-*`
definitions, only `var()` references to them.

`bin/gen-mcu-css.mjs` now writes the palette ahead of dev/build and
globals.css imports it, so it compiles into the stylesheet and ships in
the HTML. Colours are correct at first paint and identical after
hydration; the client bundle goes back to its previous size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…er 2.2.0

2.2.0 renders `<Mcu>`'s `<style>` instead of injecting it on hydration, and
keeps `"use client"` off `builder` so a server component can call it. The
generator script existed only to work around the second half, so it goes:
`layout.tsx` calls `builder(...).toCss()` at module scope and renders the
result, React hoisting it into `<head>` via `href`/`precedence`.

Same guarantee as before -- the palette is in the prerendered HTML, correct
at first paint, untouched by hydration -- with one file less and no
gitignored build artefact.

One regression, temporary: importing `builder` from the package's main
entry pulls its `"use client"` module into the client graph, +32 kB gzip
for a `<Mcu>` nothing renders. A React-free subpath is queued upstream
(abernier/material-theme-builder#160).

`minimumReleaseAgeExclude` is pnpm's supply-chain gate letting a
same-day 2.2.0 install; removable once the version has aged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3.0.0 moves the React bindings to `material-theme-builder/react` and leaves
`builder` alone at the root. We only ever imported `builder`, so the
migration is the version bump and nothing else.

It does undo the 32 kB the previous commit noted: the root no longer
re-exports a `"use client"` module, so Next stops registering `Mcu` and the
colour utilities as client references. Client chunks are back to 313 214
bytes gzip -- the number before any of this landed -- with no `md-sys-color`
string left in them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@abernier
abernier force-pushed the claude/material-theme-builder-setup-1c2f32 branch from a06fa70 to 40b86de Compare July 31, 2026 15:53
@abernier
abernier merged commit 1b4b98e into main Jul 31, 2026
2 checks passed
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