alouette
Version:
A modern, customizable design system built on top of NativeWind v5 with configurable defaults
357 lines (322 loc) • 12.7 kB
text/typescript
/* eslint-disable import-x/extensions */
// Assembles resolved color scales + the semantic tokenScaleMap into the coupled
// outputs of a theme: the base palette CSS (the `@theme` color defaults that
// generate bg-*/text-*/border-* utilities plus the twelve `.<theme>`
// blocks in hex), the optional oklch overlay CSS (the same twelve blocks
// re-emitted as `oklch()` behind `@supports`) and the resolved `themeVariables`
// maps (fully merged per theme so a theme can be applied at any depth in JS).
// Shared by the internal default build (`scripts/build-css.ts`) and the exposed
// `generateTheme`.
//
// The two outputs feed the two halves of `ScopedTheme`: the CSS blocks are what
// web applies (a `className={theme}` element, resolved by custom-property
// inheritance), the maps are what native applies (NativeWind's
// `VariableContextProvider`).
//
// Two serializations of the same ramp: sRGB hex is the only format native can
// consume (`@react-native/normalize-colors` has no oklch parser, and the JS map
// bypasses the CSS compiler entirely), so it stays the baseline everywhere;
// `oklch()` carries the display-p3 chroma headroom, is web-only, and ships as a
// separate file so a project opts into it.
import Color from "colorjs.io";
import type { AlouetteTheme } from "../core/AlouetteConfig.ts";
import type { OklchColor, OklchScale } from "./createColorScale.ts";
import { toHex } from "./createColorScale.ts";
import type { AccentName } from "./paletteSpecs.ts";
import type { Mode, ScaleNum } from "./tokenScaleMap.ts";
import { tokenScaleMap } from "./tokenScaleMap.ts";
export type ThemeScales = Record<`${AccentName}.${Mode}`, OklchScale>;
export type ColorFormatName = "oklch" | "srgb";
// Emit order (grayscale first, then accents) — the order tokens/blocks appear in
// the generated CSS and themeVariables. Kept explicit so the output is stable
// regardless of the palette-specs insertion order.
const accentEmitOrder: AccentName[] = [
"grayscale",
"brand",
"info",
"success",
"warning",
"danger",
];
interface ColorFormat {
serialize: (color: OklchColor) => string;
/** `alpha` is the hex suffix the token map is written in, e.g. `"40"`. */
withAlpha: (serialized: string, alpha: string) => string;
/** Re-express a hex literal from the token map in this format. */
literal: (hex: string) => string;
}
const round = (value: number, digits: number): number =>
Number(value.toFixed(digits));
const formatOklch = ({ lightness, chroma, hue }: OklchColor): string =>
`oklch(${round(lightness, 4)} ${round(chroma, 4)} ${round(hue, 2)})`;
const hexAlphaToFraction = (alpha: string): number =>
round(Number.parseInt(alpha, 16) / 255, 3);
const withOklchAlpha = (serialized: string, alpha: number): string =>
alpha === 1 ? serialized : `${serialized.slice(0, -1)} / ${alpha})`;
const colorFormats: Record<ColorFormatName, ColorFormat> = {
srgb: {
serialize: toHex,
withAlpha: (serialized, alpha) => serialized + alpha,
literal: (hex) => hex,
},
oklch: {
serialize: formatOklch,
withAlpha: (serialized, alpha) =>
withOklchAlpha(serialized, hexAlphaToFraction(alpha)),
literal: (hex) => {
const { coords, alpha } = new Color(hex).to("oklch");
const [lightness, chroma, hue] = coords;
// colorjs leaves missing components null (hue on achromatic colors); 0 is
// the CSS spelling.
return withOklchAlpha(
formatOklch({
lightness: lightness ?? 0,
chroma: chroma ?? 0,
hue: hue || 0,
}),
round(alpha, 3),
);
},
},
};
interface ColorAtParams {
scales: ThemeScales;
mode: Mode;
accentName: AccentName;
step: ScaleNum;
}
const colorAt = ({
scales,
mode,
accentName,
step,
}: ColorAtParams): OklchColor => scales[`${accentName}.${mode}`][step];
interface BuildThemeVarsParams {
scales: ThemeScales;
mode: Mode;
accentName: AccentName;
format: ColorFormat;
}
// Resolve every token's scale step to a concrete color for this mode/accent.
// Grayscale-only tokens resolve to null on colored accents and are skipped
// (they inherit through the CSS cascade / the merged themeVariables map).
const buildThemeVars = ({
scales,
mode,
accentName,
format,
}: BuildThemeVarsParams): Record<string, string> => {
const isGrayscale = accentName === "grayscale";
const vars: Record<string, string> = {};
for (const [token, resolver] of Object.entries(tokenScaleMap)) {
const resolved = resolver({ mode, isGrayscale, accent: accentName });
if (!resolved) continue;
if ("literal" in resolved) {
vars[token] = format.literal(resolved.literal);
continue;
}
const serialized = format.serialize(
colorAt({
scales,
mode,
accentName: resolved.source === "grayscale" ? "grayscale" : accentName,
step: resolved.step,
}),
);
vars[token] = resolved.alpha
? format.withAlpha(serialized, resolved.alpha)
: serialized;
}
return vars;
};
const emit = (vars: Record<string, string>, indent: string): string =>
Object.entries(vars)
.map(([key, value]) => `${indent}--color-${key}: ${value};`)
.join("\n");
const prefixVars = (
vars: Record<string, string>,
): Record<`--color-${string}`, string> =>
Object.fromEntries(
Object.entries(vars).map(([key, value]) => [`--color-${key}`, value]),
);
const accents = accentEmitOrder.filter((name) => name !== "grayscale");
interface ThemeTarget {
theme: AlouetteTheme;
mode: Mode;
accentName: AccentName;
}
// Base modes first, then the accent sub-themes — the order the blocks appear in
// the CSS and the keys in themeVariables.
const themeTargets: ThemeTarget[] = [
...(["light", "dark"] as const).map(
(mode): ThemeTarget => ({
theme: mode,
mode,
accentName: "grayscale",
}),
),
...(["light", "dark"] as const).flatMap((mode) =>
accents.map(
(accentName): ThemeTarget => ({
theme: `${mode}_${accentName}` as AlouetteTheme,
mode,
accentName,
}),
),
),
];
interface EmitThemeBlocksParams {
scales: ThemeScales;
format: ColorFormat;
indent: string;
}
// The twelve `.<theme>` blocks — a web-only mechanism. `ScopedTheme.web.tsx`
// applies a theme as a className and lets CSS resolve it; native pushes the
// resolved `themeVariables` map through NativeWind's `VariableContextProvider`
// and never sets a className, so these blocks are dead weight there. Both
// callers therefore keep them behind a feature query the native compiler cannot
// evaluate — {@link webOnly} here, the `oklch()` query in the overlay.
//
// Each block declares its variables on the themed element *only*: descendants
// pick them up by custom-property inheritance, and an element's own declaration
// always beats an inherited value, so the closest theme class in the tree wins
// for its whole subtree. A descendant selector (`.<theme> *`) would break that —
// every themed ancestor would match a nested element at equal specificity in the
// same layer, making file order, not proximity, decide.
//
// Plain class selectors, not `:where()`: the blocks sit in `@layer theme`, so
// unlayered author rules (Tailwind's utilities, including an arbitrary
// `[--color-x:…]`) and inline styles override them whatever their specificity.
// What the specificity does buy is the `:root`/`:host` case — a theme class set
// on the document element ties with the `@theme` defaults and wins on order.
//
// Accent blocks are partial (only the tokens the accent redefines); the rest
// inherit from the nearest ancestor theme class. `AccentScope` derives the
// accent theme from the current mode, so that ancestor is the matching base
// mode. A forced-mode scope (`<AccentScope mode="light">` under a `dark`
// ancestor) therefore resolves base tokens from the dark ancestor on web, while
// native's merged `themeVariables` map gives the light ones.
const emitThemeBlocks = ({
scales,
format,
indent,
}: EmitThemeBlocksParams): string =>
themeTargets
.map(({ theme, mode, accentName }) => {
const vars = buildThemeVars({ scales, mode, accentName, format });
return `${indent}.${theme} {\n${emit(vars, `${indent} `)}\n${indent}}`;
})
.join("\n\n");
// Hides web-only rules from the native compiler, which skips any `@supports`
// whose condition it cannot evaluate — so the block never reaches the native
// bundle, while every browser (Vite web and Expo web alike) evaluates it true.
// `display: contents` is the condition because it is exactly what
// `ScopedTheme.web.tsx` renders to apply a theme class without affecting layout.
const webOnly = (rules: string): string => ` @supports (display: contents) {
${rules}
}`;
/**
* The base palette CSS for a set of sRGB scales: the `@theme` color defaults
* (light grayscale, which generate the color utilities) and the twelve
* `.<theme>` selector blocks, all in hex.
*
* Hex is the only format native can compile, so this file alone is a complete
* palette on every platform. Layer {@link buildOklchPaletteCss} after it to opt
* web into the wide-gamut ramp. Structural CSS (fonts, spacing, keyframes,
* utilities) lives in `core.css`, not here.
*
* Only the `@theme` half compiles on native; the theme blocks are {@link webOnly}.
* Native must therefore keep the color tokens out of react-native-css's variable
* inliner — `withAlouetteConfig` (metro.cjs) passes `inlineVariables.exclude`,
* without which every token collapses to its light value.
*/
export const buildPaletteCss = (srgbScales: ThemeScales): string => {
const srgb = colorFormats.srgb;
const lightVars = buildThemeVars({
scales: srgbScales,
mode: "light",
accentName: "grayscale",
format: srgb,
});
return `@theme {
/* color tokens — light theme as defaults, enabling bg-*, text-*, border-*
color utilities. This block is the whole palette on native, where ScopedTheme
overrides it at runtime with the themeVariables map fed to NativeWind's
VariableContextProvider. Web instead resolves the .<theme> blocks below,
applied as a className (the closest theme class wins, through inheritance). */
${emit(lightVars, " ")}
}
@layer theme {
${webOnly(emitThemeBlocks({ scales: srgbScales, format: srgb, indent: " " }))}
}
`;
};
/**
* The wide-gamut overlay for a set of display-p3 scales: the same tokens as
* {@link buildPaletteCss}, re-declared as `oklch()` inside `@supports`. Import
* it *after* the base palette CSS, and only on projects that want the extra
* chroma — it is purely additive.
*
* The `@supports` rule is what keeps native safe if the overlay is imported in a
* shared CSS entry: the react-native-css compiler drops feature queries it
* cannot evaluate, so native keeps the hex from the base palette.
*/
export const buildOklchPaletteCss = (p3Scales: ThemeScales): string => {
const oklch = colorFormats.oklch;
const lightOklchVars = buildThemeVars({
scales: p3Scales,
mode: "light",
accentName: "grayscale",
format: oklch,
});
return `/* Wide-gamut palette: the same ramp with display-p3 chroma headroom. Web only —
the native compiler drops this feature query and keeps the base palette hex. */
@supports (color: oklch(0 0 0)) {
@layer theme {
/* overrides the @theme defaults, which cannot host a feature query */
:root, :host {
${emit(lightOklchVars, " ")}
}
${emitThemeBlocks({ scales: p3Scales, format: oklch, indent: " " })}
}
}
`;
};
/**
* The fully-resolved CSS-variable map for every theme (base mode tokens + accent
* overrides merged), keyed `--color-*`. Feeds `ScopedTheme` and `useThemeToken`.
* Pair `srgb` with sRGB scales (the native-safe map) and `oklch` with p3 scales.
*/
export const buildThemeVariables = (
scales: ThemeScales,
formatName: ColorFormatName = "srgb",
): Record<AlouetteTheme, Record<`--color-${string}`, string>> => {
const format = colorFormats[formatName];
const baseVars = {
light: buildThemeVars({
scales,
mode: "light",
accentName: "grayscale",
format,
}),
dark: buildThemeVars({
scales,
mode: "dark",
accentName: "grayscale",
format,
}),
};
return Object.fromEntries(
themeTargets.map(({ theme, mode, accentName }) => [
theme,
prefixVars(
accentName === "grayscale"
? baseVars[mode]
: {
...baseVars[mode],
...buildThemeVars({ scales, mode, accentName, format }),
},
),
]),
) as Record<AlouetteTheme, Record<`--color-${string}`, string>>;
};