@khanglvm/relay 0.12.2 → 0.13.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 +1 -0
- package/docs/AGENT.md +75 -6
- package/package.json +1 -1
- package/skills/relay/SKILL.md +180 -4
- package/src/cli.js +98 -17
- package/src/mcp-ui/board.js +213 -10
- package/src/server.js +8 -3
- package/src/spec.js +272 -47
- package/src/ui/annotate.css +75 -10
- package/src/ui/annotate.js +90 -12
- package/src/ui/app.js +232 -8
- package/src/ui/blocks.css +164 -4
- package/src/ui/blocks.js +430 -33
- package/src/ui/style.css +90 -0
package/src/spec.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { CliError } from './util.js';
|
|
4
4
|
|
|
5
|
-
export const TYPES = ['single', 'multi', 'yesno', 'text', 'textarea', 'scale', 'color'];
|
|
5
|
+
export const TYPES = ['single', 'multi', 'yesno', 'text', 'textarea', 'scale', 'color', 'rank', 'checklist', 'allocate'];
|
|
6
6
|
|
|
7
7
|
const ALIASES = {
|
|
8
8
|
radio: 'single',
|
|
@@ -21,13 +21,25 @@ const ALIASES = {
|
|
|
21
21
|
likert: 'scale',
|
|
22
22
|
colour: 'color',
|
|
23
23
|
swatch: 'color',
|
|
24
|
+
ranking: 'rank',
|
|
25
|
+
order: 'rank',
|
|
26
|
+
ordering: 'rank',
|
|
27
|
+
prioritize: 'rank',
|
|
28
|
+
sort: 'rank',
|
|
29
|
+
signoff: 'checklist',
|
|
30
|
+
'sign-off': 'checklist',
|
|
31
|
+
qa: 'checklist',
|
|
32
|
+
allocation: 'allocate',
|
|
33
|
+
budget: 'allocate',
|
|
34
|
+
distribute: 'allocate',
|
|
35
|
+
points: 'allocate',
|
|
24
36
|
};
|
|
25
37
|
|
|
26
38
|
const HTML_HEIGHT = { min: 100, max: 2400, boardDefault: 400, questionDefault: 360 };
|
|
27
39
|
|
|
28
40
|
// Block heights clamp to the same window; defaults vary per block type.
|
|
29
41
|
const BLOCK_HEIGHT = { min: 100, max: 2400 };
|
|
30
|
-
export const BLOCK_TYPES = ['markdown', 'mermaid', 'graphviz', 'plantuml', 'chart', 'table', 'code', 'diff', 'video', 'html', 'image', 'palette'];
|
|
42
|
+
export const BLOCK_TYPES = ['markdown', 'mermaid', 'graphviz', 'plantuml', 'chart', 'table', 'code', 'diff', 'video', 'html', 'image', 'palette', 'kpi', 'typography', 'compare'];
|
|
31
43
|
const CHART_KINDS = ['bar', 'line', 'pie', 'doughnut', 'radar', 'scatter'];
|
|
32
44
|
|
|
33
45
|
// code/diff blocks may load their text from a local file (like htmlFile). Caps
|
|
@@ -107,6 +119,88 @@ function readTextSource(block, inlineKey, fileKey, cwd, where) {
|
|
|
107
119
|
return '';
|
|
108
120
|
}
|
|
109
121
|
|
|
122
|
+
// Resolve an image source (shared by the `image` and `compare` blocks): an
|
|
123
|
+
// http(s)/data URL passes through; a local file is read + embedded as a data
|
|
124
|
+
// URI (capped) so the board stays self-contained offline.
|
|
125
|
+
function resolveImageSrc(srcRaw, cwd, where, field) {
|
|
126
|
+
const src = asStr(srcRaw).trim();
|
|
127
|
+
if (!src) throw new CliError(`${where}: ${field} needs a "src" (http(s)/data URL or local file path).`);
|
|
128
|
+
if (/^(https?:|data:)/i.test(src)) return src;
|
|
129
|
+
const p = path.resolve(cwd, src);
|
|
130
|
+
const ext = path.extname(p).slice(1).toLowerCase();
|
|
131
|
+
const mime = IMAGE_MIMES[ext];
|
|
132
|
+
if (!mime) throw new CliError(`${where}: ${field}: unsupported image extension ".${ext}" — use ${Object.keys(IMAGE_MIMES).join('/')}, or an http(s)/data URL.`);
|
|
133
|
+
let buf;
|
|
134
|
+
try { buf = fs.readFileSync(p); } catch { throw new CliError(`${where}: ${field}: cannot read image "${src}" (resolved: ${p})`); }
|
|
135
|
+
if (buf.length > IMAGE_MAX_BYTES) throw new CliError(`${where}: ${field}: image "${src}" is ${(buf.length / 1024 / 1024).toFixed(1)}MB — max ${IMAGE_MAX_BYTES / 1024 / 1024}MB.`);
|
|
136
|
+
return `data:${mime};base64,${buf.toString('base64')}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Minimal RFC-4180-ish CSV/TSV parser: handles quoted fields containing the
|
|
140
|
+
// delimiter, embedded newlines, and "" escaped quotes. Returns an array of rows
|
|
141
|
+
// (each an array of string cells). Library-free.
|
|
142
|
+
function parseDelimited(text, delim) {
|
|
143
|
+
const rows = [];
|
|
144
|
+
let row = [];
|
|
145
|
+
let field = '';
|
|
146
|
+
let inQ = false;
|
|
147
|
+
const pushField = () => { row.push(field); field = ''; };
|
|
148
|
+
const pushRow = () => { pushField(); rows.push(row); row = []; };
|
|
149
|
+
for (let i = 0; i < text.length; i++) {
|
|
150
|
+
const c = text[i];
|
|
151
|
+
if (inQ) {
|
|
152
|
+
if (c === '"') {
|
|
153
|
+
if (text[i + 1] === '"') { field += '"'; i++; } else inQ = false;
|
|
154
|
+
} else field += c;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (c === '"') inQ = true;
|
|
158
|
+
else if (c === delim) pushField();
|
|
159
|
+
else if (c === '\n') pushRow();
|
|
160
|
+
else if (c === '\r') { /* swallow CR (CRLF) */ }
|
|
161
|
+
else field += c;
|
|
162
|
+
}
|
|
163
|
+
if (field.length || row.length) pushRow();
|
|
164
|
+
// drop a single trailing empty row from a final newline
|
|
165
|
+
if (rows.length && rows[rows.length - 1].length === 1 && rows[rows.length - 1][0] === '') rows.pop();
|
|
166
|
+
return rows;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Load table {columns, rows} from a local .csv/.tsv/.json file (rowsFile).
|
|
170
|
+
// CSV/TSV: first row = header → column keys. JSON: an array of objects (columns
|
|
171
|
+
// from the union of keys) or arrays (needs explicit "columns").
|
|
172
|
+
function loadRowsFile(file, cwd, where) {
|
|
173
|
+
const p = path.resolve(cwd, file);
|
|
174
|
+
let buf;
|
|
175
|
+
try { buf = fs.readFileSync(p); } catch { throw new CliError(`${where}: cannot read rowsFile "${file}" (resolved: ${p})`); }
|
|
176
|
+
if (buf.length > TEXT_FILE_MAX_BYTES) {
|
|
177
|
+
throw new CliError(`${where}: rowsFile "${file}" is ${(buf.length / 1024).toFixed(0)}KB — max ${TEXT_FILE_MAX_BYTES / 1024}KB.`);
|
|
178
|
+
}
|
|
179
|
+
const text = buf.toString('utf8');
|
|
180
|
+
const ext = path.extname(p).slice(1).toLowerCase();
|
|
181
|
+
if (ext === 'json') {
|
|
182
|
+
let data;
|
|
183
|
+
try { data = JSON.parse(text); } catch (e) { throw new CliError(`${where}: rowsFile "${file}" JSON parse error: ${e.message}`); }
|
|
184
|
+
if (!Array.isArray(data)) throw new CliError(`${where}: rowsFile "${file}" JSON must be an array of rows.`);
|
|
185
|
+
const cols = [];
|
|
186
|
+
for (const r of data) {
|
|
187
|
+
if (r && typeof r === 'object' && !Array.isArray(r)) {
|
|
188
|
+
for (const k of Object.keys(r)) if (!cols.includes(k)) cols.push(k);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return { columns: cols, rows: data };
|
|
192
|
+
}
|
|
193
|
+
const parsed = parseDelimited(text, ext === 'tsv' ? '\t' : ',');
|
|
194
|
+
if (!parsed.length) return { columns: [], rows: [] };
|
|
195
|
+
const header = parsed[0].map((h) => String(h).trim());
|
|
196
|
+
const rows = parsed.slice(1).map((cells) => {
|
|
197
|
+
const obj = {};
|
|
198
|
+
header.forEach((h, i) => { obj[h] = cells[i] !== undefined ? cells[i] : ''; });
|
|
199
|
+
return obj;
|
|
200
|
+
});
|
|
201
|
+
return { columns: header, rows };
|
|
202
|
+
}
|
|
203
|
+
|
|
110
204
|
// Recognizes a YouTube / Vimeo URL (or a bare YouTube id) and returns
|
|
111
205
|
// {provider, videoId, start} for an iframe embed, else null. Cross-platform —
|
|
112
206
|
// pure string parsing, no URL host assumptions beyond the known providers.
|
|
@@ -140,8 +234,8 @@ function normalizeBlock(rawBlock, id, cwd, where) {
|
|
|
140
234
|
const hasHeight = rawBlock.height !== undefined && rawBlock.height !== null && rawBlock.height !== '';
|
|
141
235
|
|
|
142
236
|
if (type === 'markdown') {
|
|
143
|
-
const md =
|
|
144
|
-
if (!md.trim()) throw new CliError(`${where}: markdown block needs a non-empty "md" string.`);
|
|
237
|
+
const md = readTextSource(rawBlock, 'md', 'mdFile', cwd, where);
|
|
238
|
+
if (!md.trim()) throw new CliError(`${where}: markdown block needs a non-empty "md" string or a readable "mdFile".`);
|
|
145
239
|
const block = { id, type: 'markdown', md };
|
|
146
240
|
if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
|
|
147
241
|
return block;
|
|
@@ -294,13 +388,21 @@ function normalizeBlock(rawBlock, id, cwd, where) {
|
|
|
294
388
|
}
|
|
295
389
|
|
|
296
390
|
if (type === 'table') {
|
|
297
|
-
|
|
298
|
-
|
|
391
|
+
// Rows/columns may come from a local .csv/.tsv/.json file instead of inline.
|
|
392
|
+
let rawColumns = rawBlock.columns;
|
|
393
|
+
let rawRows = rawBlock.rows;
|
|
394
|
+
if (typeof rawBlock.rowsFile === 'string' && rawBlock.rowsFile.trim() && !Array.isArray(rawRows)) {
|
|
395
|
+
const loaded = loadRowsFile(rawBlock.rowsFile, cwd, where);
|
|
396
|
+
rawRows = loaded.rows;
|
|
397
|
+
if (!Array.isArray(rawColumns) || !rawColumns.length) rawColumns = loaded.columns;
|
|
398
|
+
}
|
|
399
|
+
if (!Array.isArray(rawColumns) || rawColumns.length < 1) {
|
|
400
|
+
throw new CliError(`${where}: table needs a non-empty "columns" array (strings or {key,label,align?})${rawBlock.rowsFile ? ' — the rowsFile had no header/keys to infer them' : ''}.`);
|
|
299
401
|
}
|
|
300
|
-
if (!Array.isArray(
|
|
301
|
-
throw new CliError(`${where}: table needs a "rows" array.`);
|
|
402
|
+
if (!Array.isArray(rawRows)) {
|
|
403
|
+
throw new CliError(`${where}: table needs a "rows" array or a readable "rowsFile".`);
|
|
302
404
|
}
|
|
303
|
-
const columns =
|
|
405
|
+
const columns = rawColumns.map((c, k) => {
|
|
304
406
|
if (typeof c === 'string' || typeof c === 'number') {
|
|
305
407
|
const key = String(c);
|
|
306
408
|
return { key, label: key };
|
|
@@ -316,7 +418,7 @@ function normalizeBlock(rawBlock, id, cwd, where) {
|
|
|
316
418
|
});
|
|
317
419
|
// Normalize array rows into objects keyed by column key, so the client
|
|
318
420
|
// (and table-cell annotation values) always index rows the same way.
|
|
319
|
-
const rows =
|
|
421
|
+
const rows = rawRows.map((r, ri) => {
|
|
320
422
|
if (Array.isArray(r)) {
|
|
321
423
|
const obj = {};
|
|
322
424
|
columns.forEach((col, ci) => {
|
|
@@ -329,36 +431,75 @@ function normalizeBlock(rawBlock, id, cwd, where) {
|
|
|
329
431
|
});
|
|
330
432
|
const block = { id, type: 'table', columns, rows };
|
|
331
433
|
if (rawBlock.sortable === true) block.sortable = true;
|
|
434
|
+
if (rawBlock.filterable === true) block.filterable = true;
|
|
435
|
+
if (rawBlock.exportable === true) block.exportable = true;
|
|
332
436
|
if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
|
|
333
437
|
return block;
|
|
334
438
|
}
|
|
335
439
|
|
|
336
440
|
if (type === 'image') {
|
|
337
|
-
const src = asStr(rawBlock.src ?? rawBlock.file ?? rawBlock.url).trim();
|
|
338
|
-
if (!src) throw new CliError(`${where}: image block needs a "src" (http(s)/data URL or local file path).`);
|
|
339
441
|
const block = { id, type: 'image' };
|
|
442
|
+
block.src = resolveImageSrc(rawBlock.src ?? rawBlock.file ?? rawBlock.url, cwd, where, 'image block');
|
|
340
443
|
if (rawBlock.alt !== undefined) block.alt = asStr(rawBlock.alt);
|
|
341
444
|
if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
const
|
|
349
|
-
if (!
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
block
|
|
445
|
+
return block;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (type === 'kpi') {
|
|
449
|
+
// Stat cards: a row of big-number metrics, each with an optional delta
|
|
450
|
+
// (up/down tinted) and sublabel. For "revenue ↑12%" at a glance, no chart.
|
|
451
|
+
const rawItems = Array.isArray(rawBlock.items) ? rawBlock.items : [];
|
|
452
|
+
if (!rawItems.length) throw new CliError(`${where}: kpi block needs a non-empty "items" array of {label, value, delta?, dir?, sub?}.`);
|
|
453
|
+
const items = rawItems.map((it, k) => {
|
|
454
|
+
if (it === null || typeof it !== 'object' || Array.isArray(it)) throw new CliError(`${where}.items[${k}]: must be an object {label, value, …}.`);
|
|
455
|
+
const out = { label: asStr(it.label), value: asStr(it.value) };
|
|
456
|
+
if (it.delta !== undefined && it.delta !== null && it.delta !== '') out.delta = asStr(it.delta);
|
|
457
|
+
const dir = asStr(it.dir ?? it.deltaDir).trim().toLowerCase();
|
|
458
|
+
if (dir === 'up' || dir === 'down' || dir === 'flat') out.dir = dir;
|
|
459
|
+
if (it.sub !== undefined) out.sub = asStr(it.sub);
|
|
460
|
+
return out;
|
|
461
|
+
});
|
|
462
|
+
const block = { id, type: 'kpi', items };
|
|
463
|
+
if (rawBlock.title !== undefined) block.title = asStr(rawBlock.title);
|
|
464
|
+
return block;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (type === 'typography') {
|
|
468
|
+
// Type specimens: render sample text at given size/weight/font so a designer
|
|
469
|
+
// can react to type choices the way they react to a palette.
|
|
470
|
+
const raw = Array.isArray(rawBlock.specimens) ? rawBlock.specimens : (Array.isArray(rawBlock.samples) ? rawBlock.samples : []);
|
|
471
|
+
if (!raw.length) throw new CliError(`${where}: typography block needs a non-empty "specimens" array of {label?, size?, weight?, text?}.`);
|
|
472
|
+
const specimens = raw.map((s, k) => {
|
|
473
|
+
if (s === null || typeof s !== 'object' || Array.isArray(s)) throw new CliError(`${where}.specimens[${k}]: must be an object.`);
|
|
474
|
+
const out = { text: asStr(s.text ?? s.sample) || 'The quick brown fox jumps over the lazy dog' };
|
|
475
|
+
if (s.label !== undefined) out.label = asStr(s.label);
|
|
476
|
+
if (s.size !== undefined) out.size = asStr(s.size);
|
|
477
|
+
if (s.weight !== undefined) out.weight = asStr(s.weight);
|
|
478
|
+
if (s.font !== undefined) out.font = asStr(s.font);
|
|
479
|
+
const lh = s.lineHeight ?? s.leading;
|
|
480
|
+
if (lh !== undefined) out.lineHeight = asStr(lh);
|
|
481
|
+
const ls = s.letterSpacing ?? s.tracking;
|
|
482
|
+
if (ls !== undefined) out.letterSpacing = asStr(ls);
|
|
483
|
+
return out;
|
|
484
|
+
});
|
|
485
|
+
const block = { id, type: 'typography', specimens };
|
|
486
|
+
if (rawBlock.title !== undefined) block.title = asStr(rawBlock.title);
|
|
487
|
+
if (rawBlock.font !== undefined) block.font = asStr(rawBlock.font);
|
|
488
|
+
return block;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (type === 'compare') {
|
|
492
|
+
// Before/after: two images with a draggable divider to compare a redesign.
|
|
493
|
+
const beforeRaw = rawBlock.before && typeof rawBlock.before === 'object' ? rawBlock.before.src : (rawBlock.before ?? rawBlock.beforeSrc);
|
|
494
|
+
const afterRaw = rawBlock.after && typeof rawBlock.after === 'object' ? rawBlock.after.src : (rawBlock.after ?? rawBlock.afterSrc);
|
|
495
|
+
const block = {
|
|
496
|
+
id, type: 'compare',
|
|
497
|
+
before: resolveImageSrc(beforeRaw, cwd, where, '"before"'),
|
|
498
|
+
after: resolveImageSrc(afterRaw, cwd, where, '"after"'),
|
|
499
|
+
};
|
|
500
|
+
block.beforeLabel = asStr(rawBlock.beforeLabel ?? (rawBlock.before && rawBlock.before.label) ?? 'Before') || 'Before';
|
|
501
|
+
block.afterLabel = asStr(rawBlock.afterLabel ?? (rawBlock.after && rawBlock.after.label) ?? 'After') || 'After';
|
|
502
|
+
if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
|
|
362
503
|
return block;
|
|
363
504
|
}
|
|
364
505
|
|
|
@@ -429,7 +570,15 @@ function buildBlocks(rawObj, cwd, where, prefix) {
|
|
|
429
570
|
if (rawObj.blocks !== undefined && rawObj.blocks !== null) {
|
|
430
571
|
if (!Array.isArray(rawObj.blocks)) throw new CliError(`${where}.blocks: must be an array.`);
|
|
431
572
|
rawObj.blocks.forEach((b, i) => {
|
|
432
|
-
|
|
573
|
+
const nb = normalizeBlock(b, nextId(), cwd, `${where}.blocks[${i}]`);
|
|
574
|
+
// Cross-block fields handled uniformly so every block type supports them:
|
|
575
|
+
// `ref` = a stable name a markdown reference link can open in a modal;
|
|
576
|
+
// `pins` = enable coordinate pin-comments on an image.
|
|
577
|
+
if (b && typeof b === 'object') {
|
|
578
|
+
if (typeof b.ref === 'string' && b.ref.trim()) nb.ref = b.ref.trim();
|
|
579
|
+
if (nb.type === 'image' && b.pins === true) nb.pins = true;
|
|
580
|
+
}
|
|
581
|
+
blocks.push(nb);
|
|
433
582
|
});
|
|
434
583
|
}
|
|
435
584
|
return blocks;
|
|
@@ -478,15 +627,17 @@ export function normalizeSpec(raw, { cwd = process.cwd() } = {}) {
|
|
|
478
627
|
label,
|
|
479
628
|
description: asStr(rq.description),
|
|
480
629
|
required: rq.required === true,
|
|
481
|
-
//
|
|
482
|
-
// the user can qualify their pick; other
|
|
483
|
-
// note:false turns it off
|
|
484
|
-
note: rq.note === undefined
|
|
630
|
+
// Decision types (single/rank/checklist/allocate) show the optional
|
|
631
|
+
// per-answer note by default so the user can qualify their pick; other
|
|
632
|
+
// types stay opt-in. An explicit note:false turns it off.
|
|
633
|
+
note: rq.note === undefined
|
|
634
|
+
? (type === 'single' || type === 'rank' || type === 'checklist' || type === 'allocate')
|
|
635
|
+
: rq.note === true,
|
|
485
636
|
blocks: buildBlocks(rq, cwd, where, `${id}-`),
|
|
486
637
|
placeholder: asStr(rq.placeholder),
|
|
487
638
|
};
|
|
488
639
|
|
|
489
|
-
if (type === 'single' || type === 'multi') {
|
|
640
|
+
if (type === 'single' || type === 'multi' || type === 'rank' || type === 'checklist' || type === 'allocate') {
|
|
490
641
|
const opts = Array.isArray(rq.options) ? rq.options : [];
|
|
491
642
|
q.options = opts.map((o, j) => {
|
|
492
643
|
if (typeof o === 'string' || typeof o === 'number') {
|
|
@@ -507,13 +658,46 @@ export function normalizeSpec(raw, { cwd = process.cwd() } = {}) {
|
|
|
507
658
|
}
|
|
508
659
|
throw new CliError(`${where}.options[${j}]: must be a string or {value, label, description?, blocks?}.`);
|
|
509
660
|
});
|
|
510
|
-
|
|
511
|
-
|
|
661
|
+
const minOpts = type === 'rank' ? 2 : 1;
|
|
662
|
+
if (q.options.length < minOpts) {
|
|
663
|
+
throw new CliError(`${where}: type "${type}" needs at least ${minOpts} option${minOpts > 1 ? 's' : ''}.`);
|
|
512
664
|
}
|
|
513
665
|
// Radio (single) questions include an "Other" free-text option by default so
|
|
514
666
|
// the user is never boxed into the listed choices; opt out with other:false.
|
|
515
667
|
// Multi stays opt-in (checkbox lists are usually exhaustive on purpose).
|
|
516
|
-
|
|
668
|
+
// rank/checklist/allocate operate over a fixed set, so no "Other".
|
|
669
|
+
if (type === 'single' || type === 'multi') {
|
|
670
|
+
q.other = type === 'single' ? rq.other !== false : rq.other === true;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (type === 'checklist') {
|
|
675
|
+
// Per-item status control. Default Pass / Fail / N/A; override with
|
|
676
|
+
// "statuses" (strings or {value,label,tone?}). tone colors the chip.
|
|
677
|
+
const rawSt = Array.isArray(rq.statuses) && rq.statuses.length
|
|
678
|
+
? rq.statuses
|
|
679
|
+
: [{ value: 'pass', label: 'Pass', tone: 'ok' }, { value: 'fail', label: 'Fail', tone: 'bad' }, { value: 'na', label: 'N/A', tone: 'muted' }];
|
|
680
|
+
q.statuses = rawSt.map((s, k) => {
|
|
681
|
+
if (typeof s === 'string' || typeof s === 'number') {
|
|
682
|
+
const value = String(s).trim();
|
|
683
|
+
return { value, label: value === 'na' ? 'N/A' : value.charAt(0).toUpperCase() + value.slice(1) };
|
|
684
|
+
}
|
|
685
|
+
if (s && typeof s === 'object') {
|
|
686
|
+
const value = asStr(s.value ?? s.label).trim();
|
|
687
|
+
if (!value) throw new CliError(`${where}.statuses[${k}]: needs "value" or "label".`);
|
|
688
|
+
const out = { value, label: asStr(s.label ?? s.value) || value };
|
|
689
|
+
const tone = asStr(s.tone).trim().toLowerCase();
|
|
690
|
+
if (tone) out.tone = tone;
|
|
691
|
+
return out;
|
|
692
|
+
}
|
|
693
|
+
throw new CliError(`${where}.statuses[${k}]: must be a string or {value, label, tone?}.`);
|
|
694
|
+
});
|
|
695
|
+
if (q.statuses.length < 2) throw new CliError(`${where}: checklist needs ≥2 "statuses".`);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (type === 'allocate') {
|
|
699
|
+
q.total = clampInt(rq.total, 1, 1000000, 100);
|
|
700
|
+
if (rq.unit !== undefined) q.unit = asStr(rq.unit);
|
|
517
701
|
}
|
|
518
702
|
|
|
519
703
|
if (type === 'scale') {
|
|
@@ -529,6 +713,23 @@ export function normalizeSpec(raw, { cwd = process.cwd() } = {}) {
|
|
|
529
713
|
.map((c) => asStr(c).trim())
|
|
530
714
|
.filter(Boolean);
|
|
531
715
|
if (presets.length) q.presets = presets;
|
|
716
|
+
// Richer "pick from a palette": labeled swatch cards. Each click selects
|
|
717
|
+
// that color as the answer and each card is individually commentable.
|
|
718
|
+
// Items: a color string, or {value|color, label?}. Any CSS color system.
|
|
719
|
+
if (Array.isArray(rq.palette) && rq.palette.length) {
|
|
720
|
+
q.palette = rq.palette.map((p, k) => {
|
|
721
|
+
if (typeof p === 'string' || typeof p === 'number') {
|
|
722
|
+
const value = String(p).trim();
|
|
723
|
+
return { value, label: value };
|
|
724
|
+
}
|
|
725
|
+
if (p && typeof p === 'object') {
|
|
726
|
+
const value = asStr(p.value ?? p.color).trim();
|
|
727
|
+
if (!value) throw new CliError(`${where}.palette[${k}]: needs a "value" or "color".`);
|
|
728
|
+
return { value, label: asStr(p.label ?? p.name) || value };
|
|
729
|
+
}
|
|
730
|
+
throw new CliError(`${where}.palette[${k}]: must be a color string or {value/color, label?}.`);
|
|
731
|
+
}).filter((p) => p.value);
|
|
732
|
+
}
|
|
532
733
|
}
|
|
533
734
|
|
|
534
735
|
if (rq.default !== undefined) q.default = rq.default;
|
|
@@ -568,7 +769,8 @@ const BLOCK_SCHEMA = {
|
|
|
568
769
|
required: ['type'],
|
|
569
770
|
properties: {
|
|
570
771
|
type: { type: 'string', enum: BLOCK_TYPES },
|
|
571
|
-
md: { type: 'string', description: 'markdown: built-in mini renderer (no external library) — headings, lists, code, quotes, links, and GFM pipe tables. Text selections are commentable. For real tabular data prefer a "table" block (sortable + per-cell comments).' },
|
|
772
|
+
md: { type: 'string', description: 'markdown: built-in mini renderer (no external library) — headings, lists, code, quotes, links, images, and GFM pipe tables. Text selections are commentable. For real tabular data prefer a "table" block (sortable + per-cell comments).' },
|
|
773
|
+
mdFile: { type: 'string', description: 'markdown: path to a local .md file to load + render instead of inline "md" (e.g. view a README/plan/report). Resolved against the CWD. Quick view of one or more files: `rly view a.md b.md`.' },
|
|
572
774
|
code: { type: 'string', description: 'mermaid: diagram source (e.g. "graph TD; A-->B"); plantuml: the @startuml…@enduml source; code: the source to display (syntax-highlighted with line numbers).' },
|
|
573
775
|
codeFile: { type: 'string', description: 'code: path to a local source file to load + display instead of inline "code". Resolved against the CWD; lang defaults from the file extension.' },
|
|
574
776
|
filename: { type: 'string', description: 'code/diff: optional file name/path shown as a header label above the block.' },
|
|
@@ -594,7 +796,10 @@ const BLOCK_SCHEMA = {
|
|
|
594
796
|
items: { anyOf: [{ type: 'string' }, { type: 'object' }] },
|
|
595
797
|
},
|
|
596
798
|
rows: { type: 'array', description: 'table: array of arrays (positional) or array of objects (keyed by column key).' },
|
|
799
|
+
rowsFile: { type: 'string', description: 'table: load rows from a local .csv/.tsv/.json file instead of inline "rows". CSV/TSV first row is the header (becomes "columns" if omitted); JSON is an array of objects. Resolved against the CWD. Quick view: `rly view data.csv`.' },
|
|
597
800
|
sortable: { type: 'boolean', description: 'table: enable click-to-sort headers.' },
|
|
801
|
+
filterable: { type: 'boolean', description: 'table: show a filter box that live-filters rows by substring across all cells. Good for large tables.' },
|
|
802
|
+
exportable: { type: 'boolean', description: 'table: show a "CSV" button that downloads the (filtered) rows as a CSV file.' },
|
|
598
803
|
html: { type: 'string', description: 'html: custom markup rendered in a sandboxed iframe.' },
|
|
599
804
|
htmlFile: { type: 'string', description: 'html: path to an HTML file (alternative to "html").' },
|
|
600
805
|
src: { type: 'string', description: 'image: http(s)/data URL, or a local file path (png/jpg/gif/webp/svg/avif/bmp — embedded at spec time, served offline). video: a YouTube/Vimeo URL (embeds an iframe player), an http(s) media URL, or a local video file (mp4/webm/ogv/mov/mkv/m4v — streamed from the server, never embedded).' },
|
|
@@ -619,7 +824,23 @@ const BLOCK_SCHEMA = {
|
|
|
619
824
|
},
|
|
620
825
|
colors: { type: 'array', items: { type: 'string' }, description: 'palette shorthand: colors for a single palette (use "palettes" for several named ones). Pairs with block-level "name".' },
|
|
621
826
|
name: { type: 'string', description: 'palette shorthand: name for the single "colors" palette.' },
|
|
622
|
-
|
|
827
|
+
items: {
|
|
828
|
+
type: 'array',
|
|
829
|
+
description: 'kpi: stat cards. Each {label, value, delta?, dir? (up|down|flat — tints the delta), sub?}. Big-number metrics at a glance ("Revenue $1.2M ↑12%") with no chart.',
|
|
830
|
+
items: { type: 'object', properties: { label: { type: 'string' }, value: { type: 'string' }, delta: { type: 'string' }, dir: { type: 'string', enum: ['up', 'down', 'flat'] }, sub: { type: 'string' } }, required: ['value'] },
|
|
831
|
+
},
|
|
832
|
+
specimens: {
|
|
833
|
+
type: 'array',
|
|
834
|
+
description: 'typography: type specimens rendered at the given style. Each {label?, size? (e.g. "32px"/"2rem"), weight? (e.g. "600"), font?, lineHeight?, letterSpacing?, text?}. Block-level "font" sets a default family.',
|
|
835
|
+
items: { type: 'object', properties: { label: { type: 'string' }, size: { type: 'string' }, weight: { type: 'string' }, font: { type: 'string' }, lineHeight: { type: 'string' }, letterSpacing: { type: 'string' }, text: { type: 'string' } } },
|
|
836
|
+
},
|
|
837
|
+
ref: { type: 'string', description: 'Any block: a stable name (e.g. "velocity") that a markdown reference link [see chart](#ref:velocity) can open in a full-screen modal — so a question can point back to a visual shown earlier instead of making the user scroll up.' },
|
|
838
|
+
pins: { type: 'boolean', description: 'image: enable coordinate pin-comments — the user clicks any point on the image to drop a comment anchored to that spot (Figma-style), returned as a {kind:"image-point", x, y} annotation.' },
|
|
839
|
+
before: { description: 'compare: the "before" image — an http(s)/data URL, a local file path, or {src, label}.' },
|
|
840
|
+
after: { description: 'compare: the "after" image — an http(s)/data URL, a local file path, or {src, label}.' },
|
|
841
|
+
beforeLabel: { type: 'string', description: 'compare: caption for the before side (default "Before").' },
|
|
842
|
+
afterLabel: { type: 'string', description: 'compare: caption for the after side (default "After").' },
|
|
843
|
+
height: { type: 'integer', minimum: BLOCK_HEIGHT.min, maximum: BLOCK_HEIGHT.max, description: 'Block height in px. Defaults: chart 320, html 360; markdown/table/code flow naturally; mermaid/graphviz/plantuml natural (max 800, scrolls; set a larger height to override); image/compare natural.' },
|
|
623
844
|
},
|
|
624
845
|
},
|
|
625
846
|
};
|
|
@@ -648,13 +869,13 @@ export const SPEC_SCHEMA = {
|
|
|
648
869
|
required: ['label'],
|
|
649
870
|
properties: {
|
|
650
871
|
id: { type: 'string', description: 'Answer key in the result JSON. Defaults to q1, q2, …' },
|
|
651
|
-
type: { type: 'string', enum: ['single', 'multi', 'yesno', 'text', 'textarea', 'scale', 'color'], default: 'text' },
|
|
872
|
+
type: { type: 'string', enum: ['single', 'multi', 'yesno', 'text', 'textarea', 'scale', 'color', 'rank', 'checklist', 'allocate'], default: 'text' },
|
|
652
873
|
label: { type: 'string' },
|
|
653
874
|
description: { type: 'string' },
|
|
654
875
|
required: { type: 'boolean', default: false },
|
|
655
876
|
options: {
|
|
656
877
|
type: 'array',
|
|
657
|
-
description: 'For single/multi. Strings, or {value, label, description, blocks?}. An option\'s "blocks" render INSIDE that option card — use them to show each choice (image/chart/mermaid/html…) instead of describing it in words.',
|
|
878
|
+
description: 'For single/multi/rank/checklist/allocate. Strings, or {value, label, description, blocks?}. An option\'s "blocks" render INSIDE that option card — use them to show each choice (image/chart/mermaid/html…) instead of describing it in words. "rank": user reorders (drag/↑↓), answer is the ordered values, ≥2 options. "checklist": each option gets a status (see "statuses"), answer is {value: status}. "allocate": user distributes "total" across options, answer is {value: number}.',
|
|
658
879
|
items: {
|
|
659
880
|
anyOf: [
|
|
660
881
|
{ type: 'string' },
|
|
@@ -671,14 +892,18 @@ export const SPEC_SCHEMA = {
|
|
|
671
892
|
},
|
|
672
893
|
},
|
|
673
894
|
other: { type: 'boolean', description: 'Add a free-text "Other" option (a multi-line textarea); its text is returned verbatim as the value. Defaults ON for "single" (radio) questions so the user is never boxed in — set other:false to remove it; "multi" stays opt-in (other:true).' },
|
|
674
|
-
note: { type: 'boolean', description: 'Small optional free-text field under the question (to qualify an answer). Returned separately as result.notes[questionId]. Defaults to true for
|
|
895
|
+
note: { type: 'boolean', description: 'Small optional free-text field under the question (to qualify an answer). Returned separately as result.notes[questionId]. Defaults to true for the decision types — single, rank, checklist, allocate — so users can qualify their pick; false for other types. Set note:false to hide it, note:true to add it.' },
|
|
675
896
|
placeholder: { type: 'string', description: 'For text/textarea.' },
|
|
676
897
|
default: { description: 'Pre-selected value. Shape matches the answer shape for the type.' },
|
|
677
898
|
min: { type: 'integer', default: 1, description: 'scale only' },
|
|
678
899
|
max: { type: 'integer', default: 5, maximum: 10, description: 'scale only' },
|
|
679
900
|
minLabel: { type: 'string', description: 'scale only' },
|
|
680
901
|
maxLabel: { type: 'string', description: 'scale only' },
|
|
681
|
-
presets: { type: 'array', items: { type: 'string' }, description: 'color only: optional preset swatches (CSS colors) shown beside the native picker for one-click selection. The answer is returned as a
|
|
902
|
+
presets: { type: 'array', items: { type: 'string' }, description: 'color only: optional small preset swatches (CSS colors) shown beside the native picker for one-click selection. The answer is returned as a color string.' },
|
|
903
|
+
palette: { type: 'array', description: 'color only: a "pick from a palette" of labeled swatch CARDS — each click selects that color as the answer, and each card is individually commentable (per-color feedback). Items: a CSS color string (hex/rgb/hsl/named — multiple color systems supported via the browser, no library), or {value (or color), label?}. The native picker stays available for a custom color.', items: { anyOf: [{ type: 'string' }, { type: 'object' }] } },
|
|
904
|
+
statuses: { type: 'array', description: 'checklist only: the per-item statuses (default Pass / Fail / N/A). Strings, or {value, label, tone?} where tone ∈ ok|bad|muted|warn tints the chip. Answer is a map {optionValue: statusValue} for the items the user set.', items: { anyOf: [{ type: 'string' }, { type: 'object' }] } },
|
|
905
|
+
total: { type: 'integer', minimum: 1, default: 100, description: 'allocate only: the budget to distribute across options (default 100). Answer is a map {optionValue: number}.' },
|
|
906
|
+
unit: { type: 'string', description: 'allocate only: optional unit label shown next to the total (e.g. "%", "pts", "hrs").' },
|
|
682
907
|
blocks: BLOCK_SCHEMA,
|
|
683
908
|
html: { type: 'string', description: 'Legacy: per-question custom HTML. Normalized into an html block prepended to this question\'s "blocks".' },
|
|
684
909
|
htmlFile: { type: 'string', description: 'Legacy: path to an HTML file (alternative to "html").' },
|
package/src/ui/annotate.css
CHANGED
|
@@ -48,12 +48,22 @@
|
|
|
48
48
|
padding: 12px 14px;
|
|
49
49
|
font: 14px/1.5 var(--sans);
|
|
50
50
|
}
|
|
51
|
+
.ann-pop-head { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 8px; }
|
|
51
52
|
.ann-pop-label {
|
|
53
|
+
flex: 1; min-width: 0;
|
|
52
54
|
color: var(--muted);
|
|
53
55
|
font-size: 0.78rem;
|
|
54
|
-
margin-bottom: 8px;
|
|
55
56
|
overflow-wrap: anywhere;
|
|
56
57
|
}
|
|
58
|
+
.ann-pop-close {
|
|
59
|
+
flex: none; margin: -4px -4px 0 0;
|
|
60
|
+
background: transparent; color: var(--muted);
|
|
61
|
+
border: none; border-radius: 7px;
|
|
62
|
+
width: 26px; height: 26px;
|
|
63
|
+
font-size: 1.25rem; line-height: 1; cursor: pointer;
|
|
64
|
+
transition: color 150ms var(--ease), background 150ms var(--ease);
|
|
65
|
+
}
|
|
66
|
+
.ann-pop-close:hover { color: var(--fg); background: var(--bg-sunken); }
|
|
57
67
|
.ann-pop-existing { margin-bottom: 8px; max-height: min(50vh, 420px); overflow-y: auto; }
|
|
58
68
|
.ann-pop-existing:empty { display: none; margin: 0; }
|
|
59
69
|
|
|
@@ -63,8 +73,10 @@
|
|
|
63
73
|
border-top: 1px solid var(--border);
|
|
64
74
|
}
|
|
65
75
|
.ann-thread-head { display: flex; align-items: center; gap: 6px; }
|
|
66
|
-
.ann-thread-
|
|
76
|
+
.ann-thread-actions { margin-left: auto; display: flex; align-items: center; gap: 4px; }
|
|
67
77
|
.ann-pop-text { font-size: 0.88rem; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
78
|
+
.ann-edit-form { margin-top: 4px; }
|
|
79
|
+
.ann-edit-ta { width: 100%; }
|
|
68
80
|
|
|
69
81
|
/* author chips: "you" muted, "agent" accent */
|
|
70
82
|
.ann-chip {
|
|
@@ -146,16 +158,61 @@
|
|
|
146
158
|
.ann-cancel:hover { border-color: var(--accent); color: var(--accent); }
|
|
147
159
|
|
|
148
160
|
/* delete button (popover items + summary rows): muted, danger on hover */
|
|
161
|
+
/* Delete = a trash icon button, visually distinct from the close ×. */
|
|
149
162
|
.ann-del {
|
|
150
163
|
flex: none;
|
|
164
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
165
|
+
background: transparent; color: var(--muted);
|
|
166
|
+
border: none; border-radius: 7px;
|
|
167
|
+
width: 26px; height: 26px;
|
|
168
|
+
cursor: pointer;
|
|
169
|
+
transition: color 150ms var(--ease), background 150ms var(--ease);
|
|
170
|
+
}
|
|
171
|
+
.ann-del:hover { color: var(--danger); background: var(--accent-soft); }
|
|
172
|
+
|
|
173
|
+
/* edit button: same shape as delete, accent (not danger) on hover */
|
|
174
|
+
.ann-edit {
|
|
175
|
+
flex: none;
|
|
176
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
151
177
|
background: transparent; color: var(--muted);
|
|
152
|
-
border: none; border-radius:
|
|
153
|
-
|
|
154
|
-
font-size: 1.05rem; line-height: 1;
|
|
178
|
+
border: none; border-radius: 7px;
|
|
179
|
+
width: 26px; height: 26px;
|
|
155
180
|
cursor: pointer;
|
|
156
|
-
transition: color 150ms var(--ease);
|
|
181
|
+
transition: color 150ms var(--ease), background 150ms var(--ease);
|
|
157
182
|
}
|
|
158
|
-
.ann-
|
|
183
|
+
.ann-edit:hover { color: var(--accent); background: var(--accent-soft); }
|
|
184
|
+
|
|
185
|
+
/* ---------- delete confirmation modal ---------- */
|
|
186
|
+
.ann-confirm-scrim {
|
|
187
|
+
position: fixed; inset: 0; z-index: 80;
|
|
188
|
+
background: rgba(28, 27, 25, 0.4);
|
|
189
|
+
display: flex; align-items: center; justify-content: center;
|
|
190
|
+
padding: 20px;
|
|
191
|
+
}
|
|
192
|
+
.ann-confirm {
|
|
193
|
+
width: min(360px, 92vw);
|
|
194
|
+
background: var(--card); color: var(--fg);
|
|
195
|
+
border: 1px solid var(--border); border-radius: 14px;
|
|
196
|
+
box-shadow: var(--shadow-lift);
|
|
197
|
+
padding: 20px;
|
|
198
|
+
font: 14px/1.5 var(--sans);
|
|
199
|
+
}
|
|
200
|
+
.ann-confirm-title { font: 600 1.05rem var(--sans); margin-bottom: 6px; }
|
|
201
|
+
.ann-confirm-msg { color: var(--fg-2); font-size: 0.9rem; }
|
|
202
|
+
.ann-confirm-skip {
|
|
203
|
+
display: flex; align-items: center; gap: 8px;
|
|
204
|
+
margin: 14px 0 4px; color: var(--fg-2); font-size: 0.85rem; cursor: pointer;
|
|
205
|
+
}
|
|
206
|
+
.ann-confirm-skip input { width: 15px; height: 15px; accent-color: var(--accent); cursor: pointer; }
|
|
207
|
+
.ann-confirm-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
|
|
208
|
+
.ann-confirm-cancel, .ann-confirm-del {
|
|
209
|
+
border-radius: 9px; padding: 8px 16px; font: 600 0.88rem var(--sans); cursor: pointer;
|
|
210
|
+
border: 1px solid var(--border-strong); transition: all 150ms var(--ease);
|
|
211
|
+
}
|
|
212
|
+
.ann-confirm-cancel { background: var(--card); color: var(--fg-2); }
|
|
213
|
+
.ann-confirm-cancel:hover { background: var(--bg-sunken); color: var(--fg); }
|
|
214
|
+
.ann-confirm-del { background: var(--danger); color: var(--danger-fg); border-color: var(--danger); }
|
|
215
|
+
.ann-confirm-del:hover { filter: brightness(1.06); }
|
|
159
216
|
|
|
160
217
|
/* ---------- floating selection button ---------- */
|
|
161
218
|
.ann-selbtn {
|
|
@@ -274,9 +331,8 @@
|
|
|
274
331
|
.ann-rail-list .ann-sum-row + .ann-sum-row { border-top: 1px solid var(--border); }
|
|
275
332
|
.ann-rail-list .ann-sum-row:hover { border-color: var(--accent); box-shadow: var(--shadow-card); }
|
|
276
333
|
|
|
277
|
-
/* The rail
|
|
278
|
-
|
|
279
|
-
Comments button, which flashes when a new comment lands. */
|
|
334
|
+
/* The rail NEVER auto-opens (that shifted the board unexpectedly) — it's reached
|
|
335
|
+
only via the floating Comments button, which flashes when a new comment lands. */
|
|
280
336
|
@keyframes ann-toggle-pulse {
|
|
281
337
|
0% { transform: scale(1); }
|
|
282
338
|
35% { transform: scale(1.12); box-shadow: 0 0 0 4px var(--accent-soft); }
|
|
@@ -284,6 +340,15 @@
|
|
|
284
340
|
}
|
|
285
341
|
.ann-rail-toggle.pulse { animation: ann-toggle-pulse 600ms var(--ease); border-color: var(--accent); color: var(--accent); }
|
|
286
342
|
|
|
343
|
+
/* When opened on a roomy screen, the rail DOCKS and consumes page space (pushes
|
|
344
|
+
content) instead of covering it — so the board stays visible beside it and
|
|
345
|
+
clicking a comment reveals the highlighted item in the page. No scrim while
|
|
346
|
+
docked. Narrow screens fall back to an overlay + scrim (no room to dock). */
|
|
347
|
+
@media (min-width: 980px) {
|
|
348
|
+
.ann-rail-scrim { display: none !important; }
|
|
349
|
+
body.ann-rail-open { padding-right: 340px; }
|
|
350
|
+
}
|
|
351
|
+
|
|
287
352
|
/* ---------- jump-to flash ---------- */
|
|
288
353
|
.ann-flash { animation: ann-flash 1s var(--ease); }
|
|
289
354
|
@keyframes ann-flash {
|