@ghostmind-dev/colors 0.1.0 → 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/README.md CHANGED
@@ -19,8 +19,8 @@ An app can carry the system without offering themes.
19
19
  import { createColors, switchValue } from '@ghostmind-dev/colors/app';
20
20
 
21
21
  const colors = createColors({
22
- themes: switchValue(process.env.COLOR_THEMES), // off unless "on", "true" or "1"
23
- own: 'dark', // a built-in id, or a ThemeDefinition of your own
22
+ themes: switchValue(process.env.COLOR_THEMES), // off unless "on", "true" or "1"; or a list: ['light', 'moss']
23
+ own: 'dark', // a built-in id, a built-in with overrides, or a ThemeDefinition
24
24
  });
25
25
 
26
26
  colors.css(); // the stylesheet for <head>
@@ -37,7 +37,21 @@ colors.apply(saved); // switch the live document; always the app's own theme whe
37
37
 
38
38
  Turning it on changes no component: they name roles either way.
39
39
 
40
- `own` can be a full `ThemeDefinition` written for one app. Its colours need not come from any built-in theme, but it passes the same validation, and a bad one throws when the app starts.
40
+ ### The app's own version
41
+
42
+ `own` takes three forms:
43
+
44
+ ```ts
45
+ own: 'dark' // a built-in, as it is
46
+ own: { extends: 'dark', name: 'Potion', // a built-in with only what differs
47
+ palette: { brand: { '500': '#7c5cff' } },
48
+ chips: { tint: 0.2 } }
49
+ own: myThemeDefinition // a full ThemeDefinition, colours of its own
50
+ ```
51
+
52
+ The second form is plain data, so every platform can read the same object. Palette steps replace the base's one by one; `chips`, `code` and `lists` replace field by field. Only steps and fields the base already has can be set, and the base's contrast floor is not carried over. With the switch on, the override applies to the app's own theme only: the built-ins stay the same in every app.
53
+
54
+ Every form is validated, the override is also held to the contrast gate, and a bad one throws when the app starts. `extendTheme(override)` in `./create` does the same without throwing, for an editor: `{ ok, problems, theme, score, lowest, failures }`.
41
55
 
42
56
  ## Tailwind
43
57
 
@@ -76,7 +90,7 @@ The whole palette is derived from the base and three numbers, then validated and
76
90
  | `./app` | `createColors`, `switchValue`, `OWN_THEME` |
77
91
  | `./engine` | `ThemeDefinition`, `validateThemeDefinition`, `deriveTheme`, chips |
78
92
  | `./contrast` | `scoreTheme`, `contrastOf` |
79
- | `./create` | `createTheme`, `validateThemeInput` |
93
+ | `./create` | `createTheme`, `validateThemeInput`, `extendTheme`, `ThemeOverride` |
80
94
  | `./tailwind` | `themeRoles`, `semantic`, `role`, `alpha` |
81
95
  | `./native` | `platformColors`, `semanticColors` |
82
96
  | `./tokens.json` | the tokens as data |
package/dist/app.d.ts CHANGED
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Off (the default), the app has ONE look: its own theme, painted on `:root`. No choice is read
5
5
  * from storage, there is nothing to pick, and the stylesheet holds one rule instead of one per
6
- * theme. The app's own theme is either a built-in id or a full ThemeDefinition written for that
7
- * app, so its colours need not come from any theme of ours.
6
+ * theme. The app's own theme is a built-in id, a built-in with only what differs, or a full
7
+ * ThemeDefinition written for that app, so its colours need not come from any theme of ours.
8
8
  *
9
9
  * On, every built-in theme is offered beside the app's own, and the saved choice is honoured.
10
10
  *
@@ -13,13 +13,20 @@
13
13
  */
14
14
  import { type ThemeId } from './index.js';
15
15
  import { type Scheme, type ThemeDefinition } from './engine.js';
16
+ import { type ThemeOverride } from './create.js';
16
17
  /** The id an app's own ThemeDefinition is registered under. */
17
18
  export declare const OWN_THEME: ThemeId;
