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
16 changes: 16 additions & 0 deletions .changeset/color-system-brands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@skillsoft/gamut-styles': minor
'@skillsoft/variance': minor
'@skillsoft/gamut': minor
---

Brands on the generated color system.

- **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`.
2 changes: 2 additions & 0 deletions packages/.storybook/components/Elements/DocsContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
GamutProvider,
platformPalette,
skillsoftTheme,
originalCodecademyTheme,
ColorMode,
} from '@skillsoft/gamut-styles';
import {
Expand All @@ -22,6 +23,7 @@ export const storybookTheme = createTheme(skillsoftTheme)

const themeMap = {
skillsoft: skillsoftTheme,
originalCodecademy: originalCodecademyTheme,
} as const;

const themeSpecificStories = {};
Expand Down
5 changes: 4 additions & 1 deletion packages/.storybook/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,10 @@ export const globalTypes = {
defaultValue: 'skillsoft',
toolbar: {
icon: 'paintbrush',
items: [{ value: 'skillsoft', title: 'Skillsoft' }],
items: [
{ value: 'skillsoft', title: 'Skillsoft' },
{ value: 'originalCodecademy', title: 'Original Codecademy' },
],
showName: true,
},
},
Expand Down
31 changes: 18 additions & 13 deletions packages/.storybook/theming/GamutThemeProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ColorModes,
GamutProvider,
skillsoftTheme,
originalCodecademyTheme,
} from '@skillsoft/gamut-styles';
import { Theme } from '@emotion/react';
import tokensCss from '@skillsoft/gamut-styles/tokens.css?inline';
Expand All @@ -15,14 +16,9 @@ const STORYBOOK_CSP_NONCE = 'storybook-csp-nonce';
* See: https://github.com/storybookjs/storybook/issues/12255
*/

/* The skillsoft theme's surface-default values, as palette colors. */
const skillsoftBackground: Record<ColorModes, string> = {
light: 'palette-neutral-light-100',
dark: 'palette-neutral-dark-1100',
};

const themeMap = {
skillsoft: skillsoftTheme,
originalCodecademy: originalCodecademyTheme,
} as const;

type GlobalsContext = {
Expand All @@ -39,14 +35,23 @@ export const withEmotion = (Story: any, context: GlobalsContext) => {
const selectedTheme = context.globals.theme;
const useLogicalProperties = context.globals.logicalProps === 'true';
const direction = context.globals.direction ?? 'ltr';
/* The theme takes its values from tokens.css, not Emotion. */
const tokens = <style>{tokensCss}</style>;
const bg = skillsoftBackground[colorMode];
const background = (skillsoftTheme._tokens.colors as Record<string, string>)[
bg
];
const storyRef = useRef<HTMLDivElement>(null);
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.
*/
const generated = 'contentModes' in currentTheme ? currentTheme : undefined;
const tokens = generated && <style>{tokensCss}</style>;
const bg = generated
? generated.modes[colorMode]['surface-default']
: undefined;
const background = generated
? (generated._tokens.colors as Record<string, string>)[bg as string]
: undefined;
const storyRef = useRef<HTMLDivElement>(null);

useLayoutEffect(() => {
const storyEl = storyRef.current?.closest(
Expand Down
14 changes: 7 additions & 7 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 Expand Up @@ -53,8 +53,8 @@ Storybook has a live example under **Foundations / Color system / CSS contract**
- Roles: `--color-<role>`, for example `--color-action-primary`.
- `white` and `black` keep their bare names as boundary tokens.

The `--color-` prefix on primitives matches what variance emits for every
color key, so the Emotion and CSS routes use one naming scheme.
The `--color-` prefix is the color scale's prefix in variance, so the Emotion and
CSS routes use one naming scheme.

### Known limits

Expand Down
4 changes: 3 additions & 1 deletion packages/gamut-styles/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"require": "./dist/tokens/index.js"
},
"./tokens.css": "./dist/tokens/generated/tokens.css",
"./tokens/*.tokens.json": "./dist/tokens/generated/*.tokens.json"
"./tokens/*.tokens.json": "./dist/tokens/generated/*.tokens.json",
"./tokens/brands/*.tokens.json": "./dist/tokens/generated/brands/*.tokens.json"
},
"files": [
"dist",
Expand All @@ -48,6 +49,7 @@
"scripts": {
"build": "nx build gamut-styles",
"build:tokens": "jiti scripts/build-tokens.ts",
"dump:sandbox": "jiti scripts/dump-sandbox.ts",
"validate:tokens": "jiti scripts/validate-tokens.ts"
},
"sideEffects": false,
Expand Down
8 changes: 6 additions & 2 deletions packages/gamut-styles/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"tsdown",
"tsc --emitDeclarationOnly --project tsconfig.lib.json",
"cpy './src/**/*.{d.ts,svg,png}' ./dist",
"cpy './src/tokens/generated/*.{css,json}' ./dist/tokens/generated"
"cpy './src/tokens/generated/*.{css,json}' ./dist/tokens/generated",
"cpy './src/tokens/generated/brands/*.json' ./dist/tokens/generated/brands"
],
"parallel": false
}
Expand All @@ -44,7 +45,10 @@
"dependsOn": ["build"],
"options": {
"cwd": "{projectRoot}",
"commands": ["publint .", "attw --pack . --ignore-rules false-cjs no-resolution"],
"commands": [
"publint .",
"attw --pack . --ignore-rules false-cjs no-resolution"
],
"parallel": false
}
},
Expand Down
138 changes: 123 additions & 15 deletions packages/gamut-styles/scripts/build-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,32 @@
* Output lands in src/tokens/generated/ and is checked in, so the theme and
* the package build don't need the generator (or culori) at runtime.
*/
import { mkdir, writeFile } from 'node:fs/promises';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';

import StyleDictionary from 'style-dictionary';
import { fileHeader, formattedVariables } from 'style-dictionary/utils';

import { skillsoftBrand } from '../src/colorSystem/brands/skillsoft';
import { BRANDS, DEFAULT_BRAND } from '../src/colorSystem/brands';
import {
EXTENSION,
gradientCssVars,
toDtcgMode,
toDtcgPalette,
toTsModule,
} from '../src/colorSystem/emit';
import { validateMigration } from '../src/colorSystem/migration';
import type { Mode } from '../src/colorSystem/roles';
import { MODES } from '../src/colorSystem/roles';
import type { ColorSystem } from '../src/colorSystem/system';
import { createColorSystem } from '../src/colorSystem/system';
import { validate } from '../src/colorSystem/validate';

const OUT = join(__dirname, '../src/tokens/generated');

/* Cascade layer order: Panda's default, which is Chakra's plus `utilities`. */
const LAYERS = ['reset', 'base', 'tokens', 'recipes', 'utilities'];

const SELECTORS: Record<Mode, string> = {
light: ':root, [data-color-mode="light"]',
dark: '[data-color-mode="dark"]',
Expand Down Expand Up @@ -60,7 +66,7 @@ StyleDictionary.registerFormat({
.map(
(t) =>
` --gradient-${t.path.join('-')}: ${
t.$extensions[EXTENSION].gradient.css
t.$extensions[EXTENSION].gradient.cssVars
};`
);
const { mode } = options as { mode?: Mode };
Expand Down Expand Up @@ -113,21 +119,56 @@ const cssFor = async (
return output as string;
};

/*
* A brand's role overrides: the roles whose palette reference differs from
* the default brand's in this mode, and the gradients whose circles do, on
* the element that carries both attributes (ColorMode sets them together).
* Empty when nothing differs.
*/
const roleOverrideCss = (
brand: ColorSystem,
base: ColorSystem,
mode: Mode
): string => {
const lines = [
...Object.entries(brand.roles[mode])
.filter(([token, v]) => v.key !== base.roles[mode][token].key)
.map(([token, v]) => ` --color-${token}: var(--color-${v.key});`),
...Object.entries(brand.gradients[mode])
.map(([token, g]) => [token, gradientCssVars(g)] as const)
.filter(
([token, css]) => css !== gradientCssVars(base.gradients[mode][token])
)
.map(([token, css]) => ` --gradient-${token}: ${css};`),
];
return lines.length
? `[data-brand="${
brand.brand.name
}"][data-color-mode="${mode}"] {\n${lines.join('\n')}\n}\n`
: '';
};

