Skip to content
Draft
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
3 changes: 2 additions & 1 deletion .changeset/color-system-brands.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
'@skillsoft/gamut': minor
---

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

- **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.
Expand Down
9 changes: 9 additions & 0 deletions .changeset/static-styles-groundwork.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@skillsoft/gamut-styles': minor
---

Add groundwork for static component styles. No component uses it yet.

- `defineRecipe` describes a component's styles as data (base, variant groups, defaults, compound variants), in Chakra's and Panda's recipe shape with gamut's style objects. `recipe.styles` styles it through Emotion; `recipe.classNames` gives stable class names.
- `compileRecipe` turns a recipe into plain CSS in `@layer recipes`, and `checkRecipe` reports whether it compiles the same for every brand theme.
- `StaticStyles` puts compiled CSS on the page once, through the Emotion cache, so SSR, nonces and stylis plugins work as before.
34 changes: 17 additions & 17 deletions packages/gamut-agent-tools/skills/gamut-review/SKILL.md

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions packages/gamut-styles/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ Colors come from a generated system: a palette of 8 slots × 11 grades, and 43
semantic roles in light and dark mode. The values are checked in and shipped
as static CSS, so the colors don't depend on Emotion.

| Import | What |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `@skillsoft/gamut-styles/tokens` | TS constants: `palette`, `roles`, `gradients`, `contentModes`, `brand` (OKLCH + sRGB per token) |
| Import | What |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `@skillsoft/gamut-styles/tokens` | TS constants: `palette`, `roles`, `gradients`, `contentModes`, `brand` (OKLCH + sRGB per token) |
| `@skillsoft/gamut-styles/tokens.css` | `--color-*` and `--gradient-*` custom properties. Palette on `:root`; roles on `:root, [data-color-mode="light"]` and `[data-color-mode="dark"]` |
| `@skillsoft/gamut-styles/tokens/{palette,light,dark}.tokens.json` | Color tokens in the DTCG shape: `{ colorSpace: "oklch", components, alpha, hex }` |
| `skillsoftTheme` (main entry) | The default Emotion theme, built on the roles. Gamut components are typed against it |
| `@skillsoft/gamut-styles/tokens/{palette,light,dark}.tokens.json` | Color tokens in the DTCG shape: `{ colorSpace: "oklch", components, alpha, hex }` |
| `skillsoftTheme` (main entry) | The default Emotion theme, built on the roles. Gamut components are typed against it |

### Tokens in the browser

Expand Down
127 changes: 65 additions & 62 deletions packages/gamut-styles/src/colorSystem/README.md
Original file line number Diff line number Diff line change
@@ -1,87 +1,90 @@
# Color system generator
# Color system

Build tooling for gamut-styles' color tokens. It takes each brand's hues and
writes the OKLCH palette and roles that the checked-in files under
`src/tokens/generated/` contain.

Nothing under `src/colorSystem/` runs in the browser. The themes import the
generated output, and only the build scripts import this folder. For what the
output contains and how to use it, see the gamut-styles README.
The generated OKLCH color system, ported from design's gamut-sandbox (PR #34,
`stacey/color-tokens-v2`). `__fixtures__/sandbox.json` records the sandbox commit the
port matches, and `__tests__/sandboxParity.test.ts` holds the generator to it. To take a
sandbox update, run `dump:sandbox` (below) and port until the tests pass.

## The pipeline

```
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 43 roles, light + dark, named by slot not hue
→ roles.ts ~45 roles, light + dark, named by slot not hue
→ system.ts resolved system
→ contentModes.ts which mode's content goes on each color (build time)
→ validate.ts contrast guarantees, veil floors, status trio
→ migration.ts old → new color names and component slots, checked per brand
→ emit.ts DTCG-shaped JSON and TS constants
→ scripts/build-tokens.ts writes everything into src/tokens/generated
→ validate.ts / probe.ts contrast guarantees, veil floors, status trio
→ migration.ts design's old → new map and component slots (sandbox df33b47), checked per brand
→ emit.ts DTCG 2025.10 JSON + TS constants
→ scripts/build-tokens.ts Style Dictionary → tokens.css
```

- `build-tokens` refuses to emit if a guarantee or a migration check fails.
- Style Dictionary renders `tokens.css`. The script also writes the three
`*.tokens.json` files, `tokensCss.ts` and `values.ts`.
- `probe.ts` (tenant probes) is report-only. `validate:tokens` prints the
results and doesn't assert them.

## Brands and parity

Brand inputs live in `brands/`: `skillsoft.ts` and `originalCodecademy.ts`, indexed by
`brands/index.ts`. Each one names the hue for each slot, and the generator fills the rest.

`__fixtures__/sandbox.json` records the design sandbox commit the generator matches.
`__tests__/sandboxParity.test.ts` holds the generator to that fixture. To take a sandbox
update, run `dump:sandbox` against a sandbox checkout, then port until the tests pass.
Output is checked in at `src/tokens/generated/` and ships as:

