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 +22 -12
- package/design-edit.mjs +39 -2
- package/package.json +1 -1
- package/thinkpool-capabilities.json +4 -4
- package/viewport.mjs +92 -8
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,
|
|
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:
|
|
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 &&
|
|
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
|
-
|
|
1546
|
-
|
|
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({
|
|
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('
|
|
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 &&
|
|
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 &&
|
|
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
|
"schemaVersion": 1,
|
|
3
|
-
"bundleVersion":
|
|
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":
|
|
172
|
-
"interactionPrompt": "DESIGN EDITING MODEL:
|
|
173
|
-
"turnReminder": "DESIGN ROUTE: authored HTML must produce
|
|
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
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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 } = {}) {
|