analyzthis_design 2.5.1 → 2.6.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.
Files changed (100) hide show
  1. package/HOW-TO-USE.md +249 -326
  2. package/README.md +71 -25
  3. package/agents/calibration-benchmark.json +187 -0
  4. package/agents/cards/anuj.md +1 -1
  5. package/agents/cards/arjun.md +1 -1
  6. package/agents/cards/kavi.md +2 -1
  7. package/agents/cards/mood-board.md +7 -7
  8. package/agents/cards/noor.md +1 -1
  9. package/agents/cards/priya.md +1 -1
  10. package/agents/cards/zara.md +3 -3
  11. package/agents/chain.json +1 -1
  12. package/agents/design-spec-schema.json +18 -1
  13. package/agents/manifests/mood-board.json +13 -8
  14. package/agents/model-policy.json +12 -0
  15. package/agents/router.json +2 -2
  16. package/agents/session-schema.json +12 -2
  17. package/dist/HOW-TO-USE.md +249 -326
  18. package/dist/README.md +71 -25
  19. package/dist/agents/calibration-benchmark.json +187 -0
  20. package/dist/agents/cards/anuj.md +1 -1
  21. package/dist/agents/cards/arjun.md +1 -1
  22. package/dist/agents/cards/kavi.md +2 -1
  23. package/dist/agents/cards/mood-board.md +7 -7
  24. package/dist/agents/cards/noor.md +1 -1
  25. package/dist/agents/cards/priya.md +1 -1
  26. package/dist/agents/cards/zara.md +3 -3
  27. package/dist/agents/chain.json +1 -1
  28. package/dist/agents/design-spec-schema.json +18 -1
  29. package/dist/agents/manifests/mood-board.json +13 -8
  30. package/dist/agents/model-policy.json +12 -0
  31. package/dist/agents/router.json +2 -2
  32. package/dist/agents/session-schema.json +12 -2
  33. package/dist/bin/cli.js +308 -10
  34. package/dist/lib/calibrate.js +816 -0
  35. package/dist/lib/chunk-executor.js +41 -1
  36. package/dist/lib/chunk-models.js +4 -18
  37. package/dist/lib/chunk-router.js +22 -6
  38. package/dist/lib/chunk-run.js +55 -1
  39. package/dist/lib/chunk-synthesis.js +18 -2
  40. package/dist/lib/chunk-telemetry.js +77 -17
  41. package/dist/lib/collect.js +15 -1
  42. package/dist/lib/cost.js +15 -7
  43. package/dist/lib/design-spec.js +32 -2
  44. package/dist/lib/evolution-metrics.js +23 -1
  45. package/dist/lib/feedback-submit.js +50 -1
  46. package/dist/lib/feedback.js +15 -0
  47. package/dist/lib/install.js +1 -0
  48. package/dist/lib/lessons.js +8 -0
  49. package/dist/lib/mcp-server.js +189 -7
  50. package/dist/lib/model-policy.js +174 -0
  51. package/dist/lib/model-registry.js +71 -0
  52. package/dist/lib/moodboard-film.js +119 -0
  53. package/dist/lib/moodboard-view.js +426 -0
  54. package/dist/lib/moodboard.js +638 -27
  55. package/dist/lib/orchestrator/run.js +67 -8
  56. package/dist/lib/outcome.js +7 -2
  57. package/dist/lib/output-quality.js +95 -0
  58. package/dist/lib/reference-pack.js +9 -1
  59. package/dist/lib/session.js +48 -4
  60. package/dist/lib/system-prompt.js +3 -0
  61. package/dist/lib/ui-libraries.js +232 -0
  62. package/dist/skills/anuj/SKILL.md +4 -0
  63. package/dist/skills/arjun/SKILL.md +2 -0
  64. package/dist/skills/arjun/references/lens.md +2 -0
  65. package/dist/skills/design-director/SKILL.md +2 -1
  66. package/dist/skills/design-reference/SKILL.md +6 -0
  67. package/dist/skills/design-reference/motion.csv +11 -0
  68. package/dist/skills/design-reference/schema.json +12 -0
  69. package/dist/skills/design-reference/stacks/shadcn.csv +6 -0
  70. package/dist/skills/design-reference/ui-libraries.csv +46 -0
  71. package/dist/skills/design-spec/SKILL.md +1 -1
  72. package/dist/skills/getting-started/SKILL.md +2 -2
  73. package/dist/skills/kavi/SKILL.md +3 -2
  74. package/dist/skills/meera/SKILL.md +2 -0
  75. package/dist/skills/mood-board/SKILL.md +94 -87
  76. package/dist/skills/noor/SKILL.md +4 -0
  77. package/dist/skills/persona-orchestrator/references/lens.md +4 -4
  78. package/dist/skills/priya/SKILL.md +5 -0
  79. package/dist/skills/raj/SKILL.md +2 -0
  80. package/dist/skills/zara/SKILL.md +49 -4
  81. package/package.json +13 -2
  82. package/skills/anuj/SKILL.md +4 -0
  83. package/skills/arjun/SKILL.md +2 -0
  84. package/skills/arjun/references/lens.md +2 -0
  85. package/skills/design-director/SKILL.md +2 -1
  86. package/skills/design-reference/SKILL.md +6 -0
  87. package/skills/design-reference/motion.csv +11 -0
  88. package/skills/design-reference/schema.json +12 -0
  89. package/skills/design-reference/stacks/shadcn.csv +6 -0
  90. package/skills/design-reference/ui-libraries.csv +46 -0
  91. package/skills/design-spec/SKILL.md +1 -1
  92. package/skills/getting-started/SKILL.md +2 -2
  93. package/skills/kavi/SKILL.md +3 -2
  94. package/skills/meera/SKILL.md +2 -0
  95. package/skills/mood-board/SKILL.md +94 -87
  96. package/skills/noor/SKILL.md +4 -0
  97. package/skills/persona-orchestrator/references/lens.md +4 -4
  98. package/skills/priya/SKILL.md +5 -0
  99. package/skills/raj/SKILL.md +2 -0
  100. package/skills/zara/SKILL.md +49 -4
