Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
399c188
docs(gamut-docs): add the color system concept page
dreamwasp Oct 29, 2026
ed1f8a6
docs(gamut-docs): list the rebuilt admin, platform, percipio and lxSt…
dreamwasp Oct 29, 2026
e5165c3
Merge branch 'cass-gmt-1825-color-legacy-brands' into cass-gmt-1825-c…
dreamwasp Oct 29, 2026
41c22f7
docs(gamut-docs): navy and beige unpinned; status alerts carry their ink
dreamwasp Oct 29, 2026
fcd29d1
docs(gamut-docs): percipioTheme is removed; five brands ship
dreamwasp Nov 4, 2026
f11a9dd
Merge remote-tracking branch 'origin/cass-gmt-1825-color-legacy-brand…
dreamwasp Nov 4, 2026
fe7333e
docs(gamut-docs): color system page follows design's sandbox update
dreamwasp Nov 4, 2026
0c54a72
Merge cass-gmt-1825-color-legacy-brands into cass-gmt-1825-color-docs
dreamwasp Nov 4, 2026
bdab784
docs(gamut-docs): skillsoftTheme replaces percipioTheme; 204 componen…
dreamwasp Nov 4, 2026
1f4d7fa
Merge branch 'cass-gmt-1825-color-legacy-brands' into cass-gmt-1825-c…
dreamwasp Nov 4, 2026
2d9af71
Merge remote-tracking branch 'origin/cass-gmt-1825-color-legacy-brand…
dreamwasp Nov 4, 2026
13efd24
feat(gamut-brand)!: one tokens.css per brand, and white-label tooling
dreamwasp Nov 5, 2026
5a2390c
fix(gamut-brand): dump:sandbox writes the fixture where it moved
dreamwasp Nov 5, 2026
75a06be
Merge branch 'cass-gmt-1825-color-white-label' into cass-gmt-1825-col…
dreamwasp Nov 5, 2026
ed87041
docs(gamut-docs): color system page covers per-brand CSS and building…
dreamwasp Nov 5, 2026
f73f65c
test(gamut-brand): general generator tests in place of sandbox parity
dreamwasp Nov 5, 2026
ab01baf
Merge branch 'cass-gmt-1825-color-white-label' into cass-gmt-1825-col…
dreamwasp Nov 5, 2026
3e08400
refactor: use the singular --color- prefix for color variables
dreamwasp Nov 10, 2026
6590fa7
Merge remote-tracking branch 'origin/cass-gmt-1825-color-legacy-brand…
dreamwasp Nov 10, 2026
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
5 changes: 2 additions & 3 deletions .changeset/color-system-brands.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,13 @@
'@skillsoft/gamut': minor
---

Brands on the generated color system, and `--color-*` variable names.
Brands on the generated color system.

