analyzthis_design 2.5.2 → 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.
@@ -70,31 +70,217 @@ function persistBoard(board, workspaceDir) {
70
70
  var projectId = board.project_id;
71
71
  var bDir = boardDir(projectId, board.board_id);
72
72
  board.updated_at = new Date().toISOString();
73
+ if (board.kind === 'ui') normalizeBoard(board);
73
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
+ }
74
83
  var ws = workspaceDir || defaultWorkspaceDir();
75
84
  ensureDir(ws);
76
85
  var workspacePath = path.join(ws, board.board_id + '.json');
77
86
  try { fs.writeFileSync(workspacePath, JSON.stringify(board, null, 2)); }
78
87
  catch (e) { /* best effort */ }
79
- return { board: board, boardDir: bDir, workspacePath: workspacePath };
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);
80
111
  }
81
112
 
82
- function defaultScoutBrief(task, round, vetoNotes) {
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 || '' };
83
161
  var lines = [
84
162
  'Zara Scout Pass, round ' + (round || 1) + '.',
85
- 'Job: ' + String(task).slice(0, 240),
86
- 'Find 3-5 UI frames. Search Google Images, Dribbble, or Pinterest (or live product pages). Open them. Do not invent URLs. Do not generate AI comps.',
87
- 'Lens: Peak-End, first 2 seconds. Same job? Peak feeling? One steal, one refuse. Concept shots allowed if you say they are concept. Never sell a Dribbble hero as shipped product.',
88
- 'Skip generic AI-purple heroes and cinematic GSAP landings unless this job is a marketing page.',
89
- 'For each frame write: steal (one thing), refuse (one thing), peak (one first impression).',
90
- 'Then stop. The jury (Arjun, Noor, Priya, Meera) votes. You do not score your own picks.',
163
+ 'Job: ' + clip(task, 240),
91
164
  ];
92
- if (vetoNotes) {
93
- lines.push('Last jury sent you back. Constraints: ' + String(vetoNotes).slice(0, 500));
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('; '));
94
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));
95
182
  return lines.join('\n');
96
183
  }
97
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
+
98
284
  // ─── Source discovery for mood references ───────────────────────────────────
99
285
 
100
286
  // Known hosts that expose an image or a design article we can fetch as text.
@@ -394,7 +580,10 @@ async function buildMoodBoard(opts) {
394
580
 
395
581
  var refs = [];
396
582
  var userRefs = opts.userReferences || [];
397
- var kind = opts.kind === 'ui' ? 'ui' : 'mood';
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);
398
587
  var discover = kind === 'ui' ? !!(opts.urls && opts.urls.length) : opts.discover !== false;
399
588
 