@@ -66,11 +66,229 @@ function writeJson(filePath, obj) {
66
66
  fs.writeFileSync(filePath, JSON.stringify(obj, null, 2));
67
67
  }
68
68
 
69
+ function persistBoard(board, workspaceDir) {
70
+ var projectId = board.project_id;
71
+ var bDir = boardDir(projectId, board.board_id);
72
+ board.updated_at = new Date().toISOString();
73
+ if (board.kind === 'ui') normalizeBoard(board);
74
+ writeJson(path.join(bDir, 'board.json'), board);
75
+ var htmlPath = '';
76
+ if (board.kind === 'ui') {
77
+ // Static snapshot opens straight from disk; `moodboard show` serves the
78
+ // clickable version.
79
+ htmlPath = path.join(bDir, 'index.html');
80
+ try { fs.writeFileSync(htmlPath, require('./moodboard-view').renderBoardHtml(board, { interactive: false })); }
81
+ catch (e) { htmlPath = ''; }
82
+ }
83
+ var ws = workspaceDir || defaultWorkspaceDir();
84
+ ensureDir(ws);
85
+ var workspacePath = path.join(ws, board.board_id + '.json');
86
+ try { fs.writeFileSync(workspacePath, JSON.stringify(board, null, 2)); }
87
+ catch (e) { /* best effort */ }
88
+ return { board: board, boardDir: bDir, workspacePath: workspacePath, htmlPath: htmlPath };
89
+ }
90
+
91
+ // ─── Board v3: sections, element tiles, user decisions ──────────────────────
92
+
93
+ // Section targets add up to roughly 20-24 tiles. A board only uses the
94
+ // sections named in its brief, so a dashboard can drop first_screen.
95
+ var SECTIONS = [
96
+ { id: 'first_screen', label: 'First screen', min: 3, max: 5 },
97
+ { id: 'typography', label: 'Typography', min: 3, max: 4 },
98
+ { id: 'color', label: 'Colour and contrast', min: 3, max: 4 },
99
+ { id: 'components', label: 'Components and density', min: 6, max: 8 },
100
+ { id: 'imagery', label: 'Imagery and icons', min: 2, max: 4 },
101
+ { id: 'motion', label: 'Motion and interaction', min: 1, max: 3 },
102
+ ];
103
+ var SECTION_IDS = SECTIONS.map(function(s) { return s.id; });
104
+ var TILE_TARGET = { min: 20, max: 24, sites: 6 };
105
+ var USER_DECISIONS = ['keep', 'reject', 'maybe'];
106
+ var JURY = ['arjun', 'meera', 'priya', 'noor'];
107
+ var IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.webp', '.gif'];
108
+
109
+ function clip(value, max) {
110
+ return String(value == null ? '' : value).slice(0, max);
111
+ }
112
+
113
+ function isHttpUrl(value) {
114
+ try {
115
+ var u = new URL(String(value));
116
+ return u.protocol === 'http:' || u.protocol === 'https:';
117
+ } catch (e) {
118
+ return false;
119
+ }
120
+ }
121
+
122
+ function siteOf(url) {
123
+ try { return new URL(url).hostname.replace(/^www\./, ''); }
124
+ catch (e) { return ''; }
125
+ }
126
+
127
+ // A tile is identified by where it came from plus what element it is, so a
128
+ // rejected "pricing card on linear.app" cannot sneak back in a later round.
129
+ function tileKey(pageUrl, element) {
130
+ var u = String(pageUrl || '').replace(/[?#].*$/, '').replace(/\/+$/, '').toLowerCase();
131
+ return u + '|' + String(element || '').trim().toLowerCase();
132
+ }
133
+
134
+ function defaultBrief(task, brief) {
135
+ brief = brief || {};
136
+ var sections = (brief.sections || []).filter(function(s) { return SECTION_IDS.indexOf(s) !== -1; });
137
+ return {
138
+ job: clip(brief.job || task, 240),
139
+ adjectives: (brief.adjectives || []).map(function(a) { return clip(a, 40); }).filter(Boolean).slice(0, 3),
140
+ not_this: clip(brief.not_this || '', 200),
141
+ sections: sections.length ? sections : SECTION_IDS.slice(),
142
+ confirmed_by_user: !!brief.confirmed_by_user,
143
+ };
144
+ }
145
+
146
+ function sanitizeTokens(tokens) {
147
+ tokens = tokens || {};
148
+ var hex = /^#[0-9a-f]{3,8}$/i;
149
+ return {
150
+ colors: (tokens.colors || []).map(function(c) { return String(c).trim(); })
151
+ .filter(function(c) { return hex.test(c) || /^rgba?\([\d\s.,%]+\)$/i.test(c); }).slice(0, 8),
152
+ fonts: (tokens.fonts || []).map(function(f) { return clip(f, 60).trim(); }).filter(Boolean).slice(0, 4),
153
+ radius: clip(tokens.radius || '', 24),
154
+ spacing: clip(tokens.spacing || '', 40),
155
+ font_size: clip(tokens.font_size || '', 24),
156
+ };
157
+ }
158
+
159
+ function defaultScoutBrief(task, round, constraints) {
160
+ var c = constraints && typeof constraints === 'object' ? constraints : { veto: constraints || '' };
161
+ var lines = [
162
+ 'Zara Scout Pass, round ' + (round || 1) + '.',
163
+ 'Job: ' + clip(task, 240),
164
+ ];
165
+ if (c.adjectives && c.adjectives.length) lines.push('Feel: ' + c.adjectives.join(', ') + (c.not_this ? '. Not this: ' + c.not_this : ''));
166
+ if (c.userNote || (c.kept && c.kept.length) || (c.rejected && c.rejected.length)) {
167
+ lines.push('USER DECISIONS — these outrank every persona, including you:');
168
+ if (c.userNote) lines.push(' User asked: ' + clip(c.userNote, 500));
169
+ if (c.kept && c.kept.length) lines.push(' Kept (build around these): ' + c.kept.join('; '));
170
+ if (c.rejected && c.rejected.length) lines.push(' Rejected (never bring back): ' + c.rejected.join('; '));
171
+ }
172
+ if (c.missing && c.missing.length) lines.push('Sections still thin: ' + c.missing.join(', ') + '. Fill these first.');
173
+ lines.push(
174
+ 'Build an element collage: ' + TILE_TARGET.min + '-' + TILE_TARGET.max + ' tiles from at least ' + TILE_TARGET.sites + ' different sites. Crop one element per tile (a card, a table header, a filter bar, a button group, a type pairing). Not whole pages.',
175
+ 'Hunt on real pages: live product sites, Mobbin, Dribbble, Pinterest, Awwwards, Google Images. Open each page in the browser. Never invent URLs. Never generate AI comps.',
176
+ 'Capture: browser_navigate to the page, browser_take_screenshot with ref=<element selector> and a filename, then run the capture script (moodboard capture-script) with Runtime.evaluate to read real colours, fonts, radius, spacing.',
177
+ 'Every tile needs: page_url, image_path, section (' + SECTION_IDS.join(' | ') + '), element, tokens, steal, refuse, peak.',
178
+ 'Lens: Peak-End, first 2 seconds. Concept shots are allowed if you say so. Never sell a Dribbble shot as shipped product. Skip AI-purple heroes and cinematic scroll-hijack on daily working screens.',
179
+ 'Then stop. Arjun, Noor, Priya and Meera vote as advice. The user decides.'
180
+ );
181
+ if (c.veto) lines.push('Jury notes (advice only): ' + clip(c.veto, 500));
182
+ return lines.join('\n');
183
+ }
184
+
185
+ // Older boards stored Zara's picks as `frames`. Move them into `tiles` once.
186
+ function normalizeBoard(board) {
187
+ if (!board) return board;
188
+ if (!board.brief) board.brief = defaultBrief(board.task, null);
189
+ if (!board.tiles) {
190
+ board.tiles = (board.frames || []).map(function(f, idx) {
191
+ return {
192
+ id: f.id || generateBoardId(),
193
+ n: idx + 1,
194
+ section: SECTION_IDS.indexOf(f.surface) !== -1 ? f.surface : 'components',
195
+ element: f.title || 'Frame',
196
+ site: siteOf(f.url),
197
+ page_url: f.url || '',
198
+ image: '',
199
+ image_url: f.url || '',
200
+ tokens: sanitizeTokens(null),
201
+ steal: f.steal || '', refuse: f.refuse || '', peak: f.peak || '',
202
+ round: f.round || 1,
203
+ source: f.source || 'zara_scout',
204
+ added_at: f.added_at || new Date().toISOString(),
205
+ votes: {},
206
+ user: null,
207
+ };
208
+ });
209
+ }
210
+ delete board.frames;
211
+ if (!board.decisions) board.decisions = [];
212
+ if (!board.blocked) board.blocked = [];
213
+ board.version = 3;
214
+ return board;
215
+ }
216
+
217
+ function findTile(board, ref) {
218
+ var asNum = parseInt(ref, 10);
219
+ for (var i = 0; i < board.tiles.length; i++) {
220
+ var t = board.tiles[i];
221
+ if (t.id === ref || (String(asNum) === String(ref) && t.n === asNum)) return t;
222
+ }
223
+ return null;
224
+ }
225
+
226
+ // The user's decision is final. Persona votes only produce a suggestion
227
+ // while the user has not decided.
228
+ function tileStatus(tile) {
229
+ if (tile.user && USER_DECISIONS.indexOf(tile.user.decision) !== -1) {
230
+ return { status: tile.user.decision, by: 'user' };
231
+ }
232
+ var keep = 0, skip = 0;
233
+ Object.keys(tile.votes || {}).forEach(function(p) {
234
+ if (tile.votes[p].vote === 'keep') keep++;
235
+ else if (tile.votes[p].vote === 'skip') skip++;
236
+ });
237
+ if (!keep && !skip) return { status: 'undecided', by: 'none' };
238
+ return { status: keep >= skip ? 'suggested_keep' : 'suggested_skip', by: 'personas', keep: keep, skip: skip };
239
+ }
240
+
241
+ function computeDirection(board) {
242
+ var kept = board.tiles.filter(function(t) { return tileStatus(t).status === 'keep'; });
243
+ var rejected = board.tiles.filter(function(t) { return tileStatus(t).status === 'reject'; });
244
+ var palette = [];
245
+ var fonts = [];
246
+ kept.forEach(function(t) {
247
+ (t.tokens.colors || []).forEach(function(c) { if (palette.indexOf(c.toLowerCase()) === -1) palette.push(c.toLowerCase()); });
248
+ (t.tokens.fonts || []).forEach(function(f) { if (fonts.indexOf(f) === -1) fonts.push(f); });
249
+ });
250
+ var bySection = {};
251
+ board.brief.sections.forEach(function(s) { bySection[s] = 0; });
252
+ kept.forEach(function(t) { if (bySection[t.section] != null) bySection[t.section]++; });
253
+ var overruled = kept.filter(function(t) {
254
+ var skips = Object.keys(t.votes || {}).filter(function(p) { return t.votes[p].vote === 'skip'; });
255
+ return skips.length > 0;
256
+ }).map(function(t) { return t.n; });
257
+ return {
258
+ kept: kept.map(function(t) {
259
+ return { n: t.n, section: t.section, element: t.element, site: t.site, page_url: t.page_url, steal: t.steal, tokens: t.tokens, note: (t.user && t.user.note) || '' };
260
+ }),
261
+ rejected: rejected.map(function(t) {
262
+ return { n: t.n, element: t.element, site: t.site, page_url: t.page_url, note: (t.user && t.user.note) || t.refuse || '' };
263
+ }),
264
+ palette: palette.slice(0, 10),
265
+ fonts: fonts.slice(0, 4),
266
+ by_section: bySection,
267
+ missing_sections: Object.keys(bySection).filter(function(s) { return !bySection[s]; }),
268
+ overruled_personas_on: overruled,
269
+ };
270
+ }
271
+
272
+ function scoutConstraints(board, userNote) {
273
+ var dir = computeDirection(board);
274
+ return {
275
+ adjectives: board.brief.adjectives,
276
+ not_this: board.brief.not_this,
277
+ userNote: userNote || '',
278
+ kept: dir.kept.map(function(k) { return '#' + k.n + ' ' + k.element + ' (' + k.site + ')' + (k.note ? ' — ' + k.note : ''); }),
279
+ rejected: dir.rejected.map(function(r) { return r.element + ' (' + r.site + ')' + (r.note ? ' — ' + r.note : ''); }),
280
+ missing: dir.missing_sections,
281
+ };
282
+ }
283
+
69
284
  // ─── Source discovery for mood references ───────────────────────────────────
70
285
 
71
286
  // Known hosts that expose an image or a design article we can fetch as text.
72
287
  var MOOD_REFERENCE_HOSTS = [
73
288
  'dribbble.com',
289
+ 'pinterest.com',
290
+ 'www.pinterest.com',
291
+ 'pin.it',
74
292
  'mobbin.com',
75
293
  'behance.net',
76
294
  'www.behance.net',
@@ -362,7 +580,11 @@ async function buildMoodBoard(opts) {
362
580
 
363
581
  var refs = [];
364
582
  var userRefs = opts.userReferences || [];
365
- var discover = opts.discover !== false;
583
+ // The visual element board is the default. `--kind mood` keeps the older
584
+ // link-and-tag board.
585
+ var kind = opts.kind === 'mood' ? 'mood' : 'ui';
586
+ var brief = defaultBrief(task, opts.brief);
587
+ var discover = kind === 'ui' ? !!(opts.urls && opts.urls.length) : opts.discover !== false;
366
588
 
367
589
  if (discover) {
368
590
  var plan = discoverReferenceUrls({ task: task, urls: opts.urls || [], config: config });
@@ -397,6 +619,7 @@ async function buildMoodBoard(opts) {
397
619
  var board = {
398
620
  board_id: boardId,
399
621
  project_id: projectId,
622
+ kind: kind,
400
623
  task: task,
401
624
  created_at: new Date().toISOString(),
402
625
  updated_at: new Date().toISOString(),
@@ -405,20 +628,23 @@ async function buildMoodBoard(opts) {
405
628
  deliberation: null,
406
629
  synthesis: null,
407
630
  user_contributions: userRefs.length,
631
+ scout: kind === 'ui' ? {
632
+ persona: 'zara',
633
+ round: 1,
634
+ status: 'awaiting_scout',
635
+ brief: defaultScoutBrief(task, 1, { adjectives: brief.adjectives, not_this: brief.not_this }),
636
+ last_veto: '',
637
+ } : null,
408
638
  };
409
-
410
- writeJson(path.join(bDir, 'board.json'), board);
411
-
412
- // Copy/symlink to workspace so the user can open it directly.
413
- ensureDir(workspaceDir);
414
- var workspacePath = path.join(workspaceDir, boardId + '.json');
415
- try {
416
- fs.writeFileSync(workspacePath, JSON.stringify(board, null, 2));
417
- } catch (e) {
418
- // workspace write is best-effort (e.g. read-only cwd)
639
+ if (kind === 'ui') {
640
+ board.brief = brief;
641
+ board.tiles = [];
642
+ board.decisions = [];
643
+ board.blocked = [];
644
+ board.version = 3;
419
645
  }
420
646
 
421
- return { board: board, boardDir: bDir, workspacePath: workspacePath };
647
+ return persistBoard(board, workspaceDir);
422
648
  }
423
649
 
424
650
  function detectStack(vaultPath) {
@@ -443,17 +669,26 @@ async function critiqueMoodBoard(opts) {
443
669
  var board = loadJson(boardPath);
444
670
  if (!board) throw new Error('Board not found: ' + boardId);
445
671
 
446
- var experts = opts.experts || ['arjun', 'meera', 'priya', 'zara', 'noor'];
672
+ var isUi = board.kind === 'ui';
673
+ if (isUi) normalizeBoard(board);
674
+ var experts = opts.experts || (isUi ? JURY.slice() : ['arjun', 'meera', 'priya', 'zara', 'noor']);
447
675
  var task = opts.task || board.task;
448
676
 
449
- // Build a synthetic critique task that includes the board and DS pack.
450
- var refsSummary = board.references.map(function(r, idx) {
451
- return '[' + (idx + 1) + '] ' + r.title + ' (' + r.type + ') tags=' + r.tags.join(',') + ' — ' + (r.url || r.description).slice(0, 120);
452
- }).join('\n');
677
+ if (isUi && !board.tiles.length) {
678
+ var need = new Error('Zara has not scouted yet. Add tiles, then critique.');
679
+ need.code = 'NEED_SCOUT';
680
+ throw need;
681
+ }
682
+
683
+ var refsSummary = isUi
684
+ ? juryTileLines(board)
685
+ : board.references.map(function(r, idx) {
686
+ return '[' + (idx + 1) + '] ' + r.title + ' (' + r.type + ') tags=' + (r.tags || []).join(',') + ' — ' + (r.url || r.description || '').slice(0, 120);
687
+ }).join('\n');
453
688
 
454
689
  var dsText = formatDsPack(board.design_system_pack);
455
690
 
456
- var critiqueTask = [
691
+ var critiqueTask = isUi ? juryPrompt(board, dsText) : [
457
692
  'Mood-board critique for: ' + task,
458
693
  '',
459
694
  'References:\n' + refsSummary,
@@ -463,7 +698,6 @@ async function critiqueMoodBoard(opts) {
463
698
  'Each persona must use the Honeycomb rigor matrix to evaluate which references are appropriate for the user and why. Output scores, top concerns, and a ranked shortlist of the best 2-3 references. Then participate in adversarial deliberation until consensus.'
464
699
  ].join('\n');
465
700
 
466
- // Use the orchestrator directly, but force the experts to the mood-board set.
467
701
  var result = await orchestratorRun({
468
702
  task: critiqueTask,
469
703
  experts: experts,
@@ -479,17 +713,330 @@ async function critiqueMoodBoard(opts) {
479
713
 
480
714
  board.deliberation = result.deliberation;
481
715
  board.synthesis = result.synthesis;
482
- board.updated_at = new Date().toISOString();
483
716
  board.critique_task = critiqueTask;
717
+ if (isUi) applyJuryOutcome(board, result.synthesis);
484
718
 
485
- writeJson(boardPath, board);
486
- var workspaceDir = opts.workspaceDir || defaultWorkspaceDir();
487
- ensureDir(workspaceDir);
488
- var workspacePath = path.join(workspaceDir, boardId + '.json');
489
- try { fs.writeFileSync(workspacePath, JSON.stringify(board, null, 2)); }
490
- catch (e) { /* best effort */ }
719
+ var saved = persistBoard(board, opts.workspaceDir || defaultWorkspaceDir());
720
+ return { board: saved.board, boardDir: saved.boardDir, workspacePath: saved.workspacePath, orchestratorResult: result };
721
+ }
722
+
723
+ // The jury's verdict is advice. It never closes the board or sends Zara back
724
+ // on its own — the board waits for the user.
725
+ function applyJuryOutcome(board, synthesis) {
726
+ var verdict = String((synthesis && synthesis.verdict) || '').toUpperCase();
727
+ var scout = board.scout || {};
728
+ var notes = '';
729
+ if (synthesis && synthesis.summary) notes = synthesis.summary;
730
+ else if (synthesis && synthesis.top3 && synthesis.top3.join) notes = synthesis.top3.join('; ');
731
+ else notes = verdict;
732
+ scout.status = 'awaiting_user';
733
+ scout.jury_verdict = verdict;
734
+ scout.last_veto = clip(notes, 600);
735
+ board.scout = scout;
736
+ }
491
737
 
492
- return { board: board, orchestratorResult: result };
738
+ function juryTileLines(board) {
739
+ return board.tiles.map(function(t) {
740
+ return '#' + t.n + ' [' + t.section + '] ' + t.element + ' — ' + t.site + (t.video ? ' (scroll film)' : '') + ' round=' + t.round +
741
+ '\n steal=' + t.steal + ' | refuse=' + t.refuse + ' | peak=' + t.peak +
742
+ (t.tokens.colors.length ? '\n colours=' + t.tokens.colors.join(' ') : '') +
743
+ (t.tokens.fonts.length ? ' fonts=' + t.tokens.fonts.join(', ') : '') +
744
+ (t.user ? '\n USER ALREADY DECIDED: ' + t.user.decision + (t.user.note ? ' — ' + t.user.note : '') + ' (do not argue with this)' : '');
745
+ }).join('\n');
746
+ }
747
+
748
+ function juryPrompt(board, dsText) {
749
+ return [
750
+ 'UI element-board jury for: ' + board.task,
751
+ 'You are Arjun, Noor, Priya and Meera. Zara scouted these tiles and does not vote. Your votes are advice shown to the user; the user decides.',
752
+ 'Vote KEEP or SKIP on every tile with one because-sentence. Do not reward cinematic marketing motion on a daily working surface.',
753
+ '',
754
+ 'Tiles:\n' + juryTileLines(board),
755
+ '',
756
+ dsText || '',
757
+ '',
758
+ 'Return votes as JSON: {"votes":[{"tile":<n>,"persona":"arjun","vote":"keep|skip","because":"..."}],"verdict":"SHIP|REVISE|BLOCK","summary":"..."}',
759
+ 'Then call analyzthis_mood_board with action=votes, board=' + board.board_id + ', and that JSON.'
760
+ ].join('\n');
761
+ }
762
+
763
+ function loadUiBoard(opts) {
764
+ var board = getBoard(opts);
765
+ if (board.kind !== 'ui') throw new Error('This belongs on a UI element board (kind ui).');
766
+ return normalizeBoard(board);
767
+ }
768
+
769
+ // Writes base64 screenshot data (inline, or a saved CDP Page.captureScreenshot
770
+ // response file) into the board's assets folder.
771
+ function saveBase64Image(bDir, tileId, data, label) {
772
+ if (!data || typeof data !== 'string') throw new Error('No screenshot data in ' + label);
773
+ var buf = Buffer.from(data.replace(/^data:image\/\w+;base64,/, ''), 'base64');
774
+ if (buf.length > 8 * 1024 * 1024) throw new Error('Screenshot is larger than 8 MB: ' + label);
775
+ var isPng = buf.slice(0, 4).toString('hex') === '89504e47';
776
+ var isJpg = buf.slice(0, 2).toString('hex') === 'ffd8';
777
+ if (!isPng && !isJpg) throw new Error('Screenshot data is not a PNG or JPEG: ' + label);
778
+ var name = tileId + (isPng ? '.png' : '.jpg');
779
+ ensureDir(path.join(bDir, 'assets'));
780
+ fs.writeFileSync(path.join(bDir, 'assets', name), buf);
781
+ return { image: 'assets/' + name, image_url: '' };
782
+ }
783
+
784
+ function resolveImage(board, bDir, tileId, tile) {
785
+ if (tile.image_base64) return saveBase64Image(bDir, tileId, tile.image_base64, 'image_base64');
786
+ if (tile.image_path && path.extname(String(tile.image_path)).toLowerCase() === '.json') {
787
+ var jsonPath = path.resolve(String(tile.image_path));
788
+ var raw = loadJson(jsonPath);
789
+ return saveBase64Image(bDir, tileId, raw && (raw.data || (raw.result && raw.result.data)), jsonPath);
790
+ }
791
+ if (tile.image_path) {
792
+ var src = path.resolve(String(tile.image_path));
793
+ var ext = path.extname(src).toLowerCase();
794
+ if (IMAGE_EXTS.indexOf(ext) === -1) throw new Error('Image must be one of ' + IMAGE_EXTS.join(', ') + ': ' + src);
795
+ if (!fs.existsSync(src) || !fs.statSync(src).isFile()) throw new Error('Image file not found: ' + src);
796
+ if (fs.statSync(src).size > 8 * 1024 * 1024) throw new Error('Image is larger than 8 MB: ' + src);
797
+ var name = tileId + ext;
798
+ ensureDir(path.join(bDir, 'assets'));
799
+ fs.copyFileSync(src, path.join(bDir, 'assets', name));
800
+ return { image: 'assets/' + name, image_url: '' };
801
+ }
802
+ if (tile.image_url && isHttpUrl(tile.image_url)) return { image: '', image_url: String(tile.image_url) };
803
+ throw new Error('Each tile needs image_path (a saved crop) or an http(s) image_url.');
804
+ }
805
+
806
+ // Copies a scroll film (WebM/MP4, checked by file signature, not extension).
807
+ function resolveVideo(bDir, tileId, videoPath) {
808
+ if (!videoPath) return '';
809
+ var src = path.resolve(String(videoPath));
810
+ if (!fs.existsSync(src) || !fs.statSync(src).isFile()) throw new Error('Video file not found: ' + src);
811
+ if (fs.statSync(src).size > 40 * 1024 * 1024) throw new Error('Video is larger than 40 MB: ' + src);
812
+ var head = Buffer.alloc(12);
813
+ var fd = fs.openSync(src, 'r');
814
+ fs.readSync(fd, head, 0, 12, 0);
815
+ fs.closeSync(fd);
816
+ var ext = head.slice(0, 4).toString('hex') === '1a45dfa3' ? '.webm' : head.slice(4, 8).toString('latin1') === 'ftyp' ? '.mp4' : '';
817
+ if (!ext) throw new Error('Video must be WebM or MP4: ' + src);
818
+ ensureDir(path.join(bDir, 'assets'));
819
+ fs.copyFileSync(src, path.join(bDir, 'assets', tileId + ext));
820
+ return 'assets/' + tileId + ext;
821
+ }
822
+
823
+ // Up to four waypoint stills shown as a strip under a film.
824
+ function resolveStills(board, bDir, tileId, stills) {
825
+ return (Array.isArray(stills) ? stills : []).slice(0, 4).map(function(p, i) {
826
+ return resolveImage(board, bDir, tileId + '-' + (i + 1), { image_path: p }).image;
827
+ });
828
+ }
829
+
830
+ // Adds Zara's (or the user's) element crops. Every tile must say where it
831
+ // came from; tiles the user already rejected are refused.
832
+ function addTiles(opts) {
833
+ opts = opts || {};
834
+ var incoming = opts.tiles || (opts.tile ? [opts.tile] : []);
835
+ if (!opts.boardId || !incoming.length) throw new Error('--board and at least one tile are required');
836
+ var board = loadUiBoard(opts);
837
+ var bDir = boardDir(board.project_id, board.board_id);
838
+ var round = (board.scout && board.scout.round) || 1;
839
+ var added = [];
840
+ var skipped = [];
841
+ for (var i = 0; i < incoming.length; i++) {
842
+ var t = incoming[i] || {};
843
+ var pageUrl = t.page_url || t.url || '';
844
+ var element = clip(t.element || t.title || '', 120).trim();
845
+ if (!isHttpUrl(pageUrl)) { skipped.push({ element: element, reason: 'missing or invalid page_url' }); continue; }
846
+ if (!element) { skipped.push({ page_url: pageUrl, reason: 'missing element name' }); continue; }
847
+ var key = tileKey(pageUrl, element);
848
+ if (board.blocked.indexOf(key) !== -1) { skipped.push({ element: element, reason: 'user rejected this before' }); continue; }
849
+ if (board.tiles.some(function(x) { return tileKey(x.page_url, x.element) === key; })) {
850
+ skipped.push({ element: element, reason: 'already on the board' });
851
+ continue;
852
+ }
853
+ var id = generateBoardId();
854
+ var img, video, stills;
855
+ try {
856
+ // A film may arrive without a poster: its first still becomes the poster.
857
+ img = resolveImage(board, bDir, id, (t.image_path || t.image_base64 || t.image_url) ? t : { image_path: (t.stills || [])[0] });
858
+ video = resolveVideo(bDir, id, t.video_path);
859
+ stills = resolveStills(board, bDir, id, t.stills);
860
+ } catch (e) { skipped.push({ element: element, reason: e.message }); continue; }
861
+ var tile = {
862
+ id: id,
863
+ n: board.tiles.reduce(function(m, x) { return Math.max(m, x.n || 0); }, 0) + 1,
864
+ section: SECTION_IDS.indexOf(t.section) !== -1 ? t.section : 'components',
865
+ element: element,
866
+ site: siteOf(pageUrl),
867
+ page_url: String(pageUrl),
868
+ image: img.image,
869
+ image_url: img.image_url,
870
+ video: video,
871
+ stills: stills,
872
+ tokens: sanitizeTokens(t.tokens),
873
+ steal: clip(t.steal, 200),
874
+ refuse: clip(t.refuse, 200),
875
+ peak: clip(t.peak, 200),
876
+ concept: !!t.concept,
877
+ round: round,
878
+ source: t.source === 'user' ? 'user' : 'zara_scout',
879
+ added_at: new Date().toISOString(),
880
+ votes: {},
881
+ user: null,
882
+ };
883
+ board.tiles.push(tile);
884
+ added.push(tile);
885
+ }
886
+ if (board.scout && added.length) board.scout.status = 'awaiting_jury';
887
+ var saved = persistBoard(board, opts.workspaceDir);
888
+ saved.added = added;
889
+ saved.skipped = skipped;
890
+ return saved;
891
+ }
892
+
893
+ // Older `frames` input (url/title) is still accepted and becomes tiles.
894
+ function addFrames(opts) {
895
+ opts = opts || {};
896
+ var frames = opts.frames || (opts.frame ? [opts.frame] : []);
897
+ return addTiles({
898
+ project: opts.project,
899
+ boardId: opts.boardId,
900
+ workspaceDir: opts.workspaceDir,
901
+ tiles: frames.map(function(f) {
902
+ return {
903
+ page_url: f.page_url || f.url, element: f.element || f.title, section: f.section || f.surface,
904
+ image_path: f.image_path, image_url: f.image_url || f.url,
905
+ steal: f.steal, refuse: f.refuse, peak: f.peak, tokens: f.tokens,
906
+ };
907
+ }),
908
+ });
909
+ }
910
+
911
+ function getBoard(opts) {
912
+ opts = opts || {};
913
+ var projectId = opts.project || session.getProjectId();
914
+ var boardId = opts.boardId;
915
+ if (!boardId) throw new Error('board is required');
916
+ var board = loadJson(path.join(boardDir(projectId, boardId), 'board.json'));
917
+ if (!board) throw new Error('Board not found: ' + boardId);
918
+ return board;
919
+ }
920
+
921
+ // Persona votes are stored per tile as advice. Zara may not vote on tiles she
922
+ // scouted, and no persona vote can change a tile the user has decided.
923
+ function recordPersonaVotes(opts) {
924
+ opts = opts || {};
925
+ var board = loadUiBoard(opts);
926
+ var votes = opts.votes || [];
927
+ var ignored = [];
928
+ for (var i = 0; i < votes.length; i++) {
929
+ var v = votes[i] || {};
930
+ var persona = String(v.persona || '').toLowerCase();
931
+ var vote = String(v.vote || '').toLowerCase();
932
+ var tile = findTile(board, v.tile);
933
+ if (!tile) { ignored.push({ tile: v.tile, reason: 'unknown tile' }); continue; }
934
+ if (JURY.indexOf(persona) === -1) { ignored.push({ tile: v.tile, persona: persona, reason: 'not on the jury' }); continue; }
935
+ if (vote !== 'keep' && vote !== 'skip') { ignored.push({ tile: v.tile, reason: 'vote must be keep or skip' }); continue; }
936
+ tile.votes[persona] = { vote: vote, because: clip(v.because, 240), at: new Date().toISOString() };
937
+ }
938
+ var synthesis = { verdict: String(opts.verdict || '').toUpperCase(), summary: clip(opts.summary, 1000), top3: opts.top3 || [] };
939
+ applyJuryOutcome(board, synthesis);
940
+ board.synthesis = synthesis;
941
+ var saved = persistBoard(board, opts.workspaceDir);
942
+ saved.ignored = ignored;
943
+ return saved;
944
+ }
945
+
946
+ function recordJuryVerdict(opts) {
947
+ return recordPersonaVotes(opts);
948
+ }
949
+
950
+ // The user's choice on a tile is final. It is written to an append-only
951
+ // ledger, and a rejection blocks that element from ever being re-added.
952
+ function recordUserDecision(opts) {
953
+ opts = opts || {};
954
+ var board = loadUiBoard(opts);
955
+ var tile = findTile(board, opts.tile);
956
+ if (!tile) throw new Error('Tile not found: ' + opts.tile);
957
+ var decision = String(opts.decision || '').toLowerCase();
958
+ if (decision !== 'clear' && USER_DECISIONS.indexOf(decision) === -1) {
959
+ throw new Error('Decision must be keep, reject, maybe, or clear.');
960
+ }
961
+ var key = tileKey(tile.page_url, tile.element);
962
+ var now = new Date().toISOString();
963
+ tile.user = decision === 'clear' ? null : { decision: decision, note: clip(opts.note, 500), at: now };
964
+ board.blocked = board.blocked.filter(function(k) { return k !== key; });
965
+ if (decision === 'reject') board.blocked.push(key);
966
+ board.decisions.push({ tile_id: tile.id, n: tile.n, decision: decision, note: clip(opts.note, 500), at: now, by: 'user' });
967
+ return persistBoard(board, opts.workspaceDir);
968
+ }
969
+
970
+ // User-requested rounds have no cap. Zara gets the user's notes, keeps and
971
+ // rejections as the top constraints.
972
+ function requestRescout(opts) {
973
+ opts = opts || {};
974
+ var board = loadUiBoard(opts);
975
+ board.scout = board.scout || { persona: 'zara', round: 1 };
976
+ board.scout.round = (board.scout.round || 1) + 1;
977
+ board.scout.status = 'awaiting_scout';
978
+ board.scout.user_note = clip(opts.note, 500);
979
+ board.scout.brief = defaultScoutBrief(board.task, board.scout.round, scoutConstraints(board, opts.note));
980
+ board.decisions.push({ decision: 'rescout', note: clip(opts.note, 500), at: new Date().toISOString(), by: 'user' });
981
+ return persistBoard(board, opts.workspaceDir);
982
+ }
983
+
984
+ // Locking writes the user's direction into the session so every persona
985
+ // reads it before designing.
986
+ function lockDirection(opts) {
987
+ opts = opts || {};
988
+ var board = loadUiBoard(opts);
989
+ var dir = computeDirection(board);
990
+ if (!dir.kept.length) throw new Error('Keep at least one tile before locking the direction.');
991
+ var now = new Date().toISOString();
992
+ board.direction = dir;
993
+ board.direction.locked_at = now;
994
+ board.direction.note = clip(opts.note, 500);
995
+ board.scout = board.scout || {};
996
+ board.scout.status = 'done';
997
+ board.decisions.push({ decision: 'lock', note: clip(opts.note, 500), at: now, by: 'user' });
998
+ var saved = persistBoard(board, opts.workspaceDir);
999
+ try {
1000
+ session.update({
1001
+ project: board.project_id,
1002
+ patch: {
1003
+ design_direction: {
1004
+ board_id: board.board_id,
1005
+ task: board.task,
1006
+ locked_at: now,
1007
+ locked_by: 'user',
1008
+ rule: 'User-locked mood board. Outranks every persona opinion. Do not reintroduce rejected elements.',
1009
+ adjectives: board.brief.adjectives,
1010
+ not_this: board.brief.not_this,
1011
+ note: board.direction.note,
1012
+ palette: dir.palette,
1013
+ fonts: dir.fonts,
1014
+ kept: dir.kept,
1015
+ rejected: dir.rejected,
1016
+ },
1017
+ },
1018
+ });
1019
+ } catch (e) {
1020
+ saved.sessionError = e.message;
1021
+ }
1022
+ return saved;
1023
+ }
1024
+
1025
+ function setBrief(opts) {
1026
+ opts = opts || {};
1027
+ var board = loadUiBoard(opts);
1028
+ var next = defaultBrief(board.task, {
1029
+ job: opts.job || board.brief.job,
1030
+ adjectives: opts.adjectives || board.brief.adjectives,
1031
+ not_this: opts.not_this != null ? opts.not_this : board.brief.not_this,
1032
+ sections: opts.sections || board.brief.sections,
1033
+ confirmed_by_user: opts.confirmed_by_user != null ? opts.confirmed_by_user : board.brief.confirmed_by_user,
1034
+ });
1035
+ board.brief = next;
1036
+ if (board.scout) {
1037
+ board.scout.brief = defaultScoutBrief(board.task, board.scout.round || 1, scoutConstraints(board, board.scout.user_note));
1038
+ }
1039
+ return persistBoard(board, opts.workspaceDir);
493
1040
  }
494
1041
 
495
1042
  // ─── Add user reference and rerun ─────────────────────────────────────────────
@@ -552,12 +1099,76 @@ function listBoards(projectId) {
552
1099
  .map(function(d) { return d.name; });
553
1100
  }
554
1101
 
1102
+ // ─── Token capture script (run in the page via CDP Runtime.evaluate) ─────────
1103
+
1104
+ // Reads the element's real computed styles plus its children's, so the board
1105
+ // shows the site's actual colours and fonts instead of guesses.
1106
+ // `text` narrows the match to the first element whose visible text contains
1107
+ // it, because a bare `h1` often hits the logo instead of the headline.
1108
+ function captureScript(selector, text) {
1109
+ var sel = JSON.stringify(String(selector || 'body'));
1110
+ var txt = JSON.stringify(String(text || '').toLowerCase());
1111
+ return [
1112
+ '(function () {',
1113
+ ' var all = Array.prototype.slice.call(document.querySelectorAll(' + sel + '));',
1114
+ ' function area(el) { var r = el.getBoundingClientRect(); return r.width * r.height; }',
1115
+ ' var hits = all.filter(function (el) { return area(el) > 0 && (!' + txt + ' || el.textContent.toLowerCase().indexOf(' + txt + ') !== -1); });',
1116
+ ' var root = ' + txt + ' ? hits.sort(function (a, b) { return area(a) - area(b); })[0] : hits[0];',
1117
+ ' if (!root) return { error: "No visible element matches " + ' + sel + ' + (' + txt + ' ? " with text " + ' + txt + ' : "") };',
1118
+ ' function hex(c) {',
1119
+ ' var m = String(c).match(/rgba?\\((\\d+),\\s*(\\d+),\\s*(\\d+)(?:,\\s*([\\d.]+))?/);',
1120
+ ' if (!m || m[4] === "0") return null;',
1121
+ ' return "#" + [m[1], m[2], m[3]].map(function (n) { return ("0" + parseInt(n, 10).toString(16)).slice(-2); }).join("");',
1122
+ ' }',
1123
+ ' var counts = {}, fonts = [];',
1124
+ ' var nodes = [root].concat(Array.prototype.slice.call(root.querySelectorAll("*"), 0, 300));',
1125
+ ' nodes.forEach(function (el) {',
1126
+ ' var s = getComputedStyle(el);',
1127
+ ' [s.color, s.backgroundColor, s.borderTopColor].forEach(function (c) { var h = hex(c); if (h) counts[h] = (counts[h] || 0) + 1; });',
1128
+ ' var f = s.fontFamily.split(",")[0].replace(/["\']/g, "").trim();',
1129
+ ' if (f && fonts.indexOf(f) === -1) fonts.push(f);',
1130
+ ' });',
1131
+ // Capturing off-screen areas repeats content, so bring the element into
1132
+ // view and keep the crop within one viewport.
1133
+ ' root.scrollIntoView({ block: "start" });',
1134
+ ' var rs = getComputedStyle(root), r = root.getBoundingClientRect(), pad = 8;',
1135
+ ' return {',
1136
+ ' colors: Object.keys(counts).sort(function (a, b) { return counts[b] - counts[a]; }).slice(0, 8),',
1137
+ ' fonts: fonts.slice(0, 4),',
1138
+ ' radius: rs.borderRadius,',
1139
+ ' spacing: rs.padding,',
1140
+ ' font_size: rs.fontSize,',
1141
+ ' size: Math.round(r.width) + "x" + Math.round(r.height),',
1142
+ ' clip: { x: Math.max(0, r.left + scrollX - pad), y: Math.max(0, r.top + scrollY - pad), width: Math.ceil(r.width + pad * 2), height: Math.min(innerHeight, Math.ceil(r.height + pad * 2)), scale: 1 }',
1143
+ ' };',
1144
+ '})()',
1145
+ ].join('\n');
1146
+ }
1147
+
555
1148
  module.exports = {
556
1149
  buildMoodBoard: buildMoodBoard,
557
1150
  critiqueMoodBoard: critiqueMoodBoard,
558
1151
  addUserReferenceAndRerun: addUserReferenceAndRerun,
1152
+ addFrames: addFrames,
1153
+ addTiles: addTiles,
1154
+ getBoard: getBoard,
1155
+ loadUiBoard: loadUiBoard,
1156
+ recordJuryVerdict: recordJuryVerdict,
1157
+ recordPersonaVotes: recordPersonaVotes,
1158
+ recordUserDecision: recordUserDecision,
1159
+ requestRescout: requestRescout,
1160
+ lockDirection: lockDirection,
1161
+ setBrief: setBrief,
1162
+ computeDirection: computeDirection,
1163
+ tileStatus: tileStatus,
1164
+ juryPrompt: juryPrompt,
1165
+ captureScript: captureScript,
1166
+ SECTIONS: SECTIONS,
1167
+ TILE_TARGET: TILE_TARGET,
1168
+ defaultScoutBrief: defaultScoutBrief,
559
1169
  listBoards: listBoards,
560
1170
  boardDir: boardDir,
1171
+ siteOf: siteOf,
561
1172
  formatDsPack: formatDsPack,
562
1173
  tagHeuristics: tagHeuristics,
563
1174
  };