@g9tm/app-pilot-cli 0.0.0-stage → 0.1.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/LICENSE +16 -0
- package/bin/g9pilot.mjs +1594 -0
- package/package.json +26 -4
- package/src/design.mjs +346 -0
- package/src/visual.mjs +401 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@g9tm/app-pilot-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "g9pilot — the G9AppPilot controller CLI: signed calls, the signing proxy, the MCP server, the studio and its relay, design tokens, visual references, savepoints, conformance, and control-session summaries to a hub.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"g9pilot": "bin/g9pilot.mjs"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
"./design": "./src/design.mjs",
|
|
11
|
+
"./visual": "./src/visual.mjs",
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"bin",
|
|
16
|
+
"src",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@g9tm/app-pilot-client": "^0.1.0",
|
|
21
|
+
"@g9tm/app-pilot-mcp": "^0.1.0",
|
|
22
|
+
"@g9tm/app-pilot-tokens": "^0.1.0"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=22.18"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/design.mjs
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// g9pilot's design half: the token files (DTCG) ⇄ the app's generated module, an approval written back into the files,
|
|
2
|
+
// and the local studio — the device's screen next to its tokens, changed live, approved into the files.
|
|
3
|
+
//
|
|
4
|
+
// g9pilot tokens gen every set of g9pilot.json "tokens" → its generated module
|
|
5
|
+
// g9pilot tokens approve [--snapshot <file>] [--dry-run]
|
|
6
|
+
// the device's design.snapshot (or a saved one) → the token files, then gen.
|
|
7
|
+
// Writes files only: a commit stays the person's (or their agent's, when asked)
|
|
8
|
+
// g9pilot studio [--port 47901] http://127.0.0.1:47901 — screen, tokens, live edits, reset, approve
|
|
9
|
+
//
|
|
10
|
+
// g9pilot.json "tokens": { "root": "<dir the paths are relative to>", "sets": [{ "name": "mapChrome",
|
|
11
|
+
// "namespace": "map.chrome", "file": "tokens/map-chrome.tokens.json", "out": "app/src/ui/tokens/mapChrome.generated.ts" }] }
|
|
12
|
+
import { spawnSync } from 'node:child_process';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import http from 'node:http';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { applyValues, generateModule, spliceValues } from '@g9tm/app-pilot-tokens';
|
|
17
|
+
|
|
18
|
+
/** The configured token sets, with absolute paths. */
|
|
19
|
+
export function tokenSets(config, configDir) {
|
|
20
|
+
const t = config.tokens;
|
|
21
|
+
if (!t || !Array.isArray(t.sets) || t.sets.length === 0) throw new Error('g9pilot.json has no "tokens": { "root", "sets": [{ name, namespace, file, out }] }.');
|
|
22
|
+
const root = path.resolve(configDir, t.root ?? '.');
|
|
23
|
+
return t.sets.map((s) => {
|
|
24
|
+
for (const k of ['name', 'namespace', 'file', 'out']) if (typeof s[k] !== 'string' || !s[k]) throw new Error(`tokens.sets: a set without "${k}".`);
|
|
25
|
+
return { ...s, root, abs: path.join(root, s.file), outAbs: path.join(root, s.out) };
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
30
|
+
|
|
31
|
+
/** Every set's generated module; returns the files written (unchanged ones are not rewritten). */
|
|
32
|
+
export function tokensGen(sets) {
|
|
33
|
+
const written = [];
|
|
34
|
+
for (const set of sets) {
|
|
35
|
+
const ts = generateModule([{ name: set.name, namespace: set.namespace, file: set.file, json: readJson(set.abs) }]);
|
|
36
|
+
const previous = fs.existsSync(set.outAbs) ? fs.readFileSync(set.outAbs, 'utf8') : null;
|
|
37
|
+
if (previous === ts) continue;
|
|
38
|
+
fs.mkdirSync(path.dirname(set.outAbs), { recursive: true });
|
|
39
|
+
fs.writeFileSync(set.outAbs, ts);
|
|
40
|
+
written.push(path.relative(set.root, set.outAbs).replaceAll('\\', '/'));
|
|
41
|
+
}
|
|
42
|
+
return written;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A design snapshot ({changes: [{id, to, source: {file, path}}]}) written into the token files: each change into ITS
|
|
47
|
+
* file at ITS path, then the modules regenerated. A change whose file is not one of the configured sets is refused (the
|
|
48
|
+
* studio never writes outside them). Returns {files, changed, generated}.
|
|
49
|
+
*/
|
|
50
|
+
export function tokensApprove(sets, snapshot, { dryRun = false, after = [], check = null } = {}) {
|
|
51
|
+
const byFile = new Map();
|
|
52
|
+
// A token with no file (a library's theme colour) can be tried live but has nowhere to be written: listed, not fatal.
|
|
53
|
+
const unwritten = [];
|
|
54
|
+
for (const c of snapshot.changes ?? []) {
|
|
55
|
+
const file = c.source?.file;
|
|
56
|
+
if (!file) {
|
|
57
|
+
unwritten.push({ id: c.id, to: c.to, why: 'no token file: it belongs to a library (change it there)' });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const set = sets.find((s) => s.file === file);
|
|
61
|
+
if (!set) throw new Error(`${c.id}: its file ${file ?? '(none)'} is not one of g9pilot.json "tokens.sets" — not written.`);
|
|
62
|
+
if (!byFile.has(set.abs)) byFile.set(set.abs, { set, values: {} });
|
|
63
|
+
byFile.get(set.abs).values[c.source.path] = c.to;
|
|
64
|
+
}
|
|
65
|
+
const files = [];
|
|
66
|
+
const changed = [];
|
|
67
|
+
for (const [abs, { set, values }] of byFile) {
|
|
68
|
+
const text = fs.readFileSync(abs, 'utf8');
|
|
69
|
+
const { json, changed: here } = applyValues(JSON.parse(text), values);
|
|
70
|
+
if (here.length === 0) continue;
|
|
71
|
+
// Only the changed values, in the file's own layout (a small, definite change — plan §7.6).
|
|
72
|
+
if (!dryRun) fs.writeFileSync(abs, spliceValues(text, json, here.map((c) => c.id)));
|
|
73
|
+
files.push(set.file);
|
|
74
|
+
changed.push(...here);
|
|
75
|
+
}
|
|
76
|
+
const generated = dryRun || files.length === 0 ? [] : tokensGen(sets);
|
|
77
|
+
// After a real write (plan §7.6 steps 3 and 6, plan v2 P1-18): the project's own checks (g9pilot.json
|
|
78
|
+
// `tokens.afterApprove`: typecheck, unit tests — each [cmd, …args] from the token root), and `check()` — the reference
|
|
79
|
+
// images to look at again (`tokens.references`); a changed picture is LISTED for a person (`g9pilot visual approve`),
|
|
80
|
+
// never approved here. A failed check is reported, the files stay written: the person decides.
|
|
81
|
+
const ran = [];
|
|
82
|
+
if (!dryRun && files.length) {
|
|
83
|
+
for (const cmd of after) {
|
|
84
|
+
const [file, ...args] = cmd;
|
|
85
|
+
const t0 = Date.now();
|
|
86
|
+
const r = spawnSync(file === 'node' ? process.execPath : file, args, { cwd: sets[0]?.root, encoding: 'utf8', shell: process.platform === 'win32' && !/\.(exe|cmd)$/i.test(file) && file !== 'node', timeout: 15 * 60_000, maxBuffer: 64 * 1024 * 1024 });
|
|
87
|
+
ran.push({ cmd: cmd.join(' '), ok: r.status === 0, ms: Date.now() - t0, ...(r.status === 0 ? {} : { tail: `(${r.stdout ?? ''}${r.stderr ?? ''})`.trim().split(/\r?\n/).slice(-6).join('\n') }) });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const references = !dryRun && files.length && check ? check() : null;
|
|
91
|
+
return { files, changed, generated, dryRun, ...(unwritten.length ? { unwritten } : {}), ...(ran.length ? { checks: ran } : {}), ...(references ? { references } : {}) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---- the local studio ---------------------------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The studio's server. `call(command, args)` reaches the device (signed by the caller); `approve(snapshot)` writes the
|
|
98
|
+
* files. Loopback only. Returns the http.Server.
|
|
99
|
+
*/
|
|
100
|
+
/**
|
|
101
|
+
* «This raw edit equals token X» (plan v2 P1-17): each raw style edit made through the CDP (`design.rawEdits`) is read
|
|
102
|
+
* as `property: value` pairs; a px / plain number equal to a dimension / number token, or a colour equal to a colour
|
|
103
|
+
* token, is a suggestion to express the edit as that token instead. Pure; returns [{style, property, value, token}].
|
|
104
|
+
*/
|
|
105
|
+
export function suggestTokens(tokens, edits) {
|
|
106
|
+
const norm = (c) => {
|
|
107
|
+
const m = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(String(c).trim());
|
|
108
|
+
if (!m) return null;
|
|
109
|
+
const h = m[1].length === 3 ? m[1].split('').map((x) => x + x).join('') : m[1];
|
|
110
|
+
return `#${h.toLowerCase()}`;
|
|
111
|
+
};
|
|
112
|
+
const out = [];
|
|
113
|
+
for (const e of edits ?? []) {
|
|
114
|
+
for (const part of String(e.style ?? '').split(';')) {
|
|
115
|
+
const i = part.indexOf(':');
|
|
116
|
+
if (i < 0) continue;
|
|
117
|
+
const property = part.slice(0, i).trim();
|
|
118
|
+
const raw = part.slice(i + 1).trim();
|
|
119
|
+
const num = /^(-?\d+(?:\.\d+)?)(px|rpx)?$/.exec(raw);
|
|
120
|
+
const color = norm(raw);
|
|
121
|
+
for (const t of tokens ?? []) {
|
|
122
|
+
const numeric = typeof t.value === 'number';
|
|
123
|
+
const hit = num && numeric ? Math.abs(Number(num[1]) - t.value) < 0.01 : color && typeof t.value === 'string' ? norm(t.value) === color : false;
|
|
124
|
+
if (hit) out.push({ style: e.style, property, value: raw, token: t.id });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The studio server. One device (`call`) or several (`devices: [{name, call}]`: a token change goes to all of them, each
|
|
133
|
+
* shot names its device); `modes` from g9pilot.json `studio.modes` — [{name, commands: [{command, args}]}], each a state a
|
|
134
|
+
* shot is taken in (light / dark × Persian / English through the app's own commands); `reference(device, name)` →
|
|
135
|
+
* a `g9pilot visual` check of that device against a reference ({verdict, ratio, diff: base64 PNG}) for the overlay.
|
|
136
|
+
*/
|
|
137
|
+
export function studioServer({ port = 47901, call, devices = null, modes = [], reference = null, approve, title = 'G9AppPilot Studio', lang = 'en' }) {
|
|
138
|
+
const devs = devices?.length ? devices : [{ name: 'device', call }];
|
|
139
|
+
const deviceOf = (u) => devs.find((d) => d.name === u.searchParams.get('device')) ?? devs[0];
|
|
140
|
+
const all = async (command, args) => {
|
|
141
|
+
const answers = await Promise.all(devs.map(async (d) => ({ device: d.name, answer: await d.call(command, args).catch((e) => ({ ok: false, error: e.message })) })));
|
|
142
|
+
const failed = answers.find((x) => !x.answer?.ok);
|
|
143
|
+
return failed ? { ok: false, error: `${failed.device}: ${failed.answer?.error?.message ?? failed.answer?.error ?? 'no answer'}`, devices: answers } : { ok: true, result: answers[0].answer.result, devices: answers.map((x) => x.device) };
|
|
144
|
+
};
|
|
145
|
+
const send = (res, status, type, body) => {
|
|
146
|
+
res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store' });
|
|
147
|
+
res.end(body);
|
|
148
|
+
};
|
|
149
|
+
const json = (res, status, value) => send(res, status, 'application/json; charset=utf-8', JSON.stringify(value));
|
|
150
|
+
const body = async (req) => {
|
|
151
|
+
const chunks = [];
|
|
152
|
+
for await (const c of req) chunks.push(c);
|
|
153
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
154
|
+
return text ? JSON.parse(text) : {};
|
|
155
|
+
};
|
|
156
|
+
const server = http.createServer(async (req, res) => {
|
|
157
|
+
try {
|
|
158
|
+
const u = new URL(req.url ?? '/', 'http://localhost');
|
|
159
|
+
if (req.method === 'GET' && u.pathname === '/') return send(res, 200, 'text/html; charset=utf-8', studioHtml(title, { lang: u.searchParams.get('lang') ?? lang }));
|
|
160
|
+
if (req.method === 'GET' && u.pathname === '/studio.js') return send(res, 200, 'text/javascript; charset=utf-8', studioScript());
|
|
161
|
+
if (req.method === 'GET' && u.pathname === '/api/devices') return json(res, 200, { ok: true, devices: devs.map((d) => d.name), modes: modes.map((m) => m.name), reference: !!reference });
|
|
162
|
+
if (req.method === 'GET' && u.pathname === '/api/tokens') return json(res, 200, await devs[0].call('design.tokens', {}));
|
|
163
|
+
if (req.method === 'GET' && u.pathname === '/api/snapshot') return json(res, 200, await devs[0].call('design.snapshot', {}));
|
|
164
|
+
if (req.method === 'GET' && u.pathname === '/api/shot') {
|
|
165
|
+
const d = deviceOf(u);
|
|
166
|
+
// A mode first (its commands, then the app settles): the grid's light / dark × fa / en.
|
|
167
|
+
const mode = modes[Number(u.searchParams.get('mode') ?? -1)];
|
|
168
|
+
if (mode) {
|
|
169
|
+
for (const c of mode.commands ?? []) {
|
|
170
|
+
const r = await d.call(c.command, c.args ?? {});
|
|
171
|
+
if (!r?.ok) return json(res, 502, { ok: false, error: `${mode.name}: ${c.command}: ${r?.error?.message ?? r?.error ?? 'no answer'}` });
|
|
172
|
+
}
|
|
173
|
+
await d.call('ui.waitIdle', {}).catch(() => null);
|
|
174
|
+
}
|
|
175
|
+
const a = await d.call('ui.screenshot', { maxLongEdgePx: Number(u.searchParams.get('w') ?? 480) * 2, maxWidth: Number(u.searchParams.get('w') ?? 480) });
|
|
176
|
+
if (!a.ok) return json(res, 502, a);
|
|
177
|
+
return send(res, 200, 'image/jpeg', Buffer.from(a.result.base64, 'base64'));
|
|
178
|
+
}
|
|
179
|
+
// The ruler: the drawn elements with their boxes (DevTool's in-app CDP, screen.model) and the screen's size.
|
|
180
|
+
if (req.method === 'GET' && u.pathname === '/api/model') return json(res, 200, await deviceOf(u).call('screen.model', { maxNodes: 2500 }));
|
|
181
|
+
if (req.method === 'GET' && u.pathname === '/api/diff') {
|
|
182
|
+
if (!reference) return json(res, 404, { ok: false, error: 'No references here (g9pilot.json "visual").' });
|
|
183
|
+
return json(res, 200, { ok: true, result: await reference(deviceOf(u).name, String(u.searchParams.get('name') ?? '')) });
|
|
184
|
+
}
|
|
185
|
+
if (req.method === 'GET' && u.pathname === '/api/suggest') {
|
|
186
|
+
const [t, r] = await Promise.all([devs[0].call('design.tokens', {}), devs[0].call('design.rawEdits', {})]);
|
|
187
|
+
if (!t?.ok) return json(res, 200, t);
|
|
188
|
+
return json(res, 200, { ok: true, result: { suggestions: r?.ok ? suggestTokens(t.result.tokens, r.result.edits) : [], rawEdits: r?.ok ? r.result.edits.length : null } });
|
|
189
|
+
}
|
|
190
|
+
if (req.method === 'POST' && u.pathname === '/api/set') return json(res, 200, await all('design.set', await body(req)));
|
|
191
|
+
if (req.method === 'POST' && u.pathname === '/api/reset') return json(res, 200, await all('design.reset', await body(req)));
|
|
192
|
+
if (req.method === 'POST' && u.pathname === '/api/approve') {
|
|
193
|
+
const snap = await devs[0].call('design.snapshot', {});
|
|
194
|
+
if (!snap.ok) return json(res, 502, snap);
|
|
195
|
+
const { dryRun } = await body(req);
|
|
196
|
+
return json(res, 200, { ok: true, result: approve(snap.result, { dryRun: dryRun === true }) });
|
|
197
|
+
}
|
|
198
|
+
return json(res, 404, { ok: false, error: 'not found' });
|
|
199
|
+
} catch (e) {
|
|
200
|
+
return json(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
server.listen(port, '127.0.0.1');
|
|
204
|
+
return server;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* One static page (no external script): the screen, the token tree with inputs, reset, approve. The SAME page runs in
|
|
209
|
+
* both places (plan §7.4: one UI, two transports): `mode: 'local'` behind `g9pilot studio` (approve writes the token
|
|
210
|
+
* files), `mode: 'hub'` in a hub's studio page (served at /studio/; approve stores a design approval, nothing is
|
|
211
|
+
* written). Its requests are relative and carry the page's own query (`?relay=…&device=…` on a hub), so the same
|
|
212
|
+
* script reaches either server. `lang: 'fa'` draws it in Persian, right to left.
|
|
213
|
+
*/
|
|
214
|
+
export function studioHtml(title, { mode = 'local', lang = 'en', scriptUrl = 'studio.js', headers = {} } = {}) {
|
|
215
|
+
const fa = lang === 'fa';
|
|
216
|
+
const T = fa
|
|
217
|
+
? {
|
|
218
|
+
reset: 'برگرداندن همه', dry: 'پیشنمایش تأیید', approve: mode === 'hub' ? 'تأیید (یک رکورد تأیید طراحی)' : 'تأیید در فایلهای توکن',
|
|
219
|
+
again: mode === 'hub' ? 'دوباره بزن تا ثبت شود' : 'دوباره بزن تا نوشته شود (بیکامیت)', changed: 'تغییرکرده', screenIn: 'صفحه در', ms: 'میلیثانیه',
|
|
220
|
+
noScreen: 'صفحه نیامد: ', refused: 'رد شد: ', set: 'گذاشته شد', back: 'برگشت به ', approvalRefused: 'تأیید رد شد: ',
|
|
221
|
+
wouldWrite: 'نوشته میشود: ', wrote: 'نوشته شد: ', regenerated: 'دوباره ساخته شد: ', stored: 'تأیید طراحی ثبت شد: ', apply: 'برای نوشتن در کد، روی سیستم توسعه: ',
|
|
222
|
+
wouldStore: 'اینها ثبت میشوند:', nothing: 'چیزی تغییر نکرده', screen: 'صفحهٔ دستگاه',
|
|
223
|
+
grid: 'همهٔ دستگاهها و حالتها', save: 'نگهداشتن بهعنوان', compare: 'مقایسهٔ A/B/C', ruler: 'خطکش', reference: 'نام عکس مرجع',
|
|
224
|
+
overlay: 'روکش اختلاف', suggest: 'پیشنهاد توکن', pick: 'دو المان را روی صفحه بزن', gap: 'فاصله', saved: 'نگه داشته شد: ',
|
|
225
|
+
noVariant: 'اول دستکم یک گزینه (A، B یا C) را نگه دار', equals: ' برابر توکن ', noSuggest: 'ویرایش خامی برابر یک توکن نیست', rawEdits: ' ویرایش خام',
|
|
226
|
+
diffRatio: 'اختلاف: ', current: 'حالت فعلی',
|
|
227
|
+
}
|
|
228
|
+
: {
|
|
229
|
+
reset: 'Reset all', dry: 'Preview approval', approve: mode === 'hub' ? 'Approve (a design approval record)' : 'Approve into the token files',
|
|
230
|
+
again: mode === 'hub' ? 'Click again to store it' : 'Click again to write (no commit)', changed: 'changed', screenIn: 'screen in', ms: 'ms',
|
|
231
|
+
noScreen: 'no screen: ', refused: 'refused: ', set: 'set', back: 'Back to ', approvalRefused: 'approval refused: ',
|
|
232
|
+
wouldWrite: 'would write ', wrote: 'wrote ', regenerated: 'regenerated ', stored: 'design approval stored: ', apply: 'to write it into the code, on a development machine: ',
|
|
233
|
+
wouldStore: 'these would be stored:', nothing: 'nothing changed', screen: 'The device screen',
|
|
234
|
+
grid: 'Every device and mode', save: 'Keep as', compare: 'Compare A/B/C', ruler: 'Ruler', reference: 'Reference name',
|
|
235
|
+
overlay: 'Difference overlay', suggest: 'Token suggestions', pick: 'tap two elements on the screen', gap: 'gap', saved: 'kept: ',
|
|
236
|
+
noVariant: 'keep at least one option (A, B or C) first', equals: ' equals token ', noSuggest: 'no raw edit equals a token', rawEdits: ' raw edit(s)',
|
|
237
|
+
diffRatio: 'difference: ', current: 'current',
|
|
238
|
+
};
|
|
239
|
+
return `<!doctype html>
|
|
240
|
+
<html lang="${fa ? 'fa' : 'en'}" dir="${fa ? 'rtl' : 'ltr'}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
241
|
+
<title>${title}</title>
|
|
242
|
+
<style>
|
|
243
|
+
:root{--bg:#f6f7f5;--fg:#1d2b22;--muted:#5d6b62;--line:#d9dfd9;--card:#fff;--accent:#2e7d32;--warn:#b26a00}
|
|
244
|
+
@media (prefers-color-scheme:dark){:root{--bg:#141815;--fg:#e4ebe5;--muted:#9aa89e;--line:#2c352f;--card:#1c221e;--accent:#66bb6a;--warn:#ffb74d}}
|
|
245
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.${fa ? '9' : '5'} ${fa ? 'Vazirmatn,' : ''}system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
246
|
+
header{display:flex;align-items:center;gap:12px;padding:12px 16px;border-bottom:1px solid var(--line);flex-wrap:wrap}
|
|
247
|
+
header h1{font-size:16px;margin:0}header .sp{flex:1}button{font:inherit;border:1px solid var(--line);background:var(--card);color:var(--fg);border-radius:8px;padding:6px 12px;cursor:pointer}
|
|
248
|
+
button.primary{background:var(--accent);border-color:var(--accent);color:#fff}main{display:grid;grid-template-columns:minmax(260px,420px) 1fr;gap:16px;padding:16px}
|
|
249
|
+
@media (max-width:760px){main{grid-template-columns:1fr}}
|
|
250
|
+
.screen{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:8px;text-align:center}.screen img{max-width:100%;border-radius:8px}
|
|
251
|
+
.screen .meta{color:var(--muted);font-size:12px;margin-top:6px}
|
|
252
|
+
.tokens{background:var(--card);border:1px solid var(--line);border-radius:12px;overflow:hidden}
|
|
253
|
+
.group{padding:8px 12px;font-weight:600;border-top:1px solid var(--line);background:color-mix(in srgb,var(--card),var(--line) 30%);direction:ltr;text-align:start}
|
|
254
|
+
.row{display:grid;grid-template-columns:1fr 110px 70px 28px;gap:8px;align-items:center;padding:6px 12px;border-top:1px solid var(--line)}
|
|
255
|
+
.row .id{font-family:ui-monospace,Consolas,monospace;font-size:12.5px;direction:ltr;unicode-bidi:isolate}.row .desc{color:var(--muted);font-size:12px}
|
|
256
|
+
.row input{width:100%;font:inherit;padding:4px 6px;border:1px solid var(--line);border-radius:6px;background:var(--bg);color:var(--fg);direction:ltr}
|
|
257
|
+
.row.changed input{border-color:var(--warn)}.row .def{color:var(--muted);font-size:12px;text-align:end;direction:ltr}
|
|
258
|
+
#log{white-space:pre-wrap;font-family:ui-monospace,Consolas,monospace;font-size:12px;color:var(--muted);padding:8px 16px;direction:ltr;text-align:start}
|
|
259
|
+
nav#bench{display:flex;gap:8px;flex-wrap:wrap;align-items:center;padding:8px 16px;border-bottom:1px solid var(--line)}
|
|
260
|
+
nav#bench input[type=text]{font:inherit;padding:4px 6px;border:1px solid var(--line);border-radius:6px;background:var(--bg);color:var(--fg);width:150px;direction:ltr}
|
|
261
|
+
#board{display:flex;flex-direction:column;gap:12px;padding:0 16px}#board:empty{display:none}
|
|
262
|
+
.board-row{display:flex;gap:8px;overflow-x:auto;padding-bottom:4px}.board-row figure{margin:0;flex:0 0 auto;text-align:center}
|
|
263
|
+
.board-row img{width:180px;border-radius:8px;border:1px solid var(--line);background:var(--card)}.board-row figcaption{font-size:12px;color:var(--muted)}
|
|
264
|
+
.board-title{font-weight:600;font-size:13px}#frame{position:relative;display:inline-block}#frame img.ov{position:absolute;inset:0;width:100%;height:100%;mix-blend-mode:multiply;pointer-events:none}
|
|
265
|
+
#boxes{position:absolute;inset:0}#boxes div{position:absolute;border:1px solid rgba(30,136,229,.55);cursor:crosshair}#boxes div.on{border:2px solid #e53935;background:rgba(229,57,53,.12)}
|
|
266
|
+
#suggest li{direction:ltr;text-align:start;font-family:ui-monospace,Consolas,monospace;font-size:12px}
|
|
267
|
+
</style></head><body>
|
|
268
|
+
<header><h1>${title}</h1><span class="sp"></span><span id="count"></span>
|
|
269
|
+
<button id="reset">${T.reset}</button><button id="dry">${T.dry}</button><button id="approve" class="primary">${T.approve}</button></header>
|
|
270
|
+
${mode === 'hub' ? '' : `<nav id="bench"><button id="grid">${T.grid}</button><button data-keep="A">${T.save} A</button><button data-keep="B">${T.save} B</button><button data-keep="C">${T.save} C</button>
|
|
271
|
+
<button id="compare">${T.compare}</button><button id="ruler">${T.ruler}</button><input type="text" id="refName" placeholder="${T.reference}"><button id="overlay">${T.overlay}</button>
|
|
272
|
+
<input type="range" id="ovOpacity" min="0" max="100" value="60" aria-label="${T.overlay}"><button id="suggestBtn">${T.suggest}</button></nav>`}
|
|
273
|
+
<section id="board"></section>
|
|
274
|
+
<main><section class="screen"><div id="frame"><img id="shot" alt="${T.screen}"><div id="boxes"></div></div><div class="meta" id="shotMeta">…</div><div class="meta" id="rulerOut"></div></section>
|
|
275
|
+
<section class="tokens"><div id="tokens"></div><ul id="suggest"></ul></section></main><div id="log"></div>
|
|
276
|
+
<script type="application/json" id="studio-cfg">${JSON.stringify({ T, mode, headers }).replaceAll('<', '\\u003c')}</script>
|
|
277
|
+
<script src="${scriptUrl}"></script>
|
|
278
|
+
</body></html>`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The studio page's script (studioHtml loads it from `scriptUrl`): a file of its own, because a hub's pages may allow no
|
|
283
|
+
* inline script (CSP script-src 'self'); its words and mode come from the page's JSON block. The screen is shown as a
|
|
284
|
+
* data: URL (such a policy's img-src may have no blob:). The headers a hub wants on a write come from the page's JSON.
|
|
285
|
+
*/
|
|
286
|
+
export function studioScript() {
|
|
287
|
+
return `// G9AppPilot studio — generated by pilot/packages/cli/src/design.mjs studioScript(); do not edit.
|
|
288
|
+
const CFG=JSON.parse(document.getElementById('studio-cfg').textContent);const T=CFG.T;const MODE=CFG.mode;
|
|
289
|
+
const $=(s)=>document.querySelector(s);const log=(t)=>{$('#log').textContent=t+'\\n'+$('#log').textContent.slice(0,4000)};
|
|
290
|
+
// Relative, with the page's own query (a hub: ?relay=…&device=…): the same script reaches the local studio or a hub.
|
|
291
|
+
const api=(p,extra)=>{const q=new URLSearchParams(location.search);for(const [k,v] of Object.entries(extra||{}))q.set(k,v);const s=q.toString();return 'api/'+p+(s?'?'+s:'')};
|
|
292
|
+
const post=(p,b)=>fetch(api(p),{method:'POST',headers:{'content-type':'application/json',...(CFG.headers||{})},body:JSON.stringify(b||{})}).then(r=>r.json());
|
|
293
|
+
let shotTimer=null;
|
|
294
|
+
async function shot(){const t0=performance.now();const r=await fetch(api('shot',{w:480,t:Date.now()}));if(r.ok){const b=await r.blob();$('#shot').src=await new Promise((ok)=>{const f=new FileReader();f.onload=()=>ok(f.result);f.readAsDataURL(b)});$('#shotMeta').textContent=T.screenIn+' '+Math.round(performance.now()-t0)+' '+T.ms}else{$('#shotMeta').textContent=T.noScreen+(await r.text()).slice(0,200)}}
|
|
295
|
+
function later(){clearTimeout(shotTimer);shotTimer=setTimeout(shot,450)}
|
|
296
|
+
async function tokens(){const a=await (await fetch(api('tokens'))).json();if(!a.ok){$('#tokens').textContent=(a.error&&(a.error.message||a.error))||JSON.stringify(a);return}
|
|
297
|
+
const list=a.result.tokens;$('#count').textContent=list.filter(t=>t.changed).length+' '+T.changed;const host=$('#tokens');host.innerHTML='';let group='';
|
|
298
|
+
for(const t of list){const g=t.id.split('.').slice(0,-1).join('.');if(g!==group){group=g;const h=document.createElement('div');h.className='group';h.textContent=g;host.appendChild(h)}
|
|
299
|
+
const row=document.createElement('div');row.className='row'+(t.changed?' changed':'');
|
|
300
|
+
const left=document.createElement('div');left.innerHTML='<div class="id"></div><div class="desc"></div>';left.querySelector('.id').textContent=t.id.split('.').pop();left.querySelector('.desc').textContent=t.description||'';
|
|
301
|
+
const input=document.createElement('input');input.value=t.value;input.type=typeof t.defaultValue==='number'?'number':'text';if(input.type==='number')input.step='0.1';
|
|
302
|
+
input.addEventListener('change',async()=>{const v=input.type==='number'?Number(input.value):input.value;const r=await post('set',{id:t.id,value:v});log(r.ok?(T.set+' '+t.id+' = '+v):(T.refused+((r.error&&r.error.message)||r.error)));await tokens();later()});
|
|
303
|
+
const def=document.createElement('div');def.className='def';def.textContent=String(t.defaultValue);
|
|
304
|
+
const back=document.createElement('button');back.textContent='↺';back.title=T.back+t.defaultValue;back.addEventListener('click',async()=>{await post('reset',{id:t.id});await tokens();later()});
|
|
305
|
+
row.append(left,input,def,back);host.appendChild(row)}}
|
|
306
|
+
$('#reset').addEventListener('click',async()=>{await post('reset',{});await tokens();later()});
|
|
307
|
+
async function approve(dryRun){const r=await post('approve',{dryRun});
|
|
308
|
+
if(!r.ok){log(T.approvalRefused+((r.error&&r.error.message)||r.error));return}const x=r.result;
|
|
309
|
+
if(MODE==='hub'){if(dryRun){log(x.changes.length?T.wouldStore+'\\n'+x.changes.map(c=>' '+c.id+': '+c.from+' → '+c.to).join('\\n'):T.nothing);return}
|
|
310
|
+
log(T.stored+x.id+'\\n'+T.apply+'g9pilot approvals apply '+x.id+'\\n'+x.changes.map(c=>' '+c.id+': '+c.from+' → '+c.to).join('\\n'));return}
|
|
311
|
+
log((dryRun?T.wouldWrite:T.wrote)+x.files.join(', ')+'\\n'+x.changed.map(c=>' '+c.id+': '+c.from+' → '+c.to).join('\\n')+(x.generated.length?'\\n'+T.regenerated+x.generated.join(', '):''))}
|
|
312
|
+
$('#dry').addEventListener('click',()=>approve(true));let armed=false;$('#approve').addEventListener('click',()=>{if(!armed){armed=true;$('#approve').textContent=T.again;setTimeout(()=>{armed=false;$('#approve').textContent=T.approve},4000);return}armed=false;$('#approve').textContent=T.approve;approve(false)});
|
|
313
|
+
// ---- the workbench (plan v2 P1-17): every device × mode, options A/B/C side by side, a ruler, a reference overlay, suggestions
|
|
314
|
+
// (the local studio only: a hub's page has no workbench, and no listener is added for buttons that are not there)
|
|
315
|
+
const variants={};const board=$('#board');const on=(sel,ev,fn)=>{const e=$(sel);if(e)e.addEventListener(ev,fn)};
|
|
316
|
+
const fig=(src,cap)=>{const f=document.createElement('figure');const i=document.createElement('img');i.src=src;const c=document.createElement('figcaption');c.textContent=cap;f.append(i,c);return f};
|
|
317
|
+
const shotUrl=async(params)=>{const r=await fetch(api('shot',Object.assign({w:240,t:Date.now()},params)));if(!r.ok){const j=await r.json().catch(()=>({}));throw new Error(j.error||('HTTP '+r.status))}const b=await r.blob();return await new Promise((ok)=>{const f=new FileReader();f.onload=()=>ok(f.result);f.readAsDataURL(b)})};
|
|
318
|
+
const info=async()=>(await fetch(api('devices'))).json();
|
|
319
|
+
on('#grid','click',async()=>{const x=await info();board.innerHTML='';const modes=x.modes.length?x.modes.map((m,i)=>({m,i})):[{m:T.current,i:-1}];
|
|
320
|
+
await Promise.all(x.devices.map(async(d)=>{const t=document.createElement('div');t.className='board-title';t.textContent=d;const row=document.createElement('div');row.className='board-row';board.append(t,row);
|
|
321
|
+
for(const {m,i} of modes){try{row.append(fig(await shotUrl({device:d,mode:i}),m))}catch(e){log(d+' · '+m+': '+e.message)}}}));later()});
|
|
322
|
+
document.querySelectorAll('[data-keep]').forEach((b)=>b.addEventListener('click',async()=>{const a=await (await fetch(api('tokens'))).json();if(!a.ok)return;const v={};for(const t of a.result.tokens)if(t.changed)v[t.id]=t.value;variants[b.dataset.keep]=v;log(T.saved+b.dataset.keep+' '+JSON.stringify(v))}));
|
|
323
|
+
on('#compare','click',async()=>{const names=Object.keys(variants).sort();if(!names.length){log(T.noVariant);return}const x=await info();
|
|
324
|
+
const now=await (await fetch(api('tokens'))).json();const keep={};if(now.ok)for(const t of now.result.tokens)if(t.changed)keep[t.id]=t.value;board.innerHTML='';
|
|
325
|
+
for(const d of x.devices){const t=document.createElement('div');t.className='board-title';t.textContent=d;const row=document.createElement('div');row.className='board-row';board.append(t,row)}
|
|
326
|
+
const rows=[...board.querySelectorAll('.board-row')];
|
|
327
|
+
for(const n of names){await post('reset',{});if(Object.keys(variants[n]).length)await post('set',{values:variants[n]});
|
|
328
|
+
await Promise.all(x.devices.map(async(d,k)=>{try{rows[k].append(fig(await shotUrl({device:d}),n))}catch(e){log(d+' · '+n+': '+e.message)}}))}
|
|
329
|
+
await post('reset',{});if(Object.keys(keep).length)await post('set',{values:keep});await tokens();later()});
|
|
330
|
+
let rulerOn=false;let picked=[];
|
|
331
|
+
on('#ruler','click',async()=>{rulerOn=!rulerOn;const host=$('#boxes');host.innerHTML='';$('#rulerOut').textContent='';picked=[];if(!rulerOn)return;
|
|
332
|
+
const r=await (await fetch(api('model'))).json();if(!r.ok){log((r.error&&(r.error.message||r.error))||'no model');rulerOn=false;return}
|
|
333
|
+
const m=r.result;const sw=(m.screen&&m.screen.width)||Math.max(...m.nodes.map(n=>(n.box?n.box.x+n.box.width:0)));const k=$('#shot').clientWidth/sw;$('#rulerOut').textContent=T.pick;
|
|
334
|
+
for(const n of m.nodes){if(!n.box||n.box.width<2||n.box.height<2)continue;const b=document.createElement('div');b.style.left=(n.box.x*k)+'px';b.style.top=(n.box.y*k)+'px';b.style.width=(n.box.width*k)+'px';b.style.height=(n.box.height*k)+'px';b.title=(n.testId||n.tag||'')+' '+(n.text||'');
|
|
335
|
+
b.addEventListener('click',(ev)=>{ev.stopPropagation();b.classList.add('on');picked.push(n.box);if(picked.length===2){const [a,c]=picked;const dx=Math.max(c.x-(a.x+a.width),a.x-(c.x+c.width),0);const dy=Math.max(c.y-(a.y+a.height),a.y-(c.y+c.height),0);
|
|
336
|
+
$('#rulerOut').textContent=T.gap+': x '+Math.round(dx*10)/10+' · y '+Math.round(dy*10)/10+' (left '+Math.round((c.x-a.x)*10)/10+', top '+Math.round((c.y-a.y)*10)/10+')';picked=[];setTimeout(()=>host.querySelectorAll('.on').forEach(e=>e.classList.remove('on')),1500)}});host.appendChild(b)}});
|
|
337
|
+
on('#overlay','click',async()=>{const name=$('#refName').value.trim();const old=$('#frame img.ov');if(old)old.remove();if(!name)return;const r=await (await fetch(api('diff',{name}))).json();
|
|
338
|
+
if(!r.ok){log((r.error&&(r.error.message||r.error))||'no reference');return}const x=r.result;log(T.diffRatio+(x.verdict||'')+(x.ratio!=null?' '+(Math.round(x.ratio*10000)/100)+'%':''));
|
|
339
|
+
if(x.diff){const i=document.createElement('img');i.className='ov';i.src='data:image/png;base64,'+x.diff;i.style.opacity=$('#ovOpacity').value/100;$('#frame').appendChild(i)}});
|
|
340
|
+
on('#ovOpacity','input',()=>{const i=$('#frame img.ov');if(i)i.style.opacity=$('#ovOpacity').value/100});
|
|
341
|
+
on('#suggestBtn','click',async()=>{const r=await (await fetch(api('suggest'))).json();const ul=$('#suggest');ul.innerHTML='';if(!r.ok){log((r.error&&(r.error.message||r.error))||'');return}
|
|
342
|
+
const x=r.result;if(!x.suggestions.length){const li=document.createElement('li');li.textContent=T.noSuggest+(x.rawEdits!=null?' ('+x.rawEdits+T.rawEdits+')':'');ul.appendChild(li);return}
|
|
343
|
+
for(const s of x.suggestions){const li=document.createElement('li');li.textContent=s.property+': '+s.value+T.equals+s.token;ul.appendChild(li)}});
|
|
344
|
+
tokens().then(shot);
|
|
345
|
+
`;
|
|
346
|
+
}
|