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 (
- {[ - ...[ - "primary", - "secondary", - "tertiary", - "error", - "neutral", - "neutral-variant", - ].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() + {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) => ( + .map((tone) => ({ + tone, + label: tone, + className: undefined, + tooltip: `${palette}-${tone}`, + })); + + return ( +
+ {!noTitle &&

{title}

} + +
+ {cells.map(({ tone, label, className, tooltip }) => (
-

{tone}

+
))} +
-
- ))} + ); + })}
); } 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 `