From 1c25f3ab177ae4ed6281a3bf62aa81838dc3e1e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 08:08:16 +0000 Subject: [PATCH 01/10] feat: label each Scheme swatch in its M3 paired color Instead of white blended by difference, each role's label is written in the role M3 pairs it with (on-primary on primary, primary on on-primary, on-surface on surfaces...), as Material Theme Builder's poster does. Custom colors too, including the Tailwind story's hand-written ones. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HnjGFvhGSxgzZZiU33Wiko --- src/Mtb.stories.helpers.tsx | 36 ++++++++--- src/Scheme.tsx | 126 ++++++++++++++++++++++++++++++++---- 2 files changed, 141 insertions(+), 21 deletions(-) diff --git a/src/Mtb.stories.helpers.tsx b/src/Mtb.stories.helpers.tsx index 492571a..a200c5c 100644 --- a/src/Mtb.stories.helpers.tsx +++ b/src/Mtb.stories.helpers.tsx @@ -91,6 +91,18 @@ export function Layout({ ); } +/** + * A label written in the `--md-sys-color-*` M3 pairs with its swatch, as + * `Scheme`'s are -- inline, since `Poster`'s unlayered label rule would beat a + * `text-*` utility. + */ +function ink(role: string) { + return { + color: `var(--md-sys-color-${role})`, + mixBlendMode: "normal", + } as const; +} + /** * The custom-color roles, as Tailwind utilities. * @@ -105,48 +117,56 @@ function TailwindCustomColors() {
-

MyCustomColor1

+

MyCustomColor1

-

On MyCustomColor1

+

On MyCustomColor1

-

MyCustomColor1 Container

+

+ MyCustomColor1 Container +

-

On MyCustomColor1 Container

+

+ On MyCustomColor1 Container +

-

MyCustomColor2

+

MyCustomColor2

-

On MyCustomColor2

+

On MyCustomColor2

-

MyCustomColor2 Container

+

+ MyCustomColor2 Container +

-

On MyCustomColor2 Container

+

+ On MyCustomColor2 Container +

