Skip to content
Merged
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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<PREFIX>_LIGHT_<TOKEN>`, `<PREFIX>_DARK_<TOKEN>` and `<PREFIX>_<TOKEN>` 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.
Expand Down Expand Up @@ -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.
8 changes: 8 additions & 0 deletions globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
6 changes: 6 additions & 0 deletions src/TailwindConfigFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -142,6 +143,10 @@ export class SchemaVaultsTailwindConfigFactory
return chartColors;
}

protected get sidebarColors(): ThemeValue {
return sidebarColors;
}

protected get screenSizes(): Record<ScreenBreakpointID, `${number}px`> {
const breakpointIds: readonly ScreenBreakpointID[] =
listScreenBreakpoints();
Expand All @@ -164,6 +169,7 @@ export class SchemaVaultsTailwindConfigFactory
...this.shadcnColors,
...this.brandColors,
...this.chartColors,
...this.sidebarColors,
},
borderRadius: {
lg: "var(--radius)",
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
207 changes: 207 additions & 0 deletions src/sidebar_colors.test.ts
Original file line number Diff line number Diff line change
@@ -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<Map<string, string>> {
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<string, string>();
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([]);
});
});
22 changes: 22 additions & 0 deletions src/sidebar_colors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Config as TailwindConfig } from "tailwindcss";
import { colorWithAlphaChannel } from "./component_colors";
type TailwindConfigTheme = NonNullable<TailwindConfig["theme"]>;
type ThemeExtension = NonNullable<TailwindConfigTheme["extend"]>;
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"),
};
35 changes: 28 additions & 7 deletions src/theme_overrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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])),
}));
}

Expand Down
Loading