@solexllc/usx-theme 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solexllc/usx-theme",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Design tokens package for USX.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,11 +26,22 @@
26
26
  "./hooks": {
27
27
  "sass": "./dist/_hooks.scss"
28
28
  },
29
+ "./themes": {
30
+ "sass": "./src/_themes.scss"
31
+ },
32
+ "./themes.css": "./dist/themes.css",
33
+ "./themes/*.css": "./dist/themes/*.css",
29
34
  "./theme.css": "./dist/theme.css",
30
35
  "./theme-manifest": {
31
36
  "default": "./src/theme-manifest.js"
32
37
  },
33
38
  "./theme-manifest.json": "./dist/theme-manifest.json",
39
+ "./derive": {
40
+ "default": "./src/derive.js"
41
+ },
42
+ "./presets": {
43
+ "default": "./src/presets.js"
44
+ },
34
45
  "./system-colors": {
35
46
  "default": "./src/system-colors.generated.js"
36
47
  },
@@ -41,9 +52,13 @@
41
52
  "src",
42
53
  "build.js"
43
54
  ],
55
+ "devDependencies": {
56
+ "sass": "^1.98.0"
57
+ },
44
58
  "scripts": {
45
59
  "build": "node build.js",
46
60
  "dev": "node build.js --watch",
61
+ "test": "node build.js && node test/themes-check.mjs",
47
62
  "generate:system-colors": "node scripts/generate-system-colors.js"
48
63
  }
49
64
  }
@@ -0,0 +1,120 @@
1
+ // _themes.scss — opt-in prebuilt (and custom) themes for the themed USX build.
2
+ //
3
+ // Only the themes you list are emitted, so unused presets never reach your
4
+ // bundle. Themes apply via a `data-theme` attribute (`<html data-theme="forest">`);
5
+ // `$default` also applies on `:root` with no attribute, and `$prefersdark`
6
+ // applies on `:root` under `prefers-color-scheme: dark`. Pair with
7
+ // `@solexllc/usx-theme/theme.css` (the :root defaults) as usual.
8
+ //
9
+ // @use 'pkg:@solexllc/usx-theme/themes' with (
10
+ // $themes: (forest, carbon, ocean),
11
+ // $default: forest,
12
+ // $prefersdark: carbon,
13
+ // );
14
+ //
15
+ // `$themes` may also be a map to tweak a prebuilt theme or add your own —
16
+ // the Theme Playground's "Sass" export produces a ready-to-paste entry:
17
+ //
18
+ // $themes: (
19
+ // forest: (),
20
+ // acme: (
21
+ // color-scheme: light,
22
+ // --usx-color-primary: #b00020,
23
+ // --usx-color-primary-hover: #8a0018,
24
+ // ),
25
+ // )
26
+ //
27
+ // Every emitted theme sets the union of tokens across all emitted themes
28
+ // (falling back to the :root default), so switching `data-theme` never leaks
29
+ // a value from the previous theme — even for nested per-section theming.
30
+ @use 'sass:list';
31
+ @use 'sass:map';
32
+ @use 'sass:meta';
33
+ @use '../dist/themes-registry' as registry;
34
+
35
+ $themes: () !default;
36
+ $default: null !default;
37
+ $prefersdark: null !default;
38
+
39
+ // Prebuilt theme names, for consumers that want to list/iterate them.
40
+ $available: map.keys(registry.$themes);
41
+
42
+ // Normalizes `$themes` (list of names, or name → overrides map) into
43
+ // name → full token map, merging user overrides over the prebuilt entry.
44
+ @function -resolve-themes($config) {
45
+ @if meta.type-of($config) != 'map' {
46
+ $as-map: ();
47
+ @each $name in $config {
48
+ $as-map: map.set($as-map, $name, ());
49
+ }
50
+ $config: $as-map;
51
+ }
52
+ @each $flagged in ($default, $prefersdark) {
53
+ @if $flagged and not map.has-key($config, $flagged) {
54
+ $config: map.set($config, $flagged, ());
55
+ }
56
+ }
57
+
58
+ $out: ();
59
+ @each $name, $overrides in $config {
60
+ $prebuilt: map.get(registry.$themes, $name);
61
+ @if not $prebuilt and list.length(map.keys(map.remove($overrides, color-scheme))) == 0 {
62
+ @error "usx-theme: unknown theme '#{$name}'. Prebuilt themes: #{$available}. To add a custom theme, give it a token map.";
63
+ }
64
+ $out: map.set($out, $name, map.merge($prebuilt or (), $overrides));
65
+ }
66
+ @return $out;
67
+ }
68
+
69
+ @function -token-union($themes) {
70
+ $keys: ();
71
+ @each $name, $tokens in $themes {
72
+ @each $key in map.keys(map.remove($tokens, color-scheme)) {
73
+ $keys: map.set($keys, $key, true);
74
+ }
75
+ }
76
+ @return map.keys($keys);
77
+ }
78
+
79
+ @mixin -declarations($tokens, $keys) {
80
+ color-scheme: map.get($tokens, color-scheme) or light;
81
+ @each $key in $keys {
82
+ $value: map.get($tokens, $key);
83
+ @if $value == null {
84
+ $value: map.get(registry.$defaults, $key);
85
+ }
86
+ @if $value == null {
87
+ @error "usx-theme: '#{$key}' is not a themeable token (see theme-manifest.js).";
88
+ }
89
+ #{$key}: #{meta.inspect($value)};
90
+ }
91
+ }
92
+
93
+ // Emits one theme under an arbitrary selector — for ad-hoc use outside the
94
+ // `$themes` config (no union with other themes is applied).
95
+ @mixin theme($tokens, $selector: ':root') {
96
+ #{$selector} {
97
+ @include -declarations($tokens, map.keys(map.remove($tokens, color-scheme)));
98
+ }
99
+ }
100
+
101
+ $-resolved: -resolve-themes($themes);
102
+ $-keys: -token-union($-resolved);
103
+
104
+ @if $default {
105
+ :root {
106
+ @include -declarations(map.get($-resolved, $default), $-keys);
107
+ }
108
+ }
109
+ @if $prefersdark {
110
+ @media (prefers-color-scheme: dark) {
111
+ :root {
112
+ @include -declarations(map.get($-resolved, $prefersdark), $-keys);
113
+ }
114
+ }
115
+ }
116
+ @each $name, $tokens in $-resolved {
117
+ [data-theme='#{$name}'] {
118
+ @include -declarations($tokens, $-keys);
119
+ }
120
+ }
@@ -46,7 +46,6 @@
46
46
  // ── Layout (compile-time only) ───────────────────────────────────────────────
