create-pmos 0.204.0 → 0.206.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 (36) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +2 -2
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/payload/state/log.md +1 -1
  7. package/workspace/c3-capture-test.js +206 -0
  8. package/workspace/c3-ib-more-test.js +7 -1
  9. package/workspace/extension/README.md +22 -7
  10. package/workspace/extension/capture.js +114 -0
  11. package/workspace/extension/manifest.json +49 -8
  12. package/workspace/extension/popup.css +28 -0
  13. package/workspace/extension/popup.html +15 -0
  14. package/workspace/extension/popup.js +195 -0
  15. package/workspace/guide/jira.md +1 -0
  16. package/workspace/guide/report-design.md +15 -1
  17. package/workspace/lib/engine/boards.js +45 -2
  18. package/workspace/lib/extension-cli.js +6 -4
  19. package/workspace/lib/jira-cli.js +26 -3
  20. package/workspace/lib/jira-status.js +136 -6
  21. package/workspace/lib/report-design.js +62 -4
  22. package/workspace/lib/routes-boards.js +7 -3
  23. package/workspace/lib/routes-report-design.js +33 -0
  24. package/workspace/package-lock.json +2 -2
  25. package/workspace/package.json +1 -1
  26. package/workspace/public/pm/c3-boot.js +2 -1
  27. package/workspace/public/pm/c3-capture.css +23 -0
  28. package/workspace/public/pm/c3-capture.js +152 -0
  29. package/workspace/public/pm/c3-demo-boards.js +11 -1
  30. package/workspace/public/pm/c3-design-studio.js +17 -5
  31. package/workspace/public/pm/c3-ib-sprints.js +60 -13
  32. package/workspace/public/pm/c3-initiatives.css +5 -0
  33. package/workspace/public/pm/c3-shell.js +2 -0
  34. package/workspace/skills/jira-sync.skill +11 -5
  35. package/workspace/skills/report-design.skill +1 -0
  36. package/workspace/test-lists/report-design-studio-ui.json +3 -3
@@ -47,7 +47,8 @@ const issues = require('./issues.js');
47
47
 
48
48
  const EVENTS = Object.freeze(['describe', 'sample', 'fetched', 'took', 'turn', 'version', 'accept', 'revert', 'error', 'cycle-decision']);
49
49
  const FORMATS = Object.freeze(['deck', 'doc']);
50
- const SAMPLE_KINDS = Object.freeze(['pdf', 'pptx', 'docx', 'image', 'link', 'pmos-doc']);
50
+ const SAMPLE_KINDS = Object.freeze(['pdf', 'pptx', 'docx', 'image', 'pictures', 'link', 'pmos-doc']);
51
+ const MAX_PICTURES = 12;
51
52
  const MAX_SAMPLE_BYTES = 20 * 1024 * 1024;
52
53
  const MAX_THUMB_BYTES = 256 * 1024;
53
54
  const MAX_TEXT = 4000;