- **Breaking:** color CSS variables are renamed from `--color-*` to `--color-*`, in `@skillsoft/variance`'s `createTheme` and in `tokens.css`. Raw CSS that reads `var(--color-…)` has to be updated.
- **Breaking:** `tokens.css` puts its rules in `@layer tokens`, with `reset, base, tokens, recipes, utilities` declared first.
- Adds `originalCodecademyTheme`, today's core colors, fonts and hard-offset elevation rebuilt on the generated system. `tokens.css` carries a palette block per brand under `[data-brand]`.
- A brand can override a palette step with an exact hex, or point a role at a different step (and, optionally, a different alpha) in one mode. Overrides go through the same contrast checks as generated values.
- `ColorMode` on generated themes sets `data-color-mode` and `data-brand` instead of injecting every role variable.
- Fixes `Background` painting the flipped mode's color for an alias `bg`, such as `bg="primary"`.
- `Alert`: General uses `surface-brand-subtle`. Gradient alerts size their gradient to the alert, not the viewport. Status alerts (error, success, notice) put their status ink on the message, and a floating Alert's border takes the same ink. The CTA follows the Alert's color mode instead of always rendering dark.
- Original Codecademy no longer pins navy and beige; their generated steps are used as is. Yellow stays exact.
- **Breaking:** the `backdrop` role is renamed `shadow-scrim`, and two shadow roles are added: `shadow-key` and `shadow-ambient`, the two layers every elevation shadow is drawn in (design's sandbox, `df33b47`). Brand themes' `elevation` now uses them, so shadows no longer render `undefined`: Skillsoft gets the soft raised shadow, and Original Codecademy keeps old core's hard offset with a transparent `shadow-ambient`.
- **Breaking:** the `backdrop` role is renamed `shadow-scrim`, and two shadow roles are added: `shadow-key` and `shadow-ambient`, the two layers every elevation shadow is drawn in. Brand themes' `elevation` now uses them, so shadows no longer render `undefined`: Skillsoft gets the soft raised shadow, and Original Codecademy keeps old core's hard offset with a transparent `shadow-ambient`.
2 changes: 1 addition & 1 deletion .changeset/color-system-legacy-brands.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
Rebuild `adminTheme`, `platformTheme` and `lxStudioTheme` as brands of the generated color system, so they define every color role.

- Each brand takes its colors from what the old theme rendered in each job, including what it inherited from core, such as lxStudio's yellow accent. Admin and platform inherit Original Codecademy's pins (yellow exact; navy and beige not pinned). lxStudio pins only its near-white page; its yellow is generated, and its dark mode keeps the sapphire primary instead of core's yellow one.
- Fonts, weights and lxStudio's radii are unchanged. Elevation is the soft shadow drawn in the shadow roles (design's sandbox, `df33b47`); only Original Codecademy keeps the hard offset. `platformTheme` still starts in dark mode and keeps its `editor-*` code editor colors with their old values.
- Fonts, weights and lxStudio's radii are unchanged. Elevation is the soft shadow drawn in the shadow roles; only Original Codecademy keeps the hard offset. `platformTheme` still starts in dark mode and keeps its `editor-*` code editor colors with their old values.
- The two-color brand gradient pairs primary with accent-alt for every brand with a yellow accent.
- **Visual change:** the rebuilt themes aren't pixel-identical to the old ones; role grades come from design's system. Old color names that only these themes had are gone.
- **Breaking:** `percipioTheme` and the `PercipioTheme` type are removed. `skillsoftTheme`, the new default, replaces it: it keeps Percipio's fonts, font weights and resting shadow, with colors generated from the Skillsoft brand's hues. Use `skillsoftTheme` wherever an app used `percipioTheme`.
2 changes: 1 addition & 1 deletion .changeset/color-system-tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
'@skillsoft/gamut': minor
---

Add the generated color system from design's sandbox (gamut-sandbox PR #34).
Add the generated OKLCH color system.

- `@skillsoft/gamut-styles/tokens`, `/tokens.css` and `/tokens/*.tokens.json` export an OKLCH palette and 43 semantic roles in light and dark, as static CSS, DTCG JSON and TS constants. The `tokens` entry has no React or Emotion dependency.
- `skillsoftTheme` is the new default theme. Its color values come from `tokens.css`, which `GamutProvider` injects; pass `useTokens={false}` if the page loads `@skillsoft/gamut-styles/tokens.css` itself. The static file also works without React or Emotion.
Expand Down
14 changes: 14 additions & 0 deletions .changeset/color-system-white-label.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@skillsoft/gamut-brand': minor
'@skillsoft/gamut-styles': minor
---

Add `@skillsoft/gamut-brand`, for teams building their own brand on gamut, and give every brand its own `tokens.css`.

