@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/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 = asStr(rawBlock.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
- if (!Array.isArray(rawBlock.columns) || rawBlock.columns.length < 1) {
298
- throw new CliError(`${where}: table needs a non-empty "columns" array (strings or {key,label,align?}).`);
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(rawBlock.rows)) {
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 = rawBlock.columns.map((c, k) => {
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 = rawBlock.rows.map((r, ri) => {
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
- if (/^(https?:|data:)/i.test(src)) {
343
- block.src = src;
344
- return block;
345
- }
346
- const p = path.resolve(cwd, src);
347
- const ext = path.extname(p).slice(1).toLowerCase();
348
- const mime = IMAGE_MIMES[ext];
349
- if (!mime) {
350
- throw new CliError(`${where}: unsupported image extension ".${ext}" — use ${Object.keys(IMAGE_MIMES).join('/')}, or an http(s)/data URL.`);
351
- }
352
- let buf;
353
- try {
354
- buf = fs.readFileSync(p);
355
- } catch {
356
- throw new CliError(`${where}: cannot read image "${src}" (resolved: ${p})`);
357
- }
358
- if (buf.length > IMAGE_MAX_BYTES) {
359
- throw new CliError(`${where}: image "${src}" is ${(buf.length / 1024 / 1024).toFixed(1)}MB — max ${IMAGE_MAX_BYTES / 1024 / 1024}MB.`);
360
- }
361
- block.src = `data:${mime};base64,${buf.toString('base64')}`;
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
- blocks.push(normalizeBlock(b, nextId(), cwd, `${where}.blocks[${i}]`));
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
- // Radio (single) questions show the optional per-answer note by default so
482
- // the user can qualify their pick; other types stay opt-in. An explicit
483
- // note:false turns it off for a single question.
484
- note: rq.note === undefined ? type === 'single' : rq.note === true,
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
- if (q.options.length < 1) {
511
- throw new CliError(`${where}: type "${type}" needs at least 1 option.`);
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
- q.other = type === 'single' ? rq.other !== false : rq.other === true;
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
- 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/image natural (max 1200, scrolls).' },
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 "single" (radio) questions so users can comment on their pick, false for other types; set note:false to hide it on a single question.' },
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 hex string.' },
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").' },
@@ -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-head .ann-del { margin-left: auto; }
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: 6px;
153
- padding: 2px 6px;
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-del:hover { color: var(--danger); }
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 always overlays the board (scrim dims, content never moves) — opening
278
- it must NOT shift layout, on any width. It's reached only via the floating
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 {