Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/scheme-label-inks.md
Original file line number Diff line number Diff line change
@@ -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 `<p>`s it holds:
it inks only the labels `Scheme` and `Shades` write, and whatever else goes in
paints its own.

`<Scheme tw>` 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`.

`<Shades tw>` (new prop, which follows `<Scheme tw>` when nested in one) paints
the plugin's eleven Tailwind shades (`bg-primary-500`…), and `<Scheme tw>`
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()`.
423 changes: 19 additions & 404 deletions src/Mtb.stories.helpers.tsx

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/Mtb.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -713,7 +713,7 @@ export const TailwindSt: Story = {
render: (args) => (
<Mtb {...args}>
<Layout>
<TailwindScheme />
<TailwindScheme customColors={args.customColors} />
</Layout>
</Mtb>
),
Expand Down
105 changes: 105 additions & 0 deletions src/Scheme.ink.test.ts
Original file line number Diff line number Diff line change
@@ -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})`,
);
});
});
87 changes: 87 additions & 0 deletions src/Scheme.ink.ts
Original file line number Diff line number Diff line change
@@ -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. `<Mtb 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<string, string> = {
"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);
}
Loading
Loading