Skip to content
Open
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
36 changes: 36 additions & 0 deletions .changeset/align-semantic-color-tokens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
"@makeplane/propel": minor
---

Align the semantic color tokens with the Figma "Colors - Semantics" collection across all four
themes, closing 29 tokens that had drifted.

The priority ramp is the substantive change: `--priority-*` was declared once at `:root`, so dark
and dark-contrast rendered light-mode priority colors. It now resolves per theme, and gains a sixth
step, `--priority-unlit`, exposed as `text-priority-unlit` and `border-priority-unlit`. The light
values were also wrong at every step and have been corrected.

Also fixed: `--neutral-800` (light) and `--neutral-white` (dark) primitives, which pulled
`text-disabled`, `text-on-color`, `text-icon-on-color`, `border-inverse`, and
`illustration-stroke-secondary` off-spec; the light icon ladder, where `text-icon-tertiary`,
`text-icon-placeholder`, and `text-icon-disabled` each sat one rung too light, with the matching
light-contrast overrides added; label steps for orange, pink, grey, indigo, and purple; and the
missing contrast overrides for `text-warning-primary` and `label-purple-bg-strong`.

`border-info-strong` in dark-contrast was resolving to a dark navy against a dark canvas — it drew
from `--blue-400`, which sits at the dark end of the inverted dark ramp. It now matches the other
intents.

`--scrollbar-thumb-active` drops from 40% to 30% alpha to match Figma, making the press state
identical to hover.

Three of the corrected values are below WCAG AA where the previous values were above it, and are
carried anyway because they are the published spec: `label-orange-text` on `label-orange-bg` in
light is 3.00:1 (was 5.57:1), `label-pink-text` on `label-pink-bg` in light is 4.04:1 (was 5.50:1),
and the `neutral-800` primitive is 4.23:1 on white (was 4.54:1). The `color-contrast` a11y rule is
disabled on the Badge `Variants` stories for the first two, with the reason recorded inline. These
need raising at source in Figma.

`PreviewCardEyebrowLabel` moves from `text-disabled` to `text-tertiary`. It was painting content
with the one token that is exempt from contrast requirements, which only passed because
`neutral-800` sat 0.04 above the AA threshold before this correction.
99 changes: 99 additions & 0 deletions apps/docs/src/components/ColorIndex.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
---
import {
COLOR_FAMILIES,
SWATCH_FIELDS,
THEMES,
type IndexedToken,
type Theme,
} from "~/lib/color-tokens";

// The complete namespace, generated from `@theme inline` rather than curated. Where ColorRole
// explains a handful of tokens in prose, this is a reference: dense rows, no purpose column, every
// token present. A reader lands here from the roles above when they need a token that the reading
// order does not cover — a status border, a priority step, a scrollbar state.
//
// Values are resolved literals, not `var()`, for the same reason as everywhere else on this page:
// `@theme inline` inlines the semantic properties into utilities instead of emitting them, so
// nothing painted with `var(--background-color-canvas)` would render.

const swatchBase = "size-7 shrink-0 rounded border";

// on-color needs a filled surface behind it and inverse needs the inverse surface — on the plain
// field both vanish in light mode, where the two resolve to white along with the field itself.
const fieldFor = (token: IndexedToken, theme: Theme): string => {
const fields = SWATCH_FIELDS[theme];
if (token.utility.includes("on-color")) return fields.accent;
if (token.utility.includes("inverse")) return fields.inverse;
return fields.surface;
};

const chip = (token: IndexedToken, theme: Theme, swatch: string) => {
const value = token.values[theme];
const field = fieldFor(token, theme);
if (swatch === "border") {
return { class: `${swatchBase} border-md`, style: `border-color: ${value}; background-color: ${field}` };
}
if (swatch === "text") {
return {
class: `${swatchBase} flex items-center justify-center border-subtle text-body-xs-semibold`,
style: `color: ${value}; background-color: ${field}`,
};
}
// A fill is layered over the field rather than replacing it, so alpha tokens — the transparent
// layers, the backdrop, the scrollbar thumb — composite against the theme instead of the page.
return {
class: `${swatchBase} border-strong`,
style: `background-color: ${field}; background-image: linear-gradient(${value}, ${value})`,
};
};
---