47
47
  $max-layout-width: 60rem !default;
48
48
  $max-sidebar-width: 16rem !default;
49
- $dynamic-content-width: calc(50% + 22rem) !default;
50
49
 
51
50
  // ── Breakpoints (compile-time only) ─────────────────────────────────────────
52
51
  $breakpoint-mobile: 640px !default;
package/src/derive.js ADDED
@@ -0,0 +1,233 @@
1
+ // derive.js — pure JS color derivation + theme serialization.
2
+ //
3
+ // Shared by build.js (bakes the prebuilt themes in presets.js into
4
+ // dist/themes*) and the Storybook Theme Playground (live controls + export).
5
+ //
6
+ // Derivation model: every derived shade in the manifest records its base token
7
+ // (`derivedFrom`). When the user picks a new base color, each shade is
8
+ // re-derived by applying the HSL delta between the DEFAULT base and the
9
+ // DEFAULT shade to the NEW base value. With the default base, this reproduces
10
+ // the default shade exactly (round-trip rounding aside) — see the derivation
11
+ // parity test.
12
+
13
+ import { themeManifest } from './theme-manifest.js';
14
+
15
+ export { themeManifest };
16
+
17
+ // ── Color conversion ─────────────────────────────────────────────────────────
18
+
19
+ export function hexToRgb(hex) {
20
+ let h = hex.replace('#', '');
21
+ if (h.length === 3) h = h.split('').map((ch) => ch + ch).join('');
22
+ const n = parseInt(h, 16);
23
+ return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
24
+ }
25
+
26
+ export function rgbToHex({ r, g, b }) {
27
+ const to2 = (v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0');
28
+ return `#${to2(r)}${to2(g)}${to2(b)}`;
29
+ }
30
+
31
+ export function rgbToHsl({ r, g, b }) {
32
+ const rn = r / 255;
33
+ const gn = g / 255;
34
+ const bn = b / 255;
35
+ const max = Math.max(rn, gn, bn);
36
+ const min = Math.min(rn, gn, bn);
37
+ const l = (max + min) / 2;
38
+ let h = 0;
39
+ let s = 0;
40
+ if (max !== min) {
41
+ const d = max - min;
42
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
43
+ if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
44
+ else if (max === gn) h = ((bn - rn) / d + 2) / 6;
45
+ else h = ((rn - gn) / d + 4) / 6;
46
+ }
47
+ return { h: h * 360, s: s * 100, l: l * 100 };
48
+ }
49
+
50
+ export function hslToRgb({ h, s, l }) {
51
+ const hn = (((h % 360) + 360) % 360) / 360;
52
+ const sn = Math.max(0, Math.min(100, s)) / 100;
53
+ const ln = Math.max(0, Math.min(100, l)) / 100;
54
+ if (sn === 0) {
55
+ const v = ln * 255;
56
+ return { r: v, g: v, b: v };
57
+ }
58
+ const q = ln < 0.5 ? ln * (1 + sn) : ln + sn - ln * sn;
59
+ const p = 2 * ln - q;
60
+ const channel = (t) => {
61
+ let tn = ((t % 1) + 1) % 1;
62
+ if (tn < 1 / 6) return p + (q - p) * 6 * tn;
63
+ if (tn < 1 / 2) return q;
64
+ if (tn < 2 / 3) return p + (q - p) * (2 / 3 - tn) * 6;
65
+ return p;
66
+ };
67
+ return {
68
+ r: channel(hn + 1 / 3) * 255,
69
+ g: channel(hn) * 255,
70
+ b: channel(hn - 1 / 3) * 255
71
+ };
72
+ }
73
+
74
+ export const hexToHsl = (hex) => rgbToHsl(hexToRgb(hex));
75
+ export const hslToHex = (hsl) => rgbToHex(hslToRgb(hsl));
76
+
77
+ // ── Derivation ───────────────────────────────────────────────────────────────
78
+
79
+ // Re-derive a shade for a new base color by applying the HSL delta between
80
+ // the default base and the default shade.
81
+ export function deriveShade(newBaseHex, defaultBaseHex, defaultShadeHex) {
82
+ const base = hexToHsl(defaultBaseHex);
83
+ const shade = hexToHsl(defaultShadeHex);
84
+ const next = hexToHsl(newBaseHex);
85
+ if (base.s < 5) {
86
+ // Achromatic default base (e.g. white surfaces, grays): its hue/sat delta
87
+ // to the shade is ill-defined, so shift lightness only. A chromatic new
88
+ // base keeps its own hue/sat; an achromatic one inherits the shade's.
89
+ const keepShadeTint = next.s < 5;
90
+ return hslToHex({
91
+ h: keepShadeTint ? shade.h : next.h,
92
+ s: keepShadeTint ? shade.s : next.s,
93
+ l: next.l + (shade.l - base.l)
94
+ });
95
+ }
96
+ return hslToHex({
97
+ h: next.h + (shade.h - base.h),
98
+ s: next.s + (shade.s - base.s),
99
+ l: next.l + (shade.l - base.l)
100
+ });
101
+ }
102
+
103
+ const byName = new Map(themeManifest.map((t) => [t.name, t]));
104
+
105
+ // build.js --watch re-imports the manifest with a cache-busting query, so it
106
+ // passes its own fresh copy in rather than relying on this module's import.
107
+ const indexCache = new WeakMap();
108
+ function indexOf(manifest) {
109
+ if (manifest === themeManifest) return byName;
110
+ let idx = indexCache.get(manifest);
111
+ if (!idx) {
112
+ idx = new Map(manifest.map((t) => [t.name, t]));
113
+ indexCache.set(manifest, idx);
114
+ }
115
+ return idx;
116
+ }
117
+
118
+ export function getToken(name) {
119
+ return byName.get(name);
120
+ }
121
+
122
+ // All manifest entries whose `derivedFrom` is `baseName`, restricted to the
123
+ // same `group` as the base itself. `derivedFrom` is also used by "component"
124
+ // tokens purely to auto-recompute when their underlying color changes (e.g.
125
+ // usx-accordion-content-bg → color-light); those aren't true
126
+ // palette "shades" and must not be nested under the base's Colors-panel
127
+ // control (they already get their own row under Component colors).
128
+ export function shadesOf(baseName) {
129
+ const base = byName.get(baseName);
130
+ return themeManifest.filter((t) => t.derivedFrom === baseName && (!base || t.group === base.group));
131
+ }
132
+
133
+ // Base (underived) color tokens — the primary playground controls.
134
+ export function baseColorTokens() {
135
+ return themeManifest.filter(
136
+ (t) => t.type === 'color' && !t.derivedFrom && t.group === 'color'
137
+ );
138
+ }
139
+
140
+ // Compute the effective value of every manifest token given user overrides.
141
+ //
142
+ // overrides: { [tokenName]: value } — explicit user-set values.
143
+ // autoDerive: { [baseName]: boolean } — when true (default), shades of a
144
+ // changed base are re-derived unless individually overridden.
145
+ //
146
+ // Returns { [tokenName]: effectiveValue }.
147
+ export function resolveTheme(overrides = {}, autoDerive = {}, manifest = themeManifest) {
148
+ const index = indexOf(manifest);
149
+ const out = {};
150
+ for (const t of manifest) {
151
+ if (overrides[t.name] !== undefined && overrides[t.name] !== '') {
152
+ out[t.name] = overrides[t.name];
153
+ continue;
154
+ }
155
+ const base = t.derivedFrom && index.get(t.derivedFrom);
156
+ const baseOverride = base && overrides[base.name];
157
+ if (base && baseOverride && (autoDerive[base.name] ?? true)) {
158
+ out[t.name] = deriveShade(baseOverride, base.defaultValue, t.defaultValue);
159
+ } else {
160
+ out[t.name] = t.defaultValue;
161
+ }
162
+ }
163
+ return out;
164
+ }
165
+
166
+ // Build a { '--usx-*': value } style object of CHANGED tokens only.
167
+ export function themeToCssVars(resolved) {
168
+ const vars = {};
169
+ for (const t of themeManifest) {
170
+ const value = resolved[t.name];
171
+ if (value !== undefined && value.toLowerCase?.() !== t.defaultValue.toLowerCase()) {
172
+ vars[t.cssVar] = value;
173
+ } else if (value !== undefined && value !== t.defaultValue) {
174
+ vars[t.cssVar] = value;
175
+ }
176
+ }
177
+ return vars;
178
+ }
179
+
180
+ // Ordered [cssVar, value] pairs for a resolved theme (changed-only by default).
181
+ export function themeEntries(resolved, { changedOnly = true, manifest = themeManifest } = {}) {
182
+ const entries = [];
183
+ for (const t of manifest) {
184
+ const value = resolved[t.name] ?? t.defaultValue;
185
+ const changed = String(value).toLowerCase() !== t.defaultValue.toLowerCase();
186
+ if (!changedOnly || changed) entries.push([t.cssVar, value]);
187
+ }
188
+ return entries;
189
+ }
190
+
191
+ // `data-theme` attribute value for a display name: "GOV.UK" → "gov-uk".
192
+ export function themeSlug(name) {
193
+ return String(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
194
+ }
195
+
196
+ // `color-scheme` hint for a resolved theme, judged by its page surface:
197
+ // tells the browser to render native UI (scrollbars, form controls) to match.
198
+ export function colorSchemeOf(resolved) {
199
+ const surface = resolved['surface-1'];
200
+ if (!/^#[0-9a-f]{3,8}$/i.test(surface || '')) return 'light';
201
+ return hexToHsl(surface).l < 50 ? 'dark' : 'light';
202
+ }
203
+
204
+ export function themeSelector(name) {
205
+ return `[data-theme="${themeSlug(name)}"]`;
206
+ }
207
+
208
+ // Generate an exportable CSS block. `selector` defaults to `:root`; pass a
209
+ // theme name via `name` to target `[data-theme="<slug>"]` instead.
210
+ export function themeToCss(resolved, { changedOnly = true, name, selector, colorScheme, manifest = themeManifest } = {}) {
211
+ const lines = themeEntries(resolved, { changedOnly, manifest }).map(([cssVar, value]) => ` ${cssVar}: ${value};`);
212
+ if (!lines.length && !name) return '/* No changes from the default theme. */\n';
213
+ if (colorScheme) lines.unshift(` color-scheme: ${colorScheme};`);
214
+ return `${selector || (name ? themeSelector(name) : ':root')} {\n${lines.join('\n')}\n}\n`;
215
+ }
216
+
217
+ // A CSS value written so Sass parses it back to the same text: comma lists
218
+ // (font stacks) must be parenthesized or Sass reads them as more map entries.
219
+ export function toSassValue(value) {
220
+ const v = String(value);
221
+ return v.includes(',') ? `(${v})` : v;
222
+ }
223
+
224
+ // A `<slug>: ( ... )` map entry for the `$themes` config of
225
+ // `pkg:@solexllc/usx-theme/themes` — the same shape the prebuilt registry
226
+ // uses, so a Playground export pastes in alongside the built-in themes.
227
+ export function themeToSass(resolved, { name = 'custom', changedOnly = true, colorScheme, manifest = themeManifest, indent = '' } = {}) {
228
+ const lines = [`${indent} color-scheme: ${colorScheme || colorSchemeOf(resolved)},`];
229
+ for (const [cssVar, value] of themeEntries(resolved, { changedOnly, manifest })) {
230
+ lines.push(`${indent} ${cssVar}: ${toSassValue(value)},`);
231
+ }
232
+ return `${indent}${themeSlug(name)}: (\n${lines.join('\n')}\n${indent}),\n`;
233
+ }