thinkpool-pair 0.7.288 → 0.7.290

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bridge.mjs CHANGED
@@ -76,7 +76,7 @@ import { writeLaneArtifact, digestSlice, appendDigest, resumeLane } from './flow
76
76
  import { createFlowWorktree, worktreeSpec } from './flow-worktree.mjs'
77
77
  import { startPreview, stopAllPreviews, previews } from './flow-preview.mjs'
78
78
  import { ViewportManager, createViewportTools, sharedViewportBrowser } from './viewport.mjs'
79
- import { deleteDesignAssetDraft, designPrompt, designTranscript, materializeDesignAsset, resolveDesignSource, resolveManifestDesignSource, restoreDesignSources, syncRealtimeAuth, validateDesignBatchRequest } from './design-edit.mjs'
79
+ import { deleteDesignAssetDraft, designPrompt, designTranscript, materializeDesignAsset, refreshDesignSource, resolveManifestDesignSource, restoreDesignSources, syncRealtimeAuth, validateDesignBatchRequest } from './design-edit.mjs'
80
80
  // FL-M9 — per-lane preview servers leak (one per done lane, never stopped until shutdown).
81
81
  // Lane previews are keyed `lane:<flowId>:<laneId>`; stop a whole flow's set when it assembles
82
82
  // (the assembled preview supersedes them) or when a lane is reverted.