<div class="not-prose flex flex-col gap-10">
{
COLOR_FAMILIES.map((family) => (
<section>
<h3 class="text-body-md-semibold text-primary">
{family.title}
<span class="text-body-sm-regular text-tertiary"> · {family.tokens.length} tokens</span>
</h3>
<p class="mt-1 mb-3 max-w-2xl text-body-sm-regular text-secondary">{family.blurb}</p>
<div class="divide-y divide-subtle overflow-hidden rounded-lg border border-subtle">
<div class="grid grid-cols-[8rem_minmax(0,1fr)] gap-4 bg-surface-1 px-4 py-2 text-caption-md-medium text-tertiary">
<span>L · D · LC · DC</span>
<span>Utility</span>
</div>
{family.tokens.map((token) => {
// Collapsing repeats keeps the common case — one value across all four themes — from
// reading as four unrelated colors that happen to match.
const distinct = [...new Set(THEMES.map((theme) => token.values[theme]))];
return (
<div class="grid grid-cols-[8rem_minmax(0,1fr)] items-center gap-4 px-4 py-2">
<div class="flex gap-1">
{THEMES.map((theme) => {
const swatch = chip(token, theme, family.swatch);
return (
<span aria-hidden="true" class={swatch.class} style={swatch.style}>
{family.swatch === "text" ? "A" : null}
</span>
);
})}
</div>
<div class="min-w-0">
<p class="font-code text-body-sm-regular text-primary">{token.utility}</p>
<p class="font-code text-13 text-tertiary">
{distinct.map((value, index) => (
<>
{index > 0 ? " · " : null}
<span class="whitespace-nowrap">{value}</span>
</>
))}
</p>
</div>
</div>
);
})}
</div>
</section>
))
}
</div>
56 changes: 31 additions & 25 deletions apps/docs/src/components/ColorRole.astro
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
---
import { COLOR_ROLES, SWATCH_FIELDS, type ColorToken, type Theme } from "~/lib/color-tokens";
import {
COLOR_ROLES,
SWATCH_FIELDS,
THEMES,
type ColorToken,
type Theme,
} from "~/lib/color-tokens";

type Props = {
/** A role key from `COLOR_ROLES` — "surfaces", "text", "border", "accent", … */
Expand All @@ -15,12 +21,12 @@ if (!role) {
);
}

// Both themes are shown side by side because several roles — the surface ladder especially — are
// only a few percent apart inside one theme, and it is the light-to-dark flip that shows what the
// token actually does. Chips paint resolved literals over a field resolved from the same theme, so
// each column stays on its own theme instead of following the reader's — including alpha fills,
// which composite over the field rather than over the page.
const swatchBase = "size-11 shrink-0 rounded-md border";
// All four themes are shown side by side because several roles — the surface ladder especially —
// are only a few percent apart inside one theme, and it is the flip between themes that shows what
// the token actually does. Chips paint resolved literals over a field resolved from the same
// theme, so each column stays on its own theme instead of following the reader's — including alpha
// fills, which composite over the field rather than over the page.
const swatchBase = "size-9 shrink-0 rounded-md border";

// On-color belongs on a filled accent surface and inverse on the inverse surface — on the plain
// field both are invisible in light mode, where all three resolve to white.
Expand All @@ -32,7 +38,7 @@ const fieldFor = (token: ColorToken, theme: Theme): string => {
};

