UNPKG

alouette

Version:

A modern, customizable design system built on top of NativeWind v5 with configurable defaults

504 lines (493 loc) • 18.7 kB
import Color from 'colorjs.io'; import { mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; const defaultPaletteSpecs = { grayscale: { type: "grayscale", hue: 0, intensity: 0 }, brand: { type: "accent", hue: 225 }, // Slightly pinker tints (hueHi 20): a pale red reads as salmon otherwise. danger: { type: "accent", hue: 27, hueHi: 20 }, info: { type: "accent", hue: 233 }, success: { type: "accent", hue: 145 }, // Amber ramp: cream tints at the light end, bronze shadows — the classic // warning gold rather than lemon yellow. warning: { type: "brightAccent", hue: 85, hueHi: 95, hueLo: 75 } }; const lightnessRamps = { grayscale: { dark: [0.18, 0.24, 0.28, 0.32, 0.36, 0.4, 0.45, 0.795, 0.865, 0.96, 1], light: [1, 0.98, 0.948, 0.89, 0.85, 0.79, 0.61, 0.54, 0.48, 0.42, 0.27] }, accent: { dark: [0.18, 0.22, 0.26, 0.3, 0.34, 0.4, 0.5, 0.7, 0.82, 0.865, 0.955], light: [ 0.988, 0.968, 0.948, 0.89, 0.85, 0.79, 0.62, 0.54, 0.48, 0.42, 0.27 ] }, brightAccent: { dark: [0.28, 0.32, 0.4, 0.45, 0.5, 0.53, 0.56, 0.8, 0.86, 0.9, 0.955], light: [0.988, 0.968, 0.948, 0.928, 0.89, 0.82, 0.8, 0.6, 0.56, 0.44, 0.27] } }; const relativeChromaCurve = { dark: [0.6, 0.68, 0.72, 0.76, 0.78, 0.84, 0.88, 0.88, 0.85, 0.82, 0.86], light: [0.5, 0.52, 0.55, 0.6, 0.75, 0.78, 0.92, 0.97, 0.97, 0.97, 0.95] }; const maxChroma = ({ lightness, hue, gamut }) => { const space = gamut === "p3" ? "p3" : "srgb"; let low = 0; let high = 0.5; for (let i = 0; i < 20; i++) { const mid = (low + high) / 2; if (new Color("oklch", [lightness, mid, hue]).to(space).inGamut()) { low = mid; } else { high = mid; } } return low; }; const toHex = ({ lightness, chroma, hue }) => { const color = new Color("oklch", [lightness, chroma, hue]).to("srgb"); const hex = color.toGamut({ method: "css" }).toString({ format: "hex" }); const full = hex.length === 4 ? hex.replace(/^#(.)(.)(.)$/, "#$1$1$2$2$3$3") : hex; return full.toUpperCase(); }; const createOklchScale = (spec, mode, gamut) => { const hueHi = spec.hueHi ?? spec.hue; const hueLo = spec.hueLo ?? spec.hue; const intensity = spec.intensity ?? 1; const ramp = lightnessRamps[spec.type][mode]; const steps = ramp.map((lightness, index) => { const hue = hueLo + (hueHi - hueLo) * lightness; const chroma = relativeChromaCurve[mode][index] * intensity * maxChroma({ lightness, hue, gamut }); return { lightness, chroma, hue }; }); return Object.fromEntries( steps.map((color, index) => [index + 1, color]) ); }; const createColorScale = (spec, mode) => { const scale = createOklchScale(spec, mode, "srgb"); return Object.fromEntries( Object.entries(scale).map(([step, color]) => [step, toHex(color)]) ); }; const step = (source, dark, light, alpha) => { return ({ mode }) => { const resolved = { source, step: mode === "dark" ? dark : light }; if (alpha) resolved.alpha = alpha; return resolved; }; }; const self = (dark, light = dark, alpha) => step("self", dark, light, alpha); const gray = (dark, light = dark) => step("grayscale", dark, light); const selfAdaptive = (grayscaleDark, coloredDark, grayscaleLight = grayscaleDark, coloredLight = coloredDark) => ({ isGrayscale, mode }) => ({ source: "self", step: (() => { if (mode === "dark") return isGrayscale ? grayscaleDark : coloredDark; return isGrayscale ? grayscaleLight : coloredLight; })() }); const grayscaleOnly = (resolver) => (ctx) => ctx.isGrayscale ? resolver(ctx) : null; const perMode = (values) => grayscaleOnly(({ mode }) => ({ literal: values[mode] })); const tokenScaleMap = { /* grayscale-only base tokens */ translucent: perMode({ dark: "#1f1e1e55", light: "#ffffff66" }), /* grayscale-only shadow colors — the layers of the `--shadow-*` tokens in core.css. A dark ground swallows a drop shadow that reads on a light one, and makes the white inset rim that disappears on a light one glare, so dark mode deepens the drop layers and dims the rims. The lowered inset is not deepened: it falls on the element's own ground, not on the page, and in dark mode that ground can be light (a pressed neutral `filled` button, an `enabled` Switch track), where a half-black inset reads as a smear. */ "shadow-highlight": perMode({ dark: "#ffffff40", light: "#ffffff33" }), "dark-shadow": perMode({ dark: "#00000080", light: "#00000030" }), "soft-shadow": perMode({ dark: "#00000066", light: "#00000025" }), "shadow-lowered-dark": perMode({ dark: "#000000", light: "#00000030" }), "shadow-lowered-highlight": perMode({ dark: "#ffffff12", light: "#ffffff15" }), "bar-dark-shadow": perMode({ dark: "#00000066", light: "#00000020" }), "bar-soft-shadow": perMode({ dark: "#0000004d", light: "#00000018" }), /* grayscale-only backgrounds */ screen: grayscaleOnly(self(2, 3)), highlight: grayscaleOnly(self(4, 1)), /* grayscale-only texts */ "disabled-sharp": grayscaleOnly(gray(8, 9)), "disabled-muted": grayscaleOnly(gray(7, 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-filled-disabled": grayscaleOnly(gray(5, 6)), "interactive-tonal-disabled": grayscaleOnly(gray(5, 4)), "interactive-outlined-disabled": grayscaleOnly(gray(6, 6)), "interactive-accent-outlined-disabled": grayscaleOnly(gray(6, 6)), /* backgrounds */ surface: self(3, 2), // The accent's solid fill under `on-accent` ink (Avatar, Badge, BrandLogo, // the web Switch track). Neutral, it is the `filled` button's ground: the // sharp ink turned into a ground, black in light mode and white in dark. enabled: selfAdaptive(10, 6, 11, 8), "highlight-accent": self(4, 3), 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 filled *button* needs (darker than the // surface it sits on), which is why the two are separate tokens. emphasis: selfAdaptive(6, 5, 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 */ // PressableBox's `tonal` variant, the default material of Button, // IconButton and PressableListItem: a ground lighter than the page it sits // on, lifted by `shadow-s`. Its ground is a *tone*, not the accent's fill, so // the hue is carried by the `on-tonal` ink. That is what separates it from // `emphasis`, which is the accent's fill — a SegmentedBar chip has to win // against its track, a tonal pressable 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-tonal`'s // job anyway. Dark has no pale end, so it takes the accent's own dark ground // (6 → 7), one notch under the filled fill. Neutral walks the card steps in // both (1 → 2 → 3 light, 6 → 7 dark, which has nothing above 7 to press // into). "interactive-tonal-pressable": selfAdaptive(6, 6, 1, 2), "interactive-tonal-hover": selfAdaptive(7, 7, 2, 3), "interactive-tonal-focus": selfAdaptive(7, 7, 2, 3), "interactive-tonal-active": selfAdaptive(7, 7, 3, 4), // PressableBox's `filled` variant: the accent's fill. Colored, it takes the // same steps in every accent. Neutral, it is the sharp ink turned into a // ground — near black in light mode (11 → 10 → 9), near white in dark // (10 → 11 → 9) — so the neutral filled button reads as the strongest action // rather than a dull gray, and its `on-accent` label flips to dark in dark // mode. // Colored dark press has no step of its own: the scale jumps from 7 // (#555555) to 8 (#BCBCBC, where the text tones start), and white ink on // #BCBCBC is 1.9:1 — so it holds at hover's value rather than climbing into // the text tones or receding to a darker step. "interactive-filled-pressable": selfAdaptive(10, 6, 11, 9), "interactive-filled-hover": selfAdaptive(11, 7, 10, 8), "interactive-filled-focus": selfAdaptive(11, 7, 10, 8), "interactive-filled-active": selfAdaptive(9, 7, 9, 7), // 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), // The ink of the `filled` and `enabled` grounds: white on every colored fill // and on the near-black neutral one in light mode; dark on the near-white // neutral fill in dark mode. "on-accent": ({ isGrayscale, mode }) => ({ source: "grayscale", step: (() => { if (mode === "light") return 1; return isGrayscale ? 1 : 11; })() }), "on-accent-muted": selfAdaptive(7, 10, 4, 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: (() => { if (mode === "dark") return 11; return isGrayscale ? 11 : 1; })() }), // The ink of a `tonal` pressable — label, icon and caret. A light ground // can only tint (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 ground already *is* the accent's, where an accent ink // would be a tint of the color under it (4:1), so it keeps the sharp ink. "on-tonal": ({ 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") }; const accentEmitOrder = [ "grayscale", "brand", "info", "success", "warning", "danger" ]; const round = (value, digits) => Number(value.toFixed(digits)); const formatOklch = ({ lightness, chroma, hue }) => `oklch(${round(lightness, 4)} ${round(chroma, 4)} ${round(hue, 2)})`; const hexAlphaToFraction = (alpha) => round(Number.parseInt(alpha, 16) / 255, 3); const withOklchAlpha = (serialized, alpha) => alpha === 1 ? serialized : `${serialized.slice(0, -1)} / ${alpha})`; const colorFormats = { 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; return withOklchAlpha( formatOklch({ lightness: lightness ?? 0, chroma: chroma ?? 0, hue: hue || 0 }), round(alpha, 3) ); } } }; const colorAt = ({ scales, mode, accentName, step }) => scales[`${accentName}.${mode}`][step]; const buildThemeVars = ({ scales, mode, accentName, format }) => { const isGrayscale = accentName === "grayscale"; const vars = {}; 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, indent) => Object.entries(vars).map(([key, value]) => `${indent}--color-${key}: ${value};`).join("\n"); const prefixVars = (vars) => Object.fromEntries( Object.entries(vars).map(([key, value]) => [`--color-${key}`, value]) ); const accents = accentEmitOrder.filter((name) => name !== "grayscale"); const themeTargets = [ ...["light", "dark"].map( (mode) => ({ theme: mode, mode, accentName: "grayscale" }) ), ...["light", "dark"].flatMap( (mode) => accents.map( (accentName) => ({ theme: `${mode}_${accentName}`, mode, accentName }) ) ) ]; const emitThemeBlocks = ({ scales, format, indent }) => themeTargets.map(({ theme, mode, accentName }) => { const vars = buildThemeVars({ scales, mode, accentName, format }); return `${indent}.${theme} { ${emit(vars, `${indent} `)} ${indent}}`; }).join("\n\n"); const webOnly = (rules) => ` @supports (display: contents) { ${rules} }`; const buildPaletteCss = (srgbScales) => { const srgb = colorFormats.srgb; const lightVars = buildThemeVars({ scales: srgbScales, mode: "light", accentName: "grayscale", format: srgb }); return `@theme { /* The light theme as defaults. The whole palette on native, where ScopedTheme overrides it at runtime with the themeVariables map; web resolves the .<theme> blocks below. */ ${emit(lightVars, " ")} } @layer theme { ${webOnly(emitThemeBlocks({ scales: srgbScales, format: srgb, indent: " " }))} } `; }; const buildOklchPaletteCss = (p3Scales) => { 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 \u2014 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: " " })} } } `; }; const buildThemeVariables = (scales, formatName = "srgb") => { 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 }) } ) ]) ); }; const generateTheme = (overrides) => { const specs = { ...defaultPaletteSpecs, ...overrides }; const scalesForGamut = (gamut) => Object.fromEntries( Object.keys(specs).flatMap((name) => [ [`${name}.light`, createOklchScale(specs[name], "light", gamut)], [`${name}.dark`, createOklchScale(specs[name], "dark", gamut)] ]) ); const srgbScales = scalesForGamut("srgb"); const p3Scales = scalesForGamut("p3"); return { css: buildPaletteCss(srgbScales), oklchCss: buildOklchPaletteCss(p3Scales), themeVariables: buildThemeVariables(srgbScales, "srgb"), oklchThemeVariables: buildThemeVariables(p3Scales, "oklch") }; }; const generatedHeader = "/* Generated by alouette writeTheme. DO NOT EDIT. */"; const serializeThemeVariables = (themeVariables) => `{ ${Object.entries(themeVariables).map(([theme, variables]) => { const entries = Object.entries(variables).map(([name, value]) => ` "${name}": "${value}",`).join("\n"); return ` ${theme}: { ${entries} },`; }).join("\n")} }`; const themeVariablesModule = (themeVariables) => `${generatedHeader} /* eslint-disable camelcase */ import type { ThemeVariablesMap } from "alouette"; /** * Resolved CSS-variable maps for every theme, paired with the generated palette * CSS. Pass to \`<AlouetteProvider themeVariables={...}>\`. */ export const themeVariables: ThemeVariablesMap = ${serializeThemeVariables(themeVariables)}; `; const writeTheme = ({ outDir, overrides, srgbOnly = false, cssFileName = "palette.css", themeVariablesFileName = "themeVariables.ts" }) => { const { css, oklchCss, themeVariables } = generateTheme(overrides); mkdirSync(outDir, { recursive: true }); const cssPath = join(outDir, cssFileName); writeFileSync( cssPath, `${generatedHeader} /* App palette. Import after "alouette/core.css", instead of "alouette/global.css" \u2014 which carries alouette's default palette. */ ${css}` ); const themeVariablesPath = join(outDir, themeVariablesFileName); writeFileSync(themeVariablesPath, themeVariablesModule(themeVariables)); if (srgbOnly) { return { cssPath, oklchCssPath: void 0, themeVariablesPath }; } const oklchCssPath = join(outDir, cssFileName.replace(/\.css$/, "-oklch$&")); writeFileSync( oklchCssPath, `${generatedHeader} /* Wide-gamut half of the app palette. Optional \u2014 import after "${cssFileName}" to opt web into the display-p3 ramp. */ ${oklchCss}` ); return { cssPath, oklchCssPath, themeVariablesPath }; }; export { buildOklchPaletteCss, buildPaletteCss, buildThemeVariables, createColorScale, createOklchScale, defaultPaletteSpecs, generateTheme, maxChroma, writeTheme }; //# sourceMappingURL=theme-generator-node22.mjs.map