400
589
  if (discover) {
@@ -435,7 +624,6 @@ async function buildMoodBoard(opts) {
435
624
  created_at: new Date().toISOString(),
436
625
  updated_at: new Date().toISOString(),
437
626
  references: refs,
438
- frames: [],
439
627
  design_system_pack: dsPack,
440
628
  deliberation: null,
441
629
  synthesis: null,
@@ -443,12 +631,18 @@ async function buildMoodBoard(opts) {
443
631
  scout: kind === 'ui' ? {
444
632
  persona: 'zara',
445
633
  round: 1,
446
- max_rounds: 3,
447
634
  status: 'awaiting_scout',
448
- brief: defaultScoutBrief(task, 1, ''),
635
+ brief: defaultScoutBrief(task, 1, { adjectives: brief.adjectives, not_this: brief.not_this }),
449
636
  last_veto: '',
450
637
  } : null,
451
638
  };
639
+ if (kind === 'ui') {
640
+ board.brief = brief;
641
+ board.tiles = [];
642
+ board.decisions = [];
643
+ board.blocked = [];
644
+ board.version = 3;
645
+ }
452
646
 
453
647
  return persistBoard(board, workspaceDir);
454
648
  }
@@ -476,38 +670,25 @@ async function critiqueMoodBoard(opts) {
476
670
  if (!board) throw new Error('Board not found: ' + boardId);
477
671
 
478
672
  var isUi = board.kind === 'ui';
479
- var experts = opts.experts || (isUi ? ['arjun', 'meera', 'priya', 'noor'] : ['arjun', 'meera', 'priya', 'zara', 'noor']);
673
+ if (isUi) normalizeBoard(board);
674
+ var experts = opts.experts || (isUi ? JURY.slice() : ['arjun', 'meera', 'priya', 'zara', 'noor']);
480
675
  var task = opts.task || board.task;
481
676
 
482
- if (isUi && (!board.frames || !board.frames.length)) {
483
- var need = new Error('Zara has not scouted yet. Add 3-5 frames, then critique.');
677
+ if (isUi && !board.tiles.length) {
678
+ var need = new Error('Zara has not scouted yet. Add tiles, then critique.');
484
679
  need.code = 'NEED_SCOUT';
485
680
  throw need;
486
681
  }
487
682
 
488
683
  var refsSummary = isUi
489
- ? board.frames.map(function(f, idx) {
490
- return '[' + (idx + 1) + '] ' + f.title + ' round=' + (f.round || 1) +
491
- ' steal=' + (f.steal || '') + ' refuse=' + (f.refuse || '') +
492
- ' peak=' + (f.peak || '') + ' — ' + (f.url || '').slice(0, 120);
493
- }).join('\n')
684
+ ? juryTileLines(board)
494
685
  : board.references.map(function(r, idx) {
495
686
  return '[' + (idx + 1) + '] ' + r.title + ' (' + r.type + ') tags=' + (r.tags || []).join(',') + ' — ' + (r.url || r.description || '').slice(0, 120);
496
687
  }).join('\n');
497
688
 
498
689
  var dsText = formatDsPack(board.design_system_pack);
499
690
 
500
- var critiqueTask = isUi ? [
501
- 'UI image-board jury for: ' + task,
502
- 'Zara scouted these frames. She does not vote. KEEP or SKIP each frame with one because-sentence.',
503
- 'Do not reward cinematic marketing motion on a daily working surface.',
504
- '',
505
- 'Frames:\n' + refsSummary,
506
- '',
507
- dsText,
508
- '',
509
- 'Use the Honeycomb rigor matrix. Rank the best 2-3 frames. Verdict SHIP only if the set fits the job. REVISE or BLOCK sends Zara to scout again.'
510
- ].join('\n') : [
691
+ var critiqueTask = isUi ? juryPrompt(board, dsText) : [
511
692
  'Mood-board critique for: ' + task,
512
693
  '',
513
694
  'References:\n' + refsSummary,
@@ -539,63 +720,192 @@ async function critiqueMoodBoard(opts) {
539
720
  return { board: saved.board, boardDir: saved.boardDir, workspacePath: saved.workspacePath, orchestratorResult: result };
540
721
  }
541
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.
542
725
  function applyJuryOutcome(board, synthesis) {
543
726
  var verdict = String((synthesis && synthesis.verdict) || '').toUpperCase();
544
727
  var scout = board.scout || {};
545
- var maxRounds = scout.max_rounds || 3;
546
728
  var notes = '';
547
729
  if (synthesis && synthesis.summary) notes = synthesis.summary;
548
730
  else if (synthesis && synthesis.top3 && synthesis.top3.join) notes = synthesis.top3.join('; ');
549
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
+ }
737
+
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
+ }
550
783
 
551
- if (verdict === 'SHIP') {
552
- scout.status = 'done';
553
- scout.last_veto = '';
554
- } else if ((scout.round || 1) >= maxRounds) {
555
- scout.status = 'raj';
556
- scout.last_veto = String(notes).slice(0, 600);
557
- scout.brief = 'Max scout rounds reached. Raj or the designer picks from the frames on this board.\n' + scout.last_veto;
558
- } else {
559
- scout.round = (scout.round || 1) + 1;
560
- scout.status = 'awaiting_scout';
561
- scout.last_veto = String(notes).slice(0, 600);
562
- scout.brief = defaultScoutBrief(board.task, scout.round, scout.last_veto);
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);
563
790
  }
564
- board.scout = scout;
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.');
565
804
  }
566
805
 
567
- function addFrames(opts) {
568
- opts = opts || {};
569
- var projectId = opts.project || session.getProjectId();
570
- var boardId = opts.boardId;
571
- var incoming = opts.frames || (opts.frame ? [opts.frame] : []);
572
- if (!boardId || !incoming.length) throw new Error('--board and at least one frame are required');
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
+ }
573
822
 
574
- var bDir = boardDir(projectId, boardId);
575
- var board = loadJson(path.join(bDir, 'board.json'));
576
- if (!board) throw new Error('Board not found: ' + boardId);
577
- if (board.kind !== 'ui') throw new Error('Frames belong on a --kind ui board.');
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
+ }
578
829
 
579
- board.frames = board.frames || [];
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);
580
838
  var round = (board.scout && board.scout.round) || 1;