const chip = (token: ColorToken, theme: Theme) => {
const value = token[theme];
const value = token.values[theme];
const field = fieldFor(token, theme);
if (role.swatch === "fill") {
return {
Expand All @@ -57,36 +63,36 @@ const chip = (token: ColorToken, theme: Theme) => {
<p class="mb-4 max-w-2xl text-body-sm-regular text-secondary">{role.blurb}</p>
<div class="divide-y divide-subtle overflow-hidden rounded-lg border border-subtle">
<div
class="grid grid-cols-[6.5rem_minmax(0,1fr)] gap-4 bg-surface-1 px-4 py-2 text-caption-md-medium text-tertiary sm:px-6"
class="grid grid-cols-[9.5rem_minmax(0,1fr)] gap-4 bg-surface-1 px-4 py-2 text-caption-md-medium text-tertiary sm:px-6"
>
<span>Light · Dark</span>
<span>L · D · LC · DC</span>
<span>Token</span>
</div>
{
role.tokens.map((token) => {
const light = chip(token, "light");
const dark = chip(token, "dark");
const distinct = [...new Set(THEMES.map((theme) => token.values[theme]))];
return (
<div class="grid grid-cols-[6.5rem_minmax(0,1fr)] gap-4 px-4 py-3 sm:px-6">
<div class="flex gap-2">
<span aria-hidden="true" class={light.class} style={light.style}>
{role.swatch === "text" ? "Aa" : null}
</span>
<span aria-hidden="true" class={dark.class} style={dark.style}>
{role.swatch === "text" ? "Aa" : null}
</span>
<div class="grid grid-cols-[9.5rem_minmax(0,1fr)] gap-4 px-4 py-3 sm:px-6">
<div class="flex gap-1.5">
{THEMES.map((theme) => {
const swatch = chip(token, theme);
return (
<span aria-hidden="true" class={swatch.class} style={swatch.style}>
{role.swatch === "text" ? "Aa" : null}
</span>
);
})}
</div>
<div class="min-w-0">
<p class="font-code text-body-sm-medium text-primary">{token.utility}</p>
<p class="text-body-sm-regular text-secondary">{token.purpose}</p>
<p class="mt-1 font-code text-13 text-tertiary">
<span class="whitespace-nowrap">{token.light}</span>
{token.dark && token.dark !== token.light ? (
{distinct.map((value, index) => (
<>
{" · "}
<span class="whitespace-nowrap">{token.dark}</span>
{index > 0 ? " · " : null}
<span class="whitespace-nowrap">{value}</span>
</>
) : null}
))}
</p>
</div>
</div>
Expand Down
29 changes: 13 additions & 16 deletions apps/docs/src/components/LabelGrid.astro
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
---
import { LABEL_HUES, LABEL_PROPERTIES } from "~/lib/color-tokens";
import { LABEL_HUES, LABEL_PROPERTIES, THEMES } from "~/lib/color-tokens";

// Hues x properties is a matrix, not a list. Rendering it as one grid shows the pattern in a
// glance, where a table row per token would only show that there are many of them. Each cell pairs
// the light and the dark value, resolved to literals at build time — the semantic custom
// properties never reach the browser (`@theme inline` inlines them into utilities), so a chip
// painted with `var(--color-label-…)` would render transparent.
// glance, where a table row per token would only show that there are many of them. Each cell
// carries all four themes in toolbar order, resolved to literals at build time — the semantic
// custom properties never reach the browser (`@theme inline` inlines them into utilities), so a
// chip painted with `var(--color-label-…)` would render transparent.
---

<div class="not-prose scrollbar-sm overflow-x-auto rounded-lg border border-subtle">
Expand Down Expand Up @@ -34,17 +34,14 @@ import { LABEL_HUES, LABEL_PROPERTIES } from "~/lib/color-tokens";
</th>
{LABEL_PROPERTIES.map((prop) => (
<td class="px-4 py-2">
<span class="flex gap-1.5">
<span
aria-hidden="true"
class="block size-6 rounded border border-subtle"
style={`background-color: ${label.values[prop]?.light}`}
/>
<span
aria-hidden="true"
class="block size-6 rounded border border-subtle"
style={`background-color: ${label.values[prop]?.dark}`}
/>
<span class="flex gap-1">
{THEMES.map((theme) => (
<span
aria-hidden="true"
class="block size-5 rounded border border-subtle"
style={`background-color: ${label.values[prop]?.[theme]}`}
/>
))}
</span>
<span class="sr-only">
{label.hue} {prop}
Expand Down
Loading
Loading