From 2ae51afdcd51ebf95b730bdace8822db752cdf9e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 03:08:53 +0000 Subject: [PATCH] 0.30.0 - Sidebar active gradient tokens: --sidebar-active-start and --sidebar-active-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two overridable colour tokens for the gradient that marks the active navigation sidebar item (@schemavaults/ui DashboardLayout). They sit in the "sidebar" group right after sidebar-ring and default to the brand colours, so re-theming brand-blue / brand-red re-colours the gradient unless these are set separately: --sidebar-active-start: var(--sv-theme-light-sidebar-active-start, var(--schemavaults-brand-blue)); --sidebar-active-end: var(--sv-theme-light-sidebar-active-end, var(--schemavaults-brand-red)); - theme_tokens: ModeThemeToken gains an optional `defaultFrom`, naming the token a default follows; the manifest sync test checks that such a default is exactly `var()` and that no other default hides a `var()` reference. - resolveThemeTokens resolves a `defaultFrom` default to the source token's effective value, its override included, so a settings page shows a real colour rather than `var(...)`. - Tailwind: `sidebar-active-start` / `sidebar-active-end` colours through colorWithAlphaChannel (from-/via-/to-, bg-…/20, …). - README: tokens, env var names, brand-following defaults, and guidance that they are accent colours, not text colours. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Edc98fMEUtdYir2GjG9fa4 --- README.md | 22 ++++ globals.css | 8 ++ package.json | 2 +- src/TailwindConfigFactory.ts | 6 + src/index.ts | 2 + src/sidebar_colors.test.ts | 207 +++++++++++++++++++++++++++++++++++ src/sidebar_colors.ts | 22 ++++ src/theme_overrides.ts | 35 ++++-- src/theme_tokens.test.ts | 23 +++- src/theme_tokens.ts | 82 ++++++++++---- 10 files changed, 381 insertions(+), 28 deletions(-) create mode 100644 src/sidebar_colors.test.ts create mode 100644 src/sidebar_colors.ts diff --git a/README.md b/README.md index b977f0e..889a94a 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,8 @@ const style = createThemeOverrideStyle(overrides); Values are validated and normalised before they are emitted. Tokens whose format is `hsl-channels` (the shadcn/ui component colours the Tailwind theme wraps in `hsl()`) accept bare channels (`222.2 84% 4.9%`), a hex colour, or an opaque `rgb()`/`hsl()`, and are stored as channels; `css-color` tokens (the brand colours, `warning` and the sidebar) take any CSS colour; `radius` takes a CSS length. A value that could break the stylesheet or escape an attribute is refused with a reason you can show an administrator. +A token can take its default from another token instead of a literal value (`defaultFrom` in the manifest): `globals.css` then falls back to that token's variable, so re-theming it re-colours both. `resolveThemeTokens()` reports such a token's `defaultValue` as the resolved value of the token it follows, that token's override included. + Environment variable names follow the token id: `_LIGHT_`, `_DARK_` and `_` for shared tokens, upper-cased with hyphens as underscores (`sidebar-primary-foreground` → `THEME_DARK_SIDEBAR_PRIMARY_FOREGROUND`); `themeTokenEnvironmentVariable()` returns the name for a token and scope. The generated Tailwind config safelists the `dark` class so the `.dark` token block survives Tailwind's purge in applications that only add the class at runtime. @@ -150,6 +152,26 @@ Rules for using them: Like every other token, the chart colours can be re-themed per deployment (`--sv-theme-light-chart-1`, `THEME_DARK_CHART_OTHER`, …); re-run the validator on any replacement palette. +### Sidebar active gradient + +A two-colour gradient marks the navigation sidebar item for the current page (`@schemavaults/ui`'s `DashboardLayout` paints a wash across the row, a glowing bar down its left edge and a gradient label with it). Its colours are two tokens: + +| Token | Environment variables | Default (light and dark) | +| --- | --- | --- | +| `--sidebar-active-start` | `THEME_LIGHT_SIDEBAR_ACTIVE_START`, `THEME_DARK_SIDEBAR_ACTIVE_START` | `var(--schemavaults-brand-blue)` | +| `--sidebar-active-end` | `THEME_LIGHT_SIDEBAR_ACTIVE_END`, `THEME_DARK_SIDEBAR_ACTIVE_END` | `var(--schemavaults-brand-red)` | + +```css +:root { --sidebar-active-start: var(--sv-theme-light-sidebar-active-start, var(--schemavaults-brand-blue)); } +.dark { --sidebar-active-start: var(--sv-theme-dark-sidebar-active-start, var(--schemavaults-brand-blue)); } +``` + +By default they follow the brand colours, so a deployment that re-themes `brand-blue` / `brand-red` gets a matching gradient without setting anything else. Set `--sv-theme-light-sidebar-active-start` (and friends), or the environment variables above, to give the gradient its own colours; each mode is independent, as for every other token. + +The Tailwind config exposes them as the `sidebar-active-start` and `sidebar-active-end` colours, so an application can reuse the gradient: `bg-gradient-to-r from-sidebar-active-start to-sidebar-active-end`, `bg-sidebar-active-start/20`. + +These are **accent colours, not text colours**. The UI paints the bar and the wash with them directly, and mixes each 60/40 with `--foreground` for the item's icon and label; with the default pair that mix stays at about 5.6:1 or better against the sidebar background in both modes. The raw colours are not safe for text on their own (the brand blue is 2.4:1 on the light sidebar), so avoid `text-sidebar-active-start` for body copy. When overriding them, pick saturated, mid-lightness colours: very light or very dark values wash the gradient out against one of the two modes' sidebars and pull the mixed label towards too little contrast. + ### CommonJS Note that currently CommonJS is not supported. I believe that I was struggling to get `tailwindcss-animate` working from CJS, then decided not to support it. Change your `tailwind.config.cjs` files to `tailwind.config.ts` or `tailwind.config.mjs` to use TypeScript or ES modules instead. diff --git a/globals.css b/globals.css index 2b58b8d..9554eda 100644 --- a/globals.css +++ b/globals.css @@ -64,6 +64,10 @@ --sidebar-border: var(--sv-theme-light-sidebar-border, oklch(0.922 0 0)); --sidebar-ring: var(--sv-theme-light-sidebar-ring, oklch(0.708 0 0)); + /* The gradient marking the active navigation item; follows the brand colours unless set. */ + --sidebar-active-start: var(--sv-theme-light-sidebar-active-start, var(--schemavaults-brand-blue)); + --sidebar-active-end: var(--sv-theme-light-sidebar-active-end, var(--schemavaults-brand-red)); + /* Categorical chart series, assigned in this order and never cycled. */ --chart-1: var(--sv-theme-light-chart-1, #2a78d6); --chart-2: var(--sv-theme-light-chart-2, #eb6834); @@ -120,6 +124,10 @@ --sidebar-border: var(--sv-theme-dark-sidebar-border, oklch(1 0 0 / 10%)); --sidebar-ring: var(--sv-theme-dark-sidebar-ring, oklch(0.439 0 0)); + /* The gradient marking the active navigation item; follows the brand colours unless set. */ + --sidebar-active-start: var(--sv-theme-dark-sidebar-active-start, var(--schemavaults-brand-blue)); + --sidebar-active-end: var(--sv-theme-dark-sidebar-active-end, var(--schemavaults-brand-red)); + /* Categorical chart series, assigned in this order and never cycled. */ --chart-1: var(--sv-theme-dark-chart-1, #3987e5); --chart-2: var(--sv-theme-dark-chart-2, #d95926); diff --git a/package.json b/package.json index 1feab0b..75de9fb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@schemavaults/theme", "description": "TailwindCSS theme shared by different SchemaVaults applications", - "version": "0.29.0", + "version": "0.30.0", "private": false, "license": "UNLICENSED", "repository": { diff --git a/src/TailwindConfigFactory.ts b/src/TailwindConfigFactory.ts index cbd40f6..398183c 100644 --- a/src/TailwindConfigFactory.ts +++ b/src/TailwindConfigFactory.ts @@ -2,6 +2,7 @@ import type { Config as TailwindConfig } from "tailwindcss"; import { componentColors } from "./component_colors"; import { brandColors } from "./brand_colors"; import { chartColors } from "./chart_colors"; +import { sidebarColors } from "./sidebar_colors"; import DefaultOrgScope from "./DefaultOrgScope"; // @schemavaults organization is default import { getScreenBreakpoint, @@ -142,6 +143,10 @@ export class SchemaVaultsTailwindConfigFactory return chartColors; } + protected get sidebarColors(): ThemeValue { + return sidebarColors; + } + protected get screenSizes(): Record { const breakpointIds: readonly ScreenBreakpointID[] = listScreenBreakpoints(); @@ -164,6 +169,7 @@ export class SchemaVaultsTailwindConfigFactory ...this.shadcnColors, ...this.brandColors, ...this.chartColors, + ...this.sidebarColors, }, borderRadius: { lg: "var(--radius)", diff --git a/src/index.ts b/src/index.ts index 0da6cb1..8dfb4b3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,8 @@ export { getSchemaVaultsBrandColor, brandColors } from "./brand_colors"; export { chartColors, CHART_SERIES_SLOT_COUNT } from "./chart_colors"; +export { sidebarColors } from "./sidebar_colors"; + export { getScreenBreakpoint, listScreenBreakpoints, diff --git a/src/sidebar_colors.test.ts b/src/sidebar_colors.test.ts new file mode 100644 index 0000000..4471fd6 --- /dev/null +++ b/src/sidebar_colors.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "fs"; +import { createRequire } from "node:module"; +import { join } from "path"; +import type { Config as TailwindConfig } from "tailwindcss"; +import SchemaVaultsTailwindConfigFactory from "./TailwindConfigFactory"; +import { + createThemeOverrideStyle, + renderThemeOverrideCss, + resolveThemeTokens, + themeOverridesFromEnvironment, + themeTokenEnvironmentVariable, + type ResolvedThemeTokenValue, + type ThemeOverrides, +} from "./theme_overrides"; +import { getThemeToken, listThemeTokenIds, type ThemeTokenID } from "./theme_tokens"; + +const require_ = createRequire(import.meta.url); +const postcss = require_("postcss") as ( + plugins: readonly import("postcss").AcceptedPlugin[], +) => import("postcss").Processor; +const tailwind = require_("tailwindcss") as ( + config: TailwindConfig, +) => import("postcss").AcceptedPlugin; + +/** The declarations Tailwind emits for each class, keyed by class name (see component_colors.test.ts). */ +async function compileUtilities(classNames: readonly string[]): Promise> { + const config: TailwindConfig = new SchemaVaultsTailwindConfigFactory().createConfig({ + content: ["./src/**/*.tsx"], + }); + config.content = [{ raw: classNames.join(" "), extension: "html" }]; + const compiled = await postcss([tailwind(config)]).process("@tailwind utilities;", { from: undefined }); + const emitted = new Map(); + compiled.root.walkRules((rule) => { + const declarations: string[] = []; + rule.walkDecls((declaration) => { + declarations.push(`${declaration.prop}: ${declaration.value}`); + }); + emitted.set(rule.selector.replace(/^\./, "").replace(/\\/g, ""), declarations.join("; ")); + }); + return emitted; +} + +/** The light and dark values `resolveThemeTokens` reports for a token. */ +function resolved(id: ThemeTokenID, overrides: ThemeOverrides = {}): ResolvedThemeTokenValue[] { + return resolveThemeTokens(overrides).find((row) => row.token.id === id)!.values; +} + +describe("sidebar active gradient tokens", () => { + test("follow sidebar-ring in the manifest", () => { + const ids = listThemeTokenIds(); + const ring = ids.indexOf("sidebar-ring"); + expect(ids.slice(ring + 1, ring + 3)).toEqual(["sidebar-active-start", "sidebar-active-end"]); + }); + + test.each([ + [ + "sidebar-active-start", + "Sidebar active gradient start", + "First colour of the gradient marking the active nav item.", + "brand-blue", + "--schemavaults-brand-blue", + ], + ["sidebar-active-end", "Sidebar active gradient end", "Second colour of that gradient.", "brand-red", "--schemavaults-brand-red"], + ] as const)("'%s' is a sidebar css-color that defaults to the brand colour", (id, label, description, source, sourceVariable) => { + expect(getThemeToken(id)).toEqual({ + id, + cssVariable: `--${id}`, + format: "css-color", + group: "sidebar", + label, + description, + scopes: ["light", "dark"], + defaults: { light: `var(${sourceVariable})`, dark: `var(${sourceVariable})` }, + defaultFrom: source, + }); + }); + + test("globals.css reads the override variable and falls back to the brand colour in both modes", () => { + const css = readFileSync(join(import.meta.dir, "..", "globals.css"), "utf8"); + for (const mode of ["light", "dark"]) { + expect(css).toContain( + `--sidebar-active-start: var(--sv-theme-${mode}-sidebar-active-start, var(--schemavaults-brand-blue));`, + ); + expect(css).toContain(`--sidebar-active-end: var(--sv-theme-${mode}-sidebar-active-end, var(--schemavaults-brand-red));`); + } + }); +}); + +describe("sidebar active gradient overrides", () => { + test("have environment variables named after the token", () => { + expect(themeTokenEnvironmentVariable("sidebar-active-start", "light")).toBe("THEME_LIGHT_SIDEBAR_ACTIVE_START"); + expect(themeTokenEnvironmentVariable("sidebar-active-start", "dark")).toBe("THEME_DARK_SIDEBAR_ACTIVE_START"); + expect(themeTokenEnvironmentVariable("sidebar-active-end", "light")).toBe("THEME_LIGHT_SIDEBAR_ACTIVE_END"); + expect(themeTokenEnvironmentVariable("sidebar-active-end", "dark")).toBe("THEME_DARK_SIDEBAR_ACTIVE_END"); + }); + + test("are read from the environment", () => { + const { overrides, problems } = themeOverridesFromEnvironment({ + THEME_LIGHT_SIDEBAR_ACTIVE_START: "#7c3aed", + THEME_DARK_SIDEBAR_ACTIVE_END: " oklch(0.65 0.2 350) ", + THEME_LIGHT_SIDEBAR_ACTIVE_END: "url(https://example.com)", + }); + expect(overrides).toEqual({ + light: { "sidebar-active-start": "#7c3aed" }, + dark: { "sidebar-active-end": "oklch(0.65 0.2 350)" }, + }); + expect(problems.map((problem) => problem.variable)).toEqual(["THEME_LIGHT_SIDEBAR_ACTIVE_END"]); + }); + + test("render as override variables per mode", () => { + const overrides: ThemeOverrides = { + light: { "sidebar-active-start": "#7c3aed", "sidebar-active-end": "#db2777" }, + dark: { "sidebar-active-start": "#a78bfa" }, + }; + expect(createThemeOverrideStyle(overrides)).toEqual({ + "--sv-theme-light-sidebar-active-start": "#7c3aed", + "--sv-theme-light-sidebar-active-end": "#db2777", + "--sv-theme-dark-sidebar-active-start": "#a78bfa", + }); + expect(renderThemeOverrideCss(overrides)).toBe( + ":root{--sv-theme-light-sidebar-active-start:#7c3aed;--sv-theme-light-sidebar-active-end:#db2777;--sv-theme-dark-sidebar-active-start:#a78bfa;}", + ); + }); +}); + +describe("sidebar active gradient defaults", () => { + test("resolve to the brand colours", () => { + expect(resolved("sidebar-active-start")).toEqual([ + { + scope: "light", + defaultValue: "#60a5fa", + override: null, + value: "#60a5fa", + overrideVariable: "--sv-theme-light-sidebar-active-start", + }, + { + scope: "dark", + defaultValue: "#60a5fa", + override: null, + value: "#60a5fa", + overrideVariable: "--sv-theme-dark-sidebar-active-start", + }, + ]); + expect(resolved("sidebar-active-end").map((value) => value.value)).toEqual(["#dc2626", "#dc2626"]); + }); + + test("follow a re-themed brand colour, in that mode only", () => { + const overrides: ThemeOverrides = { light: { "brand-blue": "#0ea5e9" }, dark: { "brand-red": "#f43f5e" } }; + expect(resolved("sidebar-active-start", overrides).map((value) => [value.defaultValue, value.override, value.value])).toEqual([ + ["#0ea5e9", null, "#0ea5e9"], + ["#60a5fa", null, "#60a5fa"], + ]); + expect(resolved("sidebar-active-end", overrides).map((value) => value.value)).toEqual(["#dc2626", "#f43f5e"]); + }); + + test("give way to the token's own override", () => { + const overrides: ThemeOverrides = { + light: { "brand-blue": "#0ea5e9", "sidebar-active-start": "#7c3aed" }, + }; + const [light, dark] = resolved("sidebar-active-start", overrides); + expect(light).toMatchObject({ defaultValue: "#0ea5e9", override: "#7c3aed", value: "#7c3aed" }); + expect(dark).toMatchObject({ defaultValue: "#60a5fa", override: null, value: "#60a5fa" }); + // The brand colour itself is untouched by the gradient's override. + expect(resolved("brand-blue", overrides).map((value) => value.value)).toEqual(["#0ea5e9", "#60a5fa"]); + }); +}); + +describe("sidebar active gradient Tailwind colours", () => { + test("build the gradient", async () => { + const emitted = await compileUtilities([ + "from-sidebar-active-start", + "via-sidebar-active-start", + "to-sidebar-active-end", + "from-sidebar-active-end", + "to-sidebar-active-start", + ]); + expect(emitted.get("from-sidebar-active-start")).toContain("--tw-gradient-from: color-mix(in oklab, var(--sidebar-active-start)"); + expect(emitted.get("via-sidebar-active-start")).toContain("var(--sidebar-active-start)"); + expect(emitted.get("to-sidebar-active-end")).toContain("--tw-gradient-to: color-mix(in oklab, var(--sidebar-active-end)"); + expect(emitted.get("from-sidebar-active-end")).toContain("var(--sidebar-active-end)"); + expect(emitted.get("to-sidebar-active-start")).toContain("var(--sidebar-active-start)"); + }); + + test("take the opacity modifier", async () => { + const emitted = await compileUtilities([ + "bg-sidebar-active-start/20", + "bg-sidebar-active-end/10", + "from-sidebar-active-start/15", + "shadow-sidebar-active-start/50", + ]); + expect(emitted.get("bg-sidebar-active-start/20")).toContain("var(--sidebar-active-start) calc(0.2 * 100%)"); + expect(emitted.get("bg-sidebar-active-end/10")).toContain("var(--sidebar-active-end) calc(0.1 * 100%)"); + expect(emitted.get("from-sidebar-active-start/15")).toContain("var(--sidebar-active-start) calc(0.15 * 100%)"); + expect(emitted.get("shadow-sidebar-active-start/50")).toContain("var(--sidebar-active-start)"); + }); + + test("emit background, text and border utilities", async () => { + const classNames: string[] = ["sidebar-active-start", "sidebar-active-end"].flatMap((color: string): string[] => [ + `bg-${color}`, + `text-${color}`, + `border-${color}`, + ]); + const emitted = await compileUtilities(classNames); + expect(classNames.filter((className: string): boolean => !emitted.has(className))).toEqual([]); + }); +}); diff --git a/src/sidebar_colors.ts b/src/sidebar_colors.ts new file mode 100644 index 0000000..901398f --- /dev/null +++ b/src/sidebar_colors.ts @@ -0,0 +1,22 @@ +import type { Config as TailwindConfig } from "tailwindcss"; +import { colorWithAlphaChannel } from "./component_colors"; +type TailwindConfigTheme = NonNullable; +type ThemeExtension = NonNullable; +type ThemeValue = ThemeExtension[string]; + +/** + * The two colours of the gradient that marks the active navigation item, as + * Tailwind colours, so an application can reuse it: + * `bg-gradient-to-r from-sidebar-active-start to-sidebar-active-end`, + * `bg-sidebar-active-start/20`. + * + * The tokens hold complete colours (by default a `var()` reference to the + * brand colours), so they go through `color-mix()` like the chart colours + * do, which keeps Tailwind's opacity modifier working on them. The keys are + * flat rather than nested under `sidebar` so they never collide with an + * application's own `sidebar` colour. + */ +export const sidebarColors: ThemeValue = { + "sidebar-active-start": colorWithAlphaChannel("--sidebar-active-start"), + "sidebar-active-end": colorWithAlphaChannel("--sidebar-active-end"), +}; diff --git a/src/theme_overrides.ts b/src/theme_overrides.ts index d8493cc..636bac5 100644 --- a/src/theme_overrides.ts +++ b/src/theme_overrides.ts @@ -176,7 +176,11 @@ export function renderThemeOverrideCss(overrides: ThemeOverrides, selector: stri /** The token's effective value in one scope, for a settings page. */ export interface ResolvedThemeTokenValue { scope: ThemeTokenScope; - /** The stylesheet default. */ + /** + * What the token renders as when it is not overridden itself: the + * stylesheet default, or — for a token with `defaultFrom` — the resolved + * value of the token it follows, that token's override included. + */ defaultValue: string; /** The normalised override, or null when the default applies. */ override: string | null; @@ -200,14 +204,31 @@ export interface ResolvedThemeToken { */ export function resolveThemeTokens(overrides: ThemeOverrides = {}): ResolvedThemeToken[] { const style = createThemeOverrideStyle(overrides); + + const resolveValue = ( + token: ThemeToken, + scope: ThemeTokenScope, + seen: readonly ThemeTokenID[], + ): ResolvedThemeTokenValue => { + const overrideVariable = themeOverrideVariable(token.id, scope); + const override = style[overrideVariable] ?? null; + const defaultFrom = "defaultFrom" in token ? token.defaultFrom : undefined; + let defaultValue: string; + if (defaultFrom === undefined) { + defaultValue = themeTokenDefault(token, scope) as string; + } else if (seen.includes(defaultFrom)) { + throw new Error( + `Theme token '${token.id}' takes its default from itself via ${[...seen, defaultFrom].join(" -> ")}!`, + ); + } else { + defaultValue = resolveValue(getThemeToken(defaultFrom), scope, [...seen, defaultFrom]).value; + } + return { scope, defaultValue, override, value: override ?? defaultValue, overrideVariable }; + }; + return listThemeTokens().map((token) => ({ token, - values: token.scopes.map((scope): ResolvedThemeTokenValue => { - const overrideVariable = themeOverrideVariable(token.id, scope); - const defaultValue = themeTokenDefault(token, scope) as string; - const override = style[overrideVariable] ?? null; - return { scope, defaultValue, override, value: override ?? defaultValue, overrideVariable }; - }), + values: token.scopes.map((scope): ResolvedThemeTokenValue => resolveValue(token, scope, [token.id])), })); } diff --git a/src/theme_tokens.test.ts b/src/theme_tokens.test.ts index da972f0..8efca00 100644 --- a/src/theme_tokens.test.ts +++ b/src/theme_tokens.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "fs"; import { join } from "path"; -import { listThemeTokens, themeTokenDefault } from "./theme_tokens"; +import { getThemeToken, listThemeTokens, themeTokenDefault, type ModeThemeToken } from "./theme_tokens"; import { themeOverrideVariable } from "./theme_overrides"; /** @@ -49,6 +49,27 @@ describe("THEME_TOKENS", () => { }, ); + test.each( + listThemeTokens() + .filter((token): token is ModeThemeToken => "defaultFrom" in token && token.defaultFrom !== undefined) + .map((token) => [token.id, token] as const), + )("'%s' falls back to the variable of the token it takes its default from", (_id, token) => { + const source = getThemeToken(token.defaultFrom!); + expect(source.id).not.toBe(token.id); + expect(source.scopes).toEqual(token.scopes); + for (const scope of token.scopes) { + expect(themeTokenDefault(token, scope)).toBe(`var(${source.cssVariable})`); + } + }); + + test("a default that references another variable is declared through defaultFrom", () => { + // Otherwise resolveThemeTokens would report the raw `var()` as the value. + for (const token of listThemeTokens()) { + if ("defaultFrom" in token && token.defaultFrom !== undefined) continue; + for (const scope of token.scopes) expect(themeTokenDefault(token, scope)).not.toContain("var("); + } + }); + test("globals.css declares no token the manifest lacks", () => { const known = new Set(listThemeTokens().map((t) => t.cssVariable)); for (const variable of declarations(rootBlock).keys()) expect(known).toContain(variable as `--${string}`); diff --git a/src/theme_tokens.ts b/src/theme_tokens.ts index 1a6f7eb..3ce5293 100644 --- a/src/theme_tokens.ts +++ b/src/theme_tokens.ts @@ -52,7 +52,15 @@ interface ThemeTokenBase { export interface ModeThemeToken extends ThemeTokenBase { readonly id: ModeThemeTokenID; readonly scopes: readonly ["light", "dark"]; + /** The fallback `globals.css` declares; `var(--other-token)` for a token with `defaultFrom`. */ readonly defaults: { readonly light: string; readonly dark: string }; + /** + * The token whose value this one takes, in each mode, until it is + * overridden itself: `globals.css` falls back to `var()`, so re-theming that token re-colours this one too. + * Undefined when the default is a literal value. + */ + readonly defaultFrom?: ModeThemeTokenID; } /** A token declared once, for both modes. */ @@ -96,6 +104,8 @@ const MODE_THEME_TOKEN_IDS = [ "sidebar-accent-foreground", "sidebar-border", "sidebar-ring", + "sidebar-active-start", + "sidebar-active-end", "chart-1", "chart-2", "chart-3", @@ -157,6 +167,21 @@ function color( }; } +/** + * A colour whose default is another token's value in the same mode (see + * `ModeThemeToken.defaultFrom`), exposed as `--`. + */ +function colorFrom( + id: ModeThemeTokenID, + group: ThemeTokenGroup, + label: string, + description: string, + source: ModeThemeToken, +): ModeThemeToken { + const fallback: string = `var(${source.cssVariable})`; + return { ...color(id, `--${id}`, group, label, description, fallback, fallback), defaultFrom: source.id }; +} + /** A categorical chart colour, `--chart-`, exposed as the `chart-` Tailwind colour. */ function chart( id: Extract, @@ -177,30 +202,34 @@ function chart( ); } +const BRAND_BLUE: ModeThemeToken = color( + "brand-blue", + "--schemavaults-brand-blue", + "brand", + "Brand blue", + "The SchemaVaults brand blue, available as the `schemavaults-brand-blue` Tailwind colour.", + "#60a5fa", + "#60a5fa", +); + +const BRAND_RED: ModeThemeToken = color( + "brand-red", + "--schemavaults-brand-red", + "brand", + "Brand red", + "The SchemaVaults brand red, available as the `schemavaults-brand-red` Tailwind colour.", + "#dc2626", + "#dc2626", +); + /** * Every token, in the order `globals.css` declares them. Tokens whose format * is `hsl-channels` are the shadcn/ui component colours the Tailwind theme * exposes as `bg-background`, `text-primary-foreground` and so on. */ export const THEME_TOKENS: readonly ThemeToken[] = [ - color( - "brand-blue", - "--schemavaults-brand-blue", - "brand", - "Brand blue", - "The SchemaVaults brand blue, available as the `schemavaults-brand-blue` Tailwind colour.", - "#60a5fa", - "#60a5fa", - ), - color( - "brand-red", - "--schemavaults-brand-red", - "brand", - "Brand red", - "The SchemaVaults brand red, available as the `schemavaults-brand-red` Tailwind colour.", - "#dc2626", - "#dc2626", - ), + BRAND_BLUE, + BRAND_RED, hsl("background", "page", "Page background", "The background of the page body.", "0 0% 100%", "222.2 84% 4.9%"), hsl("foreground", "page", "Page text", "The default text colour on the page background.", "222.2 84% 4.9%", "210 40% 98%"), @@ -247,6 +276,17 @@ export const THEME_TOKENS: readonly ThemeToken[] = [ color("sidebar-accent-foreground", "--sidebar-accent-foreground", "sidebar", "Sidebar accent text", "Text of a hovered navigation item.", "oklch(0.205 0 0)", "oklch(0.985 0 0)"), color("sidebar-border", "--sidebar-border", "sidebar", "Sidebar border", "The sidebar's edge and separators.", "oklch(0.922 0 0)", "oklch(1 0 0 / 10%)"), color("sidebar-ring", "--sidebar-ring", "sidebar", "Sidebar focus ring", "The outline around a focused sidebar item.", "oklch(0.708 0 0)", "oklch(0.439 0 0)"), + // The gradient marking the active navigation item: a wash across the row, + // a bar down its edge and the label. Accent colours, not text colours; they + // follow the brand colours unless a deployment sets them separately. + colorFrom( + "sidebar-active-start", + "sidebar", + "Sidebar active gradient start", + "First colour of the gradient marking the active nav item.", + BRAND_BLUE, + ), + colorFrom("sidebar-active-end", "sidebar", "Sidebar active gradient end", "Second colour of that gradient.", BRAND_RED), // Categorical chart colours: the data-viz reference palette, stepped // separately for each mode and validated in this order against the card @@ -306,7 +346,11 @@ export function themeTokenHasScope(token: ThemeToken, scope: ThemeTokenScope): b return (token.scopes as readonly ThemeTokenScope[]).includes(scope); } -/** The stylesheet's default for the token in a scope, or undefined when the scope does not apply. */ +/** + * The stylesheet's default for the token in a scope, or undefined when the + * scope does not apply. For a token with `defaultFrom` this is the `var()` + * reference `globals.css` falls back to; `resolveThemeTokens` resolves it. + */ export function themeTokenDefault(token: ThemeToken, scope: ThemeTokenScope): string | undefined { return (token.defaults as Partial>)[scope]; }