@@ -118,7 +119,8 @@ function fold(events) {
118
119
  if (FORMATS.includes(l.format)) st.format = l.format;
119
120
  st.log.push({ at: l.at, who: 'you', kind: 'describe', text: str(l.text) });
120
121
  } else if (l.ev === 'sample') {
121
- st.samples.set(l.id, { id: l.id, name: str(l.name), kind: l.kind, source: str(l.source), ext: str(l.ext), took: null, readable: l.readable !== false, at: l.at });
122
+ st.samples.set(l.id, { id: l.id, name: str(l.name), kind: l.kind, source: str(l.source), ext: str(l.ext), took: null, readable: l.readable !== false, at: l.at,
123
+ ...(l.via === 'chrome' ? { via: 'chrome' } : {}), ...(Number.isInteger(l.count) ? { count: l.count } : {}), ...(Number.isInteger(l.pages) ? { pages: l.pages } : {}) });
122
124
  st.log.push({ at: l.at, who: 'you', kind: 'sample', text: str(l.name), sample: l.id });
123
125
  } else if (l.ev === 'fetched') {
124
126
  const s = st.samples.get(l.id);
@@ -220,6 +222,32 @@ function addSample(root, cadence, input, { actor, now } = {}) {
220
222
  writeAtomic(path.join(p.samples, `${id}.md`), text);
221
223
  const fm = /^---\r?\n[\s\S]*?\btitle:\s*["']?([^\n"']+)/.exec(text);
222
224
  rec = { id, name: oneLine(input.name || (fm ? fm[1] : rel.split('/').slice(-2).join(' ')), 120), kind: 'pmos-doc', source: rel, ext: 'md', readable: true, thumb: null, text_file: null };
225
+ } else if (str(input.kind) === 'pictures' || Array.isArray(input.pictures)) {
226
+ // pictures of a whole page, scrolled top to bottom, from the Chrome extension (the page frames PMOS; see extension/popup.js)
227
+ const list = Array.isArray(input.pictures) ? input.pictures : null;
228
+ if (str(input.kind) !== 'pictures' || !list || !list.length) return refuse(400, 'pictures needs kind "pictures" and a list of pictures (base64 JPEG or PNG)');
229
+ if (list.length > MAX_PICTURES) return refuse(400, `at most ${MAX_PICTURES} pictures in one sample`);
230
+ const bufs = [];
231
+ let total = 0;
232
+ for (const [i, raw] of list.entries()) {
233
+ if (typeof raw !== 'string' || !raw) return refuse(400, `picture ${i + 1} is empty`);
234
+ const b64 = raw.replace(/^data:[^,]*,/, '');
235
+ total += Math.floor(b64.length * 3 / 4);
236
+ if (total > MAX_SAMPLE_BYTES + 4 * list.length) return refuse(413, 'a sample is at most 20 MB');
237
+ const buf = Buffer.from(b64, 'base64');
238
+ const s = buf.length ? sniff(buf) : null;
239
+ if (!s || s.kind !== 'image') return refuse(415, `picture ${i + 1} must be a PNG or a JPG`);
240
+ bufs.push({ buf, ext: s.ext });
241
+ }
242
+ if (bufs.reduce((n, b) => n + b.buf.length, 0) > MAX_SAMPLE_BYTES) return refuse(413, 'a sample is at most 20 MB');
243
+ fs.mkdirSync(p.samples, { recursive: true });
244
+ const files = bufs.map((b, i) => { const f = `${id}-${i + 1}.${b.ext}`; writeAtomic(path.join(p.samples, f), b.buf); return f; });
245
+ const first = bufs[0];
246
+ const thumb = first.buf.length <= MAX_THUMB_BYTES ? `data:image/${first.ext === 'jpg' ? 'jpeg' : 'png'};base64,${first.buf.toString('base64')}` : null;
247
+ const src = typeof input.source_url === 'string' ? input.source_url.trim() : '';
248
+ const u = parseUrl(src);
249
+ const source = u && /^https?:$/.test(u.protocol) && src.length <= 2000 ? src : 'upload';
250
+ rec = { id, name: oneLine(input.name || 'Page pictures', 120), kind: 'pictures', source, ext: first.ext, count: files.length, files, via: 'chrome', bytes: bufs.reduce((n, b) => n + b.buf.length, 0), readable: true, thumb, text_file: null };
223
251
  } else {
224
252
  const kind = str(input.kind);
225
253
  if (!['pdf', 'pptx', 'docx', 'image'].includes(kind)) return refuse(400, 'kind must be pdf, pptx, docx or image (or send a link, or a report doc)');
@@ -244,19 +272,42 @@ function addSample(root, cadence, input, { actor, now } = {}) {
244
272
  try { thumb = ooxmlThumb(buf); } catch { thumb = null; }
245
273
  }
246
274
  rec = { id, name: oneLine(input.name || `${kind} sample`, 120), kind, source: 'upload', ext, bytes: buf.length, readable, thumb, text_file: textFile };
275
+ if (kind === 'pdf' && input.from === 'chrome') {
276
+ // a PDF the Chrome extension fetched: where it came from and how many pages it has
277
+ const src = typeof input.source_url === 'string' ? input.source_url.trim() : '';
278
+ const u = parseUrl(src);
279
+ if (u && /^https?:$/.test(u.protocol) && src.length <= 2000) rec.source = src;
280
+ rec.via = 'chrome';
281
+ const n = Number(input.pages);
282
+ if (Number.isInteger(n) && n > 0 && n < 5000) rec.pages = n;
283
+ }
247
284
  }
248
285
  writeAtomic(path.join(p.samples, `${id}.json`), JSON.stringify({ ...rec, took: null, added_at: iso(now) }, null, 2) + '\n');
249
- append(root, cadence, { ev: 'sample', id, name: rec.name, kind: rec.kind, source: rec.source, ext: rec.ext, readable: rec.readable }, { actor, now });
286
+ append(root, cadence, { ev: 'sample', id, name: rec.name, kind: rec.kind, source: rec.source, ext: rec.ext, readable: rec.readable,
287
+ ...(rec.via ? { via: rec.via } : {}), ...(rec.count ? { count: rec.count } : {}), ...(rec.pages ? { pages: rec.pages } : {}) }, { actor, now });
250
288
  return { ok: true, sample: sampleOut(rec) };
251
289
  }
252
290
  function sampleOut(s, { fetching } = {}) {
253
291
  const out = { id: s.id, name: s.name, kind: s.kind, source: s.source, took: s.took || null, thumb: typeof s.thumb === 'string' && s.thumb.startsWith('data:image/') ? s.thumb : null, readable: s.readable !== false };
292
+ if (s.via === 'chrome') {
293
+ out.via = 'chrome';
294
+ out.line = chromeLine(s);
295
+ if (s.count) out.count = s.count;
296
+ if (s.pages) out.pages = s.pages;
297
+ }
254
298
  if (s.kind === 'link') {
255
299
  if (fetching) { out.fetching = true; out.hint = 'PMOS is reading this link with your connectors.'; }
256
300
  else out.hint = linkHint(s.source, { fetched: !!(s.text_file && s.fetched), reason: s.fetch_reason });
257
301
  }
258
302
  return out;
259
303
  }
304
+ /** The kind line of a sample the Chrome extension added: 'From Chrome, PDF, 12 slides' | 'From Chrome, PDF, 3 pages' | 'From Chrome, 3 page pictures'. */
305
+ function chromeLine(s) {
306
+ if (s.kind === 'pictures') { const n = s.count || 0; return n ? `From Chrome, ${n} page ${n === 1 ? 'picture' : 'pictures'}` : 'From Chrome, page pictures'; }
307
+ const g = googleOf(parseUrl(s.source));
308
+ const n = s.pages || 0;
309
+ return n ? `From Chrome, PDF, ${n} ${g && g[2] === 'Slides' ? (n === 1 ? 'slide' : 'slides') : (n === 1 ? 'page' : 'pages')}` : 'From Chrome, PDF';
310
+ }
260
311
  /* Google files: name them for what they are, and say which download PMOS can read */
261
312
  // a PDF, because the job sees its pages; from a .pptx or .docx it gets the words only, not the look
262
313
  const GOOGLE = [[/^\/presentation\//, 'Google Slides deck', 'Slides'], [/^\/document\//, 'Google Docs document', 'Docs'], [/^\/spreadsheets\//, 'Google Sheets sheet', 'Sheets']];
@@ -309,6 +360,13 @@ function recordTook(root, cadence, id, took, readable, { now } = {}) {
309
360
  /** The file the job reads for a sample: the pdf or image itself, the extracted words of a .pptx/.docx, the copy of a report doc, the text copy of a fetched link; null for any other link. */
310
361
  function sampleReadPath(root, cadence, s) {
311
362
  const p = paths(root, cadence);
363
+ if (s.kind === 'pictures') {
364
+ // every picture of the page, in order (samples/<id>-1.jpg, <id>-2.jpg, …); null when none is there
365
+ let names = [];
366
+ try { names = fs.readdirSync(p.samples).filter((f) => f.startsWith(`${s.id}-`) && /^\d+\.(jpg|png)$/.test(f.slice(s.id.length + 1))); } catch { names = []; }
367
+ names.sort((a, b) => parseInt(a.slice(s.id.length + 1), 10) - parseInt(b.slice(s.id.length + 1), 10));
368
+ return names.length ? names.map((f) => path.join(p.samples, f)) : null;
369
+ }
312
370
  if (s.kind === 'link') { const f = path.join(p.samples, `${s.id}.txt`); return s.fetched && fs.existsSync(f) ? f : null; }
313
371
  if (s.kind === 'pptx' || s.kind === 'docx') { const f = path.join(p.samples, `${s.id}.txt`); return fs.existsSync(f) ? f : null; }
314
372
  const f = path.join(p.samples, `${s.id}.${s.ext}`);
@@ -352,7 +410,7 @@ function writeBrief(root, cadence, setup, { now } = {}) {
352
410
  '## Description', '', st.description ? st.description : 'No description was given.', '', '## Samples', ''];
353
411
  const samples = [...st.samples.values()];
354
412
  if (!samples.length) lines.push('No samples.');
355
- for (const s of samples) lines.push(`- ${s.name} (${s.kind}${s.kind === 'link' || s.kind === 'pmos-doc' ? ', ' + s.source : ''}): ${s.took ? 'PMOS took ' + s.took : (s.readable ? 'nothing taken yet' : 'could not be read')}`);
413
+ for (const s of samples) lines.push(`- ${s.name} (${s.kind}${s.kind === 'link' || s.kind === 'pmos-doc' ? ', ' + s.source : ''}${s.via === 'chrome' ? ', from Chrome' : ''}): ${s.took ? 'PMOS took ' + s.took : (s.readable ? 'nothing taken yet' : 'could not be read')}`);
356
414
  lines.push('', '## Reference outline', '');
357
415
  for (const o of (Array.isArray(meta.outline) ? meta.outline : [])) lines.push(`- ${o.id} (${o.kind || 'slide'}): ${o.title || ''}`);
358
416
  const sec = (h, list) => { lines.push('', `## ${h}`, ''); if (!list || !list.length) lines.push('None written yet.'); else for (const x of list) lines.push(`- ${x}`); };
@@ -9,7 +9,7 @@
9
9
  * GET /api/boards/columns?ws&board&q&owner&cap → { ok, columns: [{ stage, cat, hint, count, rows }] }
10
10
  * GET /api/boards/initiative?ws&board&id → { ok, row, board, history, tools }
11
11
  * GET /api/boards/asks?ws&board&state → { ok, asks } state: open (default) | accepted | declined | all
12
- * GET /api/boards/sprints?ws&board → { ok, source: jira|none, readAt, sprints }
12
+ * GET /api/boards/sprints?ws&board → { ok, source: jira|none, readAt, sprints } (each with goal, summary, themes, frozen)
13
13
  * GET /api/boards/items?ws&board → { ok, build, jira, items, initiatives } PMOS tasks + Jira items (Build and deliver only)
14
14
  * GET /api/boards/anchors?ws&board → { ok, anchors: [{ value, kind, group, label }], error } the declared KRs,
15
15
  * health budgets and domains a new initiative can anchor to (D59)
@@ -548,7 +548,9 @@ const READS = {
548
548
  } catch { /* no snapshot yet */ }
549
549
  let whole = null;
550
550
  try { whole = jiraStatus.readSprints(r.w.root); } catch { whole = null; }
551
- ctx.json(res, { ok: true, ws: r.w.id, board: r.board.id, ...B.sprintsOf(snaps, (id) => ids.has(id), whole) });
551
+ let notes = null;
552
+ try { notes = jiraStatus.readSprintNotes(r.w.root); } catch { notes = null; }
553
+ ctx.json(res, { ok: true, ws: r.w.id, board: r.board.id, ...B.sprintsOf(snaps, (id) => ids.has(id), whole, notes) });
552
554
  },
553
555
  };
554
556
 
@@ -1000,7 +1002,9 @@ function meetingBoards(ctx) {
1000
1002
  } catch { /* no snapshot yet */ }
1001
1003
  let whole = null;
1002
1004
  try { whole = jiraStatus.readSprints(w.root); } catch { whole = null; }
1003
- sprints = B.sprintsOf(snaps, (id) => ids.has(id), whole);
1005
+ let notes = null;
1006
+ try { notes = jiraStatus.readSprintNotes(w.root); } catch { notes = null; }
1007
+ sprints = B.sprintsOf(snaps, (id) => ids.has(id), whole, notes);
1004
1008
  }
1005
1009
  return { ...head, health: B.health(rows), counts: { total: c.total, open: c.open, risk: c.risk, late: c.late },
1006
1010
  rows: rows.filter((r) => r.status === 'late' || r.status === 'at risk').map((r) => ({ id: r.id, title: r.title, status: r.status, end: r.end || null, owner: r.owner || null })),
@@ -5,6 +5,8 @@
5
5
  * GET /api/report-design/version?ws&id&n → text/html, the sanitized render of D<n>
6
6
  * POST /api/report-design/describe { ws, id, text, format? }
7
7
  * POST /api/report-design/sample { ws, id, name, kind, data } | { ws, id, link, name? } | { ws, id, doc, name? } → { ok, sample }
8
+ * from Chrome: { kind: 'pdf', data, from: 'chrome', source_url?, pages? } | { kind: 'pictures', pictures: [base64 JPEG or PNG, …], source_url?, name, from: 'chrome' } (the field is `from`: pm-guard reserves `via` for the writer; the record keeps via: 'chrome')
9
+ * GET /api/report-design/last?ws → { ok, id, name, at } the report whose design studio was opened last (the Chrome popup's default report); id null when none
8
10
  * POST /api/report-design/turn { ws, id, text, points: [{ n, slide, x, y, text }] } → { ok, running } ONE job per report; 409 while one runs
9
11
  * POST /api/report-design/accept { ws, id, n } → { ok, design, brief, redraw } "This is it": writes brief.md and redraws the current
10
12
  * cycle in the saved design (redraw: { path, slot, started, reason? }; a sent cycle keeps its look)
@@ -101,6 +103,11 @@ function promptFor(skill, { mode, root, cadence, setup, st, turnText, points, cy
101
103
  lines.push(samples.length ? '- Samples:' : '- Samples: none');
102
104
  for (const s of samples) {
103
105
  const f = D.sampleReadPath(root, cadence, s);
106
+ if (s.kind === 'pictures') {
107
+ const fs_ = Array.isArray(f) ? f : [];
108
+ lines.push(` - ${s.id}${s.took ? '' : ' (NEW)'} "${s.name}", pictures of a whole web page from Chrome, top to bottom, ${fs_.length ? `${fs_.length} in all: read every one, in this order, and follow the look closely: ${fs_.join(', ')}` : 'its files could not be read'}`);
109
+ continue;
110
+ }
104
111
  const where = s.kind === 'link' ? (f ? `a link: ${s.source}. PMOS read its words and structure through the PM's connectors: read ${f}. It has the words and the order, not the look` : `a link: ${s.source} (you have no web tool)`) : f ? `read ${f}` : 'its file could not be read';
105
112
  lines.push(` - ${s.id}${s.took ? '' : ' (NEW)'} "${s.name}", ${s.kind}: ${where}`);
106
113
  }
@@ -352,6 +359,26 @@ function sweep(ctx) {
352
359
  }
353
360
 
354
361
  // ── the Edit log line ───────────────────────────────────────────────────────────────────────────────────────
362
+ /* The report whose design studio was opened last, for each workspace: <runtime>/report-design-last.json { [ws]: { id, at } }. A read of a design is an open of its studio. */
363
+ function lastFile(ctx) { return ctx.runtimeDir ? path.join(ctx.runtimeDir, 'report-design-last.json') : null; }
364
+ function lastEntry(ctx, w) {
365
+ const f = lastFile(ctx);
366
+ if (!f) return null;
367
+ try { const o = JSON.parse(fs.readFileSync(f, 'utf8')); const e = o && o[w.id]; return e && D.CADENCE_RE.test(String(e.id)) ? { id: e.id, at: typeof e.at === 'string' ? e.at : null } : null; } catch { return null; }
368
+ }
369
+ const lastStudio = (ctx, w) => { const e = lastEntry(ctx, w); return e ? e.id : null; };
370
+ function rememberStudio(ctx, w, id) {
371
+ const f = lastFile(ctx);
372
+ if (!f || lastStudio(ctx, w) === id) return false;
373
+ try {
374
+ let o = {};
375
+ try { o = JSON.parse(fs.readFileSync(f, 'utf8')) || {}; } catch { o = {}; }
376
+ o[w.id] = { id, at: new Date().toISOString() };
377
+ fs.mkdirSync(path.dirname(f), { recursive: true });
378
+ fs.writeFileSync(f, JSON.stringify(o) + '\n');
379
+ return true;
380
+ } catch { return false; }
381
+ }
355
382
  function editLog(ctx, row) {
356
383
  if (!ctx.runtimeDir) return false;
357
384
  try {
@@ -430,8 +457,14 @@ async function handle(req, res, url, ctx) {
430
457
  return sendHtml(res, D.sanitize(html));
431
458
  }
432
459
  try { settleStale(w.root, id); } catch { /* a read never fails on this */ }
460
+ rememberStudio(ctx, w, id);
433
461
  return send({ status: 200, body: { ok: true, setup, design: D.designAnswer(w.root, id, designOpts(w.root, id)) } });
434
462
  }
463
+ if (sub === 'last') {
464
+ const e = lastEntry(ctx, w);
465
+ const setup = e ? setupOf(w.root, e.id) : null;
466
+ return send({ status: 200, body: { ok: true, id: setup ? e.id : null, name: setup ? setup.name : null, at: setup ? e.at : null } });
467
+ }
435
468
  if (sub === 'cycle' || sub === 'cycle-html') {
436
469
  const d = reportDoc(w, text(url.searchParams.get('path'))); if (d.error) return send(d.error);
437
470
  if (sub === 'cycle') return send({ status: 200, body: cycleAnswer(w, d.c) });
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.204.0",
3
+ "version": "0.206.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.204.0",
9
+ "version": "0.206.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.204.0",
3
+ "version": "0.206.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -6,5 +6,6 @@
6
6
  const narrow = typeof matchMedia === 'function' && matchMedia('(max-width:640px)').matches;
7
7
  let desk = false;
8
8
  try { desk = localStorage.getItem('pmos.c3.testDesk') === 'desk'; } catch { /* no storage */ }
9
- if (narrow && !desk) import('./c3-phone-boot.js');
9
+ if (new URLSearchParams(location.search).get('capture') === '1') import('./c3-capture.js'); // the PMOS Chrome extension's popup page: a small page on its own, at any width
10
+ else if (narrow && !desk) import('./c3-phone-boot.js');
10
11
  else import('./c3-shell.js');
@@ -0,0 +1,23 @@
1
+ /* pm/c3-capture.css — the page the PMOS Chrome extension frames in its popup (c3-capture.js). Mockup 42-reportdeck.css .mk-cx-pop and friends. */
2
+ .c3.c3-cap { min-height: 0; padding: 8px; background: var(--background); }
3
+ .c3 .cap-pop { display: grid; gap: 10px; padding: 12px; box-sizing: border-box; }
4
+ .c3 .cap-head { display: flex; gap: 8px; align-items: center; font-size: var(--fs-3); font-weight: 600; }
5
+ .c3 .cap-head b { font-size: var(--fs-1); color: var(--acc); }
6
+ .c3 .cap-head span { flex: 1; min-width: 0; }
7
+ .c3 .cap-what { display: grid; grid-template-columns: 56px minmax(0, 1fr); gap: 10px; align-items: start; padding: 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--background); }
8
+ .c3 .cap-what b { display: flex; gap: 6px; align-items: center; font-size: var(--fs-2); overflow-wrap: anywhere; }
9
+ .c3 .cap-what .mono { font-size: var(--fs-1); overflow-wrap: anywhere; }
10
+ .c3 .cap-thumb { display: grid; gap: 3px; align-content: start; width: 56px; aspect-ratio: 16 / 9; padding: 4px; box-sizing: border-box; border: 1px solid var(--slide-border); border-radius: 6px; background: var(--slide-bg); }
11
+ .c3 .cap-thumb i { display: block; height: 3px; background: var(--slide-track); }
12
+ .c3 .cap-thumb.deck i:first-child { height: 6px; background: var(--slide-brand); }
13
+ .c3 .cap-thumb.page { aspect-ratio: 3 / 4; width: 40px; justify-self: center; }
14
+ .c3 .cap-f { display: grid; gap: 4px; }
15
+ .c3 .cap-f select { width: 100%; box-sizing: border-box; padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--background); color: var(--foreground); font: inherit; font-size: var(--fs-3); }
16
+ .c3 .cap-lbl { font-size: var(--fs-1); }
17
+ .c3 .cap-line { font-size: var(--fs-2); line-height: 1.5; }
18
+ .c3 .cap-step { font-size: var(--fs-1); }
19
+ .c3 .cap-acts { display: flex; gap: 8px; flex-wrap: wrap; }
20
+ .c3 .cap-msg { display: flex; gap: 8px; align-items: flex-start; margin: 0; font-size: var(--fs-3); line-height: 1.5; }
21
+ .c3 .cap-msg .ic { flex: 0 0 auto; margin-top: 4px; }
22
+ .c3 .cap-prog { height: 6px; border-radius: 6px; background: var(--muted); overflow: hidden; }
23
+ .c3 .cap-prog i { display: block; width: var(--rp-w, 6%); height: 100%; background: var(--acc); }
@@ -0,0 +1,152 @@
1
+ /* pm/c3-capture.js — the page the PMOS Chrome extension frames in its popup (initiative report-design-studio; mockup 42-reportdeck.js, hashes
2
+ * #design-chrome and #design-chrome-page). c3-boot.js loads it alone for /c3.html?capture=1&pop=1&pkind=deck|doc|page&ptitle=…&purl=…[&pscreens=N].
3
+ *
4
+ * It draws "Add this page to a report design": the page, the report to add it to (the report whose studio was opened last is chosen), how it is
5
+ * saved, and Add as a sample. The extension never posts to PMOS itself: every PMOS write needs a press on a PMOS page. So this page asks its
6
+ * parent, the extension popup, with postMessage { pmosCapture: 'request' }; the popup takes the page and answers { pmosCapture: 'data', kind, … }, and
7
+ * THIS page uploads it with its own press (POST /api/report-design/sample, from 'chrome').
8
+ *
9
+ * Messages in: only from window.parent, only from a chrome-extension:// origin or this page's own origin, only these words, and 'data' only while a
10
+ * request is waiting. Messages out: to the parent's origin, never '*'. Nothing here sends anything outside the workspace. */
11
+ import { el } from './dom.js';
12
+ import { icon, pbtn, plural } from './c3-ini-ui.js';
13
+ import { pmGet, pmPost } from './http.js';
14
+ import { ensureC3Css } from './pm-work-view.js';
15
+
16
+ const q = new URLSearchParams(location.search);
17
+ const KIND = ['deck', 'doc', 'page'].includes(q.get('pkind')) ? q.get('pkind') : 'page';
18
+ const TITLE = (q.get('ptitle') || 'This page').slice(0, 120);
19
+ const URL_OF = (q.get('purl') || '').slice(0, 2000);
20
+ const SCREENS = Math.max(0, Math.min(500, parseInt(q.get('pscreens') || '0', 10) || 0));
21
+ const MAX_MB = 20;
22
+ const host = (() => { try { return new URL(URL_OF).hostname; } catch { return ''; } })();
23
+
24
+ let parentOrigin = '';
25
+ try { parentOrigin = (location.ancestorOrigins && location.ancestorOrigins[0]) || ''; } catch { parentOrigin = ''; }
26
+ if (!parentOrigin) { try { parentOrigin = new URL(document.referrer).origin; } catch { parentOrigin = ''; } }
27
+ const trusted = (o) => !!o && (o.startsWith('chrome-extension://') || o === location.origin) && (!parentOrigin || o === parentOrigin);
28
+ const say = (m) => { if (window.parent !== window && parentOrigin) { try { window.parent.postMessage(m, parentOrigin); } catch { /* the popup is gone */ } } };
29
+
30
+ const S = { st: 'load', reports: [], rep: null, last: null, err: '', step: 0, of: 1, text: '', got: '', waiting: false, root: null };
31
+
32
+ const WHAT = {
33
+ deck: { what: 'Google Slides', how: 'PMOS saves the deck as a PDF so the design can follow its look. Only your own PMOS gets it.', saving: 'Saving the deck as a PDF' },
34
+ doc: { what: 'Google Docs', how: 'PMOS saves the document as a PDF so the design can follow its look. Only your own PMOS gets it.', saving: 'Saving the document as a PDF' },
35
+ page: { what: '', how: 'PMOS saves the page as pictures, scrolled top to bottom, so the design can follow its look. Only your own PMOS gets it.', saving: 'Saving the page as pictures' },
36
+ };
37
+ const whatLine = () => (KIND === 'page' ? [host || 'Web page', SCREENS ? 'about ' + plural(SCREENS, 'screen') + ' long' : ''].filter(Boolean).join(', ') : WHAT[KIND].what);
38
+
39
+ function whatCard() {
40
+ return el('div', { class: 'cap-what', 'data-cap': 'page' },
41
+ el('span', { class: 'cap-thumb ' + (KIND === 'page' ? 'page' : 'deck'), 'aria-hidden': 'true' }, el('i'), el('i'), el('i')),
42
+ el('div', null, el('b', null, icon(KIND === 'page' ? 'notes' : 'img', 'sm'), TITLE), el('span', { class: 'dim mono' }, whatLine())));
43
+ }
44
+ function head() {
45
+ return el('div', { class: 'cap-head' }, el('b', { class: 'mono' }, 'PMOS'), el('span', null, 'Add this page to a report design'));
46
+ }
47
+ const repOf = () => S.reports.find((r) => r.key === S.rep) || null;
48
+
49
+ function bar(pct) { const i = el('i'); i.style.setProperty('--rp-w', pct + '%'); return i; }
50
+
51
+ function draw() {
52
+ const r = repOf();
53
+ let body;
54
+ if (S.st === 'load') body = [el('p', { class: 'dim mono cap-msg', role: 'status' }, 'Reading your reports.')];
55
+ else if (S.st === 'none') body = [whatCard(), el('p', { class: 'cap-msg', 'data-cap': 'none' }, icon('alert', 'sm'), el('span', null, 'There is no report set up in PMOS yet. Set one up in Reports, then add this page to its design.'))];
56
+ else if (S.st === 'error') body = [el('p', { class: 'cap-msg', role: 'alert', 'data-cap': 'error' }, icon('alert', 'sm'), el('span', null, S.err)), el('div', { class: 'cap-acts' }, pbtn('outline', 'Try again', 'undo', { 'data-cap': 'retry', onclick: () => { S.st = 'ready'; draw(); } }))];
57
+ else if (S.st === 'sending') {
58
+ const pct = Math.max(6, Math.min(100, Math.round((S.step / Math.max(1, S.of)) * 100)));
59
+ body = [whatCard(), el('p', { class: 'cap-msg', 'data-cap': 'saving' }, el('b', null, WHAT[KIND].saving)),
60
+ el('div', { class: 'cap-prog', role: 'progressbar', 'aria-label': WHAT[KIND].saving, 'aria-valuenow': String(pct), 'aria-valuemin': '0', 'aria-valuemax': '100' }, bar(pct)),
61
+ el('div', { class: 'dim mono cap-step' }, (S.text || 'Starting') + '. Keep this popup open.')];
62
+ } else if (S.st === 'done') {
63
+ body = [el('p', { class: 'cap-msg ok', 'data-cap': 'done' }, icon('check', 'sm'), el('b', null, 'Added to ' + (r ? r.name : 'the report'))),
64
+ el('div', { class: 'dim cap-line' }, TITLE + ', ' + S.got + '. PMOS reads its look on your next turn in the studio.'),
65
+ el('div', { class: 'cap-acts' }, pbtn('primary', 'Open the studio', 'spark', { 'data-cap': 'studio', onclick: openStudio }))];
66
+ } else {
67
+ body = [whatCard(),
68
+ el('label', { class: 'cap-f' }, el('span', { class: 'dim cap-lbl' }, 'Report'),
69
+ el('select', { 'data-cap-rep': '', 'aria-label': 'Report', onchange: (e) => { S.rep = e.target.value; draw(); } },
70
+ ...S.reports.map((x) => el('option', { value: x.key, selected: x.key === S.rep }, x.label)))),
71
+ S.rep === S.last ? el('div', { class: 'dim cap-lbl' }, 'The report whose studio you opened last.') : null,
72
+ el('div', { class: 'dim cap-line' }, WHAT[KIND].how),
73
+ el('div', { class: 'cap-acts' }, pbtn('primary', 'Add as a sample', 'plus', { 'data-cap': 'send', onclick: send }))];
74
+ }
75
+ S.root.replaceChildren(el('div', { class: 'card cap-pop', role: 'dialog', 'aria-label': 'PMOS extension' }, head(), ...body.filter(Boolean)));
76
+ requestAnimationFrame(() => say({ pmosCapture: 'resize', height: Math.max(120, Math.min(600, Math.ceil(S.root.getBoundingClientRect().height) + 8)) }));
77
+ }
78
+
79
+ function send() {
80
+ if (!repOf()) return;
81
+ S.st = 'sending'; S.step = 0; S.of = 1; S.text = 'Asking the page'; S.waiting = true;
82
+ draw();
83
+ if (window.parent === window) { S.waiting = false; fail('Open this page from the PMOS button in Chrome.'); return; }
84
+ say({ pmosCapture: 'request' });
85
+ }
86
+ function fail(text) { S.waiting = false; S.st = 'error'; S.err = text; draw(); }
87
+
88
+ async function upload(m) {
89
+ const r = repOf();
90
+ if (!r) return fail('Choose a report, then try again.');
91
+ const body = m.kind === 'pdf'
92
+ ? { ws: r.ws, id: r.id, name: m.name, kind: 'pdf', data: m.data, from: 'chrome', source_url: m.url, ...(m.pages ? { pages: m.pages } : {}) }
93
+ : { ws: r.ws, id: r.id, name: m.name, kind: 'pictures', pictures: m.pictures, from: 'chrome', source_url: m.url };
94
+ S.text = 'Sending it to PMOS'; S.step = S.of; draw();
95
+ const res = await Promise.resolve(pmPost('/api/report-design/sample', body)).catch((e) => ({ ok: false, error: String((e && e.message) || e) }));
96
+ if (!res || !res.ok) {
97
+ const e = String((res && res.error) || '');
98
+ return fail(res && res.status === 413 || /20 MB/.test(e) ? 'The sample is over ' + MAX_MB + ' MB. PMOS keeps a sample up to ' + MAX_MB + ' MB. Open a shorter page, or add a smaller copy.'
99
+ : e ? 'PMOS did not take the sample: ' + e + '.' : 'PMOS is not answering. Start PMOS, then try again.');
100
+ }
101
+ S.got = m.kind === 'pdf' ? (m.pages ? plural(m.pages, KIND === 'deck' ? 'slide' : 'page') + ' as a PDF' : 'a PDF') : plural(m.pictures.length, 'page picture');
102
+ S.st = 'done';
103
+ draw();
104
+ }
105
+
106
+ function openStudio() {
107
+ const r = repOf(); if (!r) return;
108
+ if (window.parent !== window) { say({ pmosCapture: 'open', report: r.id }); return; }
109
+ location.href = '/c3.html?studio=' + encodeURIComponent(r.id) + (r.ws ? '&ws=' + encodeURIComponent(r.ws) : '');
110
+ }
111
+
112
+ window.addEventListener('message', (e) => {
113
+ if (e.source !== window.parent || window.parent === window || !trusted(e.origin)) return;
114
+ const m = e.data;
115
+ if (!m || typeof m !== 'object' || typeof m.pmosCapture !== 'string' || !S.waiting) return;
116
+ if (m.pmosCapture === 'progress' && typeof m.text === 'string' && Number.isInteger(m.step) && Number.isInteger(m.of) && m.of > 0) { S.text = m.text.slice(0, 80); S.step = m.step; S.of = m.of; draw(); return; }
117
+ if (m.pmosCapture === 'error' && typeof m.error === 'string') { fail(m.error.slice(0, 400)); return; }
118
+ if (m.pmosCapture !== 'data' || S.st !== 'sending') return;
119
+ if (m.kind === 'pdf' && typeof m.data === 'string' && m.data) { S.waiting = false; upload({ kind: 'pdf', name: String(m.name || TITLE).slice(0, 120), url: String(m.url || URL_OF), pages: Number.isInteger(m.pages) ? m.pages : 0, data: m.data }); return; }
120
+ if (m.kind === 'pictures' && Array.isArray(m.pictures) && m.pictures.length && m.pictures.length <= 12 && m.pictures.every((p) => typeof p === 'string' && p)) { S.waiting = false; upload({ kind: 'pictures', name: String(m.name || TITLE).slice(0, 120), url: String(m.url || URL_OF), pictures: m.pictures }); return; }
121
+ fail('The page came back in a form PMOS could not use.');
122
+ });
123
+
124
+ async function load() {
125
+ const list = await pmGet('/api/workspaces').then((j) => (j && j.workspaces) || []).catch(() => []);
126
+ const want = q.get('ws');
127
+ const wss = want ? list.filter((w) => w.id === want) : list;
128
+ const per = await Promise.all(wss.map(async (w) => {
129
+ const [a, b] = await Promise.all([pmGet('/api/report-setups?ws=' + encodeURIComponent(w.id)).catch(() => null), pmGet('/api/report-design/last?ws=' + encodeURIComponent(w.id)).catch(() => null)]);
130
+ return { w, setups: ((a && a.setups) || []).filter((s) => s && s.id && s.status !== 'removed'), last: b && b.ok && b.id ? { id: b.id, at: b.at || '' } : null };
131
+ }));
132
+ const many = per.filter((x) => x.setups.length).length > 1;
133
+ S.reports = per.flatMap((x) => x.setups.map((s) => ({ key: x.w.id + '|' + s.id, ws: x.w.id, id: s.id, name: s.name || s.id, label: many ? (s.name || s.id) + ' (' + (x.w.name || x.w.id) + ')' : (s.name || s.id) })));
134
+ const lasts = per.filter((x) => x.last && x.setups.some((s) => s.id === x.last.id)).sort((a, b) => String(b.last.at).localeCompare(String(a.last.at)));
135
+ S.last = lasts.length ? lasts[0].w.id + '|' + lasts[0].last.id : null;
136
+ S.rep = S.last || (S.reports[0] && S.reports[0].key) || null;
137
+ S.st = S.reports.length ? 'ready' : 'none';
138
+ draw();
139
+ }
140
+
141
+ function boot() {
142
+ S.root = document.getElementById('c3-root');
143
+ const theme = q.get('theme') === 'dark' || (typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: dark)').matches && q.get('theme') !== 'light') ? 'dark' : 'light';
144
+ document.documentElement.setAttribute('data-pm-page-theme', theme);
145
+ S.root.setAttribute('data-pm-theme', theme);
146
+ S.root.classList.add('c3-cap');
147
+ ensureC3Css();
148
+ const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = new URL('./c3-capture.css', import.meta.url).href; document.head.appendChild(l);
149
+ draw();
150
+ load().then(() => say({ pmosCapture: 'ready' }), () => { S.st = 'none'; draw(); say({ pmosCapture: 'ready' }); });
151
+ }
152
+ boot();
@@ -385,7 +385,17 @@ export function handle(S, method, path, p, body) {
385
385
  const counts = { todo: issues.filter((i) => i.category === 'todo').length, doing: issues.filter((i) => i.category === 'doing').length, done: issues.filter((i) => i.category === 'done').length };
386
386
  const num = key === 'IDPF' ? 42 : 19;
387
387
  const extra = (k, state, name, cat, n) => { const is = Array.from({ length: n }, (_, i) => ({ key: key + '-' + (1100 + k * 20 + i), summary: (own[i % Math.max(1, own.length)] || { title: 'Sprint work' }).title + ': part ' + (i + 1), status: cat === 'done' ? 'Done' : 'To Do', category: cat, initiative: (own[i % Math.max(1, own.length)] || {}).id || null })); return { name, state, issues: is, counts: { todo: cat === 'todo' ? n : 0, doing: 0, done: cat === 'done' ? n : 0 } }; };
388
- return okb({ source: 'jira', readAt: TODAY + 'T08:10:00', sprints: [{ name: key + ' Sprint ' + num, state: 'active', issues, counts }, extra(1, 'future', key + ' Sprint ' + (num + 1), 'todo', 3), extra(0, 'closed', key + ' Sprint ' + (num - 1), 'done', 5)] });
388
+ // The goal and summary the Jira status check writes (sprint-goal-summary): Jira's goal is too broad for the active sprint,
389
+ // missing for the next, and fine for the closed one, which was summarised once.
390
+ const ini0 = own[0] ? own[0].id : null;
391
+ const themes = [{ title: own[0] ? own[0].title : 'Sprint work', line: 'Most items are in progress or in review.', tickets: issues.filter((i) => i.initiative === ini0).length || 1, done: issues.filter((i) => i.initiative === ini0 && i.category === 'done').length, blocked: 1, people: 'Ana, Budi', initiative: ini0 },
392
+ { title: 'Fixes and upkeep', line: 'Small fixes outside any initiative.', tickets: 2, done: 1, blocked: 0, people: 'Chen', initiative: null }];
393
+ const act = { name: key + ' Sprint ' + num, state: 'active', issues, counts, goal: { from: 'broad', text: 'Get the first initiative ready for internal users and clear the blocked work.', jira: 'Q4 delivery' },
394
+ summary: 'Most of the sprint is on the first initiative. One item is blocked. Two small fixes sit outside any initiative.', themes, writtenAt: TODAY + 'T06:30:00Z', summarisedTickets: issues.length, closed: null, frozen: false };
395
+ const fut = { ...extra(1, 'future', key + ' Sprint ' + (num + 1), 'todo', 3), goal: { from: 'none', text: 'Finish the first initiative and start the beta.', jira: null }, summary: 'Planned so far: three follow-up items.', themes: [], writtenAt: TODAY + 'T06:30:00Z', closed: null, frozen: false };
396
+ const old = { ...extra(0, 'closed', key + ' Sprint ' + (num - 1), 'done', 5), goal: { from: 'jira', text: 'Start the first initiative.', jira: 'Start the first initiative.' }, summary: 'Goal met. Five items shipped; two moved to the next sprint.', themes: [{ title: 'Sprint work', line: '', tickets: 5, done: 5, blocked: 0, people: '', initiative: ini0 }],
397
+ writtenAt: '2026-09-29T06:30:00Z', closed: { done: 5, total: 7, carried: 2, carriedTo: key + ' Sprint ' + num, points: '21 of 26' }, frozen: true };
398
+ return okb({ source: 'jira', readAt: TODAY + 'T08:10:00', sprints: [act, fut, old] });
389
399
  }
390
400
  return fail(404, 'No route ' + path);
391
401
  }
@@ -19,8 +19,8 @@ import { RD, failed, splitRender, slideCard, frame, wireFrame, diffOutlines, ens
19
19
 
20
20
  const MAX = 20 * 1024 * 1024;
21
21
  const KIND_OF = (name) => { const e = String(name || '').toLowerCase().split('.').pop(); return e === 'pdf' ? 'pdf' : e === 'pptx' ? 'pptx' : e === 'docx' ? 'docx' : /^(png|jpe?g)$/.test(e) ? 'image' : ''; };
22
- const SICON = { pdf: 'pdf', pptx: 'img', docx: 'doc', image: 'img', link: 'link', 'pmos-doc': 'doc' };
23
- const SWORD = { pdf: 'Upload, PDF', pptx: 'Upload, .pptx', docx: 'Upload, .docx', image: 'Upload, image', link: 'Link', 'pmos-doc': 'Past PMOS document' };
22
+ const SICON = { pdf: 'pdf', pptx: 'img', docx: 'doc', image: 'img', pictures: 'img', link: 'link', 'pmos-doc': 'doc' };
23
+ const SWORD = { pdf: 'Upload, PDF', pptx: 'Upload, .pptx', docx: 'Upload, .docx', image: 'Upload, image', pictures: 'Page pictures', link: 'Link', 'pmos-doc': 'Past PMOS document' };
24
24
  /* the studio's width, as a session's terminal sheet has it: half, three-quarters or the whole window; kept on this browser */
25
25
  const SIZE_KEY = 'pmos.workspace.c3.studio-size';
26
26
  export const STUDIO_SIZES = [['half', 'Half', 'Half of the window'], ['three-quarters', '3/4', 'Three-quarters of the window'], ['whole', 'Whole', 'Whole window']];
@@ -36,8 +36,8 @@ const LOCAL = {};
36
36
  const local = (id) => LOCAL[id] || (LOCAL[id] = { view: null, cmp: false, cmpA: null, sidx: 0, point: true, markers: [], draft: null, mOpen: null, panel: false, text: '', fmt: null });
37
37
 
38
38
  function sampleChip(s) {
39
- const th = s.thumb && /^data:image\//.test(s.thumb) ? el('img', { class: 'rd-dks-thumb img', src: s.thumb, alt: '' }) : el('span', { class: 'rd-dks-thumb ' + (s.kind === 'pptx' || s.kind === 'image' ? 'deck' : 'page'), 'aria-hidden': 'true' }, el('i'), el('i'), el('i'));
40
- return el('div', { class: 'rd-dks-smp', 'data-rd-sample': s.id }, th, el('div', null, el('b', null, icon(SICON[s.kind] || 'doc', 'sm'), s.name), el('span', { class: 'dim mono' }, SWORD[s.kind] || s.kind),
39
+ const th = s.thumb && /^data:image\//.test(s.thumb) ? el('img', { class: 'rd-dks-thumb img', src: s.thumb, alt: '' }) : el('span', { class: 'rd-dks-thumb ' + (s.kind === 'pptx' || s.kind === 'image' || s.kind === 'pictures' || (s.via === 'chrome' && s.kind === 'pdf') ? 'deck' : 'page'), 'aria-hidden': 'true' }, el('i'), el('i'), el('i'));
40
+ return el('div', { class: 'rd-dks-smp', 'data-rd-sample': s.id }, th, el('div', null, el('b', null, icon(SICON[s.kind] || 'doc', 'sm'), s.name), el('span', { class: 'dim mono' }, s.line || SWORD[s.kind] || s.kind),
41
41
  s.hint ? [el('p', { class: 'rd-warn', 'data-rd-hint': '' }, s.hint), s.took ? el('p', null, el('span', { class: 'dim' }, 'PMOS took: '), s.took) : null]
42
42
  : s.readable === false ? el('p', { class: 'rd-warn' }, 'PMOS could not read it with your tools. It is kept, but it does not shape the design.')
43
43
  : el('p', null, el('span', { class: 'dim' }, 'PMOS took: '), s.took || 'PMOS reads it on the next turn.')));
@@ -52,7 +52,7 @@ export function openStudio({ ws, id, name, view, onClose } = {}) {
52
52
  let size = sizeOf(sizeStore.get());
53
53
  const sizeBtns = STUDIO_SIZES.map(([k, label, title]) => btn({ class: 'ds-btn', 'aria-pressed': String(k === size), 'data-rd-size': k, title, 'aria-label': title, onclick: () => setSize(k) }, label));
54
54
  const seg = el('div', { class: 'seg rd-dks-size', role: 'group', 'aria-label': 'Studio width' }, ...sizeBtns);
55
- const ui = openSheet({ title: 'Design studio', icon: 'spark', wide: true, dw: true, cls: 'rd-wide rdsz-' + size, body, actions: [seg], onClose: () => { S.closed = true; clearTimeout(S.timer); if (onClose) onClose(); } });
55
+ const ui = openSheet({ title: 'Design studio', icon: 'spark', wide: true, dw: true, cls: 'rd-wide rdsz-' + size, body, actions: [seg], onClose: () => { S.closed = true; clearTimeout(S.timer); clearInterval(S.watch); window.removeEventListener('focus', checkNew); if (onClose) onClose(); } });
56
56
  function setSize(k) {
57
57
  ui.sheet.classList.remove('rdsz-' + size); size = sizeOf(k); ui.sheet.classList.add('rdsz-' + size);
58
58
  sizeStore.set(size);
@@ -74,6 +74,16 @@ export function openStudio({ ws, id, name, view, onClose } = {}) {
74
74
  if (n == null || S.cache[n]) return;
75
75
  try { S.cache[n] = splitRender(await RD.version(ws, id, n)); } catch (e) { const style = ''; S.cache[n] = { err: (e && e.message) || 'not readable', slides: [], secs: [], style, body: '' }; }
76
76
  }
77
+ /* A sample added from another tab (the PMOS Chrome extension) shows here: the open studio reads the design every few seconds and when the window gets focus. */
78
+ const sampleSig = (D) => ((D && D.samples) || []).map((x) => x.id + (x.fetching ? '~' : '')).join(',');
79
+ async function checkNew() {
80
+ if (S.closed || S.busy || !S.D || S.D.running) return;
81
+ const r = await RD.design(ws, id);
82
+ if (S.closed || !r || r.ok === false || !r.design || !S.D || S.D.running) return;
83
+ if (sampleSig(r.design) !== sampleSig(S.D)) load(true);
84
+ }
85
+ S.watch = setInterval(checkNew, 4000);
86
+ window.addEventListener('focus', checkNew);
77
87
  function poll() { clearTimeout(S.timer); S.timer = setTimeout(async () => { if (S.closed) return; const was = S.D && S.D.current; await load(false); if (S.D && !S.D.running && S.D.current !== was) { K.markers = []; K.draft = null; scrollLog(); } }, 1500); }
78
88
  const ver = (n) => (S.D.versions || []).find((v) => v.n === n);
79
89
  const cmpA = () => { const b = S.D.current; const a = K.cmpA != null && K.cmpA !== b && ver(K.cmpA) ? K.cmpA : (S.D.versions.filter((v) => v.n < b).pop() || S.D.versions.find((v) => v.n !== b) || {}).n; return a; };
@@ -104,6 +114,8 @@ export function openStudio({ ws, id, name, view, onClose } = {}) {
104
114
  return el('div', { class: 'rd-dks-panel', 'data-rd-panel': '' }, el('div', { class: 'ds-h' }, 'Add a sample'),
105
115
  btn({ class: 'rd-dks-opt', 'data-rd-smp': 'file', onclick: () => file.click() }, icon('drop', 'sm'), el('span', null, 'Upload a past deck or document', el('br'), el('span', { class: 'dim rd-small' }, 'PDF, .pptx, .docx or a screenshot, up to 20 MB'))), file,
106
116
  el('div', { class: 'ds-whyadd' }, link, btn({ class: 'ds-btn', 'data-rd-smp': 'link', onclick: () => addLink() }, 'Add')),
117
+ btn({ class: 'rd-dks-opt', 'data-rd-smp': 'chrome', 'aria-expanded': String(!!K.chrome), onclick: () => { K.chrome = !K.chrome; paint(); } }, icon('out', 'sm'), el('span', null, 'From Chrome', el('br'), el('span', { class: 'dim rd-small' }, 'Open any page in Chrome and press the PMOS button. Decks come as a PDF, other pages as pictures.'))),
118
+ K.chrome ? el('div', { class: 'fold', 'data-rd-chrome': '' }, icon('info', 'sm'), el('span', null, 'In Chrome, open the page you want, for example a Google Slides deck, and press the PMOS button in the toolbar. Choose this report and press Add as a sample. The sample shows here in a moment. If the PMOS button is not in Chrome yet, run ', el('code', null, 'pmos extension'), ' in a terminal for the steps to load it.')) : null,
107
119
  docs.map((d) => btn({ class: 'rd-dks-opt', 'data-rd-smp': 'doc', 'data-v': d.path, onclick: () => addDoc(d) }, icon('doc', 'sm'), el('span', null, 'Pick a past PMOS document', el('br'), el('span', { class: 'dim rd-small' }, d.name + ', ' + slotWord(d.slot))))),
108
120
  el('div', { class: 'dim rd-small' }, 'PMOS reads a sample to learn how it looks. It does not copy its numbers. A Google or Confluence link is read through your connectors for its words and structure, not its look.'));
109
121
  }