- `@skillsoft/gamut-brand` is new, and build-time only: install it as a dev dependency. `defineBrand({ name, hues })` types a brand, and `gamut-brand build <brand file>` writes its `values.ts`, `tokens.css` and DTCG JSON, refusing a brand that fails a contrast check. `--watch` regenerates when the brand file changes; `--check` fails CI when the files are out of date. The generator, the role table, the checks and gamut's own brands moved here from `@skillsoft/gamut-styles`.
- `createBrandTheme`, `BrandBase` and `BrandValues` are exported from `@skillsoft/gamut-styles`. `createBrandTheme(values, base)` turns a generated `values.ts` into a theme with the same type as gamut's own, so system props and styling functions stay typed against it.
- Each theme now carries only its own brand's colors. `GamutProvider` injects that brand's CSS, which puts its palette and its initial mode on `:root`, so a page starts in the right brand and mode without attributes on `<html>`. A brand's role overrides apply under a bare `data-color-mode` too.
- **Breaking:** the generated files moved to one folder per brand. Import `@skillsoft/gamut-styles/tokens/<brand>.css` for a brand's CSS (`tokens.css` is still the default brand) and `@skillsoft/gamut-styles/tokens/<brand>/{palette,light,dark}.tokens.json` for its DTCG files. `tokens/brands/*` is gone, and `brands` in `@skillsoft/gamut-styles/tokens` now includes the default brand.
- **Breaking:** `initialMode` moved from `createBrandTheme`'s second argument to the brand (`defineBrand({ initialMode: 'dark' })`), because the brand's CSS has to know it. `BrandBase` now requires gamut's font family and font weight keys.
- `platformTheme` now starts in dark mode in its CSS as well as in its theme, so a server-rendered page starts dark.
- The DTCG mode files include each brand's role overrides. They had the default roles for every brand.
1 change: 1 addition & 0 deletions .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"fixed": [
[
"@skillsoft/gamut-agent-tools",
"@skillsoft/gamut-brand",
"@skillsoft/gamut-codemods",
"@skillsoft/gamut-icons",
"@skillsoft/gamut-illustrations",
Expand Down
9 changes: 8 additions & 1 deletion packages/.storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,9 +219,16 @@ const config: StorybookConfig = {
find: /^@skillsoft\/gamut-styles\/tokens\.css(\?.*)?$/,
replacement: `${resolve(
__dirname,
'../gamut-styles/src/tokens/generated/tokens.css'
'../gamut-styles/src/tokens/generated/skillsoft/tokens.css'
)}$1`,
},
{
find: /^@skillsoft\/gamut-styles\/tokens\/(\w+)\.css(\?.*)?$/,
replacement: `${resolve(
__dirname,
'../gamut-styles/src/tokens/generated'
)}/$1/tokens.css$2`,
},
...normalizeAlias(config.resolve?.alias),
],
};
Expand Down
15 changes: 8 additions & 7 deletions packages/.storybook/theming/GamutThemeProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
originalCodecademyTheme,
} from '@skillsoft/gamut-styles';
import { Theme } from '@emotion/react';
import tokensCss from '@skillsoft/gamut-styles/tokens.css?inline';

const STORYBOOK_CSP_NONCE = 'storybook-csp-nonce';

