@farmslot/recipe-runner 0.23.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.
- package/CHANGELOG.md +255 -0
- package/LICENSE +21 -0
- package/README.md +274 -0
- package/bin/farmslot-visual-review.mjs +152 -0
- package/dist/adapters/app-lifecycle.d.ts +41 -0
- package/dist/adapters/app-lifecycle.d.ts.map +1 -0
- package/dist/adapters/app-lifecycle.js +224 -0
- package/dist/adapters/app-lifecycle.js.map +1 -0
- package/dist/adapters/core.d.ts +5 -0
- package/dist/adapters/core.d.ts.map +1 -0
- package/dist/adapters/core.js +433 -0
- package/dist/adapters/core.js.map +1 -0
- package/dist/adapters/gesture.d.ts +12 -0
- package/dist/adapters/gesture.d.ts.map +1 -0
- package/dist/adapters/gesture.js +76 -0
- package/dist/adapters/gesture.js.map +1 -0
- package/dist/adapters/ui.d.ts +33 -0
- package/dist/adapters/ui.d.ts.map +1 -0
- package/dist/adapters/ui.js +130 -0
- package/dist/adapters/ui.js.map +1 -0
- package/dist/cli/error-output.d.ts +6 -0
- package/dist/cli/error-output.d.ts.map +1 -0
- package/dist/cli/error-output.js +19 -0
- package/dist/cli/error-output.js.map +1 -0
- package/dist/cli/index.d.ts +12 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +29 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/run-command.d.ts +8 -0
- package/dist/cli/run-command.d.ts.map +1 -0
- package/dist/cli/run-command.js +253 -0
- package/dist/cli/run-command.js.map +1 -0
- package/dist/cli/support.d.ts +31 -0
- package/dist/cli/support.d.ts.map +1 -0
- package/dist/cli/support.js +297 -0
- package/dist/cli/support.js.map +1 -0
- package/dist/cli/validate-command.d.ts +4 -0
- package/dist/cli/validate-command.d.ts.map +1 -0
- package/dist/cli/validate-command.js +52 -0
- package/dist/cli/validate-command.js.map +1 -0
- package/dist/core/compose.d.ts +23 -0
- package/dist/core/compose.d.ts.map +1 -0
- package/dist/core/compose.js +104 -0
- package/dist/core/compose.js.map +1 -0
- package/dist/core/execution.d.ts +44 -0
- package/dist/core/execution.d.ts.map +1 -0
- package/dist/core/execution.js +304 -0
- package/dist/core/execution.js.map +1 -0
- package/dist/core/failure.d.ts +23 -0
- package/dist/core/failure.d.ts.map +1 -0
- package/dist/core/failure.js +43 -0
- package/dist/core/failure.js.map +1 -0
- package/dist/core/graph.d.ts +12 -0
- package/dist/core/graph.d.ts.map +1 -0
- package/dist/core/graph.js +65 -0
- package/dist/core/graph.js.map +1 -0
- package/dist/core/hud.d.ts +11 -0
- package/dist/core/hud.d.ts.map +1 -0
- package/dist/core/hud.js +120 -0
- package/dist/core/hud.js.map +1 -0
- package/dist/core/invocation.d.ts +13 -0
- package/dist/core/invocation.d.ts.map +1 -0
- package/dist/core/invocation.js +68 -0
- package/dist/core/invocation.js.map +1 -0
- package/dist/core/json.d.ts +9 -0
- package/dist/core/json.d.ts.map +1 -0
- package/dist/core/json.js +74 -0
- package/dist/core/json.js.map +1 -0
- package/dist/core/library-manifest.d.ts +53 -0
- package/dist/core/library-manifest.d.ts.map +1 -0
- package/dist/core/library-manifest.js +263 -0
- package/dist/core/library-manifest.js.map +1 -0
- package/dist/core/library.d.ts +80 -0
- package/dist/core/library.d.ts.map +1 -0
- package/dist/core/library.js +321 -0
- package/dist/core/library.js.map +1 -0
- package/dist/core/parameters.d.ts +5 -0
- package/dist/core/parameters.d.ts.map +1 -0
- package/dist/core/parameters.js +57 -0
- package/dist/core/parameters.js.map +1 -0
- package/dist/core/passive-observations.d.ts +25 -0
- package/dist/core/passive-observations.d.ts.map +1 -0
- package/dist/core/passive-observations.js +62 -0
- package/dist/core/passive-observations.js.map +1 -0
- package/dist/core/path.d.ts +7 -0
- package/dist/core/path.d.ts.map +1 -0
- package/dist/core/path.js +147 -0
- package/dist/core/path.js.map +1 -0
- package/dist/core/recording-cleanup.d.ts +10 -0
- package/dist/core/recording-cleanup.d.ts.map +1 -0
- package/dist/core/recording-cleanup.js +32 -0
- package/dist/core/recording-cleanup.js.map +1 -0
- package/dist/core/resolution-error.d.ts +7 -0
- package/dist/core/resolution-error.d.ts.map +1 -0
- package/dist/core/resolution-error.js +9 -0
- package/dist/core/resolution-error.js.map +1 -0
- package/dist/core/runner.d.ts +4 -0
- package/dist/core/runner.d.ts.map +1 -0
- package/dist/core/runner.js +738 -0
- package/dist/core/runner.js.map +1 -0
- package/dist/core/scroll-to.d.ts +111 -0
- package/dist/core/scroll-to.d.ts.map +1 -0
- package/dist/core/scroll-to.js +353 -0
- package/dist/core/scroll-to.js.map +1 -0
- package/dist/core/suite.d.ts +25 -0
- package/dist/core/suite.d.ts.map +1 -0
- package/dist/core/suite.js +76 -0
- package/dist/core/suite.js.map +1 -0
- package/dist/core/trace.d.ts +5 -0
- package/dist/core/trace.d.ts.map +1 -0
- package/dist/core/trace.js +25 -0
- package/dist/core/trace.js.map +1 -0
- package/dist/core/trust-error.d.ts +9 -0
- package/dist/core/trust-error.d.ts.map +1 -0
- package/dist/core/trust-error.js +18 -0
- package/dist/core/trust-error.js.map +1 -0
- package/dist/core/trust-input.d.ts +20 -0
- package/dist/core/trust-input.d.ts.map +1 -0
- package/dist/core/trust-input.js +68 -0
- package/dist/core/trust-input.js.map +1 -0
- package/dist/core/trust.d.ts +28 -0
- package/dist/core/trust.d.ts.map +1 -0
- package/dist/core/trust.js +284 -0
- package/dist/core/trust.js.map +1 -0
- package/dist/core/types.d.ts +319 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +2 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/validation.d.ts +4 -0
- package/dist/core/validation.d.ts.map +1 -0
- package/dist/core/validation.js +18 -0
- package/dist/core/validation.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/node/writers.d.ts +27 -0
- package/dist/node/writers.d.ts.map +1 -0
- package/dist/node/writers.js +99 -0
- package/dist/node/writers.js.map +1 -0
- package/dist/recording/android-mirror.d.ts +10 -0
- package/dist/recording/android-mirror.d.ts.map +1 -0
- package/dist/recording/android-mirror.js +180 -0
- package/dist/recording/android-mirror.js.map +1 -0
- package/dist/recording/capture-helper-timing.d.ts +4 -0
- package/dist/recording/capture-helper-timing.d.ts.map +1 -0
- package/dist/recording/capture-helper-timing.js +47 -0
- package/dist/recording/capture-helper-timing.js.map +1 -0
- package/dist/recording/capture-helper.d.ts +10 -0
- package/dist/recording/capture-helper.d.ts.map +1 -0
- package/dist/recording/capture-helper.js +335 -0
- package/dist/recording/capture-helper.js.map +1 -0
- package/dist/recording/cdp-video-recorder.d.ts +9 -0
- package/dist/recording/cdp-video-recorder.d.ts.map +1 -0
- package/dist/recording/cdp-video-recorder.js +319 -0
- package/dist/recording/cdp-video-recorder.js.map +1 -0
- package/dist/recording/frame-sampler.d.ts +19 -0
- package/dist/recording/frame-sampler.d.ts.map +1 -0
- package/dist/recording/frame-sampler.js +29 -0
- package/dist/recording/frame-sampler.js.map +1 -0
- package/dist/recording/timeline.d.ts +15 -0
- package/dist/recording/timeline.d.ts.map +1 -0
- package/dist/recording/timeline.js +88 -0
- package/dist/recording/timeline.js.map +1 -0
- package/dist/runtime/browser-extension.d.ts +17 -0
- package/dist/runtime/browser-extension.d.ts.map +1 -0
- package/dist/runtime/browser-extension.js +42 -0
- package/dist/runtime/browser-extension.js.map +1 -0
- package/dist/runtime/cdp.d.ts +138 -0
- package/dist/runtime/cdp.d.ts.map +1 -0
- package/dist/runtime/cdp.js +1429 -0
- package/dist/runtime/cdp.js.map +1 -0
- package/dist/runtime/decision-types.d.ts +23 -0
- package/dist/runtime/decision-types.d.ts.map +1 -0
- package/dist/runtime/decision-types.js +2 -0
- package/dist/runtime/decision-types.js.map +1 -0
- package/dist/runtime/deps-readiness.d.ts +21 -0
- package/dist/runtime/deps-readiness.d.ts.map +1 -0
- package/dist/runtime/deps-readiness.js +141 -0
- package/dist/runtime/deps-readiness.js.map +1 -0
- package/dist/runtime/log-analysis.d.ts +48 -0
- package/dist/runtime/log-analysis.d.ts.map +1 -0
- package/dist/runtime/log-analysis.js +128 -0
- package/dist/runtime/log-analysis.js.map +1 -0
- package/dist/runtime/metro-probe.d.ts +5 -0
- package/dist/runtime/metro-probe.d.ts.map +1 -0
- package/dist/runtime/metro-probe.js +24 -0
- package/dist/runtime/metro-probe.js.map +1 -0
- package/dist/runtime/operation.d.ts +32 -0
- package/dist/runtime/operation.d.ts.map +1 -0
- package/dist/runtime/operation.js +184 -0
- package/dist/runtime/operation.js.map +1 -0
- package/dist/runtime/orchestrate-up.d.ts +22 -0
- package/dist/runtime/orchestrate-up.d.ts.map +1 -0
- package/dist/runtime/orchestrate-up.js +35 -0
- package/dist/runtime/orchestrate-up.js.map +1 -0
- package/dist/runtime/react-native-bridge.d.ts +17 -0
- package/dist/runtime/react-native-bridge.d.ts.map +1 -0
- package/dist/runtime/react-native-bridge.js +57 -0
- package/dist/runtime/react-native-bridge.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +8 -0
- package/dist/version.js.map +1 -0
- package/package.json +163 -0
- package/test/fixtures/scroll-to-visible/README.md +39 -0
- package/visual-review/feedback-draft.mjs +82 -0
- package/visual-review/index.d.mts +41 -0
- package/visual-review/index.mjs +4 -0
- package/visual-review/recipe-board.mjs +142 -0
- package/visual-review/review-board.mjs +981 -0
- package/visual-review/server.mjs +69 -0
|
@@ -0,0 +1,981 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { feedbackDraftFromDocument } from './feedback-draft.mjs';
|
|
6
|
+
|
|
7
|
+
const SAFE_FILE_ID = /^[a-zA-Z0-9._-]+$/u;
|
|
8
|
+
|
|
9
|
+
const escapeHtml = (value) =>
|
|
10
|
+
String(value)
|
|
11
|
+
.replaceAll('&', '&')
|
|
12
|
+
.replaceAll('<', '<')
|
|
13
|
+
.replaceAll('>', '>')
|
|
14
|
+
.replaceAll('"', '"')
|
|
15
|
+
.replaceAll("'", ''');
|
|
16
|
+
|
|
17
|
+
const FEEDBACK_ACTIONS_HTML =
|
|
18
|
+
'<label class="feedback-open">Open feedback JSON<input data-feedback-open type="file" accept="application/json,.json" hidden></label>' +
|
|
19
|
+
'<button data-feedback-download type="button">Download feedback JSON</button>';
|
|
20
|
+
function safeFileId(value, label) {
|
|
21
|
+
const normalized = String(value);
|
|
22
|
+
if (!SAFE_FILE_ID.test(normalized)) {
|
|
23
|
+
throw new Error(`${label} must contain only letters, numbers, dots, underscores, or dashes.`);
|
|
24
|
+
}
|
|
25
|
+
return normalized;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function documentShell({ title, assetPrefix, assetVersion, body, bodyAttributes = '' }) {
|
|
29
|
+
const version = encodeURIComponent(assetVersion);
|
|
30
|
+
return `<!doctype html>
|
|
31
|
+
<html lang="en">
|
|
32
|
+
<head>
|
|
33
|
+
<meta charset="utf-8">
|
|
34
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
35
|
+
<title>${escapeHtml(title)}</title>
|
|
36
|
+
<link rel="stylesheet" href="${assetPrefix}assets/review-board.css?v=${escapeHtml(version)}">
|
|
37
|
+
</head>
|
|
38
|
+
<body ${bodyAttributes}>
|
|
39
|
+
${body}
|
|
40
|
+
<script src="${assetPrefix}assets/review-board.js?v=${escapeHtml(version)}"></script>
|
|
41
|
+
</body>
|
|
42
|
+
</html>
|
|
43
|
+
`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sourcePlatforms(source) {
|
|
47
|
+
return [
|
|
48
|
+
...new Set(
|
|
49
|
+
source.surfaces.flatMap((surface) => surface.captures.map(({ platform }) => platform)),
|
|
50
|
+
),
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function platformLabel(platform) {
|
|
55
|
+
if (platform === 'ios') return 'iOS';
|
|
56
|
+
if (platform === 'android') return 'Android';
|
|
57
|
+
if (platform === 'web') return 'Web';
|
|
58
|
+
return platform;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function platformFilterHtml(source, defaultPlatform) {
|
|
62
|
+
const platforms = sourcePlatforms(source).sort((left, right) => {
|
|
63
|
+
if (left === defaultPlatform) return -1;
|
|
64
|
+
if (right === defaultPlatform) return 1;
|
|
65
|
+
return 0;
|
|
66
|
+
});
|
|
67
|
+
if (platforms.length < 2) return '';
|
|
68
|
+
return `<div class="platform-filter" role="group" aria-label="Capture platform">
|
|
69
|
+
${platforms
|
|
70
|
+
.map(
|
|
71
|
+
(platform) =>
|
|
72
|
+
`<button type="button" data-platform-filter="${escapeHtml(platform)}" aria-pressed="false">${escapeHtml(platformLabel(platform))}</button>`,
|
|
73
|
+
)
|
|
74
|
+
.join('')}
|
|
75
|
+
<button type="button" data-platform-filter="all" aria-pressed="false">Compare platforms</button>
|
|
76
|
+
</div>`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function bodyAttributes(source, storageKey, defaultPlatform) {
|
|
80
|
+
return [
|
|
81
|
+
`data-feedback-key="${escapeHtml(storageKey)}"`,
|
|
82
|
+
`data-source-id="${escapeHtml(source.id)}"`,
|
|
83
|
+
`data-captured-at="${escapeHtml(source.capturedAt)}"`,
|
|
84
|
+
`data-platforms="${escapeHtml(sourcePlatforms(source).join(','))}"`,
|
|
85
|
+
`data-default-platform="${escapeHtml(defaultPlatform)}"`,
|
|
86
|
+
].join(' ');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function surfaceById(source) {
|
|
90
|
+
return new Map(source.surfaces.map((surface) => [surface.id, surface]));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function surfaceAncestors(source, surface) {
|
|
94
|
+
const byId = surfaceById(source);
|
|
95
|
+
const ancestors = [];
|
|
96
|
+
let parentId = surface.parentId;
|
|
97
|
+
while (parentId) {
|
|
98
|
+
const parent = byId.get(parentId);
|
|
99
|
+
if (!parent) break;
|
|
100
|
+
ancestors.unshift(parent);
|
|
101
|
+
parentId = parent.parentId;
|
|
102
|
+
}
|
|
103
|
+
return ancestors;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function incomingNavigationEdges(source, surfaceId) {
|
|
107
|
+
return (source.navigationEdges ?? []).filter((edge) => edge.toSurfaceId === surfaceId);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function navigationKindHtml(kind) {
|
|
111
|
+
return `<span class="navigation-kind navigation-kind--${escapeHtml(kind)}">${escapeHtml(kind)}</span>`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function surfaceDescendantCount(source, surfaceId) {
|
|
115
|
+
const children = source.surfaces.filter((surface) => surface.parentId === surfaceId);
|
|
116
|
+
return children.reduce((count, child) => count + 1 + surfaceDescendantCount(source, child.id), 0);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function surfaceTreeHtml(source, parentId, depth = 0) {
|
|
120
|
+
const children = source.surfaces.filter((surface) => surface.parentId === parentId);
|
|
121
|
+
if (children.length === 0) return '';
|
|
122
|
+
return `<ul class="surface-tree${parentId ? ' surface-tree--nested' : ''}"${
|
|
123
|
+
parentId
|
|
124
|
+
? ` id="surface-children-${escapeHtml(parentId)}" data-surface-children="${escapeHtml(parentId)}" hidden`
|
|
125
|
+
: ''
|
|
126
|
+
}>${children
|
|
127
|
+
.map((surface) => {
|
|
128
|
+
const incoming = incomingNavigationEdges(source, surface.id);
|
|
129
|
+
const descendantCount = surfaceDescendantCount(source, surface.id);
|
|
130
|
+
return `<li class="surface-node" data-surface-node="${escapeHtml(surface.id)}" data-surface-depth="${depth}">
|
|
131
|
+
<div class="surface-node__row">
|
|
132
|
+
${
|
|
133
|
+
descendantCount > 0
|
|
134
|
+
? `<button class="surface-toggle" type="button" data-surface-toggle="${escapeHtml(surface.id)}" data-surface-title="${escapeHtml(surface.title)}" aria-controls="surface-children-${escapeHtml(surface.id)}" aria-expanded="false"><span class="surface-toggle__icon" aria-hidden="true">›</span><span class="visually-hidden" data-toggle-label>Expand ${escapeHtml(surface.title)}</span></button>`
|
|
135
|
+
: '<span class="surface-toggle-spacer" aria-hidden="true"></span>'
|
|
136
|
+
}
|
|
137
|
+
<a class="screen-link" data-surface-platforms="${escapeHtml(surface.captures.map(({ platform }) => platform).join(','))}" href="screens/${escapeHtml(surface.id)}.html#surface-${escapeHtml(surface.id)}">
|
|
138
|
+
<div class="screen-link__meta">
|
|
139
|
+
<code>${escapeHtml(surface.id)}</code>
|
|
140
|
+
<span><span data-surface-capture-count>${surface.captures.length} capture${surface.captures.length === 1 ? '' : 's'}</span>${descendantCount > 0 ? ` · ${descendantCount} nested` : ''}</span>
|
|
141
|
+
</div>
|
|
142
|
+
<h2>${escapeHtml(surface.title)}</h2>
|
|
143
|
+
${surface.location ? `<code>${escapeHtml(surface.location)}</code>` : ''}
|
|
144
|
+
${
|
|
145
|
+
incoming.length > 0
|
|
146
|
+
? `<div class="navigation-paths">${incoming
|
|
147
|
+
.map(
|
|
148
|
+
(edge) =>
|
|
149
|
+
`${navigationKindHtml(edge.kind)}<span>from ${escapeHtml(surfaceById(source).get(edge.fromSurfaceId)?.title ?? edge.fromSurfaceId)}</span>`,
|
|
150
|
+
)
|
|
151
|
+
.join('')}</div>`
|
|
152
|
+
: ''
|
|
153
|
+
}
|
|
154
|
+
<div class="platforms">${surface.captures
|
|
155
|
+
.map(
|
|
156
|
+
(capture) =>
|
|
157
|
+
`<span data-platform="${escapeHtml(capture.platform)}">${escapeHtml(platformLabel(capture.platform))}</span>`,
|
|
158
|
+
)
|
|
159
|
+
.join('')}</div>
|
|
160
|
+
</a>
|
|
161
|
+
</div>
|
|
162
|
+
${surfaceTreeHtml(source, surface.id, depth + 1)}
|
|
163
|
+
</li>`;
|
|
164
|
+
})
|
|
165
|
+
.join('')}</ul>`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function indexHtml(source, storageKey, defaultPlatform, assetVersion) {
|
|
169
|
+
return documentShell({
|
|
170
|
+
title: source.title,
|
|
171
|
+
assetPrefix: '',
|
|
172
|
+
assetVersion,
|
|
173
|
+
bodyAttributes: bodyAttributes(source, storageKey, defaultPlatform),
|
|
174
|
+
body: `
|
|
175
|
+
<header class="board-header">
|
|
176
|
+
<div>
|
|
177
|
+
<h1>${escapeHtml(source.title)}</h1>
|
|
178
|
+
<p>${escapeHtml(source.capturedAt)}${source.description ? ` · ${escapeHtml(source.description)}` : ''}</p>
|
|
179
|
+
<p data-feedback-status class="autosave" role="status"></p>
|
|
180
|
+
</div>
|
|
181
|
+
<div class="header-actions">${platformFilterHtml(source, defaultPlatform)}${FEEDBACK_ACTIONS_HTML}</div>
|
|
182
|
+
</header>
|
|
183
|
+
<main class="screen-index" aria-label="Captured surfaces">
|
|
184
|
+
<section class="navigation-map" aria-labelledby="navigation-map-title">
|
|
185
|
+
<div class="navigation-map__heading">
|
|
186
|
+
<div><h2 id="navigation-map-title">Navigation map</h2><p>Indentation shows branches; edge labels show how the destination is presented.</p></div>
|
|
187
|
+
<div class="navigation-map__tools">
|
|
188
|
+
<div class="tree-actions" aria-label="Navigation tree controls">
|
|
189
|
+
<button type="button" data-tree-action="expand">Expand all</button>
|
|
190
|
+
<button type="button" data-tree-action="collapse">Collapse all</button>
|
|
191
|
+
</div>
|
|
192
|
+
<div class="navigation-legend" aria-label="Navigation relationship legend">
|
|
193
|
+
${['tab', 'push', 'in-place', 'modal', 'replace']
|
|
194
|
+
.map(
|
|
195
|
+
(kind) => `<span class="navigation-kind navigation-kind--${kind}">${kind}</span>`,
|
|
196
|
+
)
|
|
197
|
+
.join('')}
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
${surfaceTreeHtml(source, undefined)}
|
|
202
|
+
</section>
|
|
203
|
+
</main>`,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function screenHtml(source, storageKey, defaultPlatform, surface, index, assetVersion) {
|
|
208
|
+
const byId = surfaceById(source);
|
|
209
|
+
const ancestors = surfaceAncestors(source, surface);
|
|
210
|
+
const children = source.surfaces.filter((candidate) => candidate.parentId === surface.id);
|
|
211
|
+
const hierarchicalIds = new Set([
|
|
212
|
+
...ancestors.map((candidate) => candidate.id),
|
|
213
|
+
...children.map((candidate) => candidate.id),
|
|
214
|
+
]);
|
|
215
|
+
const related = (surface.relatedSurfaceIds ?? [])
|
|
216
|
+
.map((id) => byId.get(id))
|
|
217
|
+
.filter((candidate) => candidate && !hierarchicalIds.has(candidate.id));
|
|
218
|
+
const incoming = incomingNavigationEdges(source, surface.id);
|
|
219
|
+
const previous = source.surfaces[index - 1];
|
|
220
|
+
const next = source.surfaces[index + 1];
|
|
221
|
+
const captures = surface.captures
|
|
222
|
+
.map(
|
|
223
|
+
(capture) => `
|
|
224
|
+
<article class="capture" data-capture-platform="${escapeHtml(capture.platform)}">
|
|
225
|
+
<header class="capture__header">
|
|
226
|
+
<div><span>Same screen</span><h2>${escapeHtml(surface.title)} · ${escapeHtml(platformLabel(capture.platform))}</h2></div>
|
|
227
|
+
<code>${escapeHtml(capture.id)}</code>
|
|
228
|
+
</header>
|
|
229
|
+
<div class="capture__image">
|
|
230
|
+
<div class="annotation-stage" data-annotation-surface data-surface-id="${escapeHtml(surface.id)}" data-capture-id="${escapeHtml(capture.id)}">
|
|
231
|
+
<img src="../${escapeHtml(capture.image.path)}" alt="${escapeHtml(surface.title)} on ${escapeHtml(capture.platform)}" draggable="false">
|
|
232
|
+
<div class="annotation-markers" aria-label="Point annotations"></div>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
<aside class="capture__notes">
|
|
236
|
+
<div class="annotation-tools" aria-label="Annotation shape">
|
|
237
|
+
<button type="button" data-annotation-mode="point" aria-pressed="true">Point</button>
|
|
238
|
+
<button type="button" data-annotation-mode="area" aria-pressed="false">Area</button>
|
|
239
|
+
</div>
|
|
240
|
+
<p class="annotation-help" data-annotation-help>Click the screenshot to comment on a specific element.</p>
|
|
241
|
+
<ol class="annotation-list" data-annotation-list data-surface-id="${escapeHtml(surface.id)}" data-capture-id="${escapeHtml(capture.id)}"></ol>
|
|
242
|
+
</aside>
|
|
243
|
+
</article>`,
|
|
244
|
+
)
|
|
245
|
+
.join('');
|
|
246
|
+
|
|
247
|
+
return documentShell({
|
|
248
|
+
title: `${surface.title} · ${source.title}`,
|
|
249
|
+
assetPrefix: '../',
|
|
250
|
+
assetVersion,
|
|
251
|
+
bodyAttributes: bodyAttributes(source, storageKey, defaultPlatform),
|
|
252
|
+
body: `
|
|
253
|
+
<header class="screen-header">
|
|
254
|
+
<div>
|
|
255
|
+
<nav class="breadcrumbs" aria-label="Surface hierarchy">
|
|
256
|
+
<a href="../index.html#review-board">All surfaces</a>
|
|
257
|
+
${ancestors
|
|
258
|
+
.map(
|
|
259
|
+
(ancestor) =>
|
|
260
|
+
`<span>›</span><a href="${escapeHtml(ancestor.id)}.html#surface-${escapeHtml(ancestor.id)}">${escapeHtml(ancestor.title)}</a>`,
|
|
261
|
+
)
|
|
262
|
+
.join('')}
|
|
263
|
+
</nav>
|
|
264
|
+
<h1>${escapeHtml(surface.title)}</h1>
|
|
265
|
+
<p>${surface.location ? `<code>${escapeHtml(surface.location)}</code> · ` : ''}${escapeHtml(surface.id)}</p>
|
|
266
|
+
${
|
|
267
|
+
incoming.length > 0
|
|
268
|
+
? `<div class="navigation-context">${incoming
|
|
269
|
+
.map(
|
|
270
|
+
(edge) =>
|
|
271
|
+
`${navigationKindHtml(edge.kind)}<span>from <a href="${escapeHtml(edge.fromSurfaceId)}.html#surface-${escapeHtml(edge.fromSurfaceId)}">${escapeHtml(byId.get(edge.fromSurfaceId)?.title ?? edge.fromSurfaceId)}</a></span>`,
|
|
272
|
+
)
|
|
273
|
+
.join('')}</div>`
|
|
274
|
+
: ''
|
|
275
|
+
}
|
|
276
|
+
</div>
|
|
277
|
+
<div class="header-actions">${platformFilterHtml(source, defaultPlatform)}${FEEDBACK_ACTIONS_HTML}</div>
|
|
278
|
+
</header>
|
|
279
|
+
<nav class="route-nav" aria-label="Screen navigation">
|
|
280
|
+
${previous ? `<a href="${escapeHtml(previous.id)}.html#surface-${escapeHtml(previous.id)}">← ${escapeHtml(previous.title)}</a>` : '<span></span>'}
|
|
281
|
+
${next ? `<a href="${escapeHtml(next.id)}.html#surface-${escapeHtml(next.id)}">${escapeHtml(next.title)} →</a>` : '<span></span>'}
|
|
282
|
+
</nav>
|
|
283
|
+
${
|
|
284
|
+
children.length > 0 || related.length > 0
|
|
285
|
+
? `<nav class="surface-links" aria-label="Linked surfaces">
|
|
286
|
+
${
|
|
287
|
+
children.length > 0
|
|
288
|
+
? `<section><h2>Subscreens</h2><div>${children
|
|
289
|
+
.map(
|
|
290
|
+
(child) =>
|
|
291
|
+
`<a href="${escapeHtml(child.id)}.html#surface-${escapeHtml(child.id)}">${escapeHtml(child.title)}</a>`,
|
|
292
|
+
)
|
|
293
|
+
.join('')}</div></section>`
|
|
294
|
+
: ''
|
|
295
|
+
}
|
|
296
|
+
${
|
|
297
|
+
related.length > 0
|
|
298
|
+
? `<section><h2>Related</h2><div>${related
|
|
299
|
+
.map(
|
|
300
|
+
(candidate) =>
|
|
301
|
+
`<a href="${escapeHtml(candidate.id)}.html#surface-${escapeHtml(candidate.id)}">${escapeHtml(candidate.title)}</a>`,
|
|
302
|
+
)
|
|
303
|
+
.join('')}</div></section>`
|
|
304
|
+
: ''
|
|
305
|
+
}
|
|
306
|
+
</nav>`
|
|
307
|
+
: ''
|
|
308
|
+
}
|
|
309
|
+
<section class="screen-note">
|
|
310
|
+
<label for="surface-note">Overall feedback for this surface</label>
|
|
311
|
+
<textarea id="surface-note" data-surface-note="${escapeHtml(surface.id)}" placeholder="Describe changes that apply to the whole surface…"></textarea>
|
|
312
|
+
<p data-feedback-status class="autosave">Feedback saves in this review session.</p>
|
|
313
|
+
</section>
|
|
314
|
+
<section class="platform-context" data-platform-context data-surface-title="${escapeHtml(surface.title)}" data-surface-platforms="${escapeHtml(surface.captures.map(({ platform }) => platform).join(','))}">
|
|
315
|
+
<div><span>Screen</span><strong>${escapeHtml(surface.title)}</strong></div>
|
|
316
|
+
<p data-platform-context-copy></p>
|
|
317
|
+
</section>
|
|
318
|
+
<p class="platform-empty" data-platform-empty hidden>The selected platform is not captured for this surface.</p>
|
|
319
|
+
<main class="captures">${captures}</main>`,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const stylesheet = `
|
|
324
|
+
:root { color-scheme: dark; font-family: ui-sans-serif, system-ui, sans-serif; background: #09090f; color: #f4f4f8; }
|
|
325
|
+
* { box-sizing: border-box; }
|
|
326
|
+
body { margin: 0; padding: 24px; }
|
|
327
|
+
button { border: 1px solid #5555dd; border-radius: 8px; padding: 10px 14px; background: #3333aa; color: white; cursor: pointer; }
|
|
328
|
+
code { color: #aaaaff; overflow-wrap: anywhere; }
|
|
329
|
+
.board-header, .screen-header { display: flex; justify-content: space-between; align-items: start; gap: 20px; max-width: 1180px; margin: 0 auto 24px; }
|
|
330
|
+
.header-actions { display: flex; align-items: center; justify-content: end; gap: 10px; }
|
|
331
|
+
.feedback-open { border: 1px solid #5555dd; border-radius: 8px; padding: 10px 14px; cursor: pointer; }
|
|
332
|
+
.platform-filter { display: inline-flex; padding: 3px; border: 1px solid #30304a; border-radius: 10px; background: #12121c; }
|
|
333
|
+
.platform-filter button { border: 0; border-radius: 7px; padding: 7px 11px; background: transparent; color: #aaaabb; }
|
|
334
|
+
.platform-filter button[aria-pressed="true"] { background: #3333aa; color: white; }
|
|
335
|
+
h1 { margin: 0 0 8px; }
|
|
336
|
+
h2 { margin: 0; font-size: 18px; }
|
|
337
|
+
p { color: #aaaabb; margin: 0; }
|
|
338
|
+
.screen-index { max-width: 1180px; margin: 0 auto; }
|
|
339
|
+
.navigation-map { display: grid; gap: 18px; }
|
|
340
|
+
.navigation-map__heading { display: flex; justify-content: space-between; align-items: start; gap: 20px; padding: 18px; border: 1px solid #2a2a3a; border-radius: 12px; background: #12121c; }
|
|
341
|
+
.navigation-map__heading h2 { margin-bottom: 6px; }
|
|
342
|
+
.navigation-map__tools { display: grid; justify-items: end; gap: 10px; }
|
|
343
|
+
.tree-actions { display: flex; gap: 8px; }
|
|
344
|
+
.tree-actions button { padding: 6px 10px; background: transparent; }
|
|
345
|
+
.navigation-legend { display: flex; flex-wrap: wrap; justify-content: end; gap: 7px; }
|
|
346
|
+
.navigation-kind { display: inline-flex; align-items: center; border: 1px solid #444466; border-radius: 999px; padding: 3px 7px; color: #d8d8ff; background: #202038; font-size: 11px; font-weight: 750; line-height: 1; text-transform: uppercase; }
|
|
347
|
+
.navigation-kind--push { border-color: #6153b8; background: #29204d; }
|
|
348
|
+
.navigation-kind--in-place { border-color: #287a68; background: #163d36; }
|
|
349
|
+
.navigation-kind--tab { border-color: #3a6899; background: #18334f; }
|
|
350
|
+
.navigation-kind--modal { border-color: #9a6631; background: #4d3219; }
|
|
351
|
+
.navigation-kind--replace { border-color: #944d65; background: #492332; }
|
|
352
|
+
.navigation-context { display: flex; align-items: center; gap: 7px; margin-top: 8px; }
|
|
353
|
+
.navigation-context a { color: #cacaef; }
|
|
354
|
+
.navigation-paths { display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 6px 8px; color: #aaaabb; font-size: 12px; }
|
|
355
|
+
.surface-tree { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 14px; margin: 0; padding: 0; list-style: none; }
|
|
356
|
+
.surface-tree[hidden] { display: none; }
|
|
357
|
+
.surface-tree--nested { display: grid; grid-template-columns: 1fr; margin: 12px 0 0 18px; padding-left: 14px; border-left: 2px solid #30304a; }
|
|
358
|
+
.surface-tree:not(.surface-tree--nested) > .surface-node:has(> .surface-tree--nested) { grid-column: 1 / -1; }
|
|
359
|
+
.surface-tree:not(.surface-tree--nested) > .surface-node:has(> .surface-tree--nested) > .surface-node__row .screen-link { max-width: 360px; }
|
|
360
|
+
.surface-tree--nested .screen-link { width: 100%; max-width: 640px; min-height: 0; }
|
|
361
|
+
.surface-node__row { display: grid; grid-template-columns: 32px minmax(0, 1fr); gap: 8px; align-items: start; }
|
|
362
|
+
.surface-toggle, .surface-toggle-spacer { width: 32px; height: 32px; margin-top: 8px; }
|
|
363
|
+
.surface-toggle { display: grid; place-items: center; padding: 0; border-color: #30304a; background: #12121c; }
|
|
364
|
+
.surface-toggle:hover, .surface-toggle:focus-visible { border-color: #6666ee; }
|
|
365
|
+
.surface-toggle__icon { font-size: 24px; line-height: 1; transition: transform 120ms ease; }
|
|
366
|
+
.surface-toggle[aria-expanded="true"] .surface-toggle__icon { transform: rotate(90deg); }
|
|
367
|
+
.screen-link { display: grid; gap: 10px; min-height: 160px; padding: 18px; border: 1px solid #2a2a3a; border-radius: 12px; background: #12121c; color: inherit; text-decoration: none; }
|
|
368
|
+
.screen-link:hover, .screen-link:focus-visible { border-color: #6666ee; background: #171724; }
|
|
369
|
+
.screen-link[data-platform-available="false"] { opacity: 0.55; }
|
|
370
|
+
.screen-link__meta { display: flex; justify-content: space-between; gap: 16px; color: #a5a5b8; font-size: 12px; text-transform: uppercase; }
|
|
371
|
+
.platforms { display: flex; flex-wrap: wrap; gap: 8px; align-self: end; }
|
|
372
|
+
.platforms span { border: 1px solid #30304a; border-radius: 999px; color: #cacaef; padding: 4px 8px; font-size: 12px; text-transform: uppercase; }
|
|
373
|
+
.platforms span[aria-current="true"] { border-color: #6666ee; background: #252553; color: white; }
|
|
374
|
+
.breadcrumbs { display: flex; flex-wrap: wrap; gap: 7px; margin-bottom: 12px; color: #85859a; font-size: 13px; }
|
|
375
|
+
.breadcrumbs a, .route-nav a, .surface-links a { color: #cacaef; text-decoration: none; }
|
|
376
|
+
.route-nav { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; max-width: 1180px; margin: 0 auto 20px; }
|
|
377
|
+
.route-nav a:last-child { text-align: right; }
|
|
378
|
+
.surface-links { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; max-width: 1180px; margin: 0 auto 20px; }
|
|
379
|
+
.surface-links section { padding: 14px 18px; border: 1px solid #2a2a3a; border-radius: 12px; background: #12121c; }
|
|
380
|
+
.surface-links h2 { margin-bottom: 10px; font-size: 13px; color: #85859a; text-transform: uppercase; }
|
|
381
|
+
.surface-links section div { display: flex; flex-wrap: wrap; gap: 8px 16px; }
|
|
382
|
+
.screen-note { display: grid; gap: 10px; max-width: 1180px; margin: 0 auto 24px; padding: 18px; border: 1px solid #2a2a3a; border-radius: 12px; background: #12121c; }
|
|
383
|
+
.screen-note label { font-weight: 650; }
|
|
384
|
+
.screen-note textarea { min-height: 100px; }
|
|
385
|
+
.captures { display: grid; gap: 24px; max-width: 1180px; margin: 0 auto; }
|
|
386
|
+
.captures[data-platform-comparison="true"] { grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; }
|
|
387
|
+
.platform-context { display: flex; justify-content: space-between; align-items: center; gap: 20px; max-width: 1180px; margin: 0 auto 16px; padding: 14px 18px; border: 1px solid #30304a; border-radius: 12px; background: #12121c; }
|
|
388
|
+
.platform-context div { display: grid; gap: 3px; }
|
|
389
|
+
.platform-context span, .capture__header span { color: #85859a; font-size: 11px; font-weight: 750; letter-spacing: 0.06em; text-transform: uppercase; }
|
|
390
|
+
.platform-context strong { font-size: 15px; }
|
|
391
|
+
.platform-empty { max-width: 1180px; margin: 0 auto 24px; padding: 18px; border: 1px solid #4b3e2c; border-radius: 12px; background: #241d14; color: #e8c98c; }
|
|
392
|
+
.capture { display: grid; grid-template-columns: minmax(280px, 720px) minmax(280px, 420px); justify-content: center; align-items: start; overflow: clip; border: 1px solid #2a2a3a; border-radius: 12px; background: #12121c; }
|
|
393
|
+
.capture[hidden] { display: none; }
|
|
394
|
+
.capture__header { grid-column: 1 / -1; display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 14px 18px; border-bottom: 1px solid #2a2a3a; background: #171722; }
|
|
395
|
+
.capture__header div { display: grid; gap: 3px; }
|
|
396
|
+
.capture__header h2 { font-size: 16px; }
|
|
397
|
+
.capture__image { min-width: 0; background: #050508; }
|
|
398
|
+
.annotation-stage { position: relative; width: 100%; cursor: crosshair; }
|
|
399
|
+
.annotation-stage[data-mode="area"] { cursor: crosshair; touch-action: none; }
|
|
400
|
+
.annotation-stage img { display: block; width: 100%; height: auto; user-select: none; -webkit-user-drag: none; }
|
|
401
|
+
.annotation-markers { position: absolute; inset: 0; pointer-events: none; }
|
|
402
|
+
.annotation-marker { position: absolute; width: 28px; height: 28px; padding: 0; transform: translate(-50%, -50%); border: 2px solid white; border-radius: 50%; background: var(--annotation-color, #5855ee); font-size: 12px; font-weight: 750; pointer-events: auto; touch-action: none; }
|
|
403
|
+
.annotation-area { position: absolute; display: grid; place-items: start; min-width: 18px; min-height: 18px; padding: 3px; border: 2px solid var(--annotation-color, #8c8aff); border-radius: 5px; background: color-mix(in srgb, var(--annotation-color, #5855ee) 18%, transparent); color: white; font-size: 12px; font-weight: 750; pointer-events: auto; touch-action: none; }
|
|
404
|
+
.annotation-area--draft { border-style: dashed; pointer-events: none; }
|
|
405
|
+
.capture__notes { position: sticky; top: 24px; display: grid; gap: 12px; padding: 18px; }
|
|
406
|
+
.captures[data-platform-comparison="true"] .capture { display: block; }
|
|
407
|
+
.captures[data-platform-comparison="true"] .capture__notes { position: static; }
|
|
408
|
+
.annotation-tools { display: flex; gap: 8px; }
|
|
409
|
+
.annotation-tools button { padding: 6px 10px; background: transparent; }
|
|
410
|
+
.annotation-tools button[aria-pressed="true"] { background: #3333aa; }
|
|
411
|
+
.annotation-help { font-size: 13px; }
|
|
412
|
+
.annotation-list { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
|
|
413
|
+
.annotation-item { display: grid; grid-template-columns: 28px 1fr auto; gap: 8px; align-items: start; }
|
|
414
|
+
.annotation-number { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%; background: #5855ee; font-size: 12px; font-weight: 750; }
|
|
415
|
+
.annotation-editor { display: grid; gap: 8px; }
|
|
416
|
+
.annotation-color { display: flex; align-items: center; gap: 8px; color: #aaaabb; font-size: 12px; }
|
|
417
|
+
.annotation-color input { width: 34px; height: 24px; padding: 0; border: 0; background: transparent; cursor: pointer; }
|
|
418
|
+
.annotation-item textarea { min-height: 88px; }
|
|
419
|
+
.annotation-remove { border-color: #4a3340; background: transparent; color: #e8b9c9; padding: 6px 8px; }
|
|
420
|
+
textarea { width: 100%; resize: vertical; border: 1px solid #333348; border-radius: 8px; padding: 12px; background: #09090f; color: inherit; font: inherit; }
|
|
421
|
+
.autosave { color: #85859a; font-size: 12px; }
|
|
422
|
+
.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
|
423
|
+
@media (max-width: 760px) {
|
|
424
|
+
body { padding: 12px; }
|
|
425
|
+
.board-header, .screen-header { display: grid; }
|
|
426
|
+
.header-actions { display: grid; justify-content: stretch; }
|
|
427
|
+
.platform-filter { justify-content: stretch; }
|
|
428
|
+
.platform-filter button { flex: 1; }
|
|
429
|
+
.capture { display: block; }
|
|
430
|
+
.capture__header, .platform-context { display: grid; }
|
|
431
|
+
.capture__notes { position: static; }
|
|
432
|
+
.navigation-map__heading { display: grid; }
|
|
433
|
+
.navigation-map__tools { justify-items: start; }
|
|
434
|
+
.navigation-legend { justify-content: start; }
|
|
435
|
+
.surface-tree--nested { margin-left: 8px; padding-left: 8px; }
|
|
436
|
+
}
|
|
437
|
+
@media (max-width: 980px) {
|
|
438
|
+
.captures[data-platform-comparison="true"] { grid-template-columns: 1fr; }
|
|
439
|
+
}
|
|
440
|
+
`;
|
|
441
|
+
|
|
442
|
+
function clientScript(reviewSource) {
|
|
443
|
+
return `
|
|
444
|
+
(() => {
|
|
445
|
+
const storageKey = document.body.dataset.feedbackKey;
|
|
446
|
+
const source = ${JSON.stringify(reviewSource)};
|
|
447
|
+
const platformStorageKey = storageKey + ':platform';
|
|
448
|
+
const platforms = document.body.dataset.platforms.split(',').filter(Boolean);
|
|
449
|
+
const defaultPlatform = document.body.dataset.defaultPlatform;
|
|
450
|
+
const savedPlatform = localStorage.getItem(platformStorageKey);
|
|
451
|
+
let platformFilter = savedPlatform === 'all' || platforms.includes(savedPlatform)
|
|
452
|
+
? savedPlatform
|
|
453
|
+
: platforms.includes(defaultPlatform)
|
|
454
|
+
? defaultPlatform
|
|
455
|
+
: platforms[0] || 'all';
|
|
456
|
+
let annotationMode = 'point';
|
|
457
|
+
const annotationPalette = ['#5855ee', '#e84a8a', '#20b486', '#f29d38', '#38a9f2', '#b266e8'];
|
|
458
|
+
const annotationColor = (annotation) => {
|
|
459
|
+
if (/^#[0-9a-f]{6}$/iu.test(annotation.color || '')) return annotation.color;
|
|
460
|
+
const numericId = Number(/^annotation-(\\d+)$/u.exec(annotation.id)?.[1] || 1);
|
|
461
|
+
return annotationPalette[(numericId - 1) % annotationPalette.length];
|
|
462
|
+
};
|
|
463
|
+
const platformName = (platform) => ({ ios: 'iOS', android: 'Android', web: 'Web' })[platform] || platform;
|
|
464
|
+
|
|
465
|
+
const applyPlatformFilter = (platform) => {
|
|
466
|
+
platformFilter = platform;
|
|
467
|
+
document.body.dataset.activePlatform = platform;
|
|
468
|
+
localStorage.setItem(platformStorageKey, platform);
|
|
469
|
+
for (const button of document.querySelectorAll('[data-platform-filter]')) {
|
|
470
|
+
button.setAttribute('aria-pressed', String(button.dataset.platformFilter === platform));
|
|
471
|
+
}
|
|
472
|
+
for (const capture of document.querySelectorAll('[data-capture-platform]')) {
|
|
473
|
+
capture.hidden = platform !== 'all' && capture.dataset.capturePlatform !== platform;
|
|
474
|
+
}
|
|
475
|
+
for (const card of document.querySelectorAll('.screen-link[data-surface-platforms]')) {
|
|
476
|
+
const available = card.dataset.surfacePlatforms.split(',').filter(Boolean);
|
|
477
|
+
const matches = platform === 'all' || available.includes(platform);
|
|
478
|
+
card.dataset.platformAvailable = String(matches);
|
|
479
|
+
const count = platform === 'all' ? available.length : Number(matches);
|
|
480
|
+
const label = card.querySelector('[data-surface-capture-count]');
|
|
481
|
+
if (label) label.textContent = count > 0 ? count + ' capture' + (count === 1 ? '' : 's') : 'not captured';
|
|
482
|
+
for (const badge of card.querySelectorAll('[data-platform]')) {
|
|
483
|
+
badge.setAttribute('aria-current', String(platform !== 'all' && badge.dataset.platform === platform));
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
const visibleCaptures = [...document.querySelectorAll('[data-capture-platform]')].filter(
|
|
487
|
+
(capture) => !capture.hidden,
|
|
488
|
+
);
|
|
489
|
+
const empty = document.querySelector('[data-platform-empty]');
|
|
490
|
+
if (empty) empty.hidden = visibleCaptures.length > 0;
|
|
491
|
+
const context = document.querySelector('[data-platform-context]');
|
|
492
|
+
const contextCopy = context?.querySelector('[data-platform-context-copy]');
|
|
493
|
+
const captures = document.querySelector('.captures');
|
|
494
|
+
if (captures) captures.dataset.platformComparison = String(platform === 'all');
|
|
495
|
+
if (context && contextCopy) {
|
|
496
|
+
const available = context.dataset.surfacePlatforms.split(',').filter(Boolean);
|
|
497
|
+
if (platform === 'all') {
|
|
498
|
+
contextCopy.textContent = available.length > 1
|
|
499
|
+
? 'Same screen · separate ' + available.map(platformName).join(' and ') + ' captures. Runtime state may differ between capture sessions.'
|
|
500
|
+
: 'Only the ' + platformName(available[0]) + ' capture is available for this surface.';
|
|
501
|
+
} else if (available.includes(platform)) {
|
|
502
|
+
contextCopy.textContent = 'Showing ' + context.dataset.surfaceTitle + ' · ' + platformName(platform) + '. Choose Compare platforms to view every captured platform.';
|
|
503
|
+
} else {
|
|
504
|
+
contextCopy.textContent = 'This surface was not captured on ' + platformName(platform) + '.';
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
for (const button of document.querySelectorAll('[data-platform-filter]')) {
|
|
510
|
+
button.addEventListener('click', () => applyPlatformFilter(button.dataset.platformFilter));
|
|
511
|
+
}
|
|
512
|
+
applyPlatformFilter(platformFilter);
|
|
513
|
+
|
|
514
|
+
const setSurfaceExpanded = (button, expanded) => {
|
|
515
|
+
const children = document.getElementById(button.getAttribute('aria-controls'));
|
|
516
|
+
if (!children) return;
|
|
517
|
+
button.setAttribute('aria-expanded', String(expanded));
|
|
518
|
+
children.hidden = !expanded;
|
|
519
|
+
const label = button.querySelector('[data-toggle-label]');
|
|
520
|
+
if (label) label.textContent = (expanded ? 'Collapse ' : 'Expand ') + button.dataset.surfaceTitle;
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
for (const button of document.querySelectorAll('[data-surface-toggle]')) {
|
|
524
|
+
button.addEventListener('click', () => {
|
|
525
|
+
setSurfaceExpanded(button, button.getAttribute('aria-expanded') !== 'true');
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
for (const button of document.querySelectorAll('[data-tree-action]')) {
|
|
529
|
+
button.addEventListener('click', () => {
|
|
530
|
+
const expanded = button.dataset.treeAction === 'expand';
|
|
531
|
+
for (const toggle of document.querySelectorAll('[data-surface-toggle]')) {
|
|
532
|
+
setSurfaceExpanded(toggle, expanded);
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const emptyFeedback = () => ({ surfaceNotes: {}, annotations: [] });
|
|
538
|
+
const parseSaved = (value, label) => {
|
|
539
|
+
if (!value) return emptyFeedback();
|
|
540
|
+
try {
|
|
541
|
+
const parsed = JSON.parse(value);
|
|
542
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
543
|
+
return {
|
|
544
|
+
surfaceNotes: parsed.surfaceNotes && typeof parsed.surfaceNotes === 'object' ? parsed.surfaceNotes : {},
|
|
545
|
+
annotations: Array.isArray(parsed.annotations)
|
|
546
|
+
? parsed.annotations.map((annotation) => ({ shape: 'point', ...annotation }))
|
|
547
|
+
: [],
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
} catch (error) {
|
|
551
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
552
|
+
}
|
|
553
|
+
document.documentElement.dataset.feedbackWarning = 'Invalid ' + label + ' feedback was reset.';
|
|
554
|
+
return emptyFeedback();
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const stored = parseSaved(localStorage.getItem(storageKey), 'stored');
|
|
558
|
+
const feedback = {
|
|
559
|
+
surfaceNotes: stored.surfaceNotes,
|
|
560
|
+
annotations: stored.annotations,
|
|
561
|
+
};
|
|
562
|
+
const currentSurfaceIds = new Set(source.surfaces.map((surface) => surface.id));
|
|
563
|
+
const currentCaptureIds = new Set(
|
|
564
|
+
source.surfaces.flatMap((surface) =>
|
|
565
|
+
surface.captures.map((capture) => surface.id + ':' + capture.id),
|
|
566
|
+
),
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
const persist = () => {
|
|
570
|
+
const serialized = JSON.stringify(feedback);
|
|
571
|
+
localStorage.setItem(storageKey, serialized);
|
|
572
|
+
for (const status of document.querySelectorAll('[data-feedback-status]')) status.textContent = 'Saved';
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
const setAnnotationMode = (mode) => {
|
|
576
|
+
annotationMode = mode;
|
|
577
|
+
for (const button of document.querySelectorAll('[data-annotation-mode]')) {
|
|
578
|
+
button.setAttribute('aria-pressed', String(button.dataset.annotationMode === mode));
|
|
579
|
+
}
|
|
580
|
+
for (const stage of document.querySelectorAll('[data-annotation-surface]')) stage.dataset.mode = mode;
|
|
581
|
+
for (const help of document.querySelectorAll('[data-annotation-help]')) {
|
|
582
|
+
help.textContent = mode === 'area'
|
|
583
|
+
? 'Drag across the screenshot to comment on an area.'
|
|
584
|
+
: 'Click the screenshot to comment on a specific element.';
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
for (const button of document.querySelectorAll('[data-annotation-mode]')) {
|
|
589
|
+
button.addEventListener('click', () => setAnnotationMode(button.dataset.annotationMode));
|
|
590
|
+
}
|
|
591
|
+
setAnnotationMode(annotationMode);
|
|
592
|
+
|
|
593
|
+
for (const field of document.querySelectorAll('[data-surface-note]')) {
|
|
594
|
+
const surfaceId = field.dataset.surfaceNote;
|
|
595
|
+
field.value = feedback.surfaceNotes[surfaceId] || '';
|
|
596
|
+
field.addEventListener('input', () => {
|
|
597
|
+
feedback.surfaceNotes[surfaceId] = field.value;
|
|
598
|
+
persist();
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const renderCapture = (surfaceId, captureId) => {
|
|
603
|
+
const surface = document.querySelector('[data-annotation-surface][data-capture-id="' + CSS.escape(captureId) + '"]');
|
|
604
|
+
const list = document.querySelector('[data-annotation-list][data-capture-id="' + CSS.escape(captureId) + '"]');
|
|
605
|
+
if (!surface || !list) return;
|
|
606
|
+
const markers = surface.querySelector('.annotation-markers');
|
|
607
|
+
const annotations = feedback.annotations.filter((item) => item.surfaceId === surfaceId && item.captureId === captureId);
|
|
608
|
+
markers.replaceChildren();
|
|
609
|
+
list.replaceChildren();
|
|
610
|
+
annotations.forEach((annotation, index) => {
|
|
611
|
+
const number = index + 1;
|
|
612
|
+
const marker = document.createElement('button');
|
|
613
|
+
marker.type = 'button';
|
|
614
|
+
marker.className = annotation.shape === 'area' ? 'annotation-area' : 'annotation-marker';
|
|
615
|
+
marker.dataset.annotationMarkerId = annotation.id;
|
|
616
|
+
marker.textContent = String(number);
|
|
617
|
+
marker.style.setProperty('--annotation-color', annotationColor(annotation));
|
|
618
|
+
marker.style.left = annotation.x * 100 + '%';
|
|
619
|
+
marker.style.top = annotation.y * 100 + '%';
|
|
620
|
+
if (annotation.shape === 'area') {
|
|
621
|
+
marker.style.width = annotation.width * 100 + '%';
|
|
622
|
+
marker.style.height = annotation.height * 100 + '%';
|
|
623
|
+
}
|
|
624
|
+
marker.addEventListener('click', (event) => {
|
|
625
|
+
event.stopPropagation();
|
|
626
|
+
document.querySelector('[data-annotation-id="' + CSS.escape(annotation.id) + '"]')?.focus();
|
|
627
|
+
});
|
|
628
|
+
markers.append(marker);
|
|
629
|
+
|
|
630
|
+
const item = document.createElement('li');
|
|
631
|
+
item.className = 'annotation-item';
|
|
632
|
+
const badge = document.createElement('span');
|
|
633
|
+
badge.className = 'annotation-number';
|
|
634
|
+
badge.textContent = String(number);
|
|
635
|
+
badge.style.backgroundColor = annotationColor(annotation);
|
|
636
|
+
const field = document.createElement('textarea');
|
|
637
|
+
field.dataset.annotationId = annotation.id;
|
|
638
|
+
field.placeholder = 'What should change here?';
|
|
639
|
+
field.value = annotation.body;
|
|
640
|
+
field.addEventListener('input', () => {
|
|
641
|
+
annotation.body = field.value;
|
|
642
|
+
persist();
|
|
643
|
+
});
|
|
644
|
+
const editor = document.createElement('div');
|
|
645
|
+
editor.className = 'annotation-editor';
|
|
646
|
+
const colorLabel = document.createElement('label');
|
|
647
|
+
colorLabel.className = 'annotation-color';
|
|
648
|
+
colorLabel.textContent = 'Color';
|
|
649
|
+
const colorInput = document.createElement('input');
|
|
650
|
+
colorInput.type = 'color';
|
|
651
|
+
colorInput.value = annotationColor(annotation);
|
|
652
|
+
colorInput.addEventListener('input', () => {
|
|
653
|
+
annotation.color = colorInput.value;
|
|
654
|
+
marker.style.setProperty('--annotation-color', annotation.color);
|
|
655
|
+
badge.style.backgroundColor = annotation.color;
|
|
656
|
+
persist();
|
|
657
|
+
});
|
|
658
|
+
colorLabel.append(colorInput);
|
|
659
|
+
editor.append(field, colorLabel);
|
|
660
|
+
const remove = document.createElement('button');
|
|
661
|
+
remove.type = 'button';
|
|
662
|
+
remove.className = 'annotation-remove';
|
|
663
|
+
remove.textContent = 'Remove';
|
|
664
|
+
remove.addEventListener('click', () => {
|
|
665
|
+
feedback.annotations = feedback.annotations.filter((candidate) => candidate.id !== annotation.id);
|
|
666
|
+
persist();
|
|
667
|
+
renderCapture(surfaceId, captureId);
|
|
668
|
+
});
|
|
669
|
+
item.append(badge, editor, remove);
|
|
670
|
+
list.append(item);
|
|
671
|
+
});
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
for (const surface of document.querySelectorAll('[data-annotation-surface]')) {
|
|
675
|
+
const surfaceId = surface.dataset.surfaceId;
|
|
676
|
+
const captureId = surface.dataset.captureId;
|
|
677
|
+
renderCapture(surfaceId, captureId);
|
|
678
|
+
let areaStart = null;
|
|
679
|
+
let areaPointerId = null;
|
|
680
|
+
let moveState = null;
|
|
681
|
+
const imagePoint = (event, clampToImage = false) => {
|
|
682
|
+
const image = surface.querySelector('img');
|
|
683
|
+
const rect = image.getBoundingClientRect();
|
|
684
|
+
if (!clampToImage && (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom)) return null;
|
|
685
|
+
return {
|
|
686
|
+
x: Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)),
|
|
687
|
+
y: Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height)),
|
|
688
|
+
};
|
|
689
|
+
};
|
|
690
|
+
const nextAnnotationId = () => 'annotation-' + (feedback.annotations.reduce((max, item) => {
|
|
691
|
+
const match = /^annotation-(\\d+)$/.exec(item.id);
|
|
692
|
+
return match ? Math.max(max, Number(match[1])) : max;
|
|
693
|
+
}, 0) + 1);
|
|
694
|
+
const focusAnnotation = (annotation) => {
|
|
695
|
+
persist();
|
|
696
|
+
setTimeout(() => {
|
|
697
|
+
renderCapture(surfaceId, captureId);
|
|
698
|
+
document
|
|
699
|
+
.querySelector('[data-annotation-id="' + CSS.escape(annotation.id) + '"]')
|
|
700
|
+
?.focus();
|
|
701
|
+
}, 0);
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
surface.addEventListener('click', (event) => {
|
|
705
|
+
if (event.target.closest('.annotation-marker, .annotation-area')) return;
|
|
706
|
+
if (annotationMode !== 'point') return;
|
|
707
|
+
const point = imagePoint(event);
|
|
708
|
+
if (!point) return;
|
|
709
|
+
const annotation = {
|
|
710
|
+
id: nextAnnotationId(),
|
|
711
|
+
surfaceId,
|
|
712
|
+
captureId,
|
|
713
|
+
shape: 'point',
|
|
714
|
+
...point,
|
|
715
|
+
color: annotationPalette[feedback.annotations.length % annotationPalette.length],
|
|
716
|
+
body: '',
|
|
717
|
+
};
|
|
718
|
+
feedback.annotations.push(annotation);
|
|
719
|
+
focusAnnotation(annotation);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
surface.addEventListener('pointerdown', (event) => {
|
|
723
|
+
const marker = event.target.closest('[data-annotation-marker-id]');
|
|
724
|
+
if (marker) {
|
|
725
|
+
const annotation = feedback.annotations.find(
|
|
726
|
+
(candidate) => candidate.id === marker.dataset.annotationMarkerId,
|
|
727
|
+
);
|
|
728
|
+
if (!annotation) return;
|
|
729
|
+
event.preventDefault();
|
|
730
|
+
moveState = {
|
|
731
|
+
annotation,
|
|
732
|
+
marker,
|
|
733
|
+
pointerId: event.pointerId,
|
|
734
|
+
start: imagePoint(event, true),
|
|
735
|
+
origin: { x: annotation.x, y: annotation.y },
|
|
736
|
+
};
|
|
737
|
+
surface.setPointerCapture(event.pointerId);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
if (annotationMode !== 'area') return;
|
|
741
|
+
const point = imagePoint(event);
|
|
742
|
+
if (!point) return;
|
|
743
|
+
event.preventDefault();
|
|
744
|
+
areaStart = point;
|
|
745
|
+
areaPointerId = event.pointerId;
|
|
746
|
+
surface.setPointerCapture(event.pointerId);
|
|
747
|
+
});
|
|
748
|
+
surface.addEventListener('pointermove', (event) => {
|
|
749
|
+
if (moveState && event.pointerId === moveState.pointerId) {
|
|
750
|
+
event.preventDefault();
|
|
751
|
+
const point = imagePoint(event, true);
|
|
752
|
+
const deltaX = point.x - moveState.start.x;
|
|
753
|
+
const deltaY = point.y - moveState.start.y;
|
|
754
|
+
const maxX = moveState.annotation.shape === 'area' ? 1 - moveState.annotation.width : 1;
|
|
755
|
+
const maxY = moveState.annotation.shape === 'area' ? 1 - moveState.annotation.height : 1;
|
|
756
|
+
moveState.annotation.x = Math.max(0, Math.min(maxX, moveState.origin.x + deltaX));
|
|
757
|
+
moveState.annotation.y = Math.max(0, Math.min(maxY, moveState.origin.y + deltaY));
|
|
758
|
+
moveState.marker.style.left = moveState.annotation.x * 100 + '%';
|
|
759
|
+
moveState.marker.style.top = moveState.annotation.y * 100 + '%';
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
if (!areaStart || event.pointerId !== areaPointerId) return;
|
|
763
|
+
event.preventDefault();
|
|
764
|
+
const point = imagePoint(event, true);
|
|
765
|
+
const draft = document.createElement('span');
|
|
766
|
+
draft.className = 'annotation-area annotation-area--draft';
|
|
767
|
+
draft.style.left = Math.min(areaStart.x, point.x) * 100 + '%';
|
|
768
|
+
draft.style.top = Math.min(areaStart.y, point.y) * 100 + '%';
|
|
769
|
+
draft.style.width = Math.abs(point.x - areaStart.x) * 100 + '%';
|
|
770
|
+
draft.style.height = Math.abs(point.y - areaStart.y) * 100 + '%';
|
|
771
|
+
const markers = surface.querySelector('.annotation-markers');
|
|
772
|
+
markers.querySelector('.annotation-area--draft')?.remove();
|
|
773
|
+
markers.append(draft);
|
|
774
|
+
});
|
|
775
|
+
surface.addEventListener('pointerup', (event) => {
|
|
776
|
+
if (moveState && event.pointerId === moveState.pointerId) {
|
|
777
|
+
event.preventDefault();
|
|
778
|
+
moveState = null;
|
|
779
|
+
surface.releasePointerCapture(event.pointerId);
|
|
780
|
+
persist();
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
if (!areaStart || event.pointerId !== areaPointerId) return;
|
|
784
|
+
event.preventDefault();
|
|
785
|
+
const point = imagePoint(event, true);
|
|
786
|
+
const start = areaStart;
|
|
787
|
+
areaStart = null;
|
|
788
|
+
areaPointerId = null;
|
|
789
|
+
surface.releasePointerCapture(event.pointerId);
|
|
790
|
+
surface.querySelector('.annotation-area--draft')?.remove();
|
|
791
|
+
const width = Math.abs(point.x - start.x);
|
|
792
|
+
const height = Math.abs(point.y - start.y);
|
|
793
|
+
if (width < 0.01 || height < 0.01) return;
|
|
794
|
+
const annotation = {
|
|
795
|
+
id: nextAnnotationId(),
|
|
796
|
+
surfaceId,
|
|
797
|
+
captureId,
|
|
798
|
+
shape: 'area',
|
|
799
|
+
x: Math.min(start.x, point.x),
|
|
800
|
+
y: Math.min(start.y, point.y),
|
|
801
|
+
width,
|
|
802
|
+
height,
|
|
803
|
+
color: annotationPalette[feedback.annotations.length % annotationPalette.length],
|
|
804
|
+
body: '',
|
|
805
|
+
};
|
|
806
|
+
feedback.annotations.push(annotation);
|
|
807
|
+
focusAnnotation(annotation);
|
|
808
|
+
});
|
|
809
|
+
surface.addEventListener('pointercancel', (event) => {
|
|
810
|
+
if (moveState && event.pointerId === moveState.pointerId) {
|
|
811
|
+
moveState.annotation.x = moveState.origin.x;
|
|
812
|
+
moveState.annotation.y = moveState.origin.y;
|
|
813
|
+
moveState = null;
|
|
814
|
+
renderCapture(surfaceId, captureId);
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
if (event.pointerId !== areaPointerId) return;
|
|
818
|
+
areaStart = null;
|
|
819
|
+
areaPointerId = null;
|
|
820
|
+
surface.querySelector('.annotation-area--draft')?.remove();
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const feedbackDocument = () => ({
|
|
825
|
+
version: 1,
|
|
826
|
+
kind: 'visual-review-feedback',
|
|
827
|
+
source,
|
|
828
|
+
surfaceNotes: Object.entries(feedback.surfaceNotes)
|
|
829
|
+
.filter(([surfaceId, body]) => currentSurfaceIds.has(surfaceId) && body.trim())
|
|
830
|
+
.map(([surfaceId, body]) => ({ surfaceId, body })),
|
|
831
|
+
annotations: feedback.annotations.filter(
|
|
832
|
+
(annotation) =>
|
|
833
|
+
currentCaptureIds.has(annotation.surfaceId + ':' + annotation.captureId) &&
|
|
834
|
+
annotation.body.trim(),
|
|
835
|
+
),
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
const setFeedbackStatus = (message) => {
|
|
839
|
+
for (const status of document.querySelectorAll('[data-feedback-status]')) status.textContent = message;
|
|
840
|
+
};
|
|
841
|
+
${feedbackDraftFromDocument.toString()}
|
|
842
|
+
for (const input of document.querySelectorAll('[data-feedback-open]')) {
|
|
843
|
+
input.addEventListener('change', async () => {
|
|
844
|
+
const file = input.files?.[0];
|
|
845
|
+
input.value = '';
|
|
846
|
+
if (!file) return;
|
|
847
|
+
let restored;
|
|
848
|
+
try {
|
|
849
|
+
restored = feedbackDraftFromDocument(source, JSON.parse(await file.text()));
|
|
850
|
+
} catch (error) {
|
|
851
|
+
// An unreadable or foreign document must not replace the current draft.
|
|
852
|
+
setFeedbackStatus('Could not open ' + file.name + ': ' + error.message);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
const hasDraft =
|
|
856
|
+
feedback.annotations.length > 0 ||
|
|
857
|
+
Object.values(feedback.surfaceNotes).some((body) => body.trim());
|
|
858
|
+
if (hasDraft && !confirm('Replace the current feedback with ' + file.name + '?')) return;
|
|
859
|
+
feedback.surfaceNotes = restored.surfaceNotes;
|
|
860
|
+
feedback.annotations = restored.annotations;
|
|
861
|
+
persist();
|
|
862
|
+
for (const field of document.querySelectorAll('[data-surface-note]')) {
|
|
863
|
+
field.value = feedback.surfaceNotes[field.dataset.surfaceNote] || '';
|
|
864
|
+
}
|
|
865
|
+
for (const surface of document.querySelectorAll('[data-annotation-surface]')) {
|
|
866
|
+
renderCapture(surface.dataset.surfaceId, surface.dataset.captureId);
|
|
867
|
+
}
|
|
868
|
+
setFeedbackStatus('Opened ' + file.name);
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
for (const button of document.querySelectorAll('[data-feedback-download]')) {
|
|
873
|
+
button.addEventListener('click', () => {
|
|
874
|
+
persist();
|
|
875
|
+
const blob = new Blob([JSON.stringify(feedbackDocument(), null, 2)], { type: 'application/json' });
|
|
876
|
+
const link = document.createElement('a');
|
|
877
|
+
link.href = URL.createObjectURL(blob);
|
|
878
|
+
link.download = 'visual-feedback.json';
|
|
879
|
+
link.click();
|
|
880
|
+
URL.revokeObjectURL(link.href);
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
})();
|
|
884
|
+
`;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/** Drafts are stored per capture, so a recapture under the same source id starts empty. */
|
|
888
|
+
export function visualReviewFeedbackStorageKey(source) {
|
|
889
|
+
return `farmslot-visual-review:${source.id}:${source.capturedAt}`;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
export function generateReviewBoard({ outputDir, source, storageKey, defaultPlatform }) {
|
|
893
|
+
const normalizedSource = {
|
|
894
|
+
...source,
|
|
895
|
+
surfaces: source.surfaces.map((surface) => ({
|
|
896
|
+
...surface,
|
|
897
|
+
id: safeFileId(surface.id, 'Surface id'),
|
|
898
|
+
...(surface.parentId ? { parentId: safeFileId(surface.parentId, 'Parent surface id') } : {}),
|
|
899
|
+
...(surface.relatedSurfaceIds
|
|
900
|
+
? {
|
|
901
|
+
relatedSurfaceIds: surface.relatedSurfaceIds.map((id) =>
|
|
902
|
+
safeFileId(id, 'Related surface id'),
|
|
903
|
+
),
|
|
904
|
+
}
|
|
905
|
+
: {}),
|
|
906
|
+
captures: surface.captures.map((capture) => ({
|
|
907
|
+
...capture,
|
|
908
|
+
id: safeFileId(capture.id, 'Capture id'),
|
|
909
|
+
platform: safeFileId(capture.platform, 'Platform'),
|
|
910
|
+
})),
|
|
911
|
+
})),
|
|
912
|
+
};
|
|
913
|
+
validateSurfaceRelationships(normalizedSource.surfaces);
|
|
914
|
+
const platforms = sourcePlatforms(normalizedSource);
|
|
915
|
+
const normalizedDefaultPlatform = platforms.includes(defaultPlatform)
|
|
916
|
+
? defaultPlatform
|
|
917
|
+
: (platforms[0] ?? '');
|
|
918
|
+
const renderedClientScript = clientScript(normalizedSource);
|
|
919
|
+
const assetVersion = createHash('sha256')
|
|
920
|
+
.update(stylesheet)
|
|
921
|
+
.update('\0')
|
|
922
|
+
.update(renderedClientScript)
|
|
923
|
+
.digest('hex')
|
|
924
|
+
.slice(0, 16);
|
|
925
|
+
const assetsDir = path.join(outputDir, 'assets');
|
|
926
|
+
const screensDir = path.join(outputDir, 'screens');
|
|
927
|
+
mkdirSync(assetsDir, { recursive: true });
|
|
928
|
+
mkdirSync(screensDir, { recursive: true });
|
|
929
|
+
for (const entry of readdirSync(screensDir, { withFileTypes: true })) {
|
|
930
|
+
if (entry.isFile() && entry.name.endsWith('.html')) {
|
|
931
|
+
rmSync(path.join(screensDir, entry.name));
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
writeFileSync(
|
|
936
|
+
path.join(outputDir, 'visual-review-source.json'),
|
|
937
|
+
`${JSON.stringify(normalizedSource, null, 2)}\n`,
|
|
938
|
+
);
|
|
939
|
+
writeFileSync(path.join(assetsDir, 'review-board.css'), stylesheet);
|
|
940
|
+
writeFileSync(path.join(assetsDir, 'review-board.js'), renderedClientScript);
|
|
941
|
+
writeFileSync(
|
|
942
|
+
path.join(outputDir, 'index.html'),
|
|
943
|
+
indexHtml(normalizedSource, storageKey, normalizedDefaultPlatform, assetVersion),
|
|
944
|
+
);
|
|
945
|
+
normalizedSource.surfaces.forEach((surface, index) => {
|
|
946
|
+
writeFileSync(
|
|
947
|
+
path.join(screensDir, `${surface.id}.html`),
|
|
948
|
+
screenHtml(
|
|
949
|
+
normalizedSource,
|
|
950
|
+
storageKey,
|
|
951
|
+
normalizedDefaultPlatform,
|
|
952
|
+
surface,
|
|
953
|
+
index,
|
|
954
|
+
assetVersion,
|
|
955
|
+
),
|
|
956
|
+
);
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function validateSurfaceRelationships(surfaces) {
|
|
961
|
+
const byId = new Map(surfaces.map((surface) => [surface.id, surface]));
|
|
962
|
+
if (byId.size !== surfaces.length) throw new Error('Visual review surface ids must be unique.');
|
|
963
|
+
for (const surface of surfaces) {
|
|
964
|
+
for (const target of [surface.parentId, ...(surface.relatedSurfaceIds ?? [])].filter(Boolean)) {
|
|
965
|
+
if (!byId.has(target)) {
|
|
966
|
+
throw new Error(
|
|
967
|
+
`Visual review surface ${surface.id} references missing surface ${target}.`,
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
const seen = new Set();
|
|
972
|
+
let current = surface;
|
|
973
|
+
while (current?.parentId) {
|
|
974
|
+
if (seen.has(current.id)) {
|
|
975
|
+
throw new Error(`Visual review surface hierarchy contains a cycle at ${surface.id}.`);
|
|
976
|
+
}
|
|
977
|
+
seen.add(current.id);
|
|
978
|
+
current = byId.get(current.parentId);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
}
|