@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 +18 -4
- package/dist/app.d.ts +13 -6
- package/dist/app.js +41 -7
- package/dist/create.d.ts +36 -0
- package/dist/create.js +84 -0
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
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
|
|
24
|
+
const offered = offeredThemes(options.themes);
|
|
25
|
+
const enabled = offered.length > 0;
|
|
24
26
|
const own = registerOwn(options.own ?? 'dark');
|
|
25
|
-
|
|
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) :
|
|
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: () =>
|
|
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) :
|
|
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