const main = async () => {
const system = createColorSystem(skillsoftBrand);
const systems = BRANDS.map(createColorSystem);

const failed = [...validate(system), ...validateMigration(system)].filter(
(c) => !c.pass
/* Every brand has to pass, or nothing is emitted. */
const failed = systems.flatMap((s) =>
[...validate(s), ...validateMigration(s)]
.filter((c) => !c.pass)
.map((c) => ` ${s.brand.name}: ${c.name}: ${c.worst} at ${c.at}`)
);
if (failed.length) {
throw new Error(
`refusing to emit; failed checks:\n${failed
.map((c) => ` ${c.name}: ${c.worst} at ${c.at}`)
.join('\n')}`
);
throw new Error(`refusing to emit; failed checks:\n${failed.join('\n')}`);
}

const [system, ...others] = systems;
if (system.brand.name !== DEFAULT_BRAND.name) {
throw new Error('BRANDS must start with DEFAULT_BRAND');
}

await mkdir(OUT, { recursive: true });
/* Cleared first, so a renamed or removed brand leaves nothing behind. */
await rm(join(OUT, 'brands'), { recursive: true, force: true });
await mkdir(join(OUT, 'brands'), { recursive: true });
const files = {
palette: join(OUT, 'palette.tokens.json'),
light: join(OUT, 'light.tokens.json'),
Expand All @@ -138,9 +179,53 @@ const main = async () => {
await writeFile(files.light, json(toDtcgMode(system, 'light')));
await writeFile(files.dark, json(toDtcgMode(system, 'dark')));

const css = [
`/* Generated by scripts/build-tokens.ts from the "${system.brand.name}" brand. Do not edit. */`,
await cssFor([files.palette], ':root'),
/*
* Other brands: their palette, TS values and mode files (identical to the
* default's unless the brand overrides a role).
*/
const brandPalettes: [string, string][] = [];
for (const other of others) {
const { name } = other.brand;
const file = join(OUT, 'brands', `${name}.palette.tokens.json`);
await writeFile(file, json(toDtcgPalette(other)));
for (const mode of MODES) {
await writeFile(
join(OUT, 'brands', `${name}.${mode}.tokens.json`),
json(toDtcgMode(other, mode))
);
}
await writeFile(
join(OUT, 'brands', `${name}.values.ts`),
toTsModule(other)
);
brandPalettes.push([name, file]);
}
await writeFile(
join(OUT, 'brands', 'index.ts'),
[
'/* Generated by scripts/build-tokens.ts. Do not edit. */',
'/* Every brand but the default, whose values are the top-level exports. */',
...others.map(
({ brand }) =>
`export * as ${brand.name} from './${brand.name}.values';`
),
'',
].join('\n')
);

/*
* The default palette is on :root; each other brand's replaces it under
* `[data-brand]`. Roles resolve their palette var() where they're declared,
* so a `[data-brand]` element also needs `data-color-mode` to re-declare
* them against its palette. ColorMode sets both.
*/
const blocks = [
await cssFor([files.palette], `:root, [data-brand="${system.brand.name}"]`),
...(await Promise.all(
brandPalettes.map(([name, file]) =>
cssFor([file], `[data-brand="${name}"]`)
)
)),
await cssFor(
[files.palette, files.light],
SELECTORS.light,
Expand All @@ -153,6 +238,29 @@ const main = async () => {
'dark.tokens.json',
'dark'
),
...others.flatMap((other) =>
MODES.map((mode) => roleOverrideCss(other, system, mode))
),
].filter(Boolean);
/*
* Every block goes in the `tokens` layer, after the whole order is declared,
* so a reset, base styles or recipes added later (by us or by Panda) slot in
* around it. Unlayered styles, which is everything Emotion writes, still win.
* The statement form declares the order without emitting empty layer blocks.
*/
const css = [
`/* Generated by scripts/build-tokens.ts from the ${systems
.map((s) => `"${s.brand.name}"`)
.join(', ')} brands ("${
system.brand.name
}" is the default). Do not edit. */`,
`@layer ${LAYERS.join(', ')};`,
'',
`@layer tokens {\n${blocks
.join('\n')
.trimEnd()
.replace(/^(?=.)/gm, ' ')}\n}`,
'',
].join('\n');
await writeFile(join(OUT, 'tokens.css'), css);
/* The same CSS as a string, so GamutProvider can inject it through Emotion. */
Expand Down
Loading