| Import | What |
| ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `@skillsoft/gamut-styles/tokens` | TS constants: `palette`, `roles`, `gradients`, `contentModes` (OKLCH + sRGB per token) |
| `@skillsoft/gamut-styles/tokens.css` | `--color-*` / `--gradient-*` custom properties. Palette on `:root`; roles on `:root, [data-color-mode="light"]` and `[data-color-mode="dark"]` |
| `@skillsoft/gamut-styles/tokens/{palette,light,dark}.tokens.json` | DTCG 2025.10. Colors are `{ colorSpace: "oklch", components, alpha, hex }` |
| `skillsoftTheme` (main entry) | Emotion theme on the new roles; the default theme and what gamut is typed against |

## The CSS contract (branch `cass-color-css-contract`)

`tokens.css` owns the color values. `skillsoftTheme` carries it as
`tokensCss`, and `GamutProvider` injects it through Emotion, so a React app
gets the colors without loading anything. The static file is for pages
without React or Emotion; an app that loads it itself can pass
`useTokens={false}`.

- A mode switch is an attribute: `data-color-mode="light" | "dark"` on any
element, nested as deep as you like. Mode blocks also set `color-scheme`,
`color: var(--color-text-default)` and a default
`--color-background-current`.
- A surface without Emotion: resolve the role to its palette color in the
outer mode (`roles[mode][role].ref`), paint `var(--color-<palette>)`, and
set `data-color-mode` to `contentModes[palette]`. The palette color is the
same in every mode, so the flip can't change the background under it.
- `Background` reads `theme.contentModes` when the theme has it, and only
falls back to polished's contrast math on themes that don't.
- Example: Storybook `Foundations/Color system/CSS contract`.

## Commands

```sh
yarn workspace @skillsoft/gamut-styles build:tokens # regenerate src/tokens/generated
yarn workspace @skillsoft/gamut-styles validate:tokens # full report (CSS round-trip, tenant probes)
yarn workspace @skillsoft/gamut-styles dump:sandbox <sandbox checkout> # refresh the sandbox fixture
yarn workspace @skillsoft/gamut-styles validate:tokens # parity with design's reference fixture and CSS round-trip; prints tenant probes
npx nx test gamut-styles # guarantees; values.ts and tokens.css are current
npx nx test gamut-styles # guarantees, sandbox parity, generated files current; in CI
```

Re-run `build:tokens` after any change to a brand input or to the generator,
and commit the output with the change.
## Naming

## What CI checks
- Primitives: `--color-palette-<slot>-<grade>`, e.g. `--color-palette-primary-800`.
Slots: `neutral-light`, `neutral-dark`, `primary`, `accent`, `accent-alt`,
`danger`, `warning`, `success`. Not for components; use roles.
- Translucent primitives: `--color-palette-primary-600-a6` (6% alpha), `--color-black-a60`.
- Roles: `--color-<role>`, e.g. `--color-action-primary`.
- `white` / `black` keep their bare names (boundary tokens).

- `nx test` runs in CI. It checks the guarantees, and that `values.ts` and
`tokens.css` match the generator.
- It does not check the JSON token files. That's a known gap, listed below.
- `validate:tokens` is not run in CI. It's the full report, run by hand.
The `--color-` prefix on primitives is there because variance's `addColors`
always emits `--color-<key>`; keeping one name for both the Emotion and CSS
routes beat the shorter `--palette-*`.

## Known gaps

- The JSON token files are not covered by the drift test; only `values.ts` and
`tokens.css` are.
- Brand input isn't validated at the boundary. A non-finite hue is accepted and
produces a gray ramp, and a brand `name` is written into generated comments
unchecked.
- The generator's type declarations are emitted into the package build, because
`tsconfig.lib.json` doesn't exclude this folder. Nothing in the public exports
imports them.
- Some exported tables are mutable (`DEFAULT_HUES`, `CHROMA_CURVE`,
`FIXED_LIGHTNESS`, `FALLOFF`, `ROLES`). A consumer that changes one changes
later builds in the same process.
- `migration.ts` is a rename table: each alias is a rename, merge, split or
retire. Only rename and merge are mechanical. The codemod keeps its own copy
of the alias table, so the two can drift.
- The categorical solver isn't ported, so the `viz-*` chart colors aren't
re-solved for a new brand.
- The reference fixture measures component slots on rounded hex, and this
generator measures OKLCH floats. The 200-pairing check and its 0.074 ratio
spread come from that comparison and aren't reproduced by a test in this
package.

## Naming