Expand Down Expand Up @@ -50,14 +49,16 @@ export const withEmotion = (Story: any, context: GlobalsContext) => {
const direction = context.globals.direction ?? 'ltr';
const currentTheme = themeMap[selectedTheme];
/*
* Generated themes (color-system spike; they carry contentModes) take their
* values from tokens.css, not Emotion. Injected only for those: tokens.css
* also sets white, black and background-current on :root, which would leak
* into the other themes. They have none of core's primitives, so `navy`
* doesn't exist there; the story sits on their own surface-default.
* Generated themes (they carry contentModes) take their values from their
* brand's tokens.css, not Emotion. GamutProvider injects it; the test
* provider below runs without globals, so it injects the theme's own copy.
* Only for those: tokens.css also sets white, black and background-current
* on :root, which would leak into the other themes. They have none of
* core's primitives, so `navy` doesn't exist there; the story sits on their
* own surface-default.
*/
const generated = 'contentModes' in currentTheme ? currentTheme : undefined;
const tokens = generated && <style>{tokensCss}</style>;
const tokens = generated && <style>{generated.tokensCss}</style>;
const bg = (
generated
? generated.modes[colorMode]['surface-default']
Expand Down
1 change: 1 addition & 0 deletions packages/gamut-brand/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist
189 changes: 189 additions & 0 deletions packages/gamut-brand/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
# @skillsoft/gamut-brand

Builds a gamut color brand from a few hues. Give it a primary color, and optionally the other seven slots, and it writes the brand's palette, light and dark roles, `tokens.css`, DTCG JSON for Figma, and a typed `values.ts` for `createBrandTheme` in `@skillsoft/gamut-styles`. It refuses a brand that fails a contrast check and names the check.

It runs at build time only. Install it as a dev dependency; nothing in it reaches the browser. Apps on gamut's own themes don't need it.

The package is also where gamut's color system lives: the generator, the roles, the checks, and gamut's own five brands, which `@skillsoft/gamut-styles` builds its themes from.

## Build a brand

```sh
yarn add -D @skillsoft/gamut-brand
```

Write the brand. Only `name` and `hues.primary` are required:

```ts
// src/theme/acme.brand.ts
import { defineBrand } from '@skillsoft/gamut-brand';

export default defineBrand({
name: 'acme',
hues: { primary: '#0A5CFF', accent: '#FFB000' },
});
```

Generate its files:

```sh
gamut-brand build src/theme/acme.brand.ts
```

That writes `src/theme/acme/`:

| File | What it is |
| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `values.ts` | The palette, roles, content modes and the brand's CSS as typed constants. `createBrandTheme` takes this module |
| `tokens.css` | `--color-*` and `--gradient-*` custom properties for this brand only, starting on `:root` in its initial mode |
| `palette.tokens.json`, `light.tokens.json`, `dark.tokens.json` | DTCG 2025.10 for Figma and other tools. Each color is OKLCH with its hex alongside |

Check the files in, like any generated source. Then build the theme from them:

```tsx
// src/theme/index.ts
import {
createBrandTheme,
fontWeightMediumTitle,
percipioFontFamily,
softElevation,
} from '@skillsoft/gamut-styles';

import * as values from './acme/values';

export const acmeTheme = createBrandTheme(values, {
name: 'acme',
fontFamily: percipioFontFamily,
fontWeight: fontWeightMediumTitle,
elevation: softElevation,
});
```

```tsx
<GamutProvider theme={acmeTheme}>
<App />
</GamutProvider>
```

`GamutProvider` injects the brand's CSS, so the app loads nothing else. A page without React loads `acme/tokens.css` instead.

## Keep it current

```sh
gamut-brand build src/theme/acme.brand.ts --watch # regenerates when the brand file, or a file next to it, changes
gamut-brand build src/theme/acme.brand.ts --check # writes nothing; exits 1 if acme/ is out of date
```

Run `--watch` next to your dev server; it only rewrites files whose contents changed, so the dev server reloads once per real change. Run `--check` in CI. `--out <dir>` puts the files somewhere other than a folder named after the brand.

## Types

Every brand has the same palette keys and the same 45 roles, so a theme from `createBrandTheme` has the same type as gamut's own themes. System props, `css()`, `variant()` and `states()` stay typed against it. Declare the Emotion theme once, as for any gamut app:

```ts
// emotion.d.ts
import '@emotion/react';
import type { SkillsoftTheme } from '@skillsoft/gamut-styles';

declare module '@emotion/react' {
export interface Theme extends SkillsoftTheme {}
}
```

`<Box bg="surface-raised" />` then compiles and `<Box bg="surface-raisd" />` doesn't. The brand file is typed too: `defineBrand` rejects a slot, role or palette rung that doesn't exist.

## What a brand can set

| Field | What it does |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | Becomes `data-brand` and the output folder's name. Letters, digits and `_`, starting with a letter |
| `hues` | A hue in degrees or a color to take the hue from, per slot: `primary` (required), `neutral-light`, `neutral-dark`, `accent`, `accent-alt`, `danger`, `warning`, `success` |
| `initialMode` | `'light'` (default) or `'dark'`: the mode `tokens.css` puts on `:root` and the theme starts in |
| `overrides` | Exact colors the generator wouldn't produce: a pinned palette rung, a role pointed at another palette color in one mode, or a gradient moved to another slot. Still checked |

Only a color's hue is kept. `#0A5CFF` means "this blue's hue"; every grade's lightness is fixed, so contrast between grades is the same for every brand. To keep an exact color, pin it with `overrides.palette`.

Slots a brand leaves out are filled in: missing neutrals from the primary's hue, and missing status and accent slots from gamut's defaults. `createColorSystem(brand).brand.derived` says where each came from.

To start from one of gamut's brands, spread it:

```ts
import { defineBrand, skillsoftBrand } from '@skillsoft/gamut-brand';

export default defineBrand({
...skillsoftBrand,
name: 'acme',
hues: { ...skillsoftBrand.hues, primary: '#0A5CFF' },
});
```

## When a brand is refused

The build checks every text role against 4.5:1 and every border and icon role against 3:1 on the surfaces each is meant for, checks hover and selection veils on what they produce over each surface, keeps the three status colors apart, and checks 204 text and icon pairings inside 25 real components. It runs every check again on its own `tokens.css` read back, so what ships is what was checked.

A failure names the check, its worst value and where:

```
refusing to generate "acme"; failed checks:
text-inverse ≥ 4.5:1: 1.04 at light: action-primary
action-primary ≥ 3:1: 1.04 at light: surface-sunken
```

That brand pinned `primary-800` to a near-white, so the primary button lost its contrast.

Pick another hue, or pin the color with an override, and build again. Color-blindness separation is reported, not enforced.

## The API

The CLI is a thin layer over these, for build scripts that generate several brands:

| Export | What it does |
| ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `defineBrand(brand)` | Types a brand and returns it |
| `generateBrand(brand)` | Checks the brand and returns its files as strings; throws `BrandCheckError` |
| `writeBrand(brand, dir)` | Writes the files that changed; returns their paths |
| `staleFiles(brand, dir)` | The files in `dir` that are missing or out of date |
| `createColorSystem(brand)`, `validate(system)` | The generator and its checks, for tooling that wants the numbers |
| `skillsoftBrand`, `originalCodecademyBrand`, `adminBrand`, `platformBrand`, `lxStudioBrand` | gamut's own brands |

`@skillsoft/gamut-styles`' `build:tokens` script uses `writeBrand` for gamut's five brands.

## How the generator works

```
brands/skillsoft.ts one hue per slot (or just `primary`)
→ brand.ts fills missing slots, reports the white-label tier
→ ramp.ts 11 grades per slot: fixed L, chroma as % of gamut
→ roles.ts 45 roles, light + dark, named by slot not hue
→ system.ts resolved system, overrides applied
→ contentModes.ts which mode's content goes on each color
→ validate.ts / probe.ts contrast guarantees, veil floors, status trio
→ migration.ts design's old → new map and component slots, checked per brand
→ emit.ts DTCG 2025.10 JSON + values.ts
→ generate.ts Style Dictionary → tokens.css, read back and checked again
```

`src/__tests__/generator.test.ts` holds the rules: every grade's lightness, chroma inside sRGB, the separation guarantee at every hue, one role mapping for every brand, and every check passing for gamut's brands and for a primary at every 5° of hue.

After changing the generator, regenerate gamut's brands and review the diff:

```sh
npx nx test gamut-brand
yarn workspace @skillsoft/gamut-styles build:tokens # rewrites src/tokens/generated
yarn workspace @skillsoft/gamut-styles validate:tokens # full report for gamut's brands
```

## Naming

- Primitives: `--color-palette-<slot>-<grade>`, for example `--color-palette-primary-800`. Not for components; use roles.
- Translucent primitives: `--color-palette-primary-600-a6` (6% alpha), `--color-black-a60`.
- Roles: `--color-<role>`, for example `--color-action-primary`.
- `white` and `black` keep their bare names (boundary tokens).

The `--color-` prefix on primitives is there because variance's `addColors` always emits `--color-<key>`. One name for both the Emotion and CSS routes beat the shorter `--palette-*`.

## Known gaps

- There's no categorical solver. `viz-1` to `viz-8` use a slot and grade order solved for Skillsoft's hues; another brand gets that order unsolved, and categorical separation isn't checked.
- The theme carries sRGB copies of the values, because `BarChart` runs polished on them and polished can't parse `oklch()`.
- `tokens.css` needs `oklch()` and `color-mix()`. The hex fallback for older browsers isn't built yet.
3 changes: 3 additions & 0 deletions packages/gamut-brand/babel.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = {
extends: '../../babel.defaults.js',
};
Loading