diff --git a/src/Scheme.tsx b/src/Scheme.tsx index 82b9d80..ab390dc 100644 --- a/src/Scheme.tsx +++ b/src/Scheme.tsx @@ -35,6 +35,7 @@ import { useContext, type ComponentProps, type CSSProperties, + type ReactNode, } from "react"; import { STANDARD_TONES, @@ -173,10 +174,97 @@ const twClasses = { surfaceTint: "bg-surface-tint", } satisfies Record; +/** The `--md-sys-color-*` custom property of a role, as a CSS color. */ +function sys(role: string) { + return `var(--md-sys-color-${kebabCase(role)})`; +} + +/** + * The color each M3 token's label is written in: the role M3 pairs it with, + * as Material Theme Builder's own poster does — `on-primary` on `primary`, and + * back, `primary` on `on-primary`. + * + * Where the spec pairs a fill with no ink of its own, the ink is the role it + * is meant to be read against: `on-surface` on every surface, `surface` under + * `on-surface-variant` and `outline`, `inverse-surface` under + * `inverse-primary` (its text color on an inverse surface). + * + * `scrim` and `shadow` are black in both themes, and no role is white in both, + * hence the neutral palette's tone 100. + * + * `satisfies` keeps this exhaustive, as for `twClasses`. + * + * @see https://m3.material.io/styles/color/roles + */ +const inks = { + primary: sys("onPrimary"), + onPrimary: sys("primary"), + primaryContainer: sys("onPrimaryContainer"), + onPrimaryContainer: sys("primaryContainer"), + secondary: sys("onSecondary"), + onSecondary: sys("secondary"), + secondaryContainer: sys("onSecondaryContainer"), + onSecondaryContainer: sys("secondaryContainer"), + tertiary: sys("onTertiary"), + onTertiary: sys("tertiary"), + tertiaryContainer: sys("onTertiaryContainer"), + onTertiaryContainer: sys("tertiaryContainer"), + + error: sys("onError"), + onError: sys("error"), + errorContainer: sys("onErrorContainer"), + onErrorContainer: sys("errorContainer"), + + primaryFixed: sys("onPrimaryFixed"), + primaryFixedDim: sys("onPrimaryFixed"), + onPrimaryFixed: sys("primaryFixed"), + onPrimaryFixedVariant: sys("primaryFixed"), + secondaryFixed: sys("onSecondaryFixed"), + secondaryFixedDim: sys("onSecondaryFixed"), + onSecondaryFixed: sys("secondaryFixed"), + onSecondaryFixedVariant: sys("secondaryFixed"), + tertiaryFixed: sys("onTertiaryFixed"), + tertiaryFixedDim: sys("onTertiaryFixed"), + onTertiaryFixed: sys("tertiaryFixed"), + onTertiaryFixedVariant: sys("tertiaryFixed"), + + surfaceDim: sys("onSurface"), + surface: sys("onSurface"), + surfaceBright: sys("onSurface"), + surfaceContainerLowest: sys("onSurface"), + surfaceContainerLow: sys("onSurface"), + surfaceContainer: sys("onSurface"), + surfaceContainerHigh: sys("onSurface"), + surfaceContainerHighest: sys("onSurface"), + onSurface: sys("surface"), + onSurfaceVariant: sys("surface"), + outline: sys("surface"), + outlineVariant: sys("onSurface"), + + inverseSurface: sys("inverseOnSurface"), + inverseOnSurface: sys("inverseSurface"), + inversePrimary: sys("inverseSurface"), + scrim: "var(--md-ref-palette-neutral-100)", + shadow: "var(--md-ref-palette-neutral-100)", + + background: sys("onBackground"), + onBackground: sys("background"), + surfaceVariant: sys("onSurfaceVariant"), + surfaceTint: sys("onPrimary"), +} satisfies Record; + +/** + * A swatch's label, written in `ink` -- instead of the `Poster`'s default + * white, blended by difference. + */ +function Label({ ink, children }: { ink: string; children: ReactNode }) { + return

{children}

; +} + /** - * One color cell: the role as `title`, its human name as label, the color - * itself from `var(--md-sys-color-)` — or from the Tailwind utility when - * under a `tw` `Scheme`. + * One color cell: the role as `title`, its human name as label (in `inks`' + * color), the color itself from `var(--md-sys-color-)` — or from the + * Tailwind utility when under a `tw` `Scheme`. */ function Swatch({ role, @@ -195,14 +283,10 @@ function Swatch({
- {children ??

{startCase(role)}

} + {children ?? }
); } @@ -654,7 +738,11 @@ export function Scheme({ backgroundColor: `var(--md-sys-color-${kebabCase(customColor.name)})`, }} > -

{upperFirst(customColor.name)}

+ @@ -665,7 +753,11 @@ export function Scheme({ backgroundColor: `var(--md-sys-color-on-${kebabCase(customColor.name)})`, }} > -

On {upperFirst(customColor.name)}

+
@@ -676,7 +768,11 @@ export function Scheme({ backgroundColor: `var(--md-sys-color-${kebabCase(customColor.name)}-container)`, }} > -

{upperFirst(customColor.name)} Container

+
@@ -687,7 +783,11 @@ export function Scheme({ backgroundColor: `var(--md-sys-color-on-${kebabCase(customColor.name)}-container)`, }} > -

On {upperFirst(customColor.name)} Container

+
From 2e90cbdabdf17f70bc2531a9559c775fdd9c33c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 08:09:25 +0000 Subject: [PATCH 02/10] chore: add changeset for Scheme label inks Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HnjGFvhGSxgzZZiU33Wiko --- .changeset/scheme-label-inks.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/scheme-label-inks.md diff --git a/.changeset/scheme-label-inks.md b/.changeset/scheme-label-inks.md new file mode 100644 index 0000000..b89e4de --- /dev/null +++ b/.changeset/scheme-label-inks.md @@ -0,0 +1,8 @@ +--- +"material-theme-builder": patch +--- + +`Scheme` writes each role's label in the color M3 pairs it with — `on-primary` +on `primary`, `primary` on `on-primary`, `on-surface` on the surfaces… — as +Material Theme Builder's own poster does, instead of white blended by +difference. Custom colors too. From e45a43bbeec4c2b5d707c051ab2d16f58323c1eb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 08:13:01 +0000 Subject: [PATCH 03/10] feat: label each Shades tone in its palette's tone 100 or 10 As M3 inks its roles (on-primary 100 on 40, on-primary-container 10 on 90): tone 100 on tones up to 50, tone 10 above. With every poster label now inked, Poster drops its white + difference fallback, so the Tailwind story's custom colors use text-* utilities instead of inline styles. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HnjGFvhGSxgzZZiU33Wiko --- .changeset/scheme-label-inks.md | 13 +++++++++---- src/Mtb.stories.helpers.tsx | 31 ++++++++++--------------------- src/Scheme.tsx | 26 +++++++++++++++++++------- 3 files changed, 38 insertions(+), 32 deletions(-) diff --git a/.changeset/scheme-label-inks.md b/.changeset/scheme-label-inks.md index b89e4de..295db46 100644 --- a/.changeset/scheme-label-inks.md +++ b/.changeset/scheme-label-inks.md @@ -2,7 +2,12 @@ "material-theme-builder": patch --- -`Scheme` writes each role's label in the color M3 pairs it with — `on-primary` -on `primary`, `primary` on `on-primary`, `on-surface` on the surfaces… — as -Material Theme Builder's own poster does, instead of white blended by -difference. Custom colors too. +The poster writes each label in the color M3 pairs it with, as Material Theme +Builder's own does, instead of white blended by difference: + +- `Scheme`: each role in its counterpart — `on-primary` on `primary`, + `primary` on `on-primary`, `on-surface` on the surfaces… Custom colors too. +- `Shades`: each tone in its own palette's tone 100 on the dark half, 10 on the + light one — the tones of `on-primary` and `on-primary-container`. + +`Poster` no longer sets any `color` or `mix-blend-mode` on the `

`s it holds. diff --git a/src/Mtb.stories.helpers.tsx b/src/Mtb.stories.helpers.tsx index a200c5c..cc4c3cf 100644 --- a/src/Mtb.stories.helpers.tsx +++ b/src/Mtb.stories.helpers.tsx @@ -91,18 +91,6 @@ export function Layout({ ); } -/** - * A label written in the `--md-sys-color-*` M3 pairs with its swatch, as - * `Scheme`'s are -- inline, since `Poster`'s unlayered label rule would beat a - * `text-*` utility. - */ -function ink(role: string) { - return { - color: `var(--md-sys-color-${role})`, - mixBlendMode: "normal", - } as const; -} - /** * The custom-color roles, as Tailwind utilities. * @@ -110,26 +98,27 @@ function ink(role: string) { * because their names only exist at runtime — `bg-${name}` would never be seen * by Tailwind's source scanner, so the utility would never be generated. Here * the names are known, so the classes can be written literally and actually - * prove that `bg-myCustomColor1` & co. resolve. + * prove that `bg-myCustomColor1` & co. resolve -- and their `text-*`, which + * write each label in the role M3 pairs it with, as `Scheme`'s are. */ function TailwindCustomColors() { return (

-

MyCustomColor1

+

MyCustomColor1

-

On MyCustomColor1

+

On MyCustomColor1

-

+

MyCustomColor1 Container

@@ -137,26 +126,26 @@ function TailwindCustomColors() { className="h-(--cell) bg-on-myCustomColor1-container" title="on-myCustomColor1-container" > -

+

On MyCustomColor1 Container

-

MyCustomColor2

+

MyCustomColor2

-

On MyCustomColor2

+

On MyCustomColor2

-

+

MyCustomColor2 Container

@@ -164,7 +153,7 @@ function TailwindCustomColors() { className="h-(--cell) bg-on-myCustomColor2-container" title="on-myCustomColor2-container" > -

+

On MyCustomColor2 Container

diff --git a/src/Scheme.tsx b/src/Scheme.tsx index ab390dc..4d3e751 100644 --- a/src/Scheme.tsx +++ b/src/Scheme.tsx @@ -253,12 +253,22 @@ const inks = { surfaceTint: sys("onPrimary"), } satisfies Record; +/** A swatch's label, written in `ink`. */ +function Label({ ink, children }: { ink: string; children: ReactNode }) { + return

{children}

; +} + /** - * A swatch's label, written in `ink` -- instead of the `Poster`'s default - * white, blended by difference. + * The tone a tonal palette's label is written in, on its `tone`: 100 on the + * dark half, 10 on the light one -- the tones M3 inks its own roles with, + * `on-primary` (100) on `primary` (40), `on-primary-container` (10) on + * `primary-container` (90). + * + * The cut at 50 keeps every pair at about 4.5:1 or more: tone 50 under 100, + * tone 60 under 10. */ -function Label({ ink, children }: { ink: string; children: ReactNode }) { - return

{children}

; +function inkTone(tone: number) { + return tone <= 50 ? 100 : 10; } /** @@ -319,8 +329,6 @@ function PosterStyle({ notext }: { notext?: boolean }) { p { font-family: sans-serif; - color: white; - mix-blend-mode: difference; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -855,7 +863,11 @@ export function Shades({ backgroundColor: `var(--md-ref-palette-${isCustom ? kebabCase(name) : name}-${tone})`, }} > -

{tone}

+ ))} From fd7e059fe3f9cf403658e84a0d5eefcb893e9a73 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 08:15:01 +0000 Subject: [PATCH 04/10] fix: ink the Tailwind story's hand-written shades Each shade in its palette's 50 on 500 and darker, its 900 above -- as Shades does with tones 100 and 10, Tailwind having no tone 100. Its labels were the page's dark text, unreadable on the dark half. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HnjGFvhGSxgzZZiU33Wiko --- src/Mtb.stories.helpers.tsx | 180 ++++++++++++++++++------------------ 1 file changed, 91 insertions(+), 89 deletions(-) diff --git a/src/Mtb.stories.helpers.tsx b/src/Mtb.stories.helpers.tsx index cc4c3cf..855e16d 100644 --- a/src/Mtb.stories.helpers.tsx +++ b/src/Mtb.stories.helpers.tsx @@ -198,43 +198,45 @@ export function TailwindScheme() {
- {/* Shades */} + {/* Shades -- each written as `Shades` writes its tones: in its own + palette's lightest shade on the dark half, its 900 (tone 10) on + the light one. Tailwind has no tone 100, so 50 (tone 95) stands in. */}
{/* Primary Shades */}

Primary

-
+
50
-
+
100
-
+
200
-
+
300
-
+
400
-
+
500
-
+
600
-
+
700
-
+
800
-
+
900
-
+
950
@@ -244,37 +246,37 @@ export function TailwindScheme() {

Secondary

-
+
50
-
+
100
-
+
200
-
+
300
-
+
400
-
+
500
-
+
600
-
+
700
-
+
800
-
+
900
-
+
950
@@ -284,37 +286,37 @@ export function TailwindScheme() {

Tertiary

-
+
50
-
+
100
-
+
200
-
+
300
-
+
400
-
+
500
-
+
600
-
+
700
-
+
800
-
+
900
-
+
950
@@ -324,37 +326,37 @@ export function TailwindScheme() {

Error

-
+
50
-
+
100
-
+
200
-
+
300
-
+
400
-
+
500
-
+
600
-
+
700
-
+
800
-
+
900
-
+
950
@@ -364,37 +366,37 @@ export function TailwindScheme() {

Neutral

-
+
50
-
+
100
-
+
200
-
+
300
-
+
400
-
+
500
-
+
600
-
+
700
-
+
800
-
+
900
-
+
950
@@ -404,37 +406,37 @@ export function TailwindScheme() {

Neutral Variant

-
+
50
-
+
100
-
+
200
-
+
300
-
+
400
-
+
500
-
+
600
-
+
700
-
+
800
-
+
900
-
+
950
@@ -444,37 +446,37 @@ export function TailwindScheme() {

myCustomColor1

-
+
50
-
+
100
-
+
200
-
+
300
-
+
400
-
+
500
-
+
600
-
+
700
-
+
800
-
+
900
-
+
950
@@ -484,37 +486,37 @@ export function TailwindScheme() {

myCustomColor2

-
+
50
-
+
100
-
+
200
-
+
300
-
+
400
-
+
500
-
+
600
-
+
700
-
+
800
-
+
900
-
+
950
From 8c8f843b80c5aec55c36f14c2067e912ac1d6a77 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 08:18:30 +0000 Subject: [PATCH 05/10] fix: paint Scheme's tw secondary swatch with the M3 role Next to shadcn, whose @theme inline outranks the plugin on the name, bg-secondary lands on secondary-container. Use the README's way out, bg-[var(--md-sys-color-secondary)], which paints the M3 role with or without shadcn. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HnjGFvhGSxgzZZiU33Wiko --- .changeset/scheme-label-inks.md | 3 +++ src/Scheme.tsx | 8 +++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.changeset/scheme-label-inks.md b/.changeset/scheme-label-inks.md index 295db46..9d416f7 100644 --- a/.changeset/scheme-label-inks.md +++ b/.changeset/scheme-label-inks.md @@ -11,3 +11,6 @@ Builder's own does, instead of white blended by difference: light one — the tones of `on-primary` and `on-primary-container`. `Poster` no longer sets any `color` or `mix-blend-mode` on the `

`s it holds. + +`` paints `secondary` with `bg-[var(--md-sys-color-secondary)]`: +next to shadcn, `bg-secondary` lands on `secondary-container`. diff --git a/src/Scheme.tsx b/src/Scheme.tsx index 4d3e751..cb47067 100644 --- a/src/Scheme.tsx +++ b/src/Scheme.tsx @@ -115,13 +115,19 @@ const TwContext = createContext(false); * seen by Tailwind's source scanner, so the class would never be generated. * `satisfies` is what keeps this exhaustive: a token added to the library * breaks the build here until its utility is written down. + * + * `secondary` alone goes through an arbitrary value: next to shadcn, whose + * `@theme inline` outranks the plugin on the name, `bg-secondary` lands on + * `secondary-container` -- shadcn's `--secondary`. The README's own way out, + * and it paints the M3 role with or without shadcn. (`primary` and + * `background` collide too, but land on the same color.) */ const twClasses = { primary: "bg-primary", onPrimary: "bg-on-primary", primaryContainer: "bg-primary-container", onPrimaryContainer: "bg-on-primary-container", - secondary: "bg-secondary", + secondary: "bg-[var(--md-sys-color-secondary)]", onSecondary: "bg-on-secondary", secondaryContainer: "bg-secondary-container", onSecondaryContainer: "bg-on-secondary-container", From 7740b3e7a453de7c39eae5e1b75e779d9d58db32 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 08:27:48 +0000 Subject: [PATCH 06/10] refactor: keep every poster label ink in Scheme.ink.ts The label colors are dressing: gather them in one module -- roleInk() for scheme roles (the on- counterpart, or back, plus the spec's exceptions), toneInk() for palette tones -- so the swatches only ask it. Custom-color cells, in Scheme and in the Tailwind story, become one loop over their four roles; the story's 88 hand-written shades a table of utilities, inked with toneInk() rather than per-cell text-* classes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HnjGFvhGSxgzZZiU33Wiko --- src/Mtb.stories.helpers.tsx | 457 +++++++----------------------------- src/Scheme.ink.ts | 67 ++++++ src/Scheme.tsx | 206 ++++------------ 3 files changed, 187 insertions(+), 543 deletions(-) create mode 100644 src/Scheme.ink.ts diff --git a/src/Mtb.stories.helpers.tsx b/src/Mtb.stories.helpers.tsx index 855e16d..9095289 100644 --- a/src/Mtb.stories.helpers.tsx +++ b/src/Mtb.stories.helpers.tsx @@ -1,4 +1,5 @@ import type { Meta } from "@storybook/react-vite"; +import { kebabCase } from "lodash-es"; import { useState, type ComponentProps } from "react"; import { Fab } from "./components/m3/Fab"; import { ExportButton } from "./ExportButton"; @@ -9,8 +10,10 @@ import { schemeNames, type MtbConfig, } from "./lib/builder"; +import { SHADE_TO_TONE } from "./lib/tokens"; import type { Mtb } from "./Mtb"; import { Poster, Scheme, Shades } from "./Scheme"; +import { roleInk, toneInk } from "./Scheme.ink"; /** * ``'s props as controls, shared by every story that themes with them. @@ -98,66 +101,87 @@ export function Layout({ * because their names only exist at runtime — `bg-${name}` would never be seen * by Tailwind's source scanner, so the utility would never be generated. Here * the names are known, so the classes can be written literally and actually - * prove that `bg-myCustomColor1` & co. resolve -- and their `text-*`, which - * write each label in the role M3 pairs it with, as `Scheme`'s are. + * prove that `bg-myCustomColor1` & co. resolve. */ +const twCustomColors = [1, 2].map((n): [string, string][] => [ + [`my-custom-color-${n}`, `MyCustomColor${n}`], + [`on-my-custom-color-${n}`, `On MyCustomColor${n}`], + [`my-custom-color-${n}-container`, `MyCustomColor${n} Container`], + [`on-my-custom-color-${n}-container`, `On MyCustomColor${n} Container`], +]); +const twCustomColorClasses: Record = { + "my-custom-color-1": "bg-myCustomColor1", + "on-my-custom-color-1": "bg-on-myCustomColor1", + "my-custom-color-1-container": "bg-myCustomColor1-container", + "on-my-custom-color-1-container": "bg-on-myCustomColor1-container", + "my-custom-color-2": "bg-myCustomColor2", + "on-my-custom-color-2": "bg-on-myCustomColor2", + "my-custom-color-2-container": "bg-myCustomColor2-container", + "on-my-custom-color-2-container": "bg-on-myCustomColor2-container", +}; + function TailwindCustomColors() { return (

-
-
-

MyCustomColor1

-
-
-

On MyCustomColor1

-
-
-

- MyCustomColor1 Container -

-
-
-

- On MyCustomColor1 Container -

-
-
-
-
-

MyCustomColor2

-
-
-

On MyCustomColor2

-
-
-

- MyCustomColor2 Container -

+ {twCustomColors.map((roles, i) => ( +
+ {roles.map(([role, label]) => ( +
+

{label}

+
+ ))}
-
-

- On MyCustomColor2 Container -

+ ))} +
+ ); +} + +/** + * The tonal shades, as Tailwind utilities -- spelled out, for the same + * scanner, in `SHADE_TO_TONE`'s order. + */ +const twShades = { + Primary: + "bg-primary-50 bg-primary-100 bg-primary-200 bg-primary-300 bg-primary-400 bg-primary-500 bg-primary-600 bg-primary-700 bg-primary-800 bg-primary-900 bg-primary-950", + Secondary: + "bg-secondary-50 bg-secondary-100 bg-secondary-200 bg-secondary-300 bg-secondary-400 bg-secondary-500 bg-secondary-600 bg-secondary-700 bg-secondary-800 bg-secondary-900 bg-secondary-950", + Tertiary: + "bg-tertiary-50 bg-tertiary-100 bg-tertiary-200 bg-tertiary-300 bg-tertiary-400 bg-tertiary-500 bg-tertiary-600 bg-tertiary-700 bg-tertiary-800 bg-tertiary-900 bg-tertiary-950", + Error: + "bg-error-50 bg-error-100 bg-error-200 bg-error-300 bg-error-400 bg-error-500 bg-error-600 bg-error-700 bg-error-800 bg-error-900 bg-error-950", + Neutral: + "bg-neutral-50 bg-neutral-100 bg-neutral-200 bg-neutral-300 bg-neutral-400 bg-neutral-500 bg-neutral-600 bg-neutral-700 bg-neutral-800 bg-neutral-900 bg-neutral-950", + "Neutral Variant": + "bg-neutral-variant-50 bg-neutral-variant-100 bg-neutral-variant-200 bg-neutral-variant-300 bg-neutral-variant-400 bg-neutral-variant-500 bg-neutral-variant-600 bg-neutral-variant-700 bg-neutral-variant-800 bg-neutral-variant-900 bg-neutral-variant-950", + myCustomColor1: + "bg-myCustomColor1-50 bg-myCustomColor1-100 bg-myCustomColor1-200 bg-myCustomColor1-300 bg-myCustomColor1-400 bg-myCustomColor1-500 bg-myCustomColor1-600 bg-myCustomColor1-700 bg-myCustomColor1-800 bg-myCustomColor1-900 bg-myCustomColor1-950", + myCustomColor2: + "bg-myCustomColor2-50 bg-myCustomColor2-100 bg-myCustomColor2-200 bg-myCustomColor2-300 bg-myCustomColor2-400 bg-myCustomColor2-500 bg-myCustomColor2-600 bg-myCustomColor2-700 bg-myCustomColor2-800 bg-myCustomColor2-900 bg-myCustomColor2-950", +}; + +function TailwindShades() { + return ( +
+ {Object.entries(twShades).map(([title, classes]) => ( +
+

{title}

+
+ {SHADE_TO_TONE.map(([shade, tone], i) => ( +
+ {shade} +
+ ))} +
-
+ ))}
); } @@ -198,330 +222,7 @@ export function TailwindScheme() {
- {/* Shades -- each written as `Shades` writes its tones: in its own - palette's lightest shade on the dark half, its 900 (tone 10) on - the light one. Tailwind has no tone 100, so 50 (tone 95) stands in. */} -
- {/* Primary Shades */} -
-

Primary

-
-
- 50 -
-
- 100 -
-
- 200 -
-
- 300 -
-
- 400 -
-
- 500 -
-
- 600 -
-
- 700 -
-
- 800 -
-
- 900 -
-
- 950 -
-
-
- - {/* Secondary Shades */} -
-

Secondary

-
-
- 50 -
-
- 100 -
-
- 200 -
-
- 300 -
-
- 400 -
-
- 500 -
-
- 600 -
-
- 700 -
-
- 800 -
-
- 900 -
-
- 950 -
-
-
- - {/* Tertiary Shades */} -
-

Tertiary

-
-
- 50 -
-
- 100 -
-
- 200 -
-
- 300 -
-
- 400 -
-
- 500 -
-
- 600 -
-
- 700 -
-
- 800 -
-
- 900 -
-
- 950 -
-
-
- - {/* Error Shades */} -
-

Error

-
-
- 50 -
-
- 100 -
-
- 200 -
-
- 300 -
-
- 400 -
-
- 500 -
-
- 600 -
-
- 700 -
-
- 800 -
-
- 900 -
-
- 950 -
-
-
- - {/* Neutral Shades */} -
-

Neutral

-
-
- 50 -
-
- 100 -
-
- 200 -
-
- 300 -
-
- 400 -
-
- 500 -
-
- 600 -
-
- 700 -
-
- 800 -
-
- 900 -
-
- 950 -
-
-
- - {/* Neutral Variant Shades */} -
-

Neutral Variant

-
-
- 50 -
-
- 100 -
-
- 200 -
-
- 300 -
-
- 400 -
-
- 500 -
-
- 600 -
-
- 700 -
-
- 800 -
-
- 900 -
-
- 950 -
-
-
- - {/* myCustomColor1 Shades */} -
-

myCustomColor1

-
-
- 50 -
-
- 100 -
-
- 200 -
-
- 300 -
-
- 400 -
-
- 500 -
-
- 600 -
-
- 700 -
-
- 800 -
-
- 900 -
-
- 950 -
-
-
- - {/* myCustomColor2 Shades */} -
-

myCustomColor2

-
-
- 50 -
-
- 100 -
-
- 200 -
-
- 300 -
-
- 400 -
-
- 500 -
-
- 600 -
-
- 700 -
-
- 800 -
-
- 900 -
-
- 950 -
-
-
-
+

Every color the @plugin declares is shown here as a diff --git a/src/Scheme.ink.ts b/src/Scheme.ink.ts new file mode 100644 index 0000000..a6eec04 --- /dev/null +++ b/src/Scheme.ink.ts @@ -0,0 +1,67 @@ +/** + * The colors the poster writes its labels in -- dressing, kept here so none of + * it leaks into the swatches: they only ask `roleInk()` or `toneInk()`. + * + * Each label takes the color M3 pairs its swatch with, as Material Theme + * Builder's own poster does. + * + * @see https://m3.material.io/styles/color/roles + */ + +const sys = (role: string) => `var(--md-sys-color-${role})`; + +/** + * The roles whose ink is not simply their `on-` counterpart, or back -- the + * spec pairs them with the role they are meant to be read against. + */ +const exceptions: Record = { + ...Object.fromEntries( + ["primary", "secondary", "tertiary"].flatMap((c) => [ + [`${c}-fixed`, sys(`on-${c}-fixed`)], + [`${c}-fixed-dim`, sys(`on-${c}-fixed`)], + [`on-${c}-fixed-variant`, sys(`${c}-fixed`)], + ]), + ), + ...Object.fromEntries( + [ + "surface-dim", + "surface-bright", + "surface-container-lowest", + "surface-container-low", + "surface-container", + "surface-container-high", + "surface-container-highest", + ].map((s) => [s, sys("on-surface")]), + ), + "on-surface-variant": sys("surface"), + outline: sys("surface"), + "outline-variant": sys("on-surface"), + "inverse-surface": sys("inverse-on-surface"), + "inverse-on-surface": sys("inverse-surface"), + "inverse-primary": sys("inverse-surface"), + "surface-tint": sys("on-primary"), + // Black in both themes, and no role is white in both + scrim: "var(--md-ref-palette-neutral-100)", + shadow: "var(--md-ref-palette-neutral-100)", +}; + +/** + * The ink of a scheme role (kebab-cased, custom colors included): + * `on-primary` on `primary`, `primary` on `on-primary`... + */ +export function roleInk(role: string) { + return ( + exceptions[role] ?? + sys(role.startsWith("on-") ? role.slice(3) : `on-${role}`) + ); +} + +/** + * The ink of a tonal palette's `tone`: the palette's tone 100 on the dark + * half, 10 on the light one -- the tones of `on-primary` (100 on 40) and + * `on-primary-container` (10 on 90). The cut at 50 keeps every pair at about + * 4.5:1 or more. + */ +export function toneInk(palette: string, tone: number) { + return `var(--md-ref-palette-${palette}-${tone <= 50 ? 100 : 10})`; +} diff --git a/src/Scheme.tsx b/src/Scheme.tsx index cb47067..fe90288 100644 --- a/src/Scheme.tsx +++ b/src/Scheme.tsx @@ -42,6 +42,7 @@ import { type HexCustomColor, type TokenName, } from "./lib/builder"; +import { roleInk, toneInk } from "./Scheme.ink"; /** The class names given, space-separated -- `undefined` if there is none. */ function classNames(...names: (string | false | undefined)[]) { @@ -180,107 +181,15 @@ const twClasses = { surfaceTint: "bg-surface-tint", } satisfies Record; -/** The `--md-sys-color-*` custom property of a role, as a CSS color. */ -function sys(role: string) { - return `var(--md-sys-color-${kebabCase(role)})`; -} - -/** - * The color each M3 token's label is written in: the role M3 pairs it with, - * as Material Theme Builder's own poster does — `on-primary` on `primary`, and - * back, `primary` on `on-primary`. - * - * Where the spec pairs a fill with no ink of its own, the ink is the role it - * is meant to be read against: `on-surface` on every surface, `surface` under - * `on-surface-variant` and `outline`, `inverse-surface` under - * `inverse-primary` (its text color on an inverse surface). - * - * `scrim` and `shadow` are black in both themes, and no role is white in both, - * hence the neutral palette's tone 100. - * - * `satisfies` keeps this exhaustive, as for `twClasses`. - * - * @see https://m3.material.io/styles/color/roles - */ -const inks = { - primary: sys("onPrimary"), - onPrimary: sys("primary"), - primaryContainer: sys("onPrimaryContainer"), - onPrimaryContainer: sys("primaryContainer"), - secondary: sys("onSecondary"), - onSecondary: sys("secondary"), - secondaryContainer: sys("onSecondaryContainer"), - onSecondaryContainer: sys("secondaryContainer"), - tertiary: sys("onTertiary"), - onTertiary: sys("tertiary"), - tertiaryContainer: sys("onTertiaryContainer"), - onTertiaryContainer: sys("tertiaryContainer"), - - error: sys("onError"), - onError: sys("error"), - errorContainer: sys("onErrorContainer"), - onErrorContainer: sys("errorContainer"), - - primaryFixed: sys("onPrimaryFixed"), - primaryFixedDim: sys("onPrimaryFixed"), - onPrimaryFixed: sys("primaryFixed"), - onPrimaryFixedVariant: sys("primaryFixed"), - secondaryFixed: sys("onSecondaryFixed"), - secondaryFixedDim: sys("onSecondaryFixed"), - onSecondaryFixed: sys("secondaryFixed"), - onSecondaryFixedVariant: sys("secondaryFixed"), - tertiaryFixed: sys("onTertiaryFixed"), - tertiaryFixedDim: sys("onTertiaryFixed"), - onTertiaryFixed: sys("tertiaryFixed"), - onTertiaryFixedVariant: sys("tertiaryFixed"), - - surfaceDim: sys("onSurface"), - surface: sys("onSurface"), - surfaceBright: sys("onSurface"), - surfaceContainerLowest: sys("onSurface"), - surfaceContainerLow: sys("onSurface"), - surfaceContainer: sys("onSurface"), - surfaceContainerHigh: sys("onSurface"), - surfaceContainerHighest: sys("onSurface"), - onSurface: sys("surface"), - onSurfaceVariant: sys("surface"), - outline: sys("surface"), - outlineVariant: sys("onSurface"), - - inverseSurface: sys("inverseOnSurface"), - inverseOnSurface: sys("inverseSurface"), - inversePrimary: sys("inverseSurface"), - scrim: "var(--md-ref-palette-neutral-100)", - shadow: "var(--md-ref-palette-neutral-100)", - - background: sys("onBackground"), - onBackground: sys("background"), - surfaceVariant: sys("onSurfaceVariant"), - surfaceTint: sys("onPrimary"), -} satisfies Record; - -/** A swatch's label, written in `ink`. */ +/** A swatch's label, written in `ink` -- see `Scheme.ink.ts`. */ function Label({ ink, children }: { ink: string; children: ReactNode }) { return

{children}

; } /** - * The tone a tonal palette's label is written in, on its `tone`: 100 on the - * dark half, 10 on the light one -- the tones M3 inks its own roles with, - * `on-primary` (100) on `primary` (40), `on-primary-container` (10) on - * `primary-container` (90). - * - * The cut at 50 keeps every pair at about 4.5:1 or more: tone 50 under 100, - * tone 60 under 10. - */ -function inkTone(tone: number) { - return tone <= 50 ? 100 : 10; -} - -/** - * One color cell: the role as `title`, its human name as label (in `inks`' - * color), the color itself from `var(--md-sys-color-)` — or from the - * Tailwind utility when under a `tw` `Scheme`. + * One color cell: the role as `title`, its human name as label, the color + * itself from `var(--md-sys-color-)` — or from the Tailwind utility when + * under a `tw` `Scheme`. */ function Swatch({ role, @@ -299,10 +208,14 @@ function Swatch({
- {children ?? } + {children ?? }
); } @@ -742,70 +655,35 @@ export function Scheme({ } {customColors && customColors.length > 0 && (
- {customColors?.map((customColor) => ( -
- - - - - - - - - - - - - - - - - - - - -
- ))} + {customColors.map(({ name }) => { + const role = kebabCase(name); + const label = upperFirst(name); + + return ( +
+ {( + [ + [role, label], + [`on-${role}`, `On ${label}`], + [`${role}-container`, `${label} Container`], + [`on-${role}-container`, `On ${label} Container`], + ] as const + ).map(([role, label]) => ( + + + + + + ))} +
+ ); + })}
)} @@ -869,9 +747,7 @@ export function Shades({ backgroundColor: `var(--md-ref-palette-${isCustom ? kebabCase(name) : name}-${tone})`, }} > -
From 6bf76417b706664934d127ea0b1b68f3f0a83b5d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 08:30:11 +0000 Subject: [PATCH 07/10] fix: paint all three shadcn-claimed roles of Scheme tw with M3 vars primary, secondary and background are the names shadcn's @theme inline takes over from the plugin: without shadcn.css, all three land on shadcn's own colors. bg-[var(--md-sys-color-*)] paints the M3 role whatever else is installed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HnjGFvhGSxgzZZiU33Wiko --- .changeset/scheme-label-inks.md | 5 +++-- src/Scheme.tsx | 14 +++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.changeset/scheme-label-inks.md b/.changeset/scheme-label-inks.md index 9d416f7..5a79565 100644 --- a/.changeset/scheme-label-inks.md +++ b/.changeset/scheme-label-inks.md @@ -12,5 +12,6 @@ Builder's own does, instead of white blended by difference: `Poster` no longer sets any `color` or `mix-blend-mode` on the `

`s it holds. -`` paints `secondary` with `bg-[var(--md-sys-color-secondary)]`: -next to shadcn, `bg-secondary` lands on `secondary-container`. +`` paints `primary`, `secondary` and `background` with +`bg-[var(--md-sys-color-*)]`: next to shadcn, which claims those three names +too, `bg-secondary` lands on `secondary-container`. diff --git a/src/Scheme.tsx b/src/Scheme.tsx index fe90288..a8c340f 100644 --- a/src/Scheme.tsx +++ b/src/Scheme.tsx @@ -117,14 +117,14 @@ const TwContext = createContext(false); * `satisfies` is what keeps this exhaustive: a token added to the library * breaks the build here until its utility is written down. * - * `secondary` alone goes through an arbitrary value: next to shadcn, whose - * `@theme inline` outranks the plugin on the name, `bg-secondary` lands on - * `secondary-container` -- shadcn's `--secondary`. The README's own way out, - * and it paints the M3 role with or without shadcn. (`primary` and - * `background` collide too, but land on the same color.) + * `primary`, `secondary` and `background` go through arbitrary values: they + * are the three names shadcn claims too, and its `@theme inline` outranks the + * plugin on them -- `bg-secondary` would land on `secondary-container`, and + * all three on shadcn's own colors without `shadcn.css`. The README's way + * out, which paints the M3 role whatever else is installed. */ const twClasses = { - primary: "bg-primary", + primary: "bg-[var(--md-sys-color-primary)]", onPrimary: "bg-on-primary", primaryContainer: "bg-primary-container", onPrimaryContainer: "bg-on-primary-container", @@ -175,7 +175,7 @@ const twClasses = { shadow: "bg-shadow", // Dropped from the current spec, still emitted — see `Scheme`'s props - background: "bg-background", + background: "bg-[var(--md-sys-color-background)]", onBackground: "bg-on-background", surfaceVariant: "bg-surface-variant", surfaceTint: "bg-surface-tint", From 39cfb8923c30c1652f668aeb0ba8bca458f02127 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 08:31:36 +0000 Subject: [PATCH 08/10] docs: keep a wildcard class out of the changeset Tailwind scans the repo, and a literal bg-[var(--...-*)] in the changeset became an invalid utility, warned about at build. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HnjGFvhGSxgzZZiU33Wiko --- .changeset/scheme-label-inks.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/scheme-label-inks.md b/.changeset/scheme-label-inks.md index 5a79565..0db572c 100644 --- a/.changeset/scheme-label-inks.md +++ b/.changeset/scheme-label-inks.md @@ -12,6 +12,6 @@ Builder's own does, instead of white blended by difference: `Poster` no longer sets any `color` or `mix-blend-mode` on the `

`s it holds. -`` paints `primary`, `secondary` and `background` with -`bg-[var(--md-sys-color-*)]`: next to shadcn, which claims those three names -too, `bg-secondary` lands on `secondary-container`. +`` paints `primary`, `secondary` and `background` straight from +their `--md-sys-color-*` property, as an arbitrary value: next to shadcn, which +claims those three names too, `bg-secondary` lands on `secondary-container`. From e1cc7c9b3cd1f6cfc9a16f414cb46809268f7f5a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 08:41:40 +0000 Subject: [PATCH 09/10] refactor: let Scheme and Shades paint every Tailwind swatch themselves The Tailwind story re-drew the custom colors and the shades by hand, because Scheme and Shades could not paint them with utilities. Now Scheme's tw mode covers the custom colors (bg-on-myCustomColor1...) and Shades gets a tw mode painting the plugin's eleven shades (bg-primary-500...), both inked by Scheme.ink.ts like any swatch. Their utilities are named at runtime, so globals.css lists them for Tailwind with @source inline(). The story is now just and . Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HnjGFvhGSxgzZZiU33Wiko --- .changeset/scheme-label-inks.md | 5 ++ src/Mtb.stories.helpers.tsx | 133 ++++---------------------------- src/Mtb.stories.tsx | 2 +- src/Scheme.tsx | 112 +++++++++++++++++---------- src/styles/globals.css | 3 + 5 files changed, 96 insertions(+), 159 deletions(-) diff --git a/.changeset/scheme-label-inks.md b/.changeset/scheme-label-inks.md index 0db572c..fc32a28 100644 --- a/.changeset/scheme-label-inks.md +++ b/.changeset/scheme-label-inks.md @@ -15,3 +15,8 @@ Builder's own does, instead of white blended by difference: `` paints `primary`, `secondary` and `background` straight from their `--md-sys-color-*` property, as an arbitrary value: next to shadcn, which claims those three names too, `bg-secondary` lands on `secondary-container`. + +`` paints the custom colors with their utilities too +(`bg-on-brand`…), and `` paints the plugin's eleven Tailwind shades +(`bg-primary-500`…). Both are named at runtime, so list them for Tailwind with +`@source inline()`. diff --git a/src/Mtb.stories.helpers.tsx b/src/Mtb.stories.helpers.tsx index 9095289..721b4a7 100644 --- a/src/Mtb.stories.helpers.tsx +++ b/src/Mtb.stories.helpers.tsx @@ -1,5 +1,4 @@ import type { Meta } from "@storybook/react-vite"; -import { kebabCase } from "lodash-es"; import { useState, type ComponentProps } from "react"; import { Fab } from "./components/m3/Fab"; import { ExportButton } from "./ExportButton"; @@ -10,10 +9,8 @@ import { schemeNames, type MtbConfig, } from "./lib/builder"; -import { SHADE_TO_TONE } from "./lib/tokens"; import type { Mtb } from "./Mtb"; import { Poster, Scheme, Shades } from "./Scheme"; -import { roleInk, toneInk } from "./Scheme.ink"; /** * ``'s props as controls, shared by every story that themes with them. @@ -95,140 +92,44 @@ export function Layout({ } /** - * The custom-color roles, as Tailwind utilities. + * Renders every M3 role as a Tailwind utility class: `Scheme` and `Shades`, + * switched to their `tw` mode, where each swatch takes its color from `bg-*` + * instead of `var(--md-sys-color-*)`. * - * `Scheme` renders custom colors through inline `var(--md-sys-color-*)` styles, - * because their names only exist at runtime — `bg-${name}` would never be seen - * by Tailwind's source scanner, so the utility would never be generated. Here - * the names are known, so the classes can be written literally and actually - * prove that `bg-myCustomColor1` & co. resolve. + * The utilities they name at runtime -- shades, custom colors -- are listed for + * Tailwind by an `@source inline()` in `globals.css`. */ -const twCustomColors = [1, 2].map((n): [string, string][] => [ - [`my-custom-color-${n}`, `MyCustomColor${n}`], - [`on-my-custom-color-${n}`, `On MyCustomColor${n}`], - [`my-custom-color-${n}-container`, `MyCustomColor${n} Container`], - [`on-my-custom-color-${n}-container`, `On MyCustomColor${n} Container`], -]); -const twCustomColorClasses: Record = { - "my-custom-color-1": "bg-myCustomColor1", - "on-my-custom-color-1": "bg-on-myCustomColor1", - "my-custom-color-1-container": "bg-myCustomColor1-container", - "on-my-custom-color-1-container": "bg-on-myCustomColor1-container", - "my-custom-color-2": "bg-myCustomColor2", - "on-my-custom-color-2": "bg-on-myCustomColor2", - "my-custom-color-2-container": "bg-myCustomColor2-container", - "on-my-custom-color-2-container": "bg-on-myCustomColor2-container", -}; - -function TailwindCustomColors() { - return ( -

- {twCustomColors.map((roles, i) => ( -
- {roles.map(([role, label]) => ( -
-

{label}

-
- ))} -
- ))} -
- ); -} - -/** - * The tonal shades, as Tailwind utilities -- spelled out, for the same - * scanner, in `SHADE_TO_TONE`'s order. - */ -const twShades = { - Primary: - "bg-primary-50 bg-primary-100 bg-primary-200 bg-primary-300 bg-primary-400 bg-primary-500 bg-primary-600 bg-primary-700 bg-primary-800 bg-primary-900 bg-primary-950", - Secondary: - "bg-secondary-50 bg-secondary-100 bg-secondary-200 bg-secondary-300 bg-secondary-400 bg-secondary-500 bg-secondary-600 bg-secondary-700 bg-secondary-800 bg-secondary-900 bg-secondary-950", - Tertiary: - "bg-tertiary-50 bg-tertiary-100 bg-tertiary-200 bg-tertiary-300 bg-tertiary-400 bg-tertiary-500 bg-tertiary-600 bg-tertiary-700 bg-tertiary-800 bg-tertiary-900 bg-tertiary-950", - Error: - "bg-error-50 bg-error-100 bg-error-200 bg-error-300 bg-error-400 bg-error-500 bg-error-600 bg-error-700 bg-error-800 bg-error-900 bg-error-950", - Neutral: - "bg-neutral-50 bg-neutral-100 bg-neutral-200 bg-neutral-300 bg-neutral-400 bg-neutral-500 bg-neutral-600 bg-neutral-700 bg-neutral-800 bg-neutral-900 bg-neutral-950", - "Neutral Variant": - "bg-neutral-variant-50 bg-neutral-variant-100 bg-neutral-variant-200 bg-neutral-variant-300 bg-neutral-variant-400 bg-neutral-variant-500 bg-neutral-variant-600 bg-neutral-variant-700 bg-neutral-variant-800 bg-neutral-variant-900 bg-neutral-variant-950", - myCustomColor1: - "bg-myCustomColor1-50 bg-myCustomColor1-100 bg-myCustomColor1-200 bg-myCustomColor1-300 bg-myCustomColor1-400 bg-myCustomColor1-500 bg-myCustomColor1-600 bg-myCustomColor1-700 bg-myCustomColor1-800 bg-myCustomColor1-900 bg-myCustomColor1-950", - myCustomColor2: - "bg-myCustomColor2-50 bg-myCustomColor2-100 bg-myCustomColor2-200 bg-myCustomColor2-300 bg-myCustomColor2-400 bg-myCustomColor2-500 bg-myCustomColor2-600 bg-myCustomColor2-700 bg-myCustomColor2-800 bg-myCustomColor2-900 bg-myCustomColor2-950", -}; - -function TailwindShades() { - return ( -
- {Object.entries(twShades).map(([title, classes]) => ( -
-

{title}

-
- {SHADE_TO_TONE.map(([shade, tone], i) => ( -
- {shade} -
- ))} -
-
- ))} -
- ); -} - -/** - * Renders every M3 role as a Tailwind utility class. - * - * Reuses the `Scheme` layout — switched to its `tw` mode, where each swatch - * takes its color from `bg-*` instead of `var(--md-sys-color-*)` — and - * completes it with what `Scheme` cannot express as classes: the custom colors, - * and the tonal shades. - */ -export function TailwindScheme() { +export function TailwindScheme({ + customColors, +}: Pick, "customColors">) { return ( <> - - - + /> - - - -
- + /> + -

- Every color the @plugin declares is shown here as a - Tailwind utility class -

-
+

+ Every color the @plugin declares is shown here as a + Tailwind utility class +

); } diff --git a/src/Mtb.stories.tsx b/src/Mtb.stories.tsx index df4690c..445498c 100644 --- a/src/Mtb.stories.tsx +++ b/src/Mtb.stories.tsx @@ -713,7 +713,7 @@ export const TailwindSt: Story = { render: (args) => ( - + ), diff --git a/src/Scheme.tsx b/src/Scheme.tsx index a8c340f..0bbb5e6 100644 --- a/src/Scheme.tsx +++ b/src/Scheme.tsx @@ -13,7 +13,7 @@ * Its layout needs no Tailwind: it is shipped compiled, in `node_modules`, * where a consumer's Tailwind does not look for class names -- so it is written * as inline styles, sized by the custom properties `Poster` declares. Only - * `Scheme`'s opt-in `tw` mode uses utilities. + * `Scheme`'s and `Shades`' opt-in `tw` mode uses utilities. * * @example * ```tsx @@ -42,6 +42,7 @@ import { type HexCustomColor, type TokenName, } from "./lib/builder"; +import { CORE_PALETTES, SHADE_TO_TONE } from "./lib/tokens"; import { roleInk, toneInk } from "./Scheme.ink"; /** The class names given, space-separated -- `undefined` if there is none. */ @@ -373,6 +374,9 @@ export function Scheme({ * package for the utilities -- which it does not do in `node_modules` unless * told to: `@source "../node_modules/material-theme-builder/dist/react.js";` * (relative to the stylesheet). + * + * The custom colors' utilities are named at runtime, which no scanner sees: + * list them, e.g. `@source inline("bg-{,on-}brand{,-container}");`. */ tw?: boolean; /** The custom colors to show, as `` got them. */ @@ -663,19 +667,32 @@ export function Scheme({
{( [ - [role, label], - [`on-${role}`, `On ${label}`], - [`${role}-container`, `${label} Container`], - [`on-${role}-container`, `On ${label} Container`], + [role, name, label], + [`on-${role}`, `on-${name}`, `On ${label}`], + [ + `${role}-container`, + `${name}-container`, + `${label} Container`, + ], + [ + `on-${role}-container`, + `on-${name}-container`, + `On ${label} Container`, + ], ] as const - ).map(([role, label]) => ( + ).map(([role, twName, label]) => ( @@ -700,61 +717,72 @@ export function Scheme({ export function Shades({ customColors, noTitle, + tw = false, }: { /** Hide the palette group titles. */ noTitle?: boolean; /** The custom colors to show, as `` got them. */ customColors?: HexCustomColor[]; + /** + * Paint the plugin's eleven Tailwind shades (`bg-primary-500`) instead of + * every tone from `var(--md-ref-palette-*)`, as `Scheme`'s `tw` does. + * + * Their utilities are named at runtime, which no scanner sees: list them, + * e.g. `@source inline("bg-{primary,brand}-{50,{100..900..100},950}");`. + */ + tw?: boolean; }) { + const tones = tw + ? SHADE_TO_TONE.map(([shade, tone]) => ({ label: shade, tone })) + : STANDARD_TONES.slice() + .reverse() + .map((tone) => ({ label: tone, tone })); + return (
{[ - ...[ - "primary", - "secondary", - "tertiary", - "error", - "neutral", - "neutral-variant", - ].map((name) => ({ name, isCustom: false })), + ...CORE_PALETTES.map((name) => ({ name, isCustom: false })), ...(customColors?.map((cc) => ({ name: cc.name, isCustom: true })) || []), - ].map(({ name, isCustom }) => ( -
- {!noTitle && ( -

- {isCustom ? upperFirst(name) : name.replace("-", " ")} -

- )} - -
- {STANDARD_TONES.slice() - .reverse() - .map((tone) => ( + ].map(({ name, isCustom }) => { + const palette = isCustom ? kebabCase(name) : name; + + return ( +
+ {!noTitle && ( +

+ {isCustom ? upperFirst(name) : name.replace("-", " ")} +

+ )} + +
+ {tones.map(({ label, tone }) => (
- +
))} +
-
- ))} + ); + })}
); } diff --git a/src/styles/globals.css b/src/styles/globals.css index 86bf032..2988be1 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -39,6 +39,9 @@ @plugin "../tailwind-plugin.ts" { custom-colors: myCustomColor1, myCustomColor2; } +/* The utilities `` / `` name at runtime -- no scanner sees them. */ +@source inline("bg-{primary,secondary,tertiary,error,neutral,neutral-variant,myCustomColor1,myCustomColor2}-{50,{100..900..100},950}"); +@source inline("bg-{,on-}{myCustomColor1,myCustomColor2}{,-container}"); @custom-variant dark (&:is(.dark *)); From 016eac6c246f8625346e36397edf964f75469b07 Mon Sep 17 00:00:00 2001 From: Antoine BERNIER Date: Tue, 6 Oct 2026 11:55:24 +0200 Subject: [PATCH 10/10] fix: pair custom-color inks explicitly and spell out the core shade utilities Review of #217. - `Scheme` names each custom role's ink itself: `roleInk()`'s `on-` rule mis-inked a custom color named `onSale`, and read `Object.prototype` for one named `constructor`. One `Cell` paints every swatch; `Swatch` types it. - `Shades tw` paints the six core palettes from a literal `shadeClasses` table, which the scanner sees in `dist/react.js`, so the `@source inline()` safelist shrinks to the custom colors. It follows a ``'s context when nested, and its tooltips name the utility too. - `globals.css`: the safelist sits in the marked block, which names it. - `Scheme.ink.ts`: the table written out, scrim and shadow inked `white`, `sysColorVar()` / `refPaletteVar()` shared with the plugin. - Tests: every ink names a property `toCss()` declares, and the safelist generates exactly the utilities the components name at runtime. - Changeset `minor`, with the `tw` migration; the docs say Tailwind 4.1+. Co-Authored-By: Claude Fable 5.1 --- .changeset/scheme-label-inks.md | 18 +- src/Mtb.stories.helpers.tsx | 8 +- src/Scheme.ink.test.ts | 105 +++++++++++ src/Scheme.ink.ts | 86 +++++---- src/Scheme.tsx | 311 ++++++++++++++++++++++++-------- src/lib/tokens.ts | 29 ++- src/styles/globals.css | 12 +- src/tailwind-plugin.test.ts | 73 ++++++++ src/tailwind-plugin.ts | 6 +- 9 files changed, 521 insertions(+), 127 deletions(-) create mode 100644 src/Scheme.ink.test.ts diff --git a/.changeset/scheme-label-inks.md b/.changeset/scheme-label-inks.md index fc32a28..fe0d23b 100644 --- a/.changeset/scheme-label-inks.md +++ b/.changeset/scheme-label-inks.md @@ -1,5 +1,5 @@ --- -"material-theme-builder": patch +"material-theme-builder": minor --- The poster writes each label in the color M3 pairs it with, as Material Theme @@ -10,13 +10,19 @@ Builder's own does, instead of white blended by difference: - `Shades`: each tone in its own palette's tone 100 on the dark half, 10 on the light one — the tones of `on-primary` and `on-primary-container`. -`Poster` no longer sets any `color` or `mix-blend-mode` on the `

`s it holds. +`Poster` no longer sets any `color` or `mix-blend-mode` on the `

`s it holds: +it inks only the labels `Scheme` and `Shades` write, and whatever else goes in +paints its own. `` paints `primary`, `secondary` and `background` straight from their `--md-sys-color-*` property, as an arbitrary value: next to shadcn, which claims those three names too, `bg-secondary` lands on `secondary-container`. -`` paints the custom colors with their utilities too -(`bg-on-brand`…), and `` paints the plugin's eleven Tailwind shades -(`bg-primary-500`…). Both are named at runtime, so list them for Tailwind with -`@source inline()`. +`` (new prop, which follows `` when nested in one) paints +the plugin's eleven Tailwind shades (`bg-primary-500`…), and `` +paints the custom colors with their utilities too (`bg-on-brand`…). A custom +color's utilities are named at runtime, which no scanner sees: under `tw`, list +them with `@source inline()` (Tailwind 4.1+), e.g. +`@source inline("bg-{,on-}brand{,-container} bg-brand-{50,{100..900..100},950}");` +— without it those cells now paint nothing, where they used to fall back to +`var()`. diff --git a/src/Mtb.stories.helpers.tsx b/src/Mtb.stories.helpers.tsx index 721b4a7..ef0ddcb 100644 --- a/src/Mtb.stories.helpers.tsx +++ b/src/Mtb.stories.helpers.tsx @@ -96,8 +96,8 @@ export function Layout({ * switched to their `tw` mode, where each swatch takes its color from `bg-*` * instead of `var(--md-sys-color-*)`. * - * The utilities they name at runtime -- shades, custom colors -- are listed for - * Tailwind by an `@source inline()` in `globals.css`. + * The custom colors' utilities, named at runtime, are listed for Tailwind by + * the `@source inline()` lines in `globals.css`. */ export function TailwindScheme({ customColors, @@ -128,7 +128,9 @@ export function TailwindScheme({

Every color the @plugin declares is shown here as a - Tailwind utility class + Tailwind utility class, but primary, secondary{" "} + and background, which shadcn claims too: those are painted + from their M3 property

); diff --git a/src/Scheme.ink.test.ts b/src/Scheme.ink.test.ts new file mode 100644 index 0000000..ff22ad4 --- /dev/null +++ b/src/Scheme.ink.test.ts @@ -0,0 +1,105 @@ +/** + * `roleInk()` and `toneInk()` answer with a `var()` of a property they never + * see declared -- `toCss()` declares them, elsewhere. A `var()` of a property + * nobody declares paints nothing and throws nothing, so a typo'd key in the + * `exceptions` table, or a token the library grows later and the `on-` rule + * takes in, would only ever show as a label that cannot be read. This pins + * every ink the poster can ask for to a property `toCss()` emits. + */ + +import { kebabCase } from "lodash-es"; +import { describe, expect, it } from "vitest"; + +import { builder, STANDARD_TONES } from "./lib/builder"; +import { CORE_PALETTES, tokenNames } from "./lib/tokens"; +import { roleInk, toneInk } from "./Scheme.ink"; + +/** The custom properties `css` declares, by name. */ +function declaredProperties(css: string) { + return new Set( + css.match(/--md-(?:sys-color|ref-palette)-[a-z0-9-]+(?=:)/g) ?? [], + ); +} + +/** The property a `var(--x)` reads -- `undefined` for any other ink. */ +function propertyOf(ink: string) { + return /^var\((--[a-z0-9-]+)\)$/.exec(ink)?.[1]; +} + +const brand = { name: "brand", hex: "#00D68A" }; +const roles = tokenNames.map((name) => kebabCase(name)); + +describe("roleInk()", () => { + const declared = declaredProperties(builder("#769CDF").toCss()); + + it("should ink every scheme role with a declared role, or white", () => { + const undeclared = roles + .map((role) => [role, roleInk(role)] as const) + .filter( + ([, ink]) => ink !== "white" && !declared.has(propertyOf(ink) ?? ""), + ); + + expect(undeclared).toEqual([]); + }); + + it("should ink only scrim and shadow with white", () => { + // The two roles black in both themes, when no role is white in both + expect(roles.filter((role) => roleInk(role) === "white").sort()).toEqual([ + "scrim", + "shadow", + ]); + }); + + // A custom color's four roles, paired the way `Scheme` pairs them: it names + // the inks itself, so what has to hold is that both sides are declared. + it.each([ + ["brand", "on-brand"], + ["on-brand", "brand"], + ["brand-container", "on-brand-container"], + ["on-brand-container", "brand-container"], + ])("should find a custom color's %s and its ink %s declared", (role, ink) => { + const withBrand = declaredProperties( + builder("#769CDF", { customColors: [brand] }).toCss(), + ); + + expect(withBrand.has(`--md-sys-color-${role}`)).toBe(true); + expect(withBrand.has(`--md-sys-color-${ink}`)).toBe(true); + }); + + it("should not read the exceptions table through its prototype", () => { + // `exceptions` is a plain object: looked up as is, `constructor` would + // come back as `Object`, and `Object.hasOwn` is what keeps it out. + expect(roleInk("constructor")).toBe("var(--md-sys-color-on-constructor)"); + }); +}); + +describe("toneInk()", () => { + const declared = declaredProperties( + builder("#769CDF", { customColors: [brand] }).toCss(), + ); + + it("should ink every tone of every palette with a declared tone", () => { + const undeclared = [...CORE_PALETTES, "brand"] + .flatMap((palette) => + STANDARD_TONES.map( + (tone) => [palette, tone, toneInk(palette, tone)] as const, + ), + ) + .filter(([, , ink]) => !declared.has(propertyOf(ink) ?? "")); + + expect(undeclared).toEqual([]); + }); + + it.each([ + [0, 100], + [50, 100], + [60, 10], + [100, 10], + ])("should ink tone %i with tone %i", (tone, ink) => { + // The cut sits at 50: the dark half takes the palette's white, the light + // half its black. + expect(toneInk("primary", tone)).toBe( + `var(--md-ref-palette-primary-${ink})`, + ); + }); +}); diff --git a/src/Scheme.ink.ts b/src/Scheme.ink.ts index a6eec04..df75b23 100644 --- a/src/Scheme.ink.ts +++ b/src/Scheme.ink.ts @@ -5,54 +5,71 @@ * Each label takes the color M3 pairs its swatch with, as Material Theme * Builder's own poster does. * + * The pairs hold at the default contrast. `` moves the tones + * the surfaces sit on, and at the extremes two labels lose their legibility: + * `outline-variant` at contrast 1, `outline` at contrast -1. No fixed role + * reads on either across the whole range, and a poster painted from `var()` + * cannot pick its ink from the tone underneath. + * * @see https://m3.material.io/styles/color/roles */ -const sys = (role: string) => `var(--md-sys-color-${role})`; +import { refPaletteVar, sysColorVar } from "./lib/tokens"; /** * The roles whose ink is not simply their `on-` counterpart, or back -- the * spec pairs them with the role they are meant to be read against. */ const exceptions: Record = { - ...Object.fromEntries( - ["primary", "secondary", "tertiary"].flatMap((c) => [ - [`${c}-fixed`, sys(`on-${c}-fixed`)], - [`${c}-fixed-dim`, sys(`on-${c}-fixed`)], - [`on-${c}-fixed-variant`, sys(`${c}-fixed`)], - ]), - ), - ...Object.fromEntries( - [ - "surface-dim", - "surface-bright", - "surface-container-lowest", - "surface-container-low", - "surface-container", - "surface-container-high", - "surface-container-highest", - ].map((s) => [s, sys("on-surface")]), - ), - "on-surface-variant": sys("surface"), - outline: sys("surface"), - "outline-variant": sys("on-surface"), - "inverse-surface": sys("inverse-on-surface"), - "inverse-on-surface": sys("inverse-surface"), - "inverse-primary": sys("inverse-surface"), - "surface-tint": sys("on-primary"), + "primary-fixed": sysColorVar("on-primary-fixed"), + "primary-fixed-dim": sysColorVar("on-primary-fixed"), + "on-primary-fixed-variant": sysColorVar("primary-fixed"), + "secondary-fixed": sysColorVar("on-secondary-fixed"), + "secondary-fixed-dim": sysColorVar("on-secondary-fixed"), + "on-secondary-fixed-variant": sysColorVar("secondary-fixed"), + "tertiary-fixed": sysColorVar("on-tertiary-fixed"), + "tertiary-fixed-dim": sysColorVar("on-tertiary-fixed"), + "on-tertiary-fixed-variant": sysColorVar("tertiary-fixed"), + + "surface-dim": sysColorVar("on-surface"), + "surface-bright": sysColorVar("on-surface"), + "surface-container-lowest": sysColorVar("on-surface"), + "surface-container-low": sysColorVar("on-surface"), + "surface-container": sysColorVar("on-surface"), + "surface-container-high": sysColorVar("on-surface"), + "surface-container-highest": sysColorVar("on-surface"), + + "on-surface-variant": sysColorVar("surface"), + outline: sysColorVar("surface"), + "outline-variant": sysColorVar("on-surface"), + "inverse-surface": sysColorVar("inverse-on-surface"), + "inverse-on-surface": sysColorVar("inverse-surface"), + "inverse-primary": sysColorVar("inverse-surface"), + "surface-tint": sysColorVar("on-primary"), // Black in both themes, and no role is white in both - scrim: "var(--md-ref-palette-neutral-100)", - shadow: "var(--md-ref-palette-neutral-100)", + scrim: "white", + shadow: "white", }; /** - * The ink of a scheme role (kebab-cased, custom colors included): - * `on-primary` on `primary`, `primary` on `on-primary`... + * The ink of a scheme role: `on-primary` on `primary`, `primary` on + * `on-primary`... + * + * For the scheme's own roles. A custom color's four roles are paired by + * `Scheme` itself, which knows which is which -- a name like `onSale` would + * fool the `on-` rule below. + * + * @param role The role, kebab-cased: the `--md-sys-color-*` suffix. */ export function roleInk(role: string) { + // A plain object: looked up as is, `constructor` would read Object.prototype + const exception = Object.hasOwn(exceptions, role) + ? exceptions[role] + : undefined; + return ( - exceptions[role] ?? - sys(role.startsWith("on-") ? role.slice(3) : `on-${role}`) + exception ?? + sysColorVar(role.startsWith("on-") ? role.slice(3) : `on-${role}`) ); } @@ -61,7 +78,10 @@ export function roleInk(role: string) { * half, 10 on the light one -- the tones of `on-primary` (100 on 40) and * `on-primary-container` (10 on 90). The cut at 50 keeps every pair at about * 4.5:1 or more. + * + * @param palette The palette, kebab-cased: `primary`, `neutral-variant`... + * @param tone The tone the label sits on, 0 to 100. */ export function toneInk(palette: string, tone: number) { - return `var(--md-ref-palette-${palette}-${tone <= 50 ? 100 : 10})`; + return refPaletteVar(palette, tone <= 50 ? 100 : 10); } diff --git a/src/Scheme.tsx b/src/Scheme.tsx index 0bbb5e6..c156173 100644 --- a/src/Scheme.tsx +++ b/src/Scheme.tsx @@ -42,7 +42,12 @@ import { type HexCustomColor, type TokenName, } from "./lib/builder"; -import { CORE_PALETTES, SHADE_TO_TONE } from "./lib/tokens"; +import { + CORE_PALETTES, + refPaletteVar, + SHADE_TO_TONE, + sysColorVar, +} from "./lib/tokens"; import { roleInk, toneInk } from "./Scheme.ink"; /** The class names given, space-separated -- `undefined` if there is none. */ @@ -182,45 +187,160 @@ const twClasses = { surfaceTint: "bg-surface-tint", } satisfies Record; +/** A Tailwind shade of the plugin's: `50`, `100`... `950`. */ +type Shade = (typeof SHADE_TO_TONE)[number][0]; + +/** + * The eleven shades of each core palette, mapped to their Tailwind utilities + * -- spelled out for the same reason as `twClasses`: built at runtime, + * `bg-${palette}-${shade}` would never be seen by the scanner. `satisfies` + * keeps both axes exhaustive. + * + * A custom color's shades cannot be spelled out here: see `Shades`' `tw`. + */ +const shadeClasses = { + primary: { + 50: "bg-primary-50", + 100: "bg-primary-100", + 200: "bg-primary-200", + 300: "bg-primary-300", + 400: "bg-primary-400", + 500: "bg-primary-500", + 600: "bg-primary-600", + 700: "bg-primary-700", + 800: "bg-primary-800", + 900: "bg-primary-900", + 950: "bg-primary-950", + }, + secondary: { + 50: "bg-secondary-50", + 100: "bg-secondary-100", + 200: "bg-secondary-200", + 300: "bg-secondary-300", + 400: "bg-secondary-400", + 500: "bg-secondary-500", + 600: "bg-secondary-600", + 700: "bg-secondary-700", + 800: "bg-secondary-800", + 900: "bg-secondary-900", + 950: "bg-secondary-950", + }, + tertiary: { + 50: "bg-tertiary-50", + 100: "bg-tertiary-100", + 200: "bg-tertiary-200", + 300: "bg-tertiary-300", + 400: "bg-tertiary-400", + 500: "bg-tertiary-500", + 600: "bg-tertiary-600", + 700: "bg-tertiary-700", + 800: "bg-tertiary-800", + 900: "bg-tertiary-900", + 950: "bg-tertiary-950", + }, + error: { + 50: "bg-error-50", + 100: "bg-error-100", + 200: "bg-error-200", + 300: "bg-error-300", + 400: "bg-error-400", + 500: "bg-error-500", + 600: "bg-error-600", + 700: "bg-error-700", + 800: "bg-error-800", + 900: "bg-error-900", + 950: "bg-error-950", + }, + neutral: { + 50: "bg-neutral-50", + 100: "bg-neutral-100", + 200: "bg-neutral-200", + 300: "bg-neutral-300", + 400: "bg-neutral-400", + 500: "bg-neutral-500", + 600: "bg-neutral-600", + 700: "bg-neutral-700", + 800: "bg-neutral-800", + 900: "bg-neutral-900", + 950: "bg-neutral-950", + }, + "neutral-variant": { + 50: "bg-neutral-variant-50", + 100: "bg-neutral-variant-100", + 200: "bg-neutral-variant-200", + 300: "bg-neutral-variant-300", + 400: "bg-neutral-variant-400", + 500: "bg-neutral-variant-500", + 600: "bg-neutral-variant-600", + 700: "bg-neutral-variant-700", + 800: "bg-neutral-variant-800", + 900: "bg-neutral-variant-900", + 950: "bg-neutral-variant-950", + }, +} satisfies Record<(typeof CORE_PALETTES)[number], Record>; + /** A swatch's label, written in `ink` -- see `Scheme.ink.ts`. */ function Label({ ink, children }: { ink: string; children: ReactNode }) { return

{children}

; } /** - * One color cell: the role as `title`, its human name as label, the color - * itself from `var(--md-sys-color-)` — or from the Tailwind utility when - * under a `tw` `Scheme`. + * One color cell: `name` as `title`, `label` written in `ink`, the color + * itself from `var(--md-sys-color-)` -- or from `twClass` when under a + * `tw` `Scheme`. */ -function Swatch({ - role, +function Cell({ + name, + twClass, + ink, + label, className, style, - children, ...props }: { - /** The M3 token to paint, named as the library names it. */ - role: TokenName; -} & ComponentProps<"div">) { + /** The kebab-cased role: the `--md-sys-color-*` suffix. */ + name: string; + /** The Tailwind utility that paints it, for a `tw` `Scheme`. */ + twClass: string; + /** The label's color -- see `Scheme.ink.ts`. */ + ink: string; + label: ReactNode; +} & Omit, "title" | "children">) { const tw = useContext(TwContext); - const name = kebabCase(role); return (
- {children ?? } +
); } +/** One M3 role's cell: the token as `title`, its human name as label. */ +function Swatch({ + role, + ...props +}: { + /** The M3 token to paint, named as the library names it. */ + role: TokenName; +} & Omit, "title" | "children">) { + const name = kebabCase(role); + + return ( + + ); +} + /** * The gaps, label size and cell heights `Scheme` and `Shades` paint with, * `@scope`d to the element this sits in -- `Poster`'s. @@ -269,6 +389,9 @@ function PosterStyle({ notext }: { notext?: boolean }) { * Without it they still render, but with no gaps and unstyled labels -- so * wrap them all in one, the way the stories do. Lay it out like any `div`, * e.g. as a column with some gap between the schemes. + * + * It inks only the labels `Scheme` and `Shades` write; whatever else goes in + * paints its own. */ export function Poster({ notext = false, @@ -364,8 +487,9 @@ export function Scheme({ /** Heading displayed above the scheme. */ title?: string; /** - * Paint the swatches with Tailwind utilities (`bg-primary`) instead of the - * raw `var(--md-sys-color-primary)`. + * Paint the swatches with Tailwind utilities (`bg-on-primary`) instead of + * the raw `var(--md-sys-color-on-primary)` -- all but `primary`, `secondary` + * and `background`, which go through an arbitrary value: see `twClasses`. * * Off by default: Tailwind is optional here, so the stories are better proof * of the theme when they do without it. Only the Tailwind story turns it on — @@ -375,8 +499,9 @@ export function Scheme({ * told to: `@source "../node_modules/material-theme-builder/dist/react.js";` * (relative to the stylesheet). * - * The custom colors' utilities are named at runtime, which no scanner sees: - * list them, e.g. `@source inline("bg-{,on-}brand{,-container}");`. + * A custom color's utilities are named at runtime, which no scanner sees: + * list them, e.g. `@source inline("bg-{,on-}brand{,-container}");` + * (Tailwind 4.1+). */ tw?: boolean; /** The custom colors to show, as `` got them. */ @@ -663,39 +788,47 @@ export function Scheme({ const role = kebabCase(name); const label = upperFirst(name); + // Each of the four roles, the one it is read against (its ink), + // and its utility -- which keeps the declared spelling, as the + // plugin registers it. + const cells = [ + { + role, + ink: `on-${role}`, + twClass: `bg-${name}`, + label, + }, + { + role: `on-${role}`, + ink: role, + twClass: `bg-on-${name}`, + label: `On ${label}`, + }, + { + role: `${role}-container`, + ink: `on-${role}-container`, + twClass: `bg-${name}-container`, + label: `${label} Container`, + }, + { + role: `on-${role}-container`, + ink: `${role}-container`, + twClass: `bg-on-${name}-container`, + label: `On ${label} Container`, + }, + ]; + return (
- {( - [ - [role, name, label], - [`on-${role}`, `on-${name}`, `On ${label}`], - [ - `${role}-container`, - `${name}-container`, - `${label} Container`, - ], - [ - `on-${role}-container`, - `on-${name}-container`, - `On ${label} Container`, - ], - ] as const - ).map(([role, twName, label]) => ( - - - - + {cells.map((c) => ( + + ))}
@@ -717,7 +850,7 @@ export function Scheme({ export function Shades({ customColors, noTitle, - tw = false, + tw: twProp, }: { /** Hide the palette group titles. */ noTitle?: boolean; @@ -725,47 +858,69 @@ export function Shades({ customColors?: HexCustomColor[]; /** * Paint the plugin's eleven Tailwind shades (`bg-primary-500`) instead of - * every tone from `var(--md-ref-palette-*)`, as `Scheme`'s `tw` does. + * every tone from `var(--md-ref-palette-*)`, as `Scheme`'s `tw` does -- and + * follows it, when nested in one. * - * Their utilities are named at runtime, which no scanner sees: list them, - * e.g. `@source inline("bg-{primary,brand}-{50,{100..900..100},950}");`. + * The core palettes' utilities are spelled out (`shadeClasses`); a custom + * color's are named at runtime, which no scanner sees: list them, e.g. + * `@source inline("bg-brand-{50,{100..900..100},950}");` (Tailwind 4.1+). */ tw?: boolean; }) { - const tones = tw - ? SHADE_TO_TONE.map(([shade, tone]) => ({ label: shade, tone })) - : STANDARD_TONES.slice() - .reverse() - .map((tone) => ({ label: tone, tone })); + const twContext = useContext(TwContext); + const tw = twProp ?? twContext; + + const palettes = [ + ...CORE_PALETTES.map((name) => ({ + name, + palette: name, + title: name.replace("-", " "), + shadeClass: (shade: Shade) => shadeClasses[name][shade], + })), + ...(customColors ?? []).map(({ name }) => ({ + name, + palette: kebabCase(name), + title: upperFirst(name), + // Named at runtime -- see `tw` + shadeClass: (shade: Shade) => `bg-${name}-${shade}`, + })), + ]; return (
- {[ - ...CORE_PALETTES.map((name) => ({ name, isCustom: false })), - ...(customColors?.map((cc) => ({ name: cc.name, isCustom: true })) || - []), - ].map(({ name, isCustom }) => { - const palette = isCustom ? kebabCase(name) : name; + {palettes.map(({ name, palette, title, shadeClass }) => { + // A row: the plugin's eleven shades, or every standard tone + const cells = tw + ? SHADE_TO_TONE.map(([shade, tone]) => ({ + tone, + label: shade, + className: shadeClass(shade), + tooltip: `${palette}-${tone} (${shadeClass(shade)})`, + })) + : STANDARD_TONES.slice() + .reverse() + .map((tone) => ({ + tone, + label: tone, + className: undefined, + tooltip: `${palette}-${tone}`, + })); return (
- {!noTitle && ( -

- {isCustom ? upperFirst(name) : name.replace("-", " ")} -

- )} + {!noTitle &&

{title}

}
- {tones.map(({ label, tone }) => ( + {cells.map(({ tone, label, className, tooltip }) => (
diff --git a/src/lib/tokens.ts b/src/lib/tokens.ts index b5ece9c..569adad 100644 --- a/src/lib/tokens.ts +++ b/src/lib/tokens.ts @@ -1,5 +1,6 @@ // The M3 color vocabulary, on its own: the token names, the tonal-palette -// names, and the Tailwind shade <-> M3 tone mapping. +// names, the Tailwind shade <-> M3 tone mapping, and the `var()` references +// into the custom properties they are declared as. // // A leaf module by design. `builder.ts` owns the color engine and imports // `@material/material-color-utilities`; the Tailwind plugin @@ -9,6 +10,32 @@ /** Default CSS custom-property prefix. */ export const DEFAULT_PREFIX = "md"; +/** + * `var(---sys-color-)`: the custom property a scheme role is + * read from, as `` and `toCss()` declare it. + * + * @param role The role, kebab-cased: `on-primary-container`. + * @param prefix The prefix the properties were declared with. + */ +export function sysColorVar(role: string, prefix = DEFAULT_PREFIX) { + return `var(--${prefix}-sys-color-${role})`; +} + +/** + * `var(---ref-palette--)`: one tone of a palette. + * + * @param palette The palette, kebab-cased: `primary`, `neutral-variant`... + * @param tone The tone, 0 to 100. + * @param prefix The prefix the properties were declared with. + */ +export function refPaletteVar( + palette: string, + tone: number, + prefix = DEFAULT_PREFIX, +) { + return `var(--${prefix}-ref-palette-${palette}-${tone})`; +} + /** * Material Design 3 token names and their descriptions. * diff --git a/src/styles/globals.css b/src/styles/globals.css index 2988be1..cd6716d 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -1,6 +1,6 @@ /* * The project's stylesheet, the one `components.json` names -- so `shadcn init` - * wrote all of it except the two blocks marked below, and `shadcn add` keeps + * wrote all of it except the block marked below, and `shadcn add` keeps * that current, exactly as in a scaffolded project. Do NOT hand-edit it: it used to * be a hand-kept approximation and had quietly drifted a whole major behind * (shadcn 3 `radix-nova` defaults, missing `--font-heading` and the @@ -15,7 +15,7 @@ * pnpm dlx shadcn@latest init --preset b0 --template vite --name probe --yes * cat probe/src/index.css * - * ...then paste it below this comment, keeping the two marked blocks. + * ...then paste it below this comment, keeping the marked block. */ @import "tailwindcss"; @import "tw-animate-css"; @@ -34,14 +34,18 @@ * `@theme inline` also claims -- `background`, `primary`, `secondary` -- shadcn * wins. Which is what the alias mapping is for: `bg-secondary` follows * `--secondary`, the same property `