alouette
Version:
A modern, customizable design system built on top of NativeWind v5 with configurable defaults
238 lines (212 loc) • 9.34 kB
text/typescript
/* eslint-disable import-x/extensions */
// Single source of truth mapping each semantic token to a color-scale step,
// per mode and accent. Shared by `buildTheme.ts` (emits the CSS variables /
// themeVariables) and the repo-root `scripts/generate-palette.ts` contrast
// audit (resolves the steps a token pair actually uses), so the two can never
// drift. A token resolves to a `{ source, step }` (which palette + which scale
// step for this mode), a `{ literal }` (a fixed value), or `null` when the
// token is not emitted for the given accent.
import type { AccentName } from "./paletteSpecs.ts";
export type { AccentName } from "./paletteSpecs.ts";
export type Mode = "dark" | "light";
export type ScaleNum = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11;
export interface TokenStep {
source: "grayscale" | "self";
step: ScaleNum;
alpha?: string;
}
export interface TokenLiteral {
literal: string;
}
export type ResolvedToken = TokenLiteral | TokenStep;
export interface TokenContext {
mode: Mode;
isGrayscale: boolean;
accent: AccentName;
}
export type TokenResolver = (ctx: TokenContext) => ResolvedToken | null;
// `self` reads the accent's own palette; `gray` always reads grayscale (base
// tokens whose value is fixed regardless of accent, e.g. text-muted/on-accent).
const step = (
source: "grayscale" | "self",
dark: ScaleNum,
light: ScaleNum,
alpha?: string,
): TokenResolver => {
return ({ mode }) => {
const resolved: TokenStep = {
source,
step: mode === "dark" ? dark : light,
};
if (alpha) resolved.alpha = alpha;
return resolved;
};
};
const self = (dark: ScaleNum, light: ScaleNum = dark, alpha?: string) =>
step("self", dark, light, alpha);
const gray = (dark: ScaleNum, light: ScaleNum = dark) =>
step("grayscale", dark, light);
// Branches on both grayscale/colored and dark/light mode.
const selfAdaptive =
(
grayscaleDark: ScaleNum,
coloredDark: ScaleNum,
grayscaleLight: ScaleNum = grayscaleDark,
coloredLight: ScaleNum = coloredDark,
): TokenResolver =>
({ isGrayscale, mode }) => ({
source: "self",
step: (() => {
if (mode === "dark") return isGrayscale ? grayscaleDark : coloredDark;
return isGrayscale ? grayscaleLight : coloredLight;
})(),
});
// Emitted only in the grayscale theme; colored sub-themes inherit the value
// through the CSS cascade (they never override it).
const grayscaleOnly =
(resolver: TokenResolver): TokenResolver =>
(ctx) =>
ctx.isGrayscale ? resolver(ctx) : null;
const translucent: Record<Mode, string> = {
dark: "#1f1e1e55",
light: "#ffffff66",
};
// Insertion order is significant: it is the order tokens are emitted into
// the palette CSS / themeVariables, with the grayscale-only block first.
export const tokenScaleMap: Record<string, TokenResolver> = {
/* grayscale-only base tokens */
translucent: grayscaleOnly(({ mode }) => ({ literal: translucent[mode] })),
/* grayscale-only backgrounds */
screen: grayscaleOnly(self(2, 3)),
highlight: grayscaleOnly(self(4, 1)),
/* grayscale-only texts */
"disabled-sharp": grayscaleOnly(gray(9, 9)),
"disabled-muted": grayscaleOnly(gray(9, 7)),
"disabled-interactive": grayscaleOnly(gray(7, 6)),
"disabled-interactive-muted": grayscaleOnly(gray(4, 4)),
sharp: grayscaleOnly(gray(10, 11)),
muted: grayscaleOnly(gray(9, 10)),
/* grayscale-only unsorted */
"form-border-disabled": grayscaleOnly(gray(7, 6)),
"form-placeholder": grayscaleOnly(gray(8, 9)),
"form-disabled-text": grayscaleOnly(gray(9, 10)),
"interactive-contained-disabled": grayscaleOnly(gray(5, 6)),
"interactive-outlined-disabled": grayscaleOnly(gray(6, 6)),
"interactive-accent-outlined-disabled": grayscaleOnly(gray(6, 6)),
/* backgrounds */
surface: self(3, 2),
enabled: self(7, 9),
"highlight-accent": self(4),
lowered: self(1, 4),
// The counterpart of `lowered`: the fill of an element standing out of an
// inset track — a SegmentedBar's selected chip, ConnectionState's bar.
// Accented it is the accent's fill; in the neutral theme it stays the lightest
// step, because that element has to be lighter than the track under it. That
// is the opposite of what a neutral contained *button* needs (darker than the
// surface it sits on), which is why the two are separate tokens.
emphasis: selfAdaptive(6, 6, 1, 9),
"screen-gradient-start": self(3, 4),
"screen-gradient-middle": self(2, 5),
"screen-gradient-end": self(1, 6),
/* borders */
"border-muted": self(7, 5),
"border-sharp": self(8, 9),
/* interactive */
// The contained fill, the same steps in every theme: the neutral one is the
// grayscale accent, not the absence of one, so `accent="neutral"` takes a
// fill as dark as a colored accent's and the same white label. A pale
// neutral fill cannot work here — the light steps just above it are
// `surface` (2), `screen` (3) and `lowered` (4) themselves, so the button
// dissolves into whatever it is placed on.
// Press has no step of its own in dark mode: the scale jumps from 7 (#555555)
// to 8 (#BCBCBC, where the text tones start), and white ink on #BCBCBC is
// 1.9:1 — so a dark press holds at hover's value rather than climbing into
// the text tones or receding to a darker step.
"interactive-contained-pressable": self(6, 9),
"interactive-contained-hover": self(7, 8),
"interactive-contained-focus": self(7, 8),
"interactive-contained-active": self(7),
// PressableBox's `list` variant — a card row lifted off the surface
// (PressableListItem). Its ground is a *tone*, not the accent's fill, and it
// keeps the ambient `text-sharp` label. That is what separates it from
// `emphasis`, which is the accent's fill — a SegmentedBar chip has to win
// against its track, a list row does not.
// The two modes get there from opposite ends. Light starts at the accent's
// own `surface` step and walks down (2 → 3 → 4): the deeper tints read more
// of the hue but turn salmon rather than red, and the hue is `on-list`'s job
// anyway. Dark has no pale end, so a row takes the accent's own dark ground
// (6 → 7), one notch under the contained fill. Neutral walks the card steps
// in both (1 → 2 → 3 light — a row is never placed on a `surface`, so a hover
// at step 2 has nothing to collide with — and 6 → 7 dark, which has nothing
// above 7 to press into).
"interactive-list-pressable": selfAdaptive(6, 6, 1, 2),
"interactive-list-hover": selfAdaptive(7, 7, 2, 3),
"interactive-list-focus": selfAdaptive(7, 7, 2, 3),
"interactive-list-active": selfAdaptive(7, 7, 3, 4),
// A ground-only state set for a control that has no rest ground at all
// (PressableBox's `soft`): the fill stays a tone of the surrounding surface —
// toward the screen in light mode, a step up in dark — so the label keeps its
// own color instead of flipping onto an accent fill.
"interactive-soft-hover": self(5, 3),
"interactive-soft-focus": self(5, 3),
"interactive-soft-active": self(6, 4),
"interactive-outlined-pressable": self(7, 9),
"interactive-outlined-hover": self(8, 7),
"interactive-outlined-focus": self(8, 7),
"interactive-outlined-active": self(8, 7),
"interactive-outlined-outline-focus": self(8, 7),
"interactive-active": self(9),
"interactive-pressable": self(10),
"interactive-hover": self(11),
/* texts */
accent: selfAdaptive(11, 10),
// Every contained fill, neutral included, is dark enough for white ink.
"on-accent": gray(11, 1),
"on-accent-muted": self(10, 4),
// `emphasis` is a light chip in the neutral theme and the accent's fill when
// accented, so its ink is the only one that flips with the accent.
"on-emphasis": ({ isGrayscale, mode }) => ({
source: "grayscale",
step: ((): ScaleNum => {
if (mode === "dark") return 11;
return isGrayscale ? 11 : 1;
})(),
}),
// The ink of a `list` row — its label and its caret. A light row can only
// tint its ground (a red light enough for dark ink is a pink), so the accent
// is carried by the ink there: `accent` when accented, and the ambient sharp
// when neutral. A dark row already *is* the accent's ground, where an accent
// ink would be a tint of the color under it (4:1), so it keeps the sharp ink.
"on-list": ({ isGrayscale, mode }) => {
if (mode === "dark") return { source: "grayscale", step: 10 };
return isGrayscale
? { source: "grayscale", step: 11 }
: { source: "self", step: 10 };
},
/* specials */
selection: self(10, 10, "40"),
};
export const resolveToken = (
token: string,
ctx: TokenContext,
): ResolvedToken | null => {
const resolver = tokenScaleMap[token];
if (!resolver) throw new Error(`Unknown token: ${token}`);
return resolver(ctx);
};
// The value a token actually resolves to in a theme, following grayscale-only
// base tokens into their inherited grayscale value (used by the contrast audit,
// where e.g. `text-muted` must resolve even on a colored surface).
export const resolveTokenEffective = (
token: string,
ctx: TokenContext,
): ResolvedToken => {
return (
resolveToken(token, ctx) ??
resolveToken(token, {
mode: ctx.mode,
isGrayscale: true,
accent: "grayscale",
})!
);
};