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.
@@ -0,0 +1,426 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Mood board view (v3).
5
+ *
6
+ * renderBoardHtml — turns a UI element board into one self-contained HTML
7
+ * page: tiles grouped by section, real colour swatches, persona votes as
8
+ * advice, and the user's keep / maybe / reject on every tile.
9
+ * serveBoard — serves that page on 127.0.0.1 so the user's clicks are saved
10
+ * straight into board.json. Only same-host requests carrying the per-run
11
+ * token can write.
12
+ */
13
+
14
+ var fs = require('fs');
15
+ var path = require('path');
16
+ var http = require('http');
17
+ var crypto = require('crypto');
18
+
19
+ var SECTION_LABELS = {
20
+ first_screen: 'First screen',
21
+ typography: 'Typography',
22
+ color: 'Colour and contrast',
23
+ components: 'Components and density',
24
+ imagery: 'Imagery and icons',
25
+ motion: 'Motion and interaction',
26
+ };
27
+ var SECTION_TARGETS = {
28
+ first_screen: '3–5', typography: '3–4', color: '3–4', components: '6–8', imagery: '2–4', motion: '1–3',
29
+ };
30
+ var PERSONA_NAMES = { arjun: 'Arjun', meera: 'Meera', priya: 'Priya', noor: 'Noor', zara: 'Zara' };
31
+ var ASSET_RE = /^[a-f0-9]{6,32}(-[1-4])?\.(png|jpe?g|webp|gif|webm|mp4)$/i;
32
+ var MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', webm: 'video/webm', mp4: 'video/mp4' };
33
+
34
+ function esc(value) {
35
+ return String(value == null ? '' : value)
36
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
37
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
38
+ }
39
+
40
+ function safeHref(url) {
41
+ return /^https?:\/\//i.test(String(url || '')) ? esc(url) : '#';
42
+ }
43
+
44
+ function imageSrc(tile) {
45
+ if (tile.image && ASSET_RE.test(path.basename(tile.image))) return 'assets/' + esc(path.basename(tile.image));
46
+ if (tile.image_url && /^https?:\/\//i.test(tile.image_url)) return esc(tile.image_url);
47
+ return '';
48
+ }
49
+
50
+ function assetSrc(rel) {
51
+ return rel && ASSET_RE.test(path.basename(rel)) ? 'assets/' + esc(path.basename(rel)) : '';
52
+ }
53
+
54
+ // Film tiles: looping muted video with the poster still, then the waypoint strip.
55
+ function renderShot(tile, src) {
56
+ var alt = esc(tile.element + ' on ' + tile.site);
57
+ var video = assetSrc(tile.video);
58
+ if (!video) {
59
+ return src ? ' <img src="' + src + '" alt="' + alt + '" loading="lazy" referrerpolicy="no-referrer">' : ' <div class="noimg">No image</div>';
60
+ }
61
+ var stills = (tile.stills || []).map(assetSrc).filter(Boolean);
62
+ return ' <video class="film" src="' + video + '"' + (src ? ' poster="' + src + '"' : '') +
63
+ ' muted loop autoplay playsinline preload="metadata" aria-label="' + alt + ' (scroll film)"></video>' +
64
+ (stills.length ? '\n <div class="stills">' + stills.map(function(s, i) {
65
+ return '<img src="' + s + '" alt="Depth ' + (i + 1) + '" loading="lazy">';
66
+ }).join('') + '</div>' : '');
67
+ }
68
+
69
+ function statusOf(tile) {
70
+ if (tile.user && tile.user.decision) return { status: tile.user.decision, by: 'user' };
71
+ var keep = 0, skip = 0;
72
+ Object.keys(tile.votes || {}).forEach(function(p) {
73
+ if (tile.votes[p].vote === 'keep') keep++; else if (tile.votes[p].vote === 'skip') skip++;
74
+ });
75
+ if (!keep && !skip) return { status: 'undecided', by: 'none' };
76
+ return { status: keep >= skip ? 'suggested_keep' : 'suggested_skip', by: 'personas' };
77
+ }
78
+
79
+ function renderTokens(tokens) {
80
+ tokens = tokens || {};
81
+ var parts = [];
82
+ if (tokens.colors && tokens.colors.length) {
83
+ parts.push('<div class="swatches">' + tokens.colors.map(function(c) {
84
+ return '<span class="sw" style="background:' + esc(c) + '" title="' + esc(c) + '"></span>';
85
+ }).join('') + '</div>');
86
+ }
87
+ var facts = [];
88
+ if (tokens.fonts && tokens.fonts.length) facts.push(esc(tokens.fonts.join(', ')));
89
+ if (tokens.font_size) facts.push(esc(tokens.font_size));
90
+ if (tokens.radius && tokens.radius !== '0px') facts.push('r ' + esc(tokens.radius));
91
+ if (tokens.spacing && tokens.spacing !== '0px') facts.push('pad ' + esc(tokens.spacing));
92
+ if (facts.length) parts.push('<div class="facts">' + facts.join(' · ') + '</div>');
93
+ return parts.length ? '<div class="tokens">' + parts.join('') + '</div>' : '';
94
+ }
95
+
96
+ function renderVotes(tile) {
97
+ var keys = Object.keys(tile.votes || {});
98
+ if (!keys.length) return '';
99
+ return '<div class="votes"><span class="votes-label">Team advice</span>' + keys.map(function(p) {
100
+ var v = tile.votes[p];
101
+ return '<span class="vote vote-' + esc(v.vote) + '" title="' + esc(v.because) + '">' +
102
+ esc(PERSONA_NAMES[p] || p) + ' ' + (v.vote === 'keep' ? 'keep' : 'skip') + '</span>';
103
+ }).join('') + '</div>';
104
+ }
105
+
106
+ function renderTile(tile, interactive) {
107
+ var st = statusOf(tile);
108
+ var src = imageSrc(tile);
109
+ var decision = tile.user ? tile.user.decision : '';
110
+ var buttons = ['keep', 'maybe', 'reject'].map(function(d) {
111
+ return '<button type="button" class="pick pick-' + d + (decision === d ? ' on' : '') + '" data-tile="' + esc(tile.id) +
112
+ '" data-decision="' + d + '"' + (interactive ? '' : ' disabled') + '>' + d.charAt(0).toUpperCase() + d.slice(1) + '</button>';
113
+ }).join('');
114
+ return [
115
+ '<article class="tile st-' + esc(st.status) + '" id="t-' + esc(tile.id) + '">',
116
+ ' <figure class="shot">',
117
+ renderShot(tile, src),
118
+ ' <span class="num">' + esc(tile.n) + '</span>',
119
+ tile.video ? ' <span class="concept film-tag">Film</span>' : '',
120
+ tile.concept ? ' <span class="concept">Concept</span>' : '',
121
+ ' </figure>',
122
+ ' <div class="body">',
123
+ ' <h3>' + esc(tile.element) + '</h3>',
124
+ ' <a class="src" href="' + safeHref(tile.page_url) + '" target="_blank" rel="noopener noreferrer">' + esc(tile.site || 'source') + ' ↗</a>',
125
+ ' <dl class="notes">',
126
+ tile.steal ? ' <div><dt>Steal</dt><dd>' + esc(tile.steal) + '</dd></div>' : '',
127
+ tile.refuse ? ' <div><dt>Refuse</dt><dd>' + esc(tile.refuse) + '</dd></div>' : '',
128
+ tile.peak ? ' <div><dt>Peak</dt><dd>' + esc(tile.peak) + '</dd></div>' : '',
129
+ ' </dl>',
130
+ renderTokens(tile.tokens),
131
+ renderVotes(tile),
132
+ ' <div class="choose">',
133
+ ' <div class="picks" role="group" aria-label="Your choice for tile ' + esc(tile.n) + '">' + buttons + '</div>',
134
+ ' <input class="note" data-tile="' + esc(tile.id) + '" placeholder="Why? (optional)" maxlength="500" value="' + esc(tile.user ? tile.user.note : '') + '"' + (interactive ? '' : ' disabled') + '>',
135
+ ' </div>',
136
+ ' </div>',
137
+ '</article>',
138
+ ].join('\n');
139
+ }
140
+
141
+ function renderDirection(board) {
142
+ var kept = board.tiles.filter(function(t) { return t.user && t.user.decision === 'keep'; });
143
+ if (!kept.length) return '';
144
+ var palette = [];
145
+ var fonts = [];
146
+ kept.forEach(function(t) {
147
+ ((t.tokens && t.tokens.colors) || []).forEach(function(c) { if (palette.indexOf(c.toLowerCase()) === -1) palette.push(c.toLowerCase()); });
148
+ ((t.tokens && t.tokens.fonts) || []).forEach(function(f) { if (fonts.indexOf(f) === -1) fonts.push(f); });
149
+ });
150
+ var overruled = kept.filter(function(t) {
151
+ return Object.keys(t.votes || {}).some(function(p) { return t.votes[p].vote === 'skip'; });
152
+ }).length;
153
+ return [
154
+ '<section class="direction" aria-label="Your direction so far">',
155
+ ' <h2>Your direction so far</h2>',
156
+ ' <p>' + kept.length + ' kept: ' + kept.map(function(t) { return '#' + esc(t.n) + ' ' + esc(t.element); }).join(', ') + '</p>',
157
+ palette.length ? ' <div class="palette">' + palette.slice(0, 10).map(function(c) {
158
+ return '<span class="chip"><span class="sw lg" style="background:' + esc(c) + '"></span>' + esc(c) + '</span>';
159
+ }).join('') + '</div>' : '',
160
+ fonts.length ? ' <p class="fonts">' + fonts.slice(0, 4).map(function(f) { return '<span style="font-family:' + esc(JSON.stringify(f)) + ', inherit">' + esc(f) + '</span>'; }).join(' / ') + '</p>' : '',
161
+ overruled ? ' <p class="overruled">You kept ' + overruled + ' tile' + (overruled === 1 ? '' : 's') + ' the team advised skipping. Your call stands.</p>' : '',
162
+ '</section>',
163
+ ].join('\n');
164
+ }
165
+
166
+ function renderBoardHtml(board, opts) {
167
+ opts = opts || {};
168
+ var interactive = !!opts.interactive;
169
+ var brief = board.brief || { job: board.task, adjectives: [], not_this: '', sections: Object.keys(SECTION_LABELS) };
170
+ var tiles = board.tiles || [];
171
+ var counts = { keep: 0, maybe: 0, reject: 0, undecided: 0 };
172
+ tiles.forEach(function(t) {
173
+ var d = t.user && t.user.decision;
174
+ if (d && counts[d] != null) counts[d]++; else counts.undecided++;
175
+ });
176
+ var sites = {};
177
+ tiles.forEach(function(t) { if (t.site) sites[t.site] = true; });
178
+ var scout = board.scout || {};
179
+ var sectionIds = brief.sections && brief.sections.length ? brief.sections.slice() : Object.keys(SECTION_LABELS);
180
+ tiles.forEach(function(t) { if (sectionIds.indexOf(t.section) === -1) sectionIds.push(t.section); });
181
+
182
+ var sectionsHtml = sectionIds.map(function(sid) {
183
+ var list = tiles.filter(function(t) { return t.section === sid; });
184
+ return [
185
+ '<section class="sec" id="sec-' + esc(sid) + '">',
186
+ ' <header><h2>' + esc(SECTION_LABELS[sid] || sid) + '</h2><span class="target">' + list.length + ' of ' + esc(SECTION_TARGETS[sid] || '—') + '</span></header>',
187
+ list.length ? ' <div class="grid">' + list.map(function(t) { return renderTile(t, interactive); }).join('\n') + '</div>'
188
+ : ' <p class="thin">Zara has not found anything for this section yet.</p>',
189
+ '</section>',
190
+ ].join('\n');
191
+ }).join('\n');
192
+
193
+ var juryLine = scout.jury_verdict
194
+ ? '<p class="jury">Team advice: <strong>' + esc(scout.jury_verdict) + '</strong>' + (scout.last_veto ? ' — ' + esc(scout.last_veto) : '') + '</p>'
195
+ : '';
196
+
197
+ return [
198
+ '<!doctype html>',
199
+ '<html lang="en"><head><meta charset="utf-8">',
200
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
201
+ interactive ? '<meta name="board-token" content="' + esc(opts.token || '') + '">' : '',
202
+ '<title>Mood board — ' + esc(brief.job || board.task) + '</title>',
203
+ '<style>' + CSS + '</style>',
204
+ '</head><body class="' + (interactive ? 'live' : 'static') + '">',
205
+ '<header class="top">',
206
+ ' <div class="brief">',
207
+ ' <p class="eyebrow">Mood board · round ' + esc(scout.round || 1) + ' · ' + tiles.length + ' tiles from ' + Object.keys(sites).length + ' sites</p>',
208
+ ' <h1>' + esc(brief.job || board.task) + '</h1>',
209
+ brief.adjectives && brief.adjectives.length ? ' <p class="adj">' + brief.adjectives.map(esc).join(' <span>·</span> ') + (brief.not_this ? ' <em>Not this: ' + esc(brief.not_this) + '</em>' : '') + '</p>' : '',
210
+ juryLine,
211
+ ' </div>',
212
+ ' <div class="tally" aria-live="polite">',
213
+ ' <span class="t-keep">' + counts.keep + ' kept</span><span class="t-maybe">' + counts.maybe + ' maybe</span><span class="t-reject">' + counts.reject + ' rejected</span><span>' + counts.undecided + ' open</span>',
214
+ ' </div>',
215
+ '</header>',
216
+ '<p class="rule">Your choice overrides every persona. Team votes are advice. Images are third-party references for direction only and are not licensed for reuse.</p>',
217
+ interactive ? '' : '<p class="banner">Read-only snapshot. To choose, run <code>npx analyzthis_design moodboard show --board ' + esc(board.board_id) + '</code></p>',
218
+ scout.status === 'done' ? '<p class="banner done">Direction locked. Every persona reads it before designing.</p>' : '',
219
+ renderDirection(board),
220
+ '<main>' + sectionsHtml + '</main>',
221
+ '<footer class="actions">',
222
+ ' <textarea id="roundNote" maxlength="500" placeholder="Tell Zara what to look for next, or why you are locking this" ' + (interactive ? '' : 'disabled') + '></textarea>',
223
+ ' <div class="btns">',
224
+ ' <button type="button" id="rescout" ' + (interactive ? '' : 'disabled') + '>Send Zara back for more</button>',
225
+ ' <button type="button" id="lock" class="primary" ' + (interactive && counts.keep ? '' : 'disabled') + '>Lock this direction</button>',
226
+ ' </div>',
227
+ ' <p class="msg" id="msg" role="status"></p>',
228
+ '</footer>',
229
+ '<script>' + MOTION_JS + '</script>',
230
+ interactive ? '<script>' + CLIENT_JS + '</script>' : '',
231
+ '</body></html>',
232
+ ].join('\n');
233
+ }
234
+
235
+ var CSS = [
236
+ ':root{--paper:#f3efe6;--card:#fffdf8;--ink:#1d1c1a;--muted:#6b665c;--line:#ddd5c4;--keep:#2e6a4e;--maybe:#a87b1f;--reject:#a2412d;--serif:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif;--sans:ui-sans-serif,-apple-system,"Segoe UI",system-ui,sans-serif;--mono:ui-monospace,"SF Mono",Menlo,monospace}',
237
+ '*{box-sizing:border-box}html{scroll-padding-bottom:140px}body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.5 var(--sans);padding:0 clamp(16px,4vw,56px) 160px}',
238
+ '.top{display:flex;gap:32px;justify-content:space-between;align-items:flex-end;padding:48px 0 20px;border-bottom:1px solid var(--ink)}',
239
+ '.eyebrow{margin:0 0 8px;font:600 11px/1 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}',
240
+ 'h1{margin:0;font:400 clamp(30px,4.4vw,56px)/1.05 var(--serif);letter-spacing:-.01em;max-width:22ch}',
241
+ '.adj{margin:14px 0 0;font:italic 18px/1.4 var(--serif)}.adj span{color:var(--muted);font-style:normal}.adj em{display:block;font-style:normal;font-size:14px;color:var(--reject);font-family:var(--sans);margin-top:4px}',
242
+ '.jury{margin:10px 0 0;font-size:13px;color:var(--muted);max-width:70ch}',
243
+ '.tally{display:flex;gap:16px;font:600 12px/1 var(--mono);text-transform:uppercase;letter-spacing:.06em;white-space:nowrap}.t-keep{color:var(--keep)}.t-maybe{color:var(--maybe)}.t-reject{color:var(--reject)}',
244
+ '.rule{margin:12px 0 0;font-size:12px;color:var(--muted)}',
245
+ '.banner{margin:16px 0 0;padding:10px 14px;border:1px solid var(--line);background:var(--card);font-size:13px}.banner.done{border-color:var(--keep);color:var(--keep)}code{font-family:var(--mono);font-size:12px}',
246
+ '.direction{margin:28px 0 0;padding:20px 24px;background:var(--ink);color:var(--paper)}.direction h2{margin:0 0 6px;font:400 22px/1.2 var(--serif)}.direction p{margin:6px 0 0;font-size:13px}',
247
+ '.palette{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px}.chip{display:inline-flex;align-items:center;gap:6px;font:12px var(--mono)}.fonts span{font-size:18px}.overruled{color:#e9c98a}',
248
+ '.sec{margin-top:48px}.sec>header{display:flex;align-items:baseline;justify-content:space-between;border-bottom:1px solid var(--line);padding-bottom:8px;margin-bottom:18px}',
249
+ '.sec h2{margin:0;font:400 26px/1.1 var(--serif)}.target{font:12px var(--mono);color:var(--muted)}.thin{color:var(--muted);font-style:italic}',
250
+ '.grid{columns:300px;column-gap:18px}',
251
+ '.tile{break-inside:avoid;margin:0 0 18px;background:var(--card);border:1px solid var(--line);transition:opacity .2s ease-out,border-color .2s ease-out}',
252
+ '.tile.st-keep{border:2px solid var(--keep)}.tile.st-maybe{border:2px dashed var(--maybe)}.tile.st-reject{opacity:.42}.tile.st-reject img{filter:grayscale(1)}',
253
+ '.shot{position:relative;margin:0;background:repeating-linear-gradient(45deg,#ece6da 0 6px,#f3eee4 6px 12px);border-bottom:1px solid var(--line)}',
254
+ '.shot img,.shot video{display:block;width:100%;height:auto}.stills{display:grid;grid-template-columns:repeat(4,1fr);gap:2px;padding:2px;background:var(--ink)}.concept.film-tag{right:auto;left:44px}.noimg{padding:48px;text-align:center;color:var(--muted)}',
255
+ '.num{position:absolute;top:8px;left:8px;background:var(--ink);color:var(--paper);font:600 11px/1 var(--mono);padding:5px 7px}',
256
+ '.concept{position:absolute;top:8px;right:8px;background:var(--card);border:1px solid var(--line);font:600 10px/1 var(--mono);padding:4px 6px;text-transform:uppercase}',
257
+ '.body{padding:14px 14px 12px}.body h3{margin:0;font:600 15px/1.3 var(--sans)}.src{display:inline-block;margin-top:2px;font:12px var(--mono);color:var(--muted);text-decoration:none}.src:hover{color:var(--ink);text-decoration:underline}',
258
+ '.notes{margin:10px 0 0}.notes div{display:grid;grid-template-columns:52px 1fr;gap:6px;margin-top:4px}.notes dt{font:600 10px/1.9 var(--mono);text-transform:uppercase;color:var(--muted)}.notes dd{margin:0;font-size:13px}',
259
+ '.tokens{margin-top:10px;padding-top:10px;border-top:1px dotted var(--line)}.swatches{display:flex;gap:4px}.sw{display:inline-block;width:18px;height:18px;border:1px solid rgba(0,0,0,.15)}.sw.lg{width:22px;height:22px}',
260
+ '.facts{margin-top:6px;font:11px var(--mono);color:var(--muted)}',
261
+ '.votes{display:flex;flex-wrap:wrap;gap:4px;align-items:center;margin-top:10px}.votes-label{font:600 10px var(--mono);text-transform:uppercase;color:var(--muted);margin-right:2px}',
262
+ '.vote{font-size:11px;padding:2px 6px;border:1px solid var(--line);cursor:help}.vote-keep{color:var(--keep)}.vote-skip{color:var(--reject)}',
263
+ '.choose{margin-top:12px}.picks{display:grid;grid-template-columns:repeat(3,1fr);border:1px solid var(--ink)}',
264
+ '.pick{appearance:none;border:0;border-right:1px solid var(--ink);background:transparent;padding:8px 0;font:600 12px var(--sans);cursor:pointer;color:var(--ink);transition:background .15s ease-out,color .15s ease-out}.pick:last-child{border-right:0}',
265
+ '.pick:hover:not(:disabled){background:#efe8d9}.pick:focus-visible,.note:focus-visible,button:focus-visible,textarea:focus-visible{outline:2px solid var(--ink);outline-offset:2px}',
266
+ '.pick-keep.on{background:var(--keep);color:#fff}.pick-maybe.on{background:var(--maybe);color:#fff}.pick-reject.on{background:var(--reject);color:#fff}.pick:disabled{cursor:not-allowed;opacity:.6}',
267
+ '.note{width:100%;margin-top:6px;border:0;border-bottom:1px solid var(--line);background:transparent;padding:6px 0;font:13px var(--sans);color:var(--ink)}',
268
+ '.actions{position:fixed;left:0;right:0;bottom:0;display:grid;grid-template-columns:1fr auto;gap:12px;align-items:center;padding:14px clamp(16px,4vw,56px);background:var(--card);border-top:1px solid var(--ink)}',
269
+ '.actions textarea{height:44px;resize:none;border:1px solid var(--line);background:var(--paper);padding:10px;font:13px var(--sans)}.btns{display:flex;gap:8px}',
270
+ '.btns button{border:1px solid var(--ink);background:transparent;padding:12px 16px;font:600 13px var(--sans);cursor:pointer}.btns .primary{background:var(--ink);color:var(--paper)}.btns button:disabled{opacity:.45;cursor:not-allowed}',
271
+ '.msg{grid-column:1/-1;margin:0;font-size:12px;color:var(--muted);min-height:1em}',
272
+ '@media (max-width:720px){.top{flex-direction:column;align-items:flex-start}.actions{grid-template-columns:1fr}}',
273
+ '@media (prefers-reduced-motion:reduce){*{transition:none!important}}',
274
+ ].join('\n');
275
+
276
+ // Reduced-motion users get still posters; click a film to play or pause it.
277
+ var MOTION_JS = [
278
+ '(function(){var still=window.matchMedia&&matchMedia("(prefers-reduced-motion: reduce)").matches;',
279
+ 'document.querySelectorAll("video.film").forEach(function(v){if(still){v.removeAttribute("autoplay");v.pause();}',
280
+ 'v.addEventListener("click",function(){v.paused?v.play():v.pause();});});})();',
281
+ ].join('');
282
+
283
+ // Posts each click to the local server, then reloads in place so the page
284
+ // always shows what board.json now says.
285
+ var CLIENT_JS = [
286
+ '(function(){',
287
+ 'var token=document.querySelector("meta[name=board-token]").content;',
288
+ 'var msg=document.getElementById("msg");',
289
+ 'var y=sessionStorage.getItem("mb-scroll");if(y){window.scrollTo(0,+y);sessionStorage.removeItem("mb-scroll");}',
290
+ 'function post(url,body){msg.textContent="Saving…";return fetch(url,{method:"POST",headers:{"Content-Type":"application/json","X-Board-Token":token},body:JSON.stringify(body)})',
291
+ '.then(function(r){return r.json().then(function(j){if(!r.ok)throw new Error(j.error||"Save failed");return j;});})',
292
+ '.then(function(j){sessionStorage.setItem("mb-scroll",String(window.scrollY));location.reload();return j;})',
293
+ '.catch(function(e){msg.textContent=e.message;});}',
294
+ 'function noteFor(id){var el=document.querySelector(".note[data-tile=\\""+id+"\\"]");return el?el.value:"";}',
295
+ 'document.addEventListener("click",function(e){var b=e.target.closest(".pick");if(!b)return;',
296
+ 'var on=b.classList.contains("on");post("/api/decision",{tile:b.dataset.tile,decision:on?"clear":b.dataset.decision,note:noteFor(b.dataset.tile)});});',
297
+ 'document.querySelectorAll(".note").forEach(function(n){n.addEventListener("keydown",function(e){if(e.key!=="Enter")return;',
298
+ 'var on=document.querySelector("#t-"+n.dataset.tile+" .pick.on");if(!on){msg.textContent="Pick keep, maybe or reject first.";return;}',
299
+ 'post("/api/decision",{tile:n.dataset.tile,decision:on.dataset.decision,note:n.value});});});',
300
+ 'var note=document.getElementById("roundNote");',
301
+ 'document.getElementById("rescout").addEventListener("click",function(){post("/api/rescout",{note:note.value});});',
302
+ 'document.getElementById("lock").addEventListener("click",function(){post("/api/lock",{note:note.value});});',
303
+ '})();',
304
+ ].join('');
305
+
306
+ // ─── Local server ───────────────────────────────────────────────────────────
307
+
308
+ function sendJson(res, code, obj) {
309
+ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
310
+ res.end(JSON.stringify(obj));
311
+ }
312
+
313
+ function readBody(req, limit) {
314
+ return new Promise(function(resolve, reject) {
315
+ var size = 0;
316
+ var chunks = [];
317
+ req.on('data', function(c) {
318
+ size += c.length;
319
+ if (size > limit) { reject(new Error('Request too large')); req.destroy(); return; }
320
+ chunks.push(c);
321
+ });
322
+ req.on('end', function() {
323
+ try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')); }
324
+ catch (e) { reject(new Error('Invalid JSON')); }
325
+ });
326
+ req.on('error', reject);
327
+ });
328
+ }
329
+
330
+ function serveBoard(opts) {
331
+ opts = opts || {};
332
+ var mb = require('./moodboard');
333
+ var board = mb.loadUiBoard({ project: opts.project, boardId: opts.boardId });
334
+ var projectId = board.project_id;
335
+ var boardId = board.board_id;
336
+ var bDir = mb.boardDir(projectId, boardId);
337
+ var token = crypto.randomBytes(16).toString('hex');
338
+ var host = '127.0.0.1';
339
+ var ref = { project: projectId, boardId: boardId, workspaceDir: opts.workspaceDir };
340
+
341
+ var server = http.createServer(function(req, res) {
342
+ var port = server.address().port;
343
+ // Blocks DNS-rebinding: only our own host names may talk to this server.
344
+ var allowed = [host + ':' + port, 'localhost:' + port];
345
+ if (allowed.indexOf(String(req.headers.host || '')) === -1) return sendJson(res, 403, { error: 'Forbidden host' });
346
+ var url = new URL(req.url, 'http://' + host + ':' + port);
347
+
348
+ if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
349
+ var html;
350
+ try { html = renderBoardHtml(mb.loadUiBoard(ref), { interactive: true, token: token }); }
351
+ catch (e) { return sendJson(res, 500, { error: e.message }); }
352
+ res.writeHead(200, {
353
+ 'Content-Type': 'text/html; charset=utf-8',
354
+ 'Cache-Control': 'no-store',
355
+ 'Content-Security-Policy': "default-src 'none'; img-src 'self' https: http: data:; media-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; font-src 'self'",
356
+ 'Referrer-Policy': 'no-referrer',
357
+ });
358
+ return res.end(html);
359
+ }
360
+
361
+ if (req.method === 'GET' && url.pathname.indexOf('/assets/') === 0) {
362
+ var name = url.pathname.slice('/assets/'.length);
363
+ if (!ASSET_RE.test(name)) return sendJson(res, 404, { error: 'Not found' });
364
+ var file = path.join(bDir, 'assets', name);
365
+ if (!fs.existsSync(file)) return sendJson(res, 404, { error: 'Not found' });
366
+ var type = MIME[path.extname(name).toLowerCase().slice(1)];
367
+ var total = fs.statSync(file).size;
368
+ // Byte ranges let browsers (Safari especially) stream and loop films.
369
+ var range = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range || ''));
370
+ if (range && (range[1] || range[2])) {
371
+ var start = range[1] ? Number(range[1]) : Math.max(0, total - Number(range[2]));
372
+ var end = range[1] && range[2] ? Math.min(Number(range[2]), total - 1) : total - 1;
373
+ if (start > end || start >= total) {
374
+ res.writeHead(416, { 'Content-Range': 'bytes */' + total });
375
+ return res.end();
376
+ }
377
+ res.writeHead(206, { 'Content-Type': type, 'Content-Range': 'bytes ' + start + '-' + end + '/' + total, 'Content-Length': end - start + 1, 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-store' });
378
+ return fs.createReadStream(file, { start: start, end: end }).pipe(res);
379
+ }
380
+ res.writeHead(200, { 'Content-Type': type, 'Content-Length': total, 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-store' });
381
+ return fs.createReadStream(file).pipe(res);
382
+ }
383
+
384
+ if (req.method === 'POST' && url.pathname.indexOf('/api/') === 0) {
385
+ if (req.headers['x-board-token'] !== token) return sendJson(res, 403, { error: 'Missing or wrong board token' });
386
+ readBody(req, 16 * 1024).then(function(body) {
387
+ var out;
388
+ if (url.pathname === '/api/decision') {
389
+ out = mb.recordUserDecision({ project: projectId, boardId: boardId, workspaceDir: opts.workspaceDir, tile: String(body.tile || ''), decision: body.decision, note: body.note });
390
+ } else if (url.pathname === '/api/rescout') {
391
+ out = mb.requestRescout({ project: projectId, boardId: boardId, workspaceDir: opts.workspaceDir, note: body.note });
392
+ } else if (url.pathname === '/api/lock') {
393
+ out = mb.lockDirection({ project: projectId, boardId: boardId, workspaceDir: opts.workspaceDir, note: body.note });
394
+ } else {
395
+ return sendJson(res, 404, { error: 'Not found' });
396
+ }
397
+ if (opts.onChange) opts.onChange(url.pathname.slice(5), out.board);
398
+ sendJson(res, 200, { ok: true, status: out.board.scout && out.board.scout.status });
399
+ }).catch(function(e) {
400
+ sendJson(res, 400, { error: e.message });
401
+ });
402
+ return;
403
+ }
404
+
405
+ sendJson(res, 404, { error: 'Not found' });
406
+ });
407
+
408
+ return new Promise(function(resolve, reject) {
409
+ function listen(port) {
410
+ server.once('error', function(err) {
411
+ if (err.code === 'EADDRINUSE' && port !== 0) return listen(0);
412
+ reject(err);
413
+ });
414
+ server.listen(port, host, function() {
415
+ resolve({ server: server, token: token, url: 'http://' + host + ':' + server.address().port + '/' });
416
+ });
417
+ }
418
+ listen(opts.port != null ? Number(opts.port) : 4317);
419
+ });
420
+ }
421
+
422
+ module.exports = {
423
+ renderBoardHtml: renderBoardHtml,
424
+ serveBoard: serveBoard,
425
+ esc: esc,
426
+ };