18
19
  export interface ColorsOptions {
19
- /** The multi-theme switch. Off unless this is exactly `true`. */
20
- themes?: boolean;
21
- /** The app's own look: a built-in theme id, or a ThemeDefinition of its own. Default `dark`. */
22
- own?: ThemeId | ThemeDefinition;
20
+ /**
21
+ * The multi-theme switch. Off unless `true` (every built-in) or a list of built-in ids (only those).
22
+ * The app's own theme is always offered as well.
23
+ */
24
+ themes?: boolean | ThemeId[];
25
+ /**
26
+ * The app's own look: a built-in theme id, a built-in with only what differs (`{ extends, … }`),
27
+ * or a ThemeDefinition of its own. Default `dark`.
28
+ */
29
+ own?: ThemeId | ThemeOverride | ThemeDefinition;
23
30
  }
24
31
  export interface ThemeEntry {
25
32
  id: ThemeId;
package/dist/app.js CHANGED
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Off (the default), the app has ONE look: its own theme, painted on `:root`. No choice is read
5
5
  * from storage, there is nothing to pick, and the stylesheet holds one rule instead of one per
6
- * theme. The app's own theme is either a built-in id or a full ThemeDefinition written for that
7
- * app, so its colours need not come from any theme of ours.
6
+ * theme. The app's own theme is a built-in id, a built-in with only what differs, or a full
7
+ * ThemeDefinition written for that app, so its colours need not come from any theme of ours.
8
8
  *
9
9
  * On, every built-in theme is offered beside the app's own, and the saved choice is honoured.
10
10
  *
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { THEMES, THEME_STORAGE_KEY, applyTheme, clearThemeCache, isThemeId, requiredPalette, schemeOf, themeCss, themeRule, } from './index.js';
15
15
  import { validateThemeDefinition } from './engine.js';
16
+ import { extendTheme, isThemeOverride } from './create.js';
16
17
  /** The id an app's own ThemeDefinition is registered under. */
17
18
  export const OWN_THEME = 'own';
18
19
  /** `true`, `"true"`, `"on"` and `"1"` turn the switch on; anything else leaves it off. */
@@ -20,20 +21,29 @@ export function switchValue(value) {
20
21
  return value === true || value === 'true' || value === 'on' || value === '1';
21
22
  }
22
23
  export function createColors(options = {}) {
23
- const enabled = options.themes === true;
24
+ const offered = offeredThemes(options.themes);
25
+ const enabled = offered.length > 0;
24
26
  const own = registerOwn(options.own ?? 'dark');
25
- const resolve = (saved) => (enabled && isThemeId(saved) ? saved : own);
27
+ // The ids this app shows: its own first when it is not a built-in, then the ones it offers.
28
+ const ids = enabled ? [...new Set([...(isBuiltIn(own) ? [] : [own]), ...offered, own])] : [own];
29
+ const subset = enabled && offered.length < builtIns().length;
30
+ const resolve = (saved) => (enabled && typeof saved === 'string' && ids.includes(saved) ? saved : own);
26
31
  return {
27
32
  enabled,
28
33
  own,
29
- list: () => (enabled ? Object.keys(THEMES) : [own]).map(id => ({ id, name: THEMES[id].name, scheme: schemeOf(id) })),
34
+ list: () => (enabled && !subset ? Object.keys(THEMES) : ids).map(id => ({ id, name: THEMES[id].name, scheme: schemeOf(id) })),
30
35
  resolve,
31
36
  // On: every theme by its id, and the app's own on a bare `:root` so the server-rendered page
32
37
  // is right before any script runs. Off: the one rule.
33
- css: () => (enabled ? `${themeCss()}\n${themeRule(own, ':root:not([data-theme])')}` : themeRule(own, ':root')),
38
+ css: () => {
39
+ if (!enabled)
40
+ return themeRule(own, ':root');
41
+ const rules = subset ? ids.map(id => themeRule(id, `:root[data-theme="${id}"]`)).join('\n') : themeCss();
42
+ return `${rules}\n${themeRule(own, ':root:not([data-theme])')}`;
43
+ },
34
44
  prepaint: () => {
35
45
  const set = (id) => `var d=document.documentElement;d.dataset.theme=${id};d.classList.toggle('dark',s[${id}]==='dark')`;
36
- const schemes = Object.fromEntries((enabled ? Object.keys(THEMES) : [own]).map(id => [id, schemeOf(id)]));
46
+ const schemes = Object.fromEntries((enabled && !subset ? Object.keys(THEMES) : ids).map(id => [id, schemeOf(id)]));
37
47
  const fallback = JSON.stringify(own);
38
48
  const pick = enabled
39
49
  ? `var t=localStorage.getItem(${JSON.stringify(THEME_STORAGE_KEY)});if(!s.hasOwnProperty(t))t=${fallback};`
@@ -48,6 +58,19 @@ export function createColors(options = {}) {
48
58
  },
49
59
  };
50
60
  }
61
+ const builtIns = () => Object.keys(THEMES).filter(isBuiltIn);
62
+ const isBuiltIn = (id) => id !== OWN_THEME && !id.startsWith('_');
63
+ /** The built-ins an app offers: all for `true`, the listed ones for a list (an unknown id stops the app), none otherwise. */
64
+ function offeredThemes(themes) {
65
+ if (themes === true)
66
+ return builtIns();
67
+ if (!Array.isArray(themes))
68
+ return [];
69
+ const unknown = themes.filter(id => !isBuiltIn(id) || !THEMES[id]);
70
+ if (unknown.length)
71
+ throw new Error(`colors: no built-in theme named ${unknown.map(id => `"${id}"`).join(', ')}`);
72
+ return [...new Set(themes)];
73
+ }
51
74
  /** An id is checked; a definition is validated and registered as `own`. A bad one stops the app. */
52
75
  function registerOwn(own) {
53
76
  if (typeof own === 'string') {
@@ -55,6 +78,17 @@ function registerOwn(own) {
55
78
  throw new Error(`colors: no theme named "${own}"`);
56
79
  return own;
57
80
  }
81
+ // An override is held to the contrast gate too: the colours it changes must stay readable.
82
+ if (isThemeOverride(own)) {
83
+ const result = extendTheme(own);
84
+ if (!result.theme || !result.ok) {
85
+ const reasons = result.problems.length
86
+ ? result.problems
87
+ : result.failures.map(f => `${f.fg} on ${f.bg} is ${f.ratio.toFixed(2)}, below ${f.floor}.`);
88
+ throw new Error(`colors: the app's own theme is not usable: ${reasons.join(' ')}`);
89
+ }
90
+ own = result.theme;
91
+ }
58
92
  const problems = validateThemeDefinition(own, requiredPalette(own?.scheme === 'light' ? 'light' : 'dark'));
59
93
  if (problems.length)
60
94
  throw new Error(`colors: the app's own theme is not usable: ${problems.join(' ')}`);
package/dist/create.d.ts CHANGED
@@ -30,3 +30,39 @@ export declare function validateThemeInput(input: unknown): string[];
30
30
  * leaving it registered. `ok` only when there are no problems and no contrast failures.
31
31
  */
32
32
  export declare function createTheme(input: unknown): CreateResult;
33
+ /**
34
+ * An app's own version: a built-in theme with only what differs. Plain data, so every platform
35
+ * (web, React Native, later Swift) can read the same object. Palette steps replace the base's one
36
+ * by one; chips, code and lists replace field by field. Only steps and fields the base already
37
+ * has can be set, so an app's own version still lines up with every other app.
38
+ */
39
+ export interface ThemeOverride {
40
+ /** The built-in theme it starts from. Its scheme is the version's scheme. */
41
+ extends: string;
42
+ name?: string;
43
+ palette?: Record<string, Record<string, string>>;
44
+ chips?: Partial<ThemeDefinition['chips']>;
45
+ code?: Partial<ThemeDefinition['code']>;
46
+ lists?: Partial<ThemeDefinition['lists']>;
47
+ }
48
+ export interface ExtendResult {
49
+ ok: boolean;
50
+ problems: string[];
51
+ theme?: ThemeDefinition;
52
+ score?: number;
53
+ lowest?: number;
54
+ failures?: {
55
+ fg: string;
56
+ bg: string;
57
+ ratio: number;
58
+ floor: number;
59
+ }[];
60
+ }
61
+ /** True for a ThemeOverride's shape (it names what it extends), so `own` can take either form. */
62
+ export declare function isThemeOverride(value: unknown): value is ThemeOverride;
63
+ /**
64
+ * The override laid over its base, validated and scored like `createTheme`. `ok` only with no
65
+ * problems and no contrast failure. Never throws. The base's contrast floor is not carried over:
66
+ * changed colours are held to the normal floor.
67
+ */
68
+ export declare function extendTheme(override: unknown): ExtendResult;
package/dist/create.js CHANGED
@@ -85,3 +85,87 @@ export function createTheme(input) {
85
85
  clearThemeCache();
86
86
  }
87
87
  }
88
+ const OVERRIDE_KEYS = ['extends', 'name', 'palette', 'chips', 'code', 'lists'];
89
+ const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
90
+ /** True for a ThemeOverride's shape (it names what it extends), so `own` can take either form. */
91
+ export function isThemeOverride(value) {
92
+ return isObject(value) && 'extends' in value;
93
+ }
94
+ /**
95
+ * The override laid over its base, validated and scored like `createTheme`. `ok` only with no
96
+ * problems and no contrast failure. Never throws. The base's contrast floor is not carried over:
97
+ * changed colours are held to the normal floor.
98
+ */
99
+ export function extendTheme(override) {
100
+ if (!isObject(override))
101
+ return { ok: false, problems: ['The override must be an object.'] };
102
+ const problems = [];
103
+ for (const key of Object.keys(override))
104
+ if (!OVERRIDE_KEYS.includes(key))
105
+ problems.push(`unknown field ${key}`);
106
+ const baseId = override.extends;
107
+ const base = typeof baseId === 'string' && !baseId.startsWith('_') && baseId !== 'own' ? THEMES[baseId] : undefined;
108
+ if (!base)
109
+ return { ok: false, problems: [...problems, `extends must name a built-in theme (${Object.keys(THEMES).filter(id => id !== 'own' && !id.startsWith('_')).join(', ')}).`] };
110
+ const theme = structuredClone(base);
111
+ delete theme.contrast;
112
+ theme.derived = { extends: baseId };
113
+ if (override.name !== undefined) {
114
+ if (typeof override.name !== 'string' || !override.name.trim())
115
+ problems.push('name must be a non-empty string.');
116
+ else
117
+ theme.name = override.name.trim();
118
+ }
119
+ if (override.palette !== undefined) {
120
+ if (!isObject(override.palette))
121
+ problems.push('palette must be an object of ramps.');
122
+ else
123
+ for (const [ramp, steps] of Object.entries(override.palette)) {
124
+ if (!theme.palette[ramp]) {
125
+ problems.push(`palette ${ramp} is not a ramp of ${baseId}.`);
126
+ continue;
127
+ }
128
+ if (!isObject(steps)) {
129
+ problems.push(`palette ${ramp} must be an object of steps.`);
130
+ continue;
131
+ }
132
+ for (const [step, hex] of Object.entries(steps)) {
133
+ if (theme.palette[ramp][step] === undefined)
134
+ problems.push(`palette ${ramp}.${step} is not a step of ${baseId}.`);
135
+ else
136
+ theme.palette[ramp][step] = hex;
137
+ }
138
+ }
139
+ }
140
+ for (const part of ['chips', 'code', 'lists']) {
141
+ const patch = override[part];
142
+ if (patch === undefined)
143
+ continue;
144
+ if (!isObject(patch)) {
145
+ problems.push(`${part} must be an object.`);
146
+ continue;
147
+ }
148
+ const target = theme[part];
149
+ for (const [key, value] of Object.entries(patch)) {
150
+ if (!(key in target))
151
+ problems.push(`${part}.${key} is not a field of ${baseId}.`);
152
+ else
153
+ target[key] = value;
154
+ }
155
+ }
156
+ if (problems.length)
157
+ return { ok: false, problems };
158
+ const themeProblems = validateThemeDefinition(theme, requiredPalette(theme.scheme));
159
+ if (themeProblems.length)
160
+ return { ok: false, problems: themeProblems };
161
+ THEMES[CANDIDATE] = theme;
162
+ clearThemeCache();
163
+ try {
164
+ const scored = scoreTheme(CANDIDATE);
165
+ return { ok: scored.failures.length === 0, problems: [], theme, score: scored.score, lowest: scored.lowest?.ratio, failures: scored.failures };
166
+ }
167
+ finally {
168
+ delete THEMES[CANDIDATE];
169
+ clearThemeCache();
170
+ }
171
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghostmind-dev/colors",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "One colour theme system for every app: themes as palettes, roles, a WCAG contrast gate, and a theme derived from three numbers",
5
5
  "license": "MIT",
6
6
  "type": "module",