Output names are defined in the gamut-styles README. The generator's own
naming rule: a token's name comes from its slot or role, not from its hue, so
changing a brand's hue doesn't rename anything.
- The categorical solver isn't ported. `viz-1…8` use the slot/grade order the
sandbox solved for Skillsoft's hues; a tenant gets that order unsolved.
- The theme carries sRGB, not OKLCH: `Background` and `BarChart` run
polished's `getContrast` on theme values, and polished can't parse `oklch()`.
- Components that reference core primitives directly (`navy-900`, `gray-100`,
`yellow`, …) render wrong on this theme. Listed in Base Camp `mapping.md`.
- gamut is typed against `skillsoftTheme`, which has no legacy names. The
other themes are still exported but are no longer what components are
checked against. `ColorMode` still works on them (`roleOr` falls back to
the old names).
- `migration.ts` is design's rename table: each alias is a rename, merge,
split or retire. Only rename and merge are mechanical. It isn't shipped in
the tokens output; the codemod reads it from source.
- The sandbox measures component slots on rounded hex; this port measures
OKLCH floats. Same 200 pairings, ratios within 0.074, same pass/fail.
1 change: 1 addition & 0 deletions packages/gamut-styles/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export * from './GamutProvider';
export * from './ColorMode';
export * from './Background';
export * from './variance';
export * from './static';
export * from './cache';
export * from './variables';
export * from './styles';
Expand Down
80 changes: 80 additions & 0 deletions packages/gamut-styles/src/static/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Static styles (groundwork)

Groundwork for moving gamut's components off runtime Emotion styles. Nothing
uses it yet: no component has moved, and `GamutProvider` still delivers the
color tokens. Notes and the plan are in Base Camp, `gamut/color-tokens/how-it-works.md`
("Moving off Emotion, slowly").

Today every gamut component serializes its styles with Emotion on each render.
A component on a recipe ships CSS compiled ahead of time and only picks class
names at render.

## The pieces

| Export | What |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `defineRecipe` | A component's styles as data: `className`, `base`, `variants` (named groups), `defaultVariants`, `compoundVariants`. Chakra's and Panda's recipe shape, written with the same style objects as `css()` and `variant()` |
| `recipe.styles` | The recipe as an Emotion style function, for `styled.div(recipe.styles)`. Writes what the matching `variant()` calls would |
| `recipe.classNames(props)` | `gamut-chip gamut-chip--intent_danger gamut-chip--size_sm`. Defaults filled in; `false` turns a group off |
| `compileRecipe(recipe)` | Plain CSS for those class names, in `@layer recipes` |
| `checkRecipe(recipe)` | Whether the recipe compiles the same for every brand theme. If not, it can't be static yet |
| `<StaticStyles id styles />` | Puts compiled CSS on the page once, through the Emotion cache (SSR, nonce and stylis plugins work as they do now) |

## Moving a component

1. Rewrite its `variant()` calls as one recipe and style it with
`recipe.styles`. Still Emotion; snapshots and visuals shouldn't change. This
step is safe to ship alone.
2. Add `expect(checkRecipe(recipe).pass).toBe(true)` to its tests. If it fails,
the recipe uses a per-brand value (see the rules) and stays on step 1.
3. Compile it once at module scope and render it:

```tsx
const styles = compileRecipe(chip);

export const Chip = ({ className, ...props }: ChipProps) => {
const [variants, rest] = chip.splitProps(props);
return (
<>
<StaticStyles id={chip.className} styles={styles} />
<Box className={cx(chip.classNames(variants), className)} {...rest} />
</>
);
};
```

Style props and `styled(Chip)` still work and still win: they're Emotion,
unlayered, and the recipe is in `@layer recipes`.

4. Later, a build step writes `compileRecipe` output to a generated module (or
a stylesheet), so it doesn't run in the browser at all. Components don't
change for that.

## Rules for a recipe that compiles

- **Colors are roles.** They compile to `var(--color-<role>)`, the same for
every brand and mode.
- **No per-brand values.** Fonts, font weights and elevation are still literal
values per theme, so `fontFamily: 'accent'` fails `checkRecipe`. Those move
to CSS variables in phase 2 of the plan.
- **No render-time values.** Nothing from props beyond the variant groups, no
`useTheme()`, no polished math. Those stay in Emotion next to the recipe.
- **Always logical properties.** Static CSS can't follow `useLogicalProperties`,
so `height` compiles to `block-size` and `px` to `padding-inline-*`. Same
rendering in left-to-right horizontal text; under `dir="rtl"` inline spacing
follows the text direction even if the app uses physical mode.
- **`className` is unique,** `gamut-<component>`. It's the class prefix and the
`StaticStyles` id.

## Not done

- No build step: `compileRecipe` runs once per module in the browser.
- No boolean variants yet, so `states()` (`disabled`, `active`, …) stays in
Emotion next to the recipe. Panda and Chakra take `true` / `false` keys;
that's the shape to add.
- Delivery is the Emotion cache. React 19's `<style href precedence>` or an
imported stylesheet would replace `StaticStyles`'s insides only.
- `checkRecipe` covers the brand themes. The old themes (core, admin, …) join
once they're rebuilt as brands.
- Types are as strict as `css()`: selections are checked, style values only as
far as `css()` checks them.
Loading