diff --git a/.changeset/scheme-label-inks.md b/.changeset/scheme-label-inks.md
new file mode 100644
index 00000000..fe0d23b2
--- /dev/null
+++ b/.changeset/scheme-label-inks.md
@@ -0,0 +1,28 @@
+---
+"material-theme-builder": minor
+---
+
+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:
+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`.
+
+`` (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 492571af..ef0ddcb9 100644
--- a/src/Mtb.stories.helpers.tsx
+++ b/src/Mtb.stories.helpers.tsx
@@ -92,431 +92,46 @@ 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 custom colors' utilities, named at runtime, are listed for Tailwind by
+ * the `@source inline()` lines in `globals.css`.
*/
-function TailwindCustomColors() {
- return (
-
-
-
-
MyCustomColor1
-
-
-
On MyCustomColor1
-
-
-
MyCustomColor1 Container
-
-
-
On MyCustomColor1 Container
-
-
-
-
-
MyCustomColor2
-
-
-
On MyCustomColor2
-
-
-
MyCustomColor2 Container
-
-
-
On MyCustomColor2 Container
-
-
-
- );
-}
-
-/**
- * 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 (
<>
-
-
-
+ />
-
-
-
-
- {/* Shades */}
-
- {/* 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
- Tailwind utility class
-
-
+
+ Every color the @plugin declares is shown here as a
+ Tailwind utility class, but primary, secondary{" "}
+ and background, which shadcn claims too: those are painted
+ from their M3 property
+
>
);
}
diff --git a/src/Mtb.stories.tsx b/src/Mtb.stories.tsx
index df4690c7..445498cf 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.ink.test.ts b/src/Scheme.ink.test.ts
new file mode 100644
index 00000000..ff22ad45
--- /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
new file mode 100644
index 00000000..df75b230
--- /dev/null
+++ b/src/Scheme.ink.ts
@@ -0,0 +1,87 @@
+/**
+ * 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.
+ *
+ * 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
+ */
+
+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 = {
+ "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: "white",
+ shadow: "white",
+};
+
+/**
+ * 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 (
+ exception ??
+ sysColorVar(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.
+ *
+ * @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 refPaletteVar(palette, tone <= 50 ? 100 : 10);
+}
diff --git a/src/Scheme.tsx b/src/Scheme.tsx
index 82b9d809..c156173c 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
@@ -35,12 +35,20 @@ import {
useContext,
type ComponentProps,
type CSSProperties,
+ type ReactNode,
} from "react";
import {
STANDARD_TONES,
type HexCustomColor,
type TokenName,
} from "./lib/builder";
+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. */
function classNames(...names: (string | false | undefined)[]) {
@@ -114,13 +122,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.
+ *
+ * `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",
- secondary: "bg-secondary",
+ secondary: "bg-[var(--md-sys-color-secondary)]",
onSecondary: "bg-on-secondary",
secondaryContainer: "bg-secondary-container",
onSecondaryContainer: "bg-on-secondary-container",
@@ -167,46 +181,166 @@ 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",
} satisfies Record;
+/** A Tailwind shade of the plugin's: `50`, `100`... `950`. */
+type Shade = (typeof SHADE_TO_TONE)[number][0];
+
/**
- * 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`.
+ * 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`.
*/
-function Swatch({
- role,
+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: `name` as `title`, `label` written in `ink`, the color
+ * itself from `var(--md-sys-color-)` -- or from `twClass` when under a
+ * `tw` `Scheme`.
+ */
+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 ??
{startCase(role)}
}
+
);
}
+/** 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.
@@ -235,8 +369,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;
@@ -257,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,
@@ -352,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 —
@@ -362,6 +498,10 @@ 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).
+ *
+ * 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. */
@@ -644,54 +784,56 @@ export function Scheme({
}
{customColors && customColors.length > 0 && (
- {customColors?.map((customColor) => (
-
-
-
-
{upperFirst(customColor.name)}
-
-
-
-
-
On {upperFirst(customColor.name)}
-
-
-
-
-
{upperFirst(customColor.name)} Container
-
-
-
-
-
On {upperFirst(customColor.name)} Container
-
-
-
- ))}
+ {customColors.map(({ name }) => {
+ 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 (
+
+ {cells.map((c) => (
+
+
+
+ ))}
+
+ );
+ })}
)}
@@ -708,59 +850,94 @@ export function Scheme({
export function Shades({
customColors,
noTitle,
+ tw: twProp,
}: {
/** 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 -- and
+ * follows it, when nested in one.
+ *
+ * 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 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 (
);
}
diff --git a/src/lib/tokens.ts b/src/lib/tokens.ts
index b5ece9ca..569adad4 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 86bf0322..cd6716d8 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,11 +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 `