839
+ var added = [];
840
+ var skipped = [];
581
841
  for (var i = 0; i < incoming.length; i++) {
582
- var f = incoming[i] || {};
583
- if (!f.url) continue;
584
- board.frames.push({
585
- id: generateBoardId(),
586
- url: f.url,
587
- title: f.title || 'Frame',
588
- steal: f.steal || '',
589
- refuse: f.refuse || '',
590
- peak: f.peak || '',
591
- surface: f.surface || '',
592
- source: 'zara_scout',
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,
593
877
  round: round,
878
+ source: t.source === 'user' ? 'user' : 'zara_scout',
594
879
  added_at: new Date().toISOString(),
595
- });
880
+ votes: {},
881
+ user: null,
882
+ };
883
+ board.tiles.push(tile);
884
+ added.push(tile);
596
885
  }
597
- if (board.scout) board.scout.status = 'awaiting_jury';
598
- return persistBoard(board, opts.workspaceDir);
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
+ });
599
909
  }
600
910
 
601
911
  function getBoard(opts) {
@@ -608,17 +918,124 @@ function getBoard(opts) {
608
918
  return board;
609
919
  }
610
920
 
611
- function recordJuryVerdict(opts) {
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) {
612
924
  opts = opts || {};
613
- var board = getBoard(opts);
614
- if (board.kind !== 'ui') throw new Error('Jury verdict belongs on a ui board.');
615
- var synthesis = {
616
- verdict: String(opts.verdict || '').toUpperCase(),
617
- summary: opts.summary || '',
618
- top3: opts.top3 || [],
619
- };
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 || [] };
620
939
  applyJuryOutcome(board, synthesis);
621
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
+ }
622
1039
  return persistBoard(board, opts.workspaceDir);
623
1040
  }
624
1041
 
@@ -682,16 +1099,76 @@ function listBoards(projectId) {
682
1099
  .map(function(d) { return d.name; });
683
1100
  }
684
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
+
685
1148
  module.exports = {
686
1149
  buildMoodBoard: buildMoodBoard,
687
1150
  critiqueMoodBoard: critiqueMoodBoard,
688
1151
  addUserReferenceAndRerun: addUserReferenceAndRerun,
689
1152
  addFrames: addFrames,
1153
+ addTiles: addTiles,
690
1154
  getBoard: getBoard,
1155
+ loadUiBoard: loadUiBoard,
691
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,
692
1168
  defaultScoutBrief: defaultScoutBrief,
693
1169
  listBoards: listBoards,
694
1170
  boardDir: boardDir,
1171
+ siteOf: siteOf,
695
1172
  formatDsPack: formatDsPack,
696
1173
  tagHeuristics: tagHeuristics,
697
1174
  };