@motion-proto/live-tokens 0.73.0 → 0.74.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/.claude/skills/live-tokens-build-page/SKILL.md +2 -2
- package/.claude/skills/live-tokens-check-compliance/SKILL.md +2 -2
- package/.claude/skills/live-tokens-create-component/SKILL.md +4 -4
- package/.claude/skills/live-tokens-create-theme/SKILL.md +80 -0
- package/.claude/skills/live-tokens-create-theme/references/design-directions.md +94 -0
- package/.claude/skills/live-tokens-set-colors/SKILL.md +140 -0
- package/.claude/skills/live-tokens-set-colors/references/color-anchors.md +80 -0
- package/.claude/skills/{live-tokens-adjust-geometry → live-tokens-set-geometry}/SKILL.md +12 -7
- package/.claude/skills/live-tokens-set-geometry/references/geometry-anchors.md +61 -0
- package/.claude/skills/{live-tokens-pair-fonts → live-tokens-set-type}/SKILL.md +18 -15
- package/.claude/skills/live-tokens-set-type/references/type-anchors.md +60 -0
- package/CHANGELOG.md +91 -0
- package/README.md +24 -15
- package/bin/check-page.mjs +3 -3
- package/bin/cli.mjs +92 -55
- package/bin/lib/liveState.mjs +110 -0
- package/bin/save-theme.mjs +177 -0
- package/bin/set-colors.mjs +191 -0
- package/bin/{adjust.mjs → set-geometry.mjs} +18 -50
- package/bin/{set-fonts.mjs → set-type.mjs} +21 -55
- package/dist-plugin/{chunk-RIXO2E55.js → chunk-7VRTBGJT.js} +1 -1
- package/dist-plugin/{chunk-YLCOIGQC.js → chunk-V3YF6CGT.js} +56 -2
- package/dist-plugin/index.cjs +58 -3
- package/dist-plugin/index.js +11 -11
- package/dist-plugin/migrateData/index.cjs +56 -1
- package/dist-plugin/migrateData/index.js +2 -2
- package/dist-plugin/{generateColorsAndType → setColors}/index.cjs +1109 -1071
- package/dist-plugin/{generateColorsAndType → setColors}/index.d.cts +33 -24
- package/dist-plugin/{generateColorsAndType → setColors}/index.d.ts +33 -24
- package/dist-plugin/{generateColorsAndType → setColors}/index.js +45 -63
- package/dist-plugin/{adjust → setGeometry}/index.cjs +60 -5
- package/dist-plugin/{adjust → setGeometry}/index.d.cts +1 -1
- package/dist-plugin/{adjust → setGeometry}/index.d.ts +1 -1
- package/dist-plugin/{adjust → setGeometry}/index.js +1 -1
- package/dist-plugin/{fontPairing → setType}/index.cjs +4 -4
- package/dist-plugin/{fontPairing → setType}/index.d.cts +1 -1
- package/dist-plugin/{fontPairing → setType}/index.d.ts +1 -1
- package/dist-plugin/{themeTypes-DSV3Zisf.d.cts → themeTypes-BxRtuN5V.d.cts} +1 -1
- package/dist-plugin/{themeTypes-DSV3Zisf.d.ts → themeTypes-BxRtuN5V.d.ts} +1 -1
- package/package.json +2 -1
- package/src/editor/core/themes/{generateColorsAndType.ts → buildColors.ts} +82 -96
- package/src/editor/core/themes/migrations/2026-09-03-drop-legacy-component-keys.ts +62 -0
- package/src/editor/core/themes/migrations/index.ts +2 -0
- package/src/editor/docs/content/themes-workflow.md +2 -2
- package/src/editor/docs/content.generated.ts +1 -1
- package/src/editor/overlay/LiveEditorOverlay.svelte +519 -28
- package/src/editor/skill-atlas/SkillAtlas.svelte +2 -2
- package/src/editor/skill-atlas/skillSources.generated.ts +19 -15
- package/src/editor/skill-atlas/skillTrees.ts +785 -632
- package/src/live-tokens/data/colors-and-type/autumn.json +1 -37
- package/src/live-tokens/data/colors-and-type/default.json +1 -37
- package/src/live-tokens/data/colors-and-type/halloween.json +1 -37
- package/src/live-tokens/data/colors-and-type/midnight-study.json +1 -37
- package/src/live-tokens/data/colors-and-type/ocean.json +1 -37
- package/src/live-tokens/data/colors-and-type/royal-velvet.json +1 -37
- package/src/live-tokens/data/colors-and-type/sketchy.json +1 -37
- package/src/live-tokens/data/colors-and-type/spring-meadow.json +1 -37
- package/src/live-tokens/data/colors-and-type/sunset.json +1 -37
- package/src/live-tokens/data/themes/autumn.json +1 -37
- package/src/live-tokens/data/themes/halloween.json +1 -37
- package/src/live-tokens/data/themes/midnight-study.json +1 -37
- package/src/live-tokens/data/themes/ocean.json +1 -37
- package/src/live-tokens/data/themes/royal-velvet.json +1 -37
- package/src/live-tokens/data/themes/sketchy.json +1 -37
- package/src/live-tokens/data/themes/spring-meadow.json +1 -37
- package/src/live-tokens/data/themes/sunset.json +1 -37
- package/src/live-tokens/data/tokens.generated.css +0 -36
- package/.claude/skills/live-tokens-generate-theme/SKILL.md +0 -156
- package/.claude/skills/live-tokens-generate-theme/references/mood-vocabulary.md +0 -43
- package/.claude/skills/live-tokens-generate-theme/references/named-themes.md +0 -18
- package/.claude/skills/live-tokens-generate-theme/references/style-vocabulary.md +0 -35
- package/bin/generate-theme.mjs +0 -260
- /package/dist-plugin/{fontPairing → setType}/index.js +0 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// The live state the three `set-*` workers read, in one place.
|
|
2
|
+
//
|
|
3
|
+
// Every resolver mirrors the dev server's own resolution order — the unsaved
|
|
4
|
+
// buffer, then the open theme's copy by value, then the shipped default — so a
|
|
5
|
+
// CLI edits what the page runs. Pure file reads: the caller passes the resolved
|
|
6
|
+
// dirs, so nothing here loads `dist-plugin` and `bin/engineLoadsLazily.test.ts`
|
|
7
|
+
// holds by construction.
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
10
|
+
import { dirname, join, resolve } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
const packageDataDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..', 'src/live-tokens/data');
|
|
14
|
+
|
|
15
|
+
function readJsonIfExists(path) {
|
|
16
|
+
if (!existsSync(path)) return null;
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
19
|
+
} catch (err) {
|
|
20
|
+
throw new Error(`${path} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Local tree first, then the copy the installed package ships. */
|
|
25
|
+
function readData(localDir, packageSubdir, fileName) {
|
|
26
|
+
return (
|
|
27
|
+
readJsonIfExists(join(localDir, `${fileName}.json`)) ??
|
|
28
|
+
readJsonIfExists(join(packageDataDir, packageSubdir, `${fileName}.json`))
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `_fileName` and `_source` are read-door markers, never part of a document. */
|
|
33
|
+
export function stripMarkers(value) {
|
|
34
|
+
if (!value || typeof value !== 'object') return value;
|
|
35
|
+
const { _fileName, _source, ...rest } = value;
|
|
36
|
+
return rest;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function componentNames(componentConfigsDir) {
|
|
40
|
+
if (!existsSync(componentConfigsDir)) return [];
|
|
41
|
+
return readdirSync(componentConfigsDir, { withFileTypes: true })
|
|
42
|
+
.filter((e) => e.isDirectory())
|
|
43
|
+
.map((e) => e.name)
|
|
44
|
+
.sort();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function readActiveTheme(themesDir) {
|
|
48
|
+
if (!themesDir) return null;
|
|
49
|
+
const slug = readJsonIfExists(join(themesDir, '_active.json'))?.activeFile ?? 'default';
|
|
50
|
+
const theme = readData(themesDir, 'themes', slug);
|
|
51
|
+
return theme ? { slug, theme } : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function readLiveColorsAndType(colorsAndTypeDir, active) {
|
|
55
|
+
const working = readJsonIfExists(join(colorsAndTypeDir, '_working.json'));
|
|
56
|
+
if (working) return { colorsAndType: stripMarkers(working), source: 'working' };
|
|
57
|
+
if (active?.theme?.colorsAndType) {
|
|
58
|
+
return { colorsAndType: stripMarkers(active.theme.colorsAndType), source: 'theme' };
|
|
59
|
+
}
|
|
60
|
+
const shipped = readData(colorsAndTypeDir, 'colors-and-type', 'default');
|
|
61
|
+
if (!shipped) throw new Error(`no colors and type to read at ${colorsAndTypeDir}`);
|
|
62
|
+
return { colorsAndType: stripMarkers(shipped), source: 'default' };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `updatedAt` records when a buffer was written, not what it holds, so the
|
|
66
|
+
* comparison that tells a discard from an edit ignores it. Both writers of
|
|
67
|
+
* `colors-and-type/_working.json` ask this one question, and a rule that kept
|
|
68
|
+
* `updatedAt` missed every buffer the other verb had stamped. */
|
|
69
|
+
export function sameContent(a, b) {
|
|
70
|
+
const strip = ({ updatedAt: _updatedAt, ...rest }) => rest;
|
|
71
|
+
return JSON.stringify(strip(a)) === JSON.stringify(strip(b));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Which layer sits under the buffer: the open theme when it carries colors and
|
|
75
|
+
* type, else the shipped default. A report that names the layer reads it from
|
|
76
|
+
* here rather than re-deriving the fall-through, which is how the discard
|
|
77
|
+
* message came to name a theme that was not open. */
|
|
78
|
+
export const savedColorsAndTypeSource = (active) => (active?.theme?.colorsAndType ? 'theme' : 'default');
|
|
79
|
+
|
|
80
|
+
/** The layer under the buffer. Returning the buffer to exactly this is a
|
|
81
|
+
* discard, not an edit, so a caller compares against it before writing. */
|
|
82
|
+
export function readSavedColorsAndType(colorsAndTypeDir, active) {
|
|
83
|
+
const saved =
|
|
84
|
+
savedColorsAndTypeSource(active) === 'theme'
|
|
85
|
+
? active.theme.colorsAndType
|
|
86
|
+
: readData(colorsAndTypeDir, 'colors-and-type', 'default');
|
|
87
|
+
return saved ? stripMarkers(saved) : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Each component's live config, and whether a buffer or the open document
|
|
91
|
+
* answered. Every theme carries every component by value, but this is a raw
|
|
92
|
+
* file read, ahead of any server-side fill — a theme written before a
|
|
93
|
+
* component existed can still omit it, so a missing entry falls back to the
|
|
94
|
+
* shipped default the same way the theme's own fill would, and still reports
|
|
95
|
+
* as the document. */
|
|
96
|
+
export function readLiveComponentConfigs(componentConfigsDir, active) {
|
|
97
|
+
const configs = {};
|
|
98
|
+
const sources = {};
|
|
99
|
+
for (const comp of componentNames(componentConfigsDir)) {
|
|
100
|
+
const working = readJsonIfExists(join(componentConfigsDir, comp, '_working.json'));
|
|
101
|
+
const config =
|
|
102
|
+
working ??
|
|
103
|
+
active?.theme?.componentConfigs?.[comp] ??
|
|
104
|
+
readData(join(componentConfigsDir, comp), `component-configs/${comp}`, 'default');
|
|
105
|
+
if (!config) continue;
|
|
106
|
+
configs[comp] = stripMarkers(config);
|
|
107
|
+
sources[comp] = working ? 'working' : 'theme';
|
|
108
|
+
}
|
|
109
|
+
return { configs, sources };
|
|
110
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// `live-tokens save-theme` worker.
|
|
2
|
+
//
|
|
3
|
+
// Composes the live state into a theme document at `themes/<slug>.json` and
|
|
4
|
+
// opens it. Live state, and only live state: each layer resolves the way the
|
|
5
|
+
// dev server resolves it — the unsaved buffer, then the open theme's copy by
|
|
6
|
+
// value, then the shipped default — so the theme this writes is the look the
|
|
7
|
+
// page renders. Opening clears every `_working.json` and points
|
|
8
|
+
// `themes/_active.json` at the new document, exactly as the editor's apply door
|
|
9
|
+
// does; `--no-activate` writes the file and leaves the live state alone, which
|
|
10
|
+
// is how a set of themes comes off one starting look.
|
|
11
|
+
//
|
|
12
|
+
// This is the only verb that writes a theme. The three `set-*` verbs write
|
|
13
|
+
// buffers.
|
|
14
|
+
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
componentNames,
|
|
21
|
+
readActiveTheme,
|
|
22
|
+
readLiveColorsAndType,
|
|
23
|
+
readLiveComponentConfigs,
|
|
24
|
+
} from './lib/liveState.mjs';
|
|
25
|
+
|
|
26
|
+
const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
27
|
+
// The setColors bundle carries the slug helper, the component schema stamp and
|
|
28
|
+
// the dir resolution; composing a theme needs no color pipeline of its own.
|
|
29
|
+
const ENGINE = resolve(pkgRoot, 'dist-plugin/setColors/index.js');
|
|
30
|
+
// Source of truth: src/editor/core/themes/themeTypes.ts, which
|
|
31
|
+
// normalizeTheme.ts re-exports. This copy cannot import TS, so
|
|
32
|
+
// `bin/schemaVersionCopies.test.ts` is what catches a drift.
|
|
33
|
+
const THEME_SCHEMA_VERSION = 5;
|
|
34
|
+
|
|
35
|
+
async function loadEngine() {
|
|
36
|
+
if (!existsSync(ENGINE)) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`setColors engine not found at ${relative(process.cwd(), ENGINE)}. ` +
|
|
39
|
+
`Build the plugin first (npm run build:plugin).`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return import(ENGINE);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The destination file, read to keep its `createdAt`. Never the package copy:
|
|
46
|
+
* this is the file about to be overwritten, not a layer to fall through to. */
|
|
47
|
+
function readLocalJson(path) {
|
|
48
|
+
return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** `engine` is a test seam; the CLI always runs the compiled bundle. */
|
|
52
|
+
export async function runSaveTheme({
|
|
53
|
+
name,
|
|
54
|
+
activate = true,
|
|
55
|
+
dryRun = false,
|
|
56
|
+
colorsAndTypeDir,
|
|
57
|
+
componentConfigsDir,
|
|
58
|
+
themesDir,
|
|
59
|
+
engine,
|
|
60
|
+
} = {}) {
|
|
61
|
+
const { sanitizeFileName, resolveDataDirs, CURRENT_COMPONENT_SCHEMA_VERSION } =
|
|
62
|
+
engine ?? (await loadEngine());
|
|
63
|
+
|
|
64
|
+
const themeName = typeof name === 'string' ? name.trim() : '';
|
|
65
|
+
if (!themeName) throw new Error('save-theme needs a theme name');
|
|
66
|
+
const slug = sanitizeFileName(themeName);
|
|
67
|
+
if (slug === 'default') {
|
|
68
|
+
throw new Error('"default" is the protected package theme; pick another name');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const resolved = colorsAndTypeDir && componentConfigsDir && themesDir ? null : resolveDataDirs();
|
|
72
|
+
const dirs = {
|
|
73
|
+
colorsAndTypeDir: colorsAndTypeDir ?? resolved.colorsAndTypeDir,
|
|
74
|
+
componentConfigsDir: componentConfigsDir ?? resolved.componentConfigsDir,
|
|
75
|
+
themesDir: themesDir ?? resolved.themesDir,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const active = readActiveTheme(dirs.themesDir);
|
|
79
|
+
const { colorsAndType, source } = readLiveColorsAndType(dirs.colorsAndTypeDir, active);
|
|
80
|
+
const { configs, sources } = readLiveComponentConfigs(dirs.componentConfigsDir, active);
|
|
81
|
+
|
|
82
|
+
const themePath = join(dirs.themesDir, `${slug}.json`);
|
|
83
|
+
const existing = readLocalJson(themePath);
|
|
84
|
+
const now = new Date().toISOString();
|
|
85
|
+
const sketchSettings = active?.theme?.sketchSettings;
|
|
86
|
+
|
|
87
|
+
const theme = {
|
|
88
|
+
name: themeName,
|
|
89
|
+
createdAt: existing?.createdAt ?? now,
|
|
90
|
+
updatedAt: now,
|
|
91
|
+
schemaVersion: THEME_SCHEMA_VERSION,
|
|
92
|
+
colorsAndType: { ...colorsAndType, name: themeName },
|
|
93
|
+
componentConfigs: configs,
|
|
94
|
+
componentSchemaVersion: CURRENT_COMPONENT_SCHEMA_VERSION,
|
|
95
|
+
...(sketchSettings ? { sketchSettings } : {}),
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
if (!dryRun) {
|
|
99
|
+
mkdirSync(dirs.themesDir, { recursive: true });
|
|
100
|
+
writeFileSync(themePath, JSON.stringify(theme, null, 2) + '\n');
|
|
101
|
+
if (activate) applyTheme(slug, dirs);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// `working` is the one source label that proves a buffer answered: a
|
|
105
|
+
// component the open theme omits reports as `theme` after falling through to
|
|
106
|
+
// its shipped default, so the report says what came from a buffer and names
|
|
107
|
+
// the fall-through for the rest rather than claiming a layer per component.
|
|
108
|
+
const bufferedComponents = componentNames(dirs.componentConfigsDir).filter(
|
|
109
|
+
(comp) => sources[comp] === 'working',
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
name: themeName,
|
|
114
|
+
slug,
|
|
115
|
+
themePath,
|
|
116
|
+
existed: existing !== null,
|
|
117
|
+
dryRun,
|
|
118
|
+
activated: activate && !dryRun,
|
|
119
|
+
previousActive: active?.slug ?? 'default',
|
|
120
|
+
openTheme: active?.slug ?? null,
|
|
121
|
+
buffered: { colorsAndType: source === 'working', components: bufferedComponents },
|
|
122
|
+
components: Object.keys(configs).length,
|
|
123
|
+
sketchSettings: Boolean(sketchSettings),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The apply door's write set, reproduced: clear every working delta and point
|
|
128
|
+
* `themes/_active.json` at the open document. Production is untouched. */
|
|
129
|
+
function applyTheme(slug, dirs) {
|
|
130
|
+
const colorsWorking = join(dirs.colorsAndTypeDir, '_working.json');
|
|
131
|
+
if (existsSync(colorsWorking)) rmSync(colorsWorking);
|
|
132
|
+
|
|
133
|
+
for (const comp of componentNames(dirs.componentConfigsDir)) {
|
|
134
|
+
const workingPath = join(dirs.componentConfigsDir, comp, '_working.json');
|
|
135
|
+
if (existsSync(workingPath)) rmSync(workingPath);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
writeFileSync(join(dirs.themesDir, '_active.json'), JSON.stringify({ activeFile: slug }));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function formatSaveThemeResult(result) {
|
|
142
|
+
const root = process.cwd();
|
|
143
|
+
const lines = [];
|
|
144
|
+
const wrote = result.dryRun ? 'Would write' : result.existed ? 'Updated' : 'Created';
|
|
145
|
+
lines.push(`${wrote} theme "${result.name}" → ${relative(root, result.themePath)}`);
|
|
146
|
+
lines.push(`It carries the colors and type and ${result.components} component config(s) by value.`);
|
|
147
|
+
|
|
148
|
+
const edited = [
|
|
149
|
+
...(result.buffered.colorsAndType ? ['colors and type'] : []),
|
|
150
|
+
...result.buffered.components,
|
|
151
|
+
];
|
|
152
|
+
const open = result.openTheme ? `the open theme "${result.openTheme}"` : 'the package defaults';
|
|
153
|
+
const kept = result.dryRun ? 'Would save' : 'Saved';
|
|
154
|
+
if (edited.length > 0) {
|
|
155
|
+
lines.push(`${kept} your unsaved edits: ${edited.join(', ')}.`);
|
|
156
|
+
lines.push(`Everything else came from ${open}, or the shipped defaults where it carries no entry.`);
|
|
157
|
+
} else {
|
|
158
|
+
lines.push(`No unsaved edits; ${kept.toLowerCase()} a copy of ${open}.`);
|
|
159
|
+
}
|
|
160
|
+
if (result.sketchSettings) lines.push(`Sketch settings rode through from the open theme.`);
|
|
161
|
+
|
|
162
|
+
if (result.activated) {
|
|
163
|
+
lines.push(
|
|
164
|
+
`\nOpened "${result.slug}" (previously open: "${result.previousActive}"). ` +
|
|
165
|
+
`Reload the app to see it; switch back any time from Load in the editor's Theme panel. ` +
|
|
166
|
+
`Adopt it there to publish it to tokens.generated.css.`,
|
|
167
|
+
);
|
|
168
|
+
} else if (result.dryRun) {
|
|
169
|
+
lines.push(`\nDry run: nothing written under ${relative(root, dirname(result.themePath))}.`);
|
|
170
|
+
} else {
|
|
171
|
+
lines.push(
|
|
172
|
+
`\nNot opened (--no-activate). Your unsaved edits are still open, so the next ` +
|
|
173
|
+
`save-theme starts from the same look. Load "${result.slug}" from the editor's Theme panel to see it.`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return lines.join('\n');
|
|
177
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// `live-tokens set-colors` worker.
|
|
2
|
+
//
|
|
3
|
+
// Reads a base color file (JSON), builds the theme's whole color state via the
|
|
4
|
+
// compiled engine (dist-plugin/setColors — the CLI never imports TS sources),
|
|
5
|
+
// enforces the AA contrast gate, and writes the result into
|
|
6
|
+
// `colors-and-type/_working.json`: the same buffer the editor's own palette
|
|
7
|
+
// edits land in, so a recolor is an unsaved edit the user keeps by saving the
|
|
8
|
+
// open theme or by running `save-theme`. Named colors-and-type files, themes,
|
|
9
|
+
// tokens.css and fonts.css are never touched, and nothing is activated.
|
|
10
|
+
//
|
|
11
|
+
// Non-color content carries forward from the live colors and type, so a recolor
|
|
12
|
+
// replaces the color identity and nothing else. Swatch gradients ride along
|
|
13
|
+
// when user-tuned; stock ones are rebuilt from the new families by the engine.
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
readActiveTheme,
|
|
21
|
+
readLiveColorsAndType,
|
|
22
|
+
readSavedColorsAndType,
|
|
23
|
+
sameContent,
|
|
24
|
+
savedColorsAndTypeSource,
|
|
25
|
+
} from './lib/liveState.mjs';
|
|
26
|
+
|
|
27
|
+
const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
28
|
+
const ENGINE = resolve(pkgRoot, 'dist-plugin/setColors/index.js');
|
|
29
|
+
|
|
30
|
+
const SOURCE_LABELS = {
|
|
31
|
+
working: 'your unsaved edits',
|
|
32
|
+
theme: 'the open theme',
|
|
33
|
+
default: 'the package default',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
async function loadEngine() {
|
|
37
|
+
if (!existsSync(ENGINE)) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`setColors engine not found at ${relative(process.cwd(), ENGINE)}. ` +
|
|
40
|
+
`Build the plugin first (npm run build:plugin).`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return import(ENGINE);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readJson(path) {
|
|
47
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `engine` is a test seam; the CLI always runs the compiled bundle. */
|
|
51
|
+
export async function runSetColors({
|
|
52
|
+
baseColorsPath,
|
|
53
|
+
dryRun = false,
|
|
54
|
+
root = process.cwd(),
|
|
55
|
+
colorsAndTypeDir,
|
|
56
|
+
themesDir,
|
|
57
|
+
engine,
|
|
58
|
+
} = {}) {
|
|
59
|
+
const { buildColors, resolveDataDirs } = engine ?? (await loadEngine());
|
|
60
|
+
|
|
61
|
+
const fullPath = resolve(root, baseColorsPath);
|
|
62
|
+
if (!existsSync(fullPath)) {
|
|
63
|
+
throw new Error(`base color file not found at ${relative(root, fullPath)}`);
|
|
64
|
+
}
|
|
65
|
+
let input;
|
|
66
|
+
try {
|
|
67
|
+
input = readJson(fullPath);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
throw new Error(`base color file is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const resolved = colorsAndTypeDir && themesDir ? null : resolveDataDirs();
|
|
73
|
+
const colorsDir = colorsAndTypeDir ?? resolved.colorsAndTypeDir;
|
|
74
|
+
const themes = themesDir ?? resolved.themesDir;
|
|
75
|
+
if (!existsSync(colorsDir)) {
|
|
76
|
+
throw new Error(`no colors and type at ${relative(root, colorsDir)}. Run the dev server once to create it.`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const active = readActiveTheme(themes);
|
|
80
|
+
const { colorsAndType: live, source } = readLiveColorsAndType(colorsDir, active);
|
|
81
|
+
|
|
82
|
+
const { colors, report } = buildColors(input, {
|
|
83
|
+
cssVariables: live.cssVariables,
|
|
84
|
+
gradients: live.gradients,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const next = {
|
|
88
|
+
...live,
|
|
89
|
+
...colors,
|
|
90
|
+
updatedAt: new Date().toISOString(),
|
|
91
|
+
schemaVersion: live.schemaVersion,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const workingPath = join(colorsDir, '_working.json');
|
|
95
|
+
const saved = readSavedColorsAndType(colorsDir, active);
|
|
96
|
+
|
|
97
|
+
// Returning the buffer to what the layer under it already holds is a discard,
|
|
98
|
+
// not an edit — the same call the dev server's own PUT makes.
|
|
99
|
+
const backToSaved = saved !== null && sameContent(next, saved);
|
|
100
|
+
let wrote = null;
|
|
101
|
+
if (!dryRun) {
|
|
102
|
+
if (backToSaved) {
|
|
103
|
+
if (existsSync(workingPath)) rmSync(workingPath);
|
|
104
|
+
wrote = 'cleared';
|
|
105
|
+
} else {
|
|
106
|
+
writeFileSync(workingPath, JSON.stringify(next, null, 2) + '\n');
|
|
107
|
+
wrote = 'buffer';
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
baseColorsPath: fullPath,
|
|
113
|
+
colorsAndTypeDir: colorsDir,
|
|
114
|
+
workingPath,
|
|
115
|
+
openTheme: active?.slug ?? null,
|
|
116
|
+
source,
|
|
117
|
+
// Which layer the discard compared against, so the report can name it. It
|
|
118
|
+
// is the open theme only when one is open; with none, it is the default the
|
|
119
|
+
// package ships, and calling that "the open theme" named a file nobody had
|
|
120
|
+
// opened.
|
|
121
|
+
savedSource: savedColorsAndTypeSource(active),
|
|
122
|
+
// The base color file's `name` used to pick a theme file name. This verb
|
|
123
|
+
// writes no file of its own now, so it names nothing; say so rather than
|
|
124
|
+
// drop it in silence.
|
|
125
|
+
ignoredName: input.name === undefined ? null : String(input.name),
|
|
126
|
+
dryRun,
|
|
127
|
+
wrote,
|
|
128
|
+
report,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function formatSetColorsResult(result) {
|
|
133
|
+
const root = process.cwd();
|
|
134
|
+
const lines = [];
|
|
135
|
+
|
|
136
|
+
const from =
|
|
137
|
+
result.source === 'theme' && result.openTheme
|
|
138
|
+
? `theme "${result.openTheme}"`
|
|
139
|
+
: SOURCE_LABELS[result.source];
|
|
140
|
+
const verb = result.dryRun ? 'Would replace' : 'Replaced';
|
|
141
|
+
lines.push(`${verb} the color identity, carrying everything else forward from ${from}.`);
|
|
142
|
+
if (result.ignoredName !== null) {
|
|
143
|
+
lines.push(
|
|
144
|
+
`Ignored "name": "${result.ignoredName}". The base color file no longer names a theme; ` +
|
|
145
|
+
`name it when you run save-theme.`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
lines.push(`\nContrast report (${result.report.scheme} scheme):`);
|
|
150
|
+
const width = Math.max(...result.report.checks.map((c) => c.textVar.length));
|
|
151
|
+
for (const c of result.report.checks) {
|
|
152
|
+
const mark = c.pass ? '✓' : '✗';
|
|
153
|
+
const fixed = c.corrected ? ' (auto-corrected)' : '';
|
|
154
|
+
lines.push(
|
|
155
|
+
` ${mark} ${c.textVar.padEnd(width)} ${c.ratio.toFixed(2).padStart(6)}:1 vs ${c.against} floor ${c.floor}:1${fixed}`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
if (result.report.failures.length > 0) {
|
|
159
|
+
lines.push(`\nUnmet floors — adjust the base colors and re-run:`);
|
|
160
|
+
for (const f of result.report.failures) lines.push(` ! ${f}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
lines.push(
|
|
164
|
+
result.report.gradients === 'carried'
|
|
165
|
+
? '\nGradients: kept your tuned swatch gradients.'
|
|
166
|
+
: '\nGradients: swatch tokens rebuilt from the theme families.',
|
|
167
|
+
);
|
|
168
|
+
lines.push(`Shadows: ${result.report.shadows}; carried geometry kept.`);
|
|
169
|
+
if (result.report.canvasGradient) {
|
|
170
|
+
lines.push(`Canvas sky: ${result.report.canvasGradient}.`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (result.wrote === 'buffer') {
|
|
174
|
+
lines.push(
|
|
175
|
+
`\nReload the app to see it. This is an unsaved edit: save the open theme in the ` +
|
|
176
|
+
`editor's Theme panel to keep it, or run save-theme to write a new one.`,
|
|
177
|
+
);
|
|
178
|
+
} else if (result.wrote === 'cleared') {
|
|
179
|
+
const held =
|
|
180
|
+
result.savedSource === 'theme' ? `theme "${result.openTheme}"` : 'the package default';
|
|
181
|
+
lines.push(
|
|
182
|
+
result.source === 'working'
|
|
183
|
+
? `\nThat is what ${held} already holds, so the unsaved buffer was discarded. ` +
|
|
184
|
+
`Reload the app to see it.`
|
|
185
|
+
: `\nThat is what ${held} already holds, and there was no unsaved buffer, so nothing was written.`,
|
|
186
|
+
);
|
|
187
|
+
} else if (result.dryRun) {
|
|
188
|
+
lines.push(`\nDry run: nothing written under ${relative(root, result.colorsAndTypeDir)}.`);
|
|
189
|
+
}
|
|
190
|
+
return lines.join('\n');
|
|
191
|
+
}
|
|
@@ -1,19 +1,20 @@
|
|
|
1
|
-
// `live-tokens
|
|
1
|
+
// `live-tokens set-geometry` worker.
|
|
2
2
|
//
|
|
3
3
|
// Reads an ops file (JSON), applies it to every component's LIVE config via the
|
|
4
|
-
// compiled engine (dist-plugin/
|
|
4
|
+
// compiled engine (dist-plugin/setGeometry — the CLI never imports TS sources), and
|
|
5
5
|
// writes the result into that component's `_working.json` buffer: the same slot
|
|
6
6
|
// the editor's own edits land in, so an adjustment is an unsaved edit the user
|
|
7
7
|
// saves into a theme when they want to keep it. `default.json`, named preset
|
|
8
8
|
// files, themes, colors and type, and tokens.css are never touched.
|
|
9
9
|
|
|
10
|
-
import { existsSync, readFileSync,
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
11
11
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
|
|
14
|
+
import { componentNames, readActiveTheme, readLiveComponentConfigs } from './lib/liveState.mjs';
|
|
15
|
+
|
|
14
16
|
const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
15
|
-
const ENGINE = resolve(pkgRoot, 'dist-plugin/
|
|
16
|
-
const packageThemesDir = join(pkgRoot, 'src/live-tokens/data/themes');
|
|
17
|
+
const ENGINE = resolve(pkgRoot, 'dist-plugin/setGeometry/index.js');
|
|
17
18
|
|
|
18
19
|
const SOURCE_LABELS = {
|
|
19
20
|
working: 'your unsaved edits',
|
|
@@ -30,7 +31,7 @@ const SKIP_LABELS = [
|
|
|
30
31
|
async function loadEngine() {
|
|
31
32
|
if (!existsSync(ENGINE)) {
|
|
32
33
|
throw new Error(
|
|
33
|
-
`
|
|
34
|
+
`setGeometry engine not found at ${relative(process.cwd(), ENGINE)}. ` +
|
|
34
35
|
`Build the plugin first (npm run build:plugin).`,
|
|
35
36
|
);
|
|
36
37
|
}
|
|
@@ -41,10 +42,6 @@ function readJson(path) {
|
|
|
41
42
|
return JSON.parse(readFileSync(path, 'utf8'));
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
function readJsonIfExists(path) {
|
|
45
|
-
return existsSync(path) ? readJson(path) : null;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
45
|
/** Successive ops can touch the same alias (soften, then pill the buttons);
|
|
49
46
|
* the report shows one entry per alias, first `from` to last `to`. */
|
|
50
47
|
function collapseChanges(changes) {
|
|
@@ -57,49 +54,20 @@ function collapseChanges(changes) {
|
|
|
57
54
|
return [...byVariable.values()].filter((c) => c.from !== c.to);
|
|
58
55
|
}
|
|
59
56
|
|
|
60
|
-
/**
|
|
61
|
-
*
|
|
62
|
-
function
|
|
63
|
-
|
|
64
|
-
const slug = readJsonIfExists(join(themesDir, '_active.json'))?.activeFile ?? 'default';
|
|
65
|
-
const theme =
|
|
66
|
-
readJsonIfExists(join(themesDir, `${slug}.json`)) ??
|
|
67
|
-
readJsonIfExists(join(packageThemesDir, `${slug}.json`));
|
|
68
|
-
return theme ? { slug, theme } : null;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** Each component's live config, and whether a buffer or the open document
|
|
72
|
-
* answered. The document's own copy falls back to the shipped default for a
|
|
73
|
-
* component it somehow lacks, which reports as the document either way.
|
|
74
|
-
* Mirrors the dev server's `resolveLiveComponentConfig`, so the CLI adjusts
|
|
75
|
-
* what the page runs. */
|
|
76
|
-
function readLiveConfigs(dir, active) {
|
|
57
|
+
/** A config document names its own component, and a theme-embedded copy can
|
|
58
|
+
* omit it, so stamp it back on before the buffer is written from it. */
|
|
59
|
+
function stampedLiveConfigs(dir, active) {
|
|
60
|
+
const { configs: live, sources } = readLiveComponentConfigs(dir, active);
|
|
77
61
|
const configs = {};
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const comp = entry.name;
|
|
82
|
-
const componentDir = join(dir, comp);
|
|
83
|
-
const working = readJsonIfExists(join(componentDir, '_working.json'));
|
|
84
|
-
// Every theme carries every component by value, but this CLI reads the
|
|
85
|
-
// file straight off disk, ahead of any server-side fill — a theme
|
|
86
|
-
// written before this component existed can still omit it, so a missing
|
|
87
|
-
// entry falls back to the shipped default the same way the theme's own
|
|
88
|
-
// fill would.
|
|
89
|
-
const embedded =
|
|
90
|
-
active?.theme?.componentConfigs?.[comp] ?? readJsonIfExists(join(componentDir, 'default.json'));
|
|
91
|
-
const config = working ?? embedded;
|
|
92
|
-
if (!config) {
|
|
93
|
-
throw new Error(`component "${comp}": default.json is missing`);
|
|
94
|
-
}
|
|
95
|
-
configs[comp] = { ...config, component: comp };
|
|
96
|
-
sources[comp] = working ? 'working' : 'theme';
|
|
62
|
+
for (const comp of componentNames(dir)) {
|
|
63
|
+
if (!live[comp]) throw new Error(`component "${comp}": default.json is missing`);
|
|
64
|
+
configs[comp] = { ...live[comp], component: comp };
|
|
97
65
|
}
|
|
98
66
|
return { configs, sources };
|
|
99
67
|
}
|
|
100
68
|
|
|
101
69
|
/** `engine` is a test seam; the CLI always runs the compiled bundle. */
|
|
102
|
-
export async function
|
|
70
|
+
export async function runSetGeometry({
|
|
103
71
|
opsPath,
|
|
104
72
|
dryRun = false,
|
|
105
73
|
root = process.cwd(),
|
|
@@ -132,7 +100,7 @@ export async function runAdjust({
|
|
|
132
100
|
}
|
|
133
101
|
|
|
134
102
|
const active = readActiveTheme(themesDir ?? resolved?.themesDir);
|
|
135
|
-
const { configs, sources } =
|
|
103
|
+
const { configs, sources } = stampedLiveConfigs(dir, active);
|
|
136
104
|
const now = new Date().toISOString();
|
|
137
105
|
const { configs: next, report } = adjustAliases(configs, ops, now);
|
|
138
106
|
|
|
@@ -175,7 +143,7 @@ export async function runAdjust({
|
|
|
175
143
|
};
|
|
176
144
|
}
|
|
177
145
|
|
|
178
|
-
export function
|
|
146
|
+
export function formatSetGeometryResult(result) {
|
|
179
147
|
const root = process.cwd();
|
|
180
148
|
const lines = [];
|
|
181
149
|
const { components: changedCount, aliases, skips } = result.totals;
|
|
@@ -192,7 +160,7 @@ export function formatAdjustResult(result) {
|
|
|
192
160
|
}
|
|
193
161
|
if (result.ignoredName !== null) {
|
|
194
162
|
lines.push(
|
|
195
|
-
`Ignored "name": "${result.ignoredName}".
|
|
163
|
+
`Ignored "name": "${result.ignoredName}". set-geometry edits the open buffer, ` +
|
|
196
164
|
`so it writes no file of its own.`,
|
|
197
165
|
);
|
|
198
166
|
}
|