@@ -1415,7 +1415,7 @@ const finishDesign = (term, state, extra = {}) => {
1415
1415
  const maybeVerifyDesign = (term) => {
1416
1416
  const active = designActive.get(term)
1417
1417
  if (!active?.resultOk || !active.proof) return
1418
- finishDesign(term, 'verified-live', { message: 'Verified at desktop and mobile.', artifact: active.proof, canRestore: true })
1418
+ finishDesign(term, 'verified-live', { message: 'Verified at desktop and mobile.', artifact: active.proof, canRestore: active.record.sourceKind !== 'preview' })
1419
1419
  }
1420
1420
 
1421
1421
  function pumpDesign(term) {
@@ -1433,7 +1433,7 @@ function pumpDesign(term) {
1433
1433
  if (lane.session?.turnActive) return
1434
1434
  queue.shift()
1435
1435
  const current = designArtifacts.get(next.record.previewId)
1436
- const live = current && resolveDesignSource(current.sourcePath, current.workspaceRoot)
1436
+ const live = current && refreshDesignSource(current)
1437
1437
  if (!current || !live || live.revision !== next.record.revision) {
1438
1438
  designStatus({ previewId: next.record.previewId, requestId: next.request.cid, state: 'stale', message: 'The artifact changed. Reselect the element.' })
1439
1439
  return pumpDesign(term)
@@ -1488,7 +1488,7 @@ const handleManifest = async (box, file, term, trustedDesignSource = false) => {
1488
1488
  if (!m?.slug) return
1489
1489
  const producer = trustedDesignSource ? sessions.get(term) : null
1490
1490
  const designRecord = producer
1491
- ? resolveManifestDesignSource(m, producer.cwd || process.cwd())
1491
+ ? resolveManifestDesignSource(m, producer.cwd || process.cwd(), { box })
1492
1492
  : null
1493
1493
  // 2026-07-07: these used to swallow read errors silently — a transient
1494
1494
  // unreadable file (race with the render script, permissions, mid-write)
@@ -1542,8 +1542,11 @@ const handleManifest = async (box, file, term, trustedDesignSource = false) => {
1542
1542
  const dual = !!(paths?.html && paths?.desktop && paths?.mobile && m.desktop && m.mobile)
1543
1543
  const expectedRevision = active?.restoreRecord?.revision
1544
1544
  const revisionProvesChange = expectedRevision ? designRecord.revision === expectedRevision : designRecord.revision !== active?.record?.revision
1545
- if (correlated && dual && designRecord.sourcePath === active.record.sourcePath && revisionProvesChange) {
1546
- designRestore.set(designRecord.previewId, { priorRecord: active.record, request: active.request })
1545
+ const sameTarget = active?.record?.sourceKind === 'preview'
1546
+ ? designRecord.sourceKind === 'preview' && designRecord.captureKey === active.record.captureKey
1547
+ : designRecord.sourcePath === active?.record?.sourcePath
1548
+ if (correlated && dual && sameTarget && revisionProvesChange) {
1549
+ if (active.record.sourceKind !== 'preview') designRestore.set(designRecord.previewId, { priorRecord: active.record, request: active.request })
1547
1550
  active.proof = artifact
1548
1551
  maybeVerifyDesign(term)
1549
1552
  }
@@ -2126,7 +2129,14 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2126
2129
  if (restoredDesigns.length) process.stderr.write(`\n ◆ restored ${restoredDesigns.length} Thinkpool Design artifact${restoredDesigns.length === 1 ? '' : 's'} (${id.slice(0, 8)}).\n`)
2127
2130
  // Visual QA runs in the bridge process, outside either agent runtime's sandbox.
2128
2131
  // It remains scoped to this lane's cwd and private mockup outbox.
2129
- entry.viewport = new ViewportManager({ workspaceRoot: cwd || process.cwd(), ownerId: id, outbox: mockupOutbox })
2132
+ entry.viewport = new ViewportManager({
2133
+ workspaceRoot: cwd || process.cwd(), ownerId: id, outbox: mockupOutbox,
2134
+ designContext: () => {
2135
+ const active = designActive.get(id)
2136
+ if (!active || active.record.sourceKind !== 'preview') return null
2137
+ return { requestId: active.request.cid, parentRevision: active.record.revision, captureKey: active.record.captureKey }
2138
+ },
2139
+ })
2130
2140
  if (entry.log.length) process.stderr.write(`\n ◆ restored ${entry.log.length} prior events (${id.slice(0, 8)})${resume ? ' + resuming live context' : ''}.\n`)
2131
2141
  // Persist the permission mode alongside the transcript so a bridge restart
2132
2142
  // restores the session in the SAME mode (a bypass room stays bypass on resume).
@@ -3838,8 +3848,8 @@ channel
3838
3848
  // inferred "working…" state (no turn → no result to clear it; the old bug).
3839
3849
  // Instead announce a ◆ control line both readers see. Claude /compact + any
3840
3850
  // other slash still flow through as a real, self-terminating turn.
3841
- const ctlLine = (ctlText) => {
3842
- const evt = { kind: 'control', text: ctlText, by: payload.by, cid: payload.cid }
3851
+ const ctlLine = (ctlText, details = {}) => {
3852
+ const evt = { kind: 'control', text: ctlText, by: payload.by, cid: payload.cid, ...details }
3843
3853
  pushLog(s, evt)
3844
3854
  bcast('code-event', { term: payload.term, evt })
3845
3855
  }
@@ -3935,7 +3945,7 @@ channel
3935
3945
  if (/^\/compact\s*$/.test(text)) {
3936
3946
  if (s.runtime === 'codex') {
3937
3947
  if (s.session.turnActive) {
3938
- ctlLine('finish or stop the current Codex turn before compacting context')
3948
+ ctlLine('Compact after this turn ends.', { tone: 'caution' })
3939
3949
  return
3940
3950
  }
3941
3951
  const recap = buildRecapFromLog(s.log, RECAP_CAP, { reason: 'compact' })
@@ -4704,7 +4714,7 @@ const seenDesignRequests = new Set()
4704
4714
  designChannel
4705
4715
  .on('broadcast', { event: 'design-capability' }, ({ payload }) => {
4706
4716
  const record = payload?.previewId && designArtifacts.get(String(payload.previewId))
4707
- const live = record && resolveDesignSource(record.sourcePath, record.workspaceRoot)
4717
+ const live = record && refreshDesignSource(record)
4708
4718
  const ok = !!record && !!live && live.revision === record.revision && record.revision === payload?.revision && sessions.has(record.term)
4709
4719
  designChannel.send({ type: 'broadcast', event: 'design-capability-res', payload: { previewId: String(payload?.previewId || ''), revision: String(payload?.revision || ''), ok } })
4710
4720
  })
@@ -4759,7 +4769,7 @@ designChannel
4759
4769
  const requestId = String(payload?.cid || '').slice(0, 80)
4760
4770
  const record = designArtifacts.get(previewId)
4761
4771
  const restore = designRestore.get(previewId)
4762
- const live = record && resolveDesignSource(record.sourcePath, record.workspaceRoot)
4772
+ const live = record && refreshDesignSource(record)
4763
4773
  if (!requestId || !record || !restore?.priorRecord?.backupPath || !live || live.revision !== record.revision || payload?.revision !== record.revision) {
4764
4774
  designStatus({ previewId, requestId, state: 'stale', message: 'That verified revision can no longer be restored safely.' })
4765
4775
  return
package/design-edit.mjs CHANGED
@@ -50,15 +50,49 @@ export function resolveDesignSource(file, workspaceRoot) {
50
50
  return { previewId, revision, sourcePath, workspaceRoot: root, source }
51
51
  }
52
52
 
53
+ export function resolvePreviewDesignSource(manifest, workspaceRoot, box) {
54
+ if (!manifest || manifest.sourceKind !== 'preview' || !manifest.snapshot || !workspaceRoot || !box) return null
55
+ let sourcePath, root, outbox
56
+ try {
57
+ sourcePath = fs.realpathSync(manifest.snapshot)
58
+ root = fs.realpathSync(workspaceRoot)
59
+ outbox = fs.realpathSync(box)
60
+ const stat = fs.statSync(sourcePath)
61
+ if (!stat.isFile() || stat.size > MAX_SOURCE_BYTES) return null
62
+ } catch { return null }
63
+ const rel = path.relative(outbox, sourcePath)
64
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel) || path.extname(sourcePath).toLowerCase() !== '.html') return null
65
+ const route = clean(manifest.route || '/', 500)
66
+ const previewRoot = clean(manifest.previewRoot || 'dist', 500)
67
+ const captureKey = clean(manifest.captureKey, 1000)
68
+ if (!route.startsWith('/') || !captureKey || captureKey !== JSON.stringify([previewRoot, route])) return null
69
+ const source = fs.readFileSync(sourcePath, 'utf8')
70
+ const revision = sourceRevision(source)
71
+ const previewId = crypto.createHash('sha256')
72
+ .update(`${root}\0${captureKey}\0${revision}`)
73
+ .digest('base64url').slice(0, 32)
74
+ return { sourceKind: 'preview', previewId, revision, sourcePath, workspaceRoot: root, source, outbox, route, previewRoot, captureKey }
75
+ }
76
+
53
77
  // Older lane worktrees predate the explicit `source` manifest field and emit the
54
78
  // authored mockup path as `html` only. Preserve the explicit field when present;
55
79
  // otherwise let the same containment/generated-output checks decide whether the
56
80
  // HTML path is canonical editable source or view-only render evidence.
57
- export function resolveManifestDesignSource(manifest, workspaceRoot) {
81
+ export function resolveManifestDesignSource(manifest, workspaceRoot, { box } = {}) {
58
82
  if (!manifest || typeof manifest !== 'object') return null
83
+ if (manifest.sourceKind === 'preview') return resolvePreviewDesignSource(manifest, workspaceRoot, box)
59
84
  return resolveDesignSource(manifest.source || manifest.html, workspaceRoot)
60
85
  }
61
86
 
87
+ export function refreshDesignSource(record) {
88
+ if (!record) return null
89
+ if (record.sourceKind !== 'preview') return resolveDesignSource(record.sourcePath, record.workspaceRoot)
90
+ return resolvePreviewDesignSource({
91
+ sourceKind: 'preview', snapshot: record.sourcePath, route: record.route,
92
+ previewRoot: record.previewRoot, captureKey: record.captureKey,
93
+ }, record.workspaceRoot, record.outbox)
94
+ }
95
+
62
96
  // Design provenance is intentionally host-only, but it must survive a bridge
63
97
  // restart. Rebuild the in-memory registry from manifests already written by the
64
98
  // trusted lane render workflow; do not re-upload or re-broadcast old cards.
@@ -79,7 +113,7 @@ export function restoreDesignSources(box, workspaceRoot, term, limit = 100) {
79
113
  for (const { file } of files) {
80
114
  try {
81
115
  const manifest = JSON.parse(fs.readFileSync(file, 'utf8'))
82
- const record = resolveManifestDesignSource(manifest, workspaceRoot)
116
+ const record = resolveManifestDesignSource(manifest, workspaceRoot, { box })
83
117
  if (!record) continue
84
118
  Object.assign(record, {
85
119
  term,
@@ -256,5 +290,8 @@ export function designPrompt({ record, request, by, restore = false, priorRecord
256
290
  : batch
257
291
  ? batch.map((edit, index) => `Edit ${index + 1} of ${batch.length} · ${edit.mode}\nSelected element:\n${JSON.stringify(edit.target, null, 2)}\n\n${designAction(edit)}`).join('\n\n---\n\n')
258
292
  : `Selected element:\n${JSON.stringify(request.target, null, 2)}\n\n${designAction(request)}`
293
+ if (record.sourceKind === 'preview') {
294
+ return `A room member used ThinkPool Design Mode on a rendered application preview. This is an authorized source edit in your current workspace.\n\nRendered snapshot (read-only evidence): ${record.sourcePath}\nApplication workspace: ${record.workspaceRoot}\nPreview root: ${record.previewRoot}\nPreview route: ${record.route}\nExpected snapshot revision: ${record.revision}\nRequested by: ${clean(by || 'A room member', 120)}\nOperation: ${batch ? `${batch.length} queued edits in one batch` : request.mode}\n\n${work}\n\nRules:\n- Re-read the rendered snapshot and confirm the selected element still matches before editing.\n- Do not edit the snapshot. Locate the represented element in the application source inside the workspace and make the smallest source change that produces the requested result.\n- If the selector, text, or surrounding structure does not identify one application source location unambiguously, stop without editing and explain that the person must reselect it.\n${batch ? '- Apply the queued edits in order in one source pass, then build and capture once after the full batch.' : '- Apply only the requested change, then build and capture once.'}\n- Preserve the product page faithfully: keep its real copy, structure, fonts, assets, spacing, responsive behavior, and unrelated source unchanged.\n- Run the project build, then use preview_start with root ${record.previewRoot} and preview_capture with path ${record.route}, both viewports, and title ${JSON.stringify(record.title || record.slug || 'Design preview')}. The bridge correlates that capture to this request.\n- Do not claim the edit is verified until the room receives the new desktop and mobile capture.\n- If the build or capture fails, report the failure instead of substituting a screenshot-only result.`
295
+ }
259
296
  return `A room member used ThinkPool Design Mode on the artifact below. This is an authorized source edit in your current workspace.\n\nArtifact source: ${record.sourcePath}\nWorkspace: ${record.workspaceRoot}\nExpected source revision: ${record.revision}\nRequested by: ${clean(by || 'A room member', 120)}\nOperation: ${restore ? 'restore the immediately previous verified Design Mode revision' : batch ? `${batch.length} queued edits in one batch` : request.mode}\n\n${work}\n\nRules:\n- Re-read the exact artifact source and confirm its revision/content still matches every selected element before editing.\n${batch ? '- Apply the queued edits in order in one source pass, then render once after the full batch.' : '- Apply only the requested change, then render the result once.'}\n- Edit the canonical source above; do not search for or edit a different plausible file.\n- Preserve the represented product page faithfully: keep its real copy, structure, fonts, assets, spacing, and responsive behavior except for the selected edits. Never replace it with generic mockup content.\n- Preserve unrelated content and styling.\n- Use the existing mockup render workflow to produce both desktop and mobile captures after the edit. Run it with TP_DESIGN_REQUEST_ID=${request.cid} and TP_DESIGN_PARENT_REVISION=${record.revision} in the command environment so the proof correlates to this request.\n- Do not claim this is live until both captures succeed and the room receives the manifest.\n- If any target is stale or ambiguous, stop without editing and explain that the person must reselect it.`
260
297
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.288",
3
+ "version": "0.7.290",
4
4
  "description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "bundleVersion": 8,
3
+ "bundleVersion": 9,
4
4
  "contracts": [
5
5
  {
6
6
  "id": "room-coordination",
@@ -168,9 +168,9 @@
168
168
  },
169
169
  {
170
170
  "id": "design-workspace",
171
- "version": 3,
172
- "interactionPrompt": "DESIGN EDITING MODEL: a trusted source-backed mockup card offers Edit in Design. That explicit action—not ordinary Preview or unrelated artifact delivery—arms a persistent room-level Design workspace, opens the artifact directly in editable mode, and adds Design · Page name beneath the producing terminal in its Ensemble row for both partners; it creates no terminal, agent runtime, or worker slot. The virtual lane can be closed from that Ensemble row without deleting the artifact. Once armed, the Design lane reopens the active artifact at its latest verified revision without another agent turn, history search, HTML/path request, or re-selection. Design is the default canvas there and Preview is only a temporary interaction mode. Direct text, supported movement, and selected-image changes auto-stage as optimistic drafts in the bounded changes queue; queued edits can be reopened, revised, or removed before Apply. Apply changes sends the ordered batch once to the producing lane, that lane edits the canonical authored HTML, and a successful desktop+mobile render advances the verified revision for the room. Never call a staged draft saved or live. A preview_capture card is visual evidence, not an editable Design artifact. For direction rounds, deliver every option as its own source-backed Design card; a multi-option gallery may accompany them for comparison but must never be the only editable artifact.",
173
- "turnReminder": "DESIGN ROUTE: authored HTML must produce a source-backed Thinkpool Design card with verified desktop and mobile renders. Edit in Design explicitly arms the persistent Design workspace and adds its virtual Design · Page beneath the producing terminal in the Ensemble row; Preview alone does not arm it. For direction rounds, deliver every option as a separate source-backed Design card; gallery navigation is not a substitute for separately editable artifacts. Do not duplicate the card renders as inline PNGs. Use inline PNG evidence only when no interactive Design artifact is available.",
171
+ "version": 4,
172
+ "interactionPrompt": "DESIGN EDITING MODEL: every bridge preview_capture card and every trusted source-backed mockup card offers Edit in Design. The bridge freezes a safe rendered-DOM snapshot for application previews; Design edits are then applied by the producing lane to the real application source and verified by a fresh correlated desktop+mobile capture. For authored HTML, the producing lane edits the canonical HTML source directly. Edit in Design—not ordinary Preview or unrelated artifact delivery—arms a persistent room-level Design workspace, opens the artifact directly in editable mode, and adds Design · Page name beneath the producing terminal in its Ensemble row for both partners; it creates no terminal, agent runtime, or worker slot. The virtual lane can be closed from that Ensemble row without deleting the artifact. Once armed, the Design lane reopens the active artifact at its latest verified revision without another agent turn, history search, HTML/path request, or re-selection. Design is the default canvas there and Preview is only a temporary interaction mode. Direct text, supported movement, and selected-image changes auto-stage as optimistic drafts in the bounded changes queue; queued edits can be reopened, revised, or removed before Apply. Apply changes sends the ordered batch once to the producing lane, and a successful correlated desktop+mobile render advances the verified revision for the room. Never call a staged draft saved or live. For direction rounds, deliver every option as its own Design card; a multi-option gallery may accompany the cards for comparison but must never be the only editable artifact.",
173
+ "turnReminder": "DESIGN ROUTE: preview_capture and authored HTML must produce editable Thinkpool Design cards with verified desktop and mobile renders. Edit in Design explicitly arms the persistent Design workspace and adds its virtual Design · Page beneath the producing terminal in the Ensemble row; Preview alone does not arm it. For direction rounds, deliver every option as a separate Design card; gallery navigation is not a substitute for separately editable artifacts. Do not duplicate the card renders as inline PNGs. Use inline PNG evidence only when no interactive Design artifact is available.",
174
174
  "impact": [
175
175
  {"path": "src/pages/code/design/"},
176
176
  {"path": "src/pages/code/structured.jsx", "diffPattern": "Edit in Design|openMockup|tp-mockup-view|sourceKnown"},
package/viewport.mjs CHANGED
@@ -26,6 +26,7 @@ export const DEFAULT_VIEWPORTS = Object.freeze({
26
26
  const MAX_CAPTURE_HEIGHT = 20000
27
27
  const MAX_SETTLE_MS = 5000
28
28
  const CDP_TIMEOUT_MS = 12000
29
+ const MAX_PORTABLE_SNAPSHOT_BYTES = 1_900_000
29
30
 
30
31
  const isInside = (parent, child) => {
31
32
  const rel = path.relative(parent, child)
@@ -267,6 +268,79 @@ export class CdpBrowser {
267
268
  })
268
269
  }
269
270
 
271
+ async snapshot({ url, viewport = DEFAULT_VIEWPORTS.desktop, waitMs = 300 }) {
272
+ return this.withPage({ url, viewport, waitMs }, async ({ pipe, sessionId }) => {
273
+ // Freeze the rendered DOM, not the build entry document. A Vite/React entry
274
+ // depends on root-relative chunks and often boots to an empty shell in a
275
+ // sandboxed srcDoc; the settled DOM plus same-origin CSS/assets is portable.
276
+ const expression = `(async () => {
277
+ const clone = document.documentElement.cloneNode(true);
278
+ clone.querySelectorAll('script,link[rel="modulepreload"],link[rel="preload"]').forEach((node) => node.remove());
279
+ clone.querySelectorAll('*').forEach((node) => {
280
+ for (const attr of [...node.attributes]) if (/^on/i.test(attr.name)) node.removeAttribute(attr.name);
281
+ if (node instanceof HTMLInputElement) node.removeAttribute('value');
282
+ if (node instanceof HTMLTextAreaElement) node.textContent = '';
283
+ });
284
+ const css = [];
285
+ for (const sheet of [...document.styleSheets]) {
286
+ try { css.push([...sheet.cssRules].map((rule) => rule.cssText).join('\\n')); } catch {}
287
+ }
288
+ clone.querySelectorAll('style,link[rel="stylesheet"]').forEach((node) => node.remove());
289
+ const head = clone.querySelector('head') || clone.insertBefore(document.createElement('head'), clone.firstChild);
290
+ const style = document.createElement('style');
291
+ style.setAttribute('data-thinkpool-snapshot', '');
292
+ style.textContent = css.join('\\n');
293
+ head.appendChild(style);
294
+
295
+ const assetUrls = new Set();
296
+ const remember = (raw) => {
297
+ if (!raw || /^(data:|blob:|#)/i.test(raw)) return;
298
+ try {
299
+ const absolute = new URL(raw, location.href);
300
+ if (absolute.origin === location.origin) assetUrls.add(absolute.href);
301
+ } catch {}
302
+ };
303
+ clone.querySelectorAll('[src],[poster]').forEach((node) => {
304
+ remember(node.getAttribute('src'));
305
+ remember(node.getAttribute('poster'));
306
+ });
307
+ for (const match of style.textContent.matchAll(/url\\(\\s*(['"]?)([^'"\\)]+)\\1\\s*\\)/gi)) remember(match[2]);
308
+
309
+ const replacements = new Map();
310
+ let inlinedBytes = 0;
311
+ for (const absolute of assetUrls) {
312
+ try {
313
+ const response = await fetch(absolute);
314
+ const buffer = await response.arrayBuffer();
315
+ if (!response.ok || buffer.byteLength > 512000 || inlinedBytes + buffer.byteLength > 1000000) continue;
316
+ const bytes = new Uint8Array(buffer);
317
+ let binary = '';
318
+ for (let offset = 0; offset < bytes.length; offset += 0x8000) binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
319
+ replacements.set(absolute, 'data:' + (response.headers.get('content-type') || 'application/octet-stream') + ';base64,' + btoa(binary));
320
+ inlinedBytes += buffer.byteLength;
321
+ } catch {}
322
+ }
323
+ const replaceAsset = (raw) => {
324
+ try { return replacements.get(new URL(raw, location.href).href) || raw; } catch { return raw; }
325
+ };
326
+ clone.querySelectorAll('[src],[poster]').forEach((node) => {
327
+ for (const name of ['src', 'poster']) if (node.hasAttribute(name)) node.setAttribute(name, replaceAsset(node.getAttribute(name)));
328
+ node.removeAttribute('srcset');
329
+ });
330
+ style.textContent = style.textContent.replace(/url\\(\\s*(['"]?)([^'"\\)]+)\\1\\s*\\)/gi, (_all, quote, raw) => 'url("' + replaceAsset(raw) + '")');
331
+ return '<!doctype html>\\n' + clone.outerHTML;
332
+ })()`
333
+ const result = await pipe.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, sessionId)
334
+ if (result.exceptionDetails || typeof result.result?.value !== 'string') {
335
+ const detail = result.exceptionDetails?.exception?.description || result.exceptionDetails?.text || result.result?.description
336
+ throw new Error(`Could not freeze the rendered preview for Design${detail ? `: ${detail}` : '.'}`)
337
+ }
338
+ const html = result.result.value
339
+ if (!html.trim() || Buffer.byteLength(html) > MAX_PORTABLE_SNAPSHOT_BYTES) throw new Error('The rendered preview is too large to make editable.')
340
+ return html
341
+ })
342
+ }
343
+
270
344
  async inspect({ url, viewport = DEFAULT_VIEWPORTS.mobile, selector, waitMs = 300 }) {
271
345
  return this.withPage({ url, viewport, waitMs }, async ({ pipe, sessionId }) => {
272
346
  const expression = `(() => {
@@ -302,12 +376,13 @@ export class CdpBrowser {
302
376
  export const sharedViewportBrowser = new CdpBrowser()
303
377
 
304
378
  export class ViewportManager {
305
- constructor({ workspaceRoot, ownerId, outbox, browser = sharedViewportBrowser, startPreviewImpl = startPreview } = {}) {
379
+ constructor({ workspaceRoot, ownerId, outbox, browser = sharedViewportBrowser, startPreviewImpl = startPreview, designContext = null } = {}) {
306
380
  this.workspaceRoot = path.resolve(workspaceRoot || process.cwd())
307
381
  this.ownerId = ownerId || randomUUID()
308
382
  this.outbox = outbox || path.join(os.tmpdir(), 'thinkpool-viewport-captures', this.ownerId)
309
383
  this.browser = browser
310
384
  this.startPreviewImpl = startPreviewImpl
385
+ this.designContext = typeof designContext === 'function' ? designContext : () => null
311
386
  this.previewId = `viewport:${this.ownerId}`
312
387
  this.preview = null
313
388
  this.root = null
@@ -344,7 +419,8 @@ export class ViewportManager {
344
419
  }
345
420
 
346
421
  async capture({ route = '/', viewports = 'both', title = 'Viewport capture', fullPage = true, waitMs = 300 } = {}) {
347
- const url = this.pageUrl(route)
422
+ const normalizedRoute = normalizeRoute(route)
423
+ const url = this.pageUrl(normalizedRoute)
348
424
  const names = viewports === 'both' ? ['desktop', 'mobile'] : [viewports]
349
425
  if (names.some((name) => !DEFAULT_VIEWPORTS[name])) throw new Error('viewports must be "both", "desktop", or "mobile".')
350
426
  await fsp.mkdir(this.outbox, { recursive: true })
@@ -356,20 +432,28 @@ export class ViewportManager {
356
432
  await fsp.writeFile(file, result.png)
357
433
  captures[name] = { ...result, file }
358
434
  }
359
- // A preview root may be a multi-file build. Its index.html is not a portable
360
- // artifact: root-relative chunks cannot reliably boot when that text is later
361
- // injected into a sandboxed srcDoc (Windows Edge exposed the permanent loader).
362
- // preview_capture is therefore screenshot-only evidence. Editable mockups use
363
- // render.sh's explicit trusted `source` contract instead.
435
+ const snapshotViewport = names.includes('desktop') ? DEFAULT_VIEWPORTS.desktop : DEFAULT_VIEWPORTS[names[0]]
436
+ const snapshot = await this.browser.snapshot({ url, viewport: snapshotViewport, waitMs })
437
+ const snapshotPath = path.join(this.outbox, `${slug}--source.html`)
438
+ await fsp.writeFile(snapshotPath, snapshot)
439
+ const workspace = await fsp.realpath(this.workspaceRoot)
440
+ const previewRoot = path.relative(workspace, this.root) || '.'
441
+ const captureKey = JSON.stringify([previewRoot, normalizedRoute])
442
+ const active = this.designContext() || null
443
+ const correlation = active?.captureKey === captureKey
444
+ ? { designRequestId: active.requestId, parentRevision: active.parentRevision }
445
+ : {}
364
446
  const manifest = {
365
447
  slug, title: String(title || 'Viewport capture').slice(0, 120),
366
448
  desktop: captures.desktop?.file || '', mobile: captures.mobile?.file || '', ts: Date.now(),
449
+ sourceKind: 'preview', snapshot: snapshotPath, previewRoot, route: normalizedRoute, captureKey,
450
+ ...correlation,
367
451
  }
368
452
  const manifestPath = path.join(this.outbox, `${slug}.json`)
369
453
  const tmp = `${manifestPath}.tmp`
370
454
  await fsp.writeFile(tmp, JSON.stringify(manifest))
371
455
  await fsp.rename(tmp, manifestPath)
372
- return { url, slug, manifestPath, captures }
456
+ return { url, slug, manifestPath, captures, snapshotPath }
373
457
  }
374
458
 
375
459
  inspect({ route = '/', viewport = 'mobile', selector, waitMs = 300 } = {}) {