paseo-req-kit 1.13.0-beta.1 → 1.13.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/bin/bootstrap.d.mts +1 -0
  3. package/bin/bootstrap.mjs +31 -5
  4. package/bin/req.mjs +9 -0
  5. package/contracts/cli-json.md +2 -0
  6. package/docs/adr/0033-prototype-review-and-ui-check.md +28 -0
  7. package/docs/adr/README.md +1 -0
  8. package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +8 -0
  9. package/docs/guide//347/273/264/346/212/244/350/200/205/346/214/207/345/215/227.md +1 -1
  10. package/docs/guide//351/241/271/347/233/256/345/256/232/345/210/266/344/270/216/350/277/220/350/241/214/350/247/204/345/210/231.md +7 -1
  11. package/engine/prompts/03-design.md +5 -1
  12. package/engine/prompts/04b-design-revise.md +1 -1
  13. package/engine/prompts/browser-verify.md +2 -2
  14. package/engine/prompts/ui-check.md +15 -0
  15. package/engine/src/api.ts +17 -2
  16. package/engine/src/config-view.ts +1 -1
  17. package/engine/src/pipeline.ts +15 -7
  18. package/engine/src/review-assets/app.css +420 -0
  19. package/engine/src/review-assets/app.html +95 -0
  20. package/engine/src/review-assets/app.js +954 -0
  21. package/engine/src/review.ts +570 -0
  22. package/engine/src/ui-check-page.js +208 -0
  23. package/engine/src/ui-check.ts +366 -0
  24. package/package.json +1 -1
  25. package/paseo-plugin/client/data.ts +12 -1
  26. package/paseo-plugin/client/detail.tsx +3 -0
  27. package/paseo-plugin/client/inbox.tsx +2 -0
  28. package/paseo-plugin/client/review-link.tsx +29 -0
  29. package/paseo-plugin/index.server.ts +2 -1
  30. package/paseo-plugin/package.json +1 -1
  31. package/paseo-plugin/server/handlers.ts +7 -1
  32. package/paseo-plugin/shared/contracts.ts +9 -0
  33. package/site-dist/404.html +2 -2
  34. package/site-dist/assets/{app.C_2Vhmv-.js → app.DWPPPhFm.js} +1 -1
  35. package/site-dist/assets/changelog.md.CoelFFCL.js +1 -0
  36. package/site-dist/assets/chunks/@localSearchIndexroot.Hdq0-4L3.js +1 -0
  37. package/site-dist/assets/chunks/{VPLocalSearchBox.zPMXlb4V.js → VPLocalSearchBox.D3_9dRGY.js} +1 -1
  38. package/site-dist/assets/chunks/{theme.DRjHgCIR.js → theme.Bm8aLiLN.js} +2 -2
  39. package/site-dist/assets/{guide_getting-started.md.DjqrOQ_8.js → guide_getting-started.md.B4GJeoPz.js} +1 -1
  40. package/site-dist/assets/{guide_getting-started.md.DjqrOQ_8.lean.js → guide_getting-started.md.B4GJeoPz.lean.js} +1 -1
  41. package/site-dist/assets/guide_maintainer.md.RZIzyCTV.js +1 -0
  42. package/site-dist/assets/{guide_maintainer.md.C7Aav_lk.lean.js → guide_maintainer.md.RZIzyCTV.lean.js} +1 -1
  43. package/site-dist/assets/{guide_project.md.r2g5uU_4.js → guide_project.md.nGlTCo4D.js} +2 -2
  44. package/site-dist/assets/{guide_project.md.r2g5uU_4.lean.js → guide_project.md.nGlTCo4D.lean.js} +2 -2
  45. package/site-dist/assets/{guide_user.md.FpCmz9S0.js → guide_user.md.tiHFxIsQ.js} +1 -1
  46. package/site-dist/assets/guide_user.md.tiHFxIsQ.lean.js +1 -0
  47. package/site-dist/assets/reference_adr_0033-prototype-review-and-ui-check.md.hFcQzmaa.js +1 -0
  48. package/site-dist/assets/reference_adr_0033-prototype-review-and-ui-check.md.hFcQzmaa.lean.js +1 -0
  49. package/site-dist/assets/{reference_adr_index.md.DTVyIA6V.js → reference_adr_index.md.BYF-Ofor.js} +1 -1
  50. package/site-dist/assets/{reference_adr_index.md.DTVyIA6V.lean.js → reference_adr_index.md.BYF-Ofor.lean.js} +1 -1
  51. package/site-dist/assets/{reference_contracts_cli-json.md.gkER6rX7.js → reference_contracts_cli-json.md.N-v2HNUd.js} +1 -1
  52. package/site-dist/assets/{reference_contracts_cli-json.md.gkER6rX7.lean.js → reference_contracts_cli-json.md.N-v2HNUd.lean.js} +1 -1
  53. package/site-dist/changelog.html +5 -5
  54. package/site-dist/guide/getting-started.html +5 -5
  55. package/site-dist/guide/how-it-works.html +4 -4
  56. package/site-dist/guide/index.html +4 -4
  57. package/site-dist/guide/install.html +4 -4
  58. package/site-dist/guide/maintainer.html +5 -5
  59. package/site-dist/guide/phases.html +4 -4
  60. package/site-dist/guide/project.html +6 -6
  61. package/site-dist/guide/user.html +5 -5
  62. package/site-dist/hashmap.json +1 -1
  63. package/site-dist/index.html +4 -4
  64. package/site-dist/reference/adr/0001-step-agents-under-origin-chat.html +4 -4
  65. package/site-dist/reference/adr/0002-no-directory-switch-in-chat.html +4 -4
  66. package/site-dist/reference/adr/0003-script-engine-not-hub.html +4 -4
  67. package/site-dist/reference/adr/0004-result-files.html +4 -4
  68. package/site-dist/reference/adr/0005-no-default-branch-local-commits-only.html +4 -4
  69. package/site-dist/reference/adr/0006-skill-install-targets.html +4 -4
  70. package/site-dist/reference/adr/0007-own-interview-skill.html +4 -4
  71. package/site-dist/reference/adr/0008-entries-do-not-call-entries.html +4 -4
  72. package/site-dist/reference/adr/0009-versioned-install-layout.html +4 -4
  73. package/site-dist/reference/adr/0010-generated-feature-overview.html +4 -4
  74. package/site-dist/reference/adr/0011-resume-after-completion.html +4 -4
  75. package/site-dist/reference/adr/0012-review-panel.html +4 -4
  76. package/site-dist/reference/adr/0013-no-release-tooling.html +4 -4
  77. package/site-dist/reference/adr/0014-notifier-slot.html +4 -4
  78. package/site-dist/reference/adr/0015-incremental-continue.html +4 -4
  79. package/site-dist/reference/adr/0016-upgrade-from-tags.html +4 -4
  80. package/site-dist/reference/adr/0017-use-paseo-capabilities.html +4 -4
  81. package/site-dist/reference/adr/0018-own-worktree-option.html +4 -4
  82. package/site-dist/reference/adr/0019-parallel-orchestration.html +4 -4
  83. package/site-dist/reference/adr/0020-scheduling-through-the-chat.html +4 -4
  84. package/site-dist/reference/adr/0021-npm-version-manager.html +4 -4
  85. package/site-dist/reference/adr/0022-develop-and-verify-phases.html +4 -4
  86. package/site-dist/reference/adr/0023-types-gate-slices.html +4 -4
  87. package/site-dist/reference/adr/0024-confirm-early-review-axes.html +4 -4
  88. package/site-dist/reference/adr/0025-joint-requirements.html +4 -4
  89. package/site-dist/reference/adr/0026-machine-records-and-api.html +4 -4
  90. package/site-dist/reference/adr/0027-paseo-plugin.html +4 -4
  91. package/site-dist/reference/adr/0028-external-integrations.html +4 -4
  92. package/site-dist/reference/adr/0029-requirements-from-cards.html +4 -4
  93. package/site-dist/reference/adr/0030-card-intake-process.html +4 -4
  94. package/site-dist/reference/adr/0031-fewer-interventions.html +4 -4
  95. package/site-dist/reference/adr/0032-npm-only-install.html +4 -4
  96. package/site-dist/reference/adr/0033-prototype-review-and-ui-check.html +30 -0
  97. package/site-dist/reference/adr/index.html +5 -5
  98. package/site-dist/reference/commands.html +4 -4
  99. package/site-dist/reference/config.html +4 -4
  100. package/site-dist/reference/contracts/cli-json.html +5 -5
  101. package/site-dist/reference/contracts/feature-files.html +4 -4
  102. package/site-dist/reference/contracts/index.html +4 -4
  103. package/site-dist/reference/contracts/notifier-event.html +4 -4
  104. package/site-dist/reference/contracts/result-file.html +4 -4
  105. package/site-dist/reference/contracts/spec.html +4 -4
  106. package/site-dist/reference/contracts/state.html +4 -4
  107. package/site-dist/reference/contracts/usage-record.html +4 -4
  108. package/site-dist/reference/contracts/verify.html +4 -4
  109. package/site-dist/reference/scope.html +4 -4
  110. package/site-dist/assets/changelog.md.VHiXRXap.js +0 -1
  111. package/site-dist/assets/chunks/@localSearchIndexroot.hC_y1UNZ.js +0 -1
  112. package/site-dist/assets/guide_maintainer.md.C7Aav_lk.js +0 -1
  113. package/site-dist/assets/guide_user.md.FpCmz9S0.lean.js +0 -1
  114. /package/site-dist/assets/{changelog.md.VHiXRXap.lean.js → changelog.md.CoelFFCL.lean.js} +0 -0
@@ -0,0 +1,954 @@
1
+ // The prototype review page (req-kit 1.13). Plain JavaScript, no build step. The prototype is
2
+ // shown in a same-origin iframe in which nothing of its own runs (the server sends it with
3
+ // script-src 'none'); this page reads and marks it from outside.
4
+ (() => {
5
+ "use strict";
6
+
7
+ const WIDTHS = [
8
+ { px: 1280, label: "电脑" },
9
+ { px: 768, label: "平板" },
10
+ { px: 390, label: "手机" },
11
+ ];
12
+ const KINDS = [
13
+ { id: "text", label: "文字改成…" },
14
+ { id: "bigger", label: "大一点" },
15
+ { id: "smaller", label: "小一点" },
16
+ { id: "color", label: "换颜色" },
17
+ { id: "move", label: "换位置" },
18
+ { id: "remove", label: "删掉" },
19
+ { id: "add", label: "这里加东西" },
20
+ { id: "other", label: "其他" },
21
+ ];
22
+ const KIND_LABEL = Object.fromEntries(KINDS.map((k) => [k.id, k.label.replace("…", "")]));
23
+ const NOTE_HINT = {
24
+ text: "为什么这样改(可以不写)",
25
+ bigger: "大多少、为什么(可以不写)",
26
+ smaller: "小多少、为什么(可以不写)",
27
+ color: "想要什么颜色,例如:和旁边的“取消”按钮一样的灰色",
28
+ move: "挪到哪里,例如:放到表格右上角",
29
+ remove: "为什么不要(可以不写)",
30
+ add: "要加什么,例如:在这里加一个“导出”按钮",
31
+ other: "想怎么改",
32
+ };
33
+ const STATUS = {
34
+ waiting_for_approval: ["等你确认方案", true],
35
+ waiting_for_business: ["等你回答问题", true],
36
+ running: ["开发中", false],
37
+ completed: ["已完成", false],
38
+ stopped: ["已停止", false],
39
+ failed: ["没有完成", false],
40
+ queued: ["排队中", false],
41
+ };
42
+ const CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳";
43
+ const circled = (n) => (n >= 1 && n <= CIRCLED.length ? CIRCLED[n - 1] : `(${n})`);
44
+
45
+ const $ = (id) => document.getElementById(id);
46
+ const frame = $("frame");
47
+ const view = {
48
+ scale: 1,
49
+ info: null,
50
+ file: null,
51
+ state: null,
52
+ width: 1280,
53
+ mode: "comment",
54
+ comments: [],
55
+ general: "",
56
+ regions: [],
57
+ pending: null,
58
+ clickTimer: null,
59
+ };
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Talking to the server
63
+
64
+ async function api(path, options) {
65
+ const r = await fetch(path, options);
66
+ const body = await r.json().catch(() => ({ ok: false, message: `服务返回 ${r.status}` }));
67
+ if (!r.ok && body.ok !== true) throw Object.assign(new Error(body.message || `服务返回 ${r.status}`), { body });
68
+ return body;
69
+ }
70
+
71
+ let saveTimer = null;
72
+ function saveDraft() {
73
+ if (view.info?.readOnly) return;
74
+ clearTimeout(saveTimer);
75
+ $("saved").textContent = "正在保存…";
76
+ saveTimer = setTimeout(async () => {
77
+ try {
78
+ await api("api/draft", {
79
+ method: "PUT",
80
+ headers: { "content-type": "application/json" },
81
+ body: JSON.stringify({ comments: view.comments, general: view.general }),
82
+ });
83
+ $("saved").textContent = "已自动保存,关掉页面也不会丢。";
84
+ } catch (e) {
85
+ $("saved").textContent = `没能保存:${e.message}`;
86
+ }
87
+ }, 400);
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Header controls
92
+
93
+ function seg(container, items, current, onPick) {
94
+ container.replaceChildren(
95
+ ...items.map((it) => {
96
+ const b = document.createElement("button");
97
+ b.type = "button";
98
+ b.textContent = it.label;
99
+ b.setAttribute("aria-pressed", String(it.value === current));
100
+ b.addEventListener("click", () => onPick(it.value));
101
+ return b;
102
+ }),
103
+ );
104
+ }
105
+
106
+ function renderControls() {
107
+ const files = view.info.files;
108
+ $("filesGroup").hidden = files.length < 2;
109
+ seg($("files"), files.map((f) => ({ label: f.title, value: f.path })), view.file, (v) => {
110
+ view.file = v;
111
+ view.state = null;
112
+ loadFrame();
113
+ });
114
+ seg($("widths"), WIDTHS.map((w) => ({ label: `${w.label} ${w.px}`, value: w.px })), view.width, (v) => {
115
+ view.width = v;
116
+ renderControls();
117
+ sizeFrame();
118
+ });
119
+ seg(
120
+ $("modes"),
121
+ [
122
+ { label: "点选提意见", value: "comment" },
123
+ { label: "试用", value: "try" },
124
+ ],
125
+ view.mode,
126
+ (v) => {
127
+ view.mode = v;
128
+ closePop();
129
+ renderControls();
130
+ hint();
131
+ },
132
+ );
133
+ }
134
+
135
+ function renderStates(doc) {
136
+ const states = [...new Set([...doc.querySelectorAll("[data-state]")].map((e) => e.getAttribute("data-state")).filter(Boolean))];
137
+ $("statesGroup").hidden = states.length === 0;
138
+ if (states.length > 0 && !states.includes(view.state)) view.state = states[0];
139
+ if (states.length === 0) view.state = null;
140
+ seg($("states"), states.map((s) => ({ label: s, value: s })), view.state, (v) => {
141
+ view.state = v;
142
+ applyState();
143
+ afterLayout();
144
+ });
145
+ }
146
+
147
+ function hint() {
148
+ const scaled = view.scale < 1 ? `(窗口不够宽,按 ${Math.round(view.scale * 100)}% 显示)` : "";
149
+ $("hint").textContent =
150
+ (view.mode === "comment"
151
+ ? "点选模式:点任何地方提意见,双击文字直接改字。要像平时一样点按钮、切换页面时,换到“试用”。"
152
+ : "试用模式:可以像平时一样点。要提意见时换回“点选提意见”。") + scaled;
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // The prototype in the frame
157
+
158
+ const doc = () => frame.contentDocument;
159
+
160
+ function loadFrame() {
161
+ closePop();
162
+ renderControls();
163
+ frame.src = `proto/cur/${view.file.split("/").map(encodeURIComponent).join("/")}`;
164
+ }
165
+
166
+ frame.addEventListener("load", () => {
167
+ const d = doc();
168
+ if (!d || !d.body) return;
169
+ const style = d.createElement("style");
170
+ style.id = "rk-style";
171
+ style.textContent = `
172
+ .rk-hover { outline: 2px solid #2457d6 !important; outline-offset: 2px !important; cursor: pointer !important; }
173
+ .rk-region { outline: 2px dashed #d97706 !important; outline-offset: 4px !important; }
174
+ .rk-changed { outline: 2px solid #10b981 !important; outline-offset: 2px !important; box-shadow: inset 0 0 0 9999px rgba(16,185,129,.10) !important; }
175
+ .rk-edited { box-shadow: inset 0 0 0 9999px rgba(250,204,21,.35) !important; }
176
+ .rk-flash { outline: 3px solid #e11d48 !important; outline-offset: 3px !important; }
177
+ #rk-layer { position: absolute; left: 0; top: 0; width: 0; height: 0; z-index: 2147483646; }
178
+ #rk-layer > * { position: absolute; font: 700 12px/22px system-ui, sans-serif; border-radius: 999px; text-align: center; box-shadow: 0 1px 4px rgba(0,0,0,.35); }
179
+ .rk-badge { background: #d97706; color: #111; padding: 0 7px; pointer-events: none; white-space: nowrap; }
180
+ .rk-pin { background: #e11d48; color: #fff; min-width: 22px; height: 22px; padding: 0 5px; cursor: pointer; }
181
+ [contenteditable="true"] { outline: 2px solid #f59e0b !important; background: #fffbe6 !important; color: #111 !important; }
182
+ `;
183
+ d.head.appendChild(style);
184
+ const layer = d.createElement("div");
185
+ layer.id = "rk-layer";
186
+ d.body.appendChild(layer);
187
+ d.addEventListener("mouseover", onHover, true);
188
+ d.addEventListener("mouseout", onHoverOut, true);
189
+ d.addEventListener("click", onClick, true);
190
+ d.addEventListener("dblclick", onDblClick, true);
191
+ d.addEventListener("submit", (e) => e.preventDefault(), true);
192
+ d.addEventListener("keydown", (e) => {
193
+ if (e.key === "Escape") closePop();
194
+ });
195
+ renderStates(d);
196
+ applyState();
197
+ afterLayout();
198
+ });
199
+
200
+ function applyState() {
201
+ const d = doc();
202
+ if (!d) return;
203
+ let s = d.getElementById("rk-state");
204
+ if (!s) {
205
+ s = d.createElement("style");
206
+ s.id = "rk-state";
207
+ d.head.appendChild(s);
208
+ }
209
+ s.textContent = view.state ? `[data-state]:not([data-state="${CSS.escape(view.state)}"]) { display: none !important; }` : "";
210
+ renderStates(d);
211
+ }
212
+
213
+ /** The prototype at the chosen width, shrunk to fit when the window is narrower. */
214
+ function sizeFrame() {
215
+ frame.style.width = `${view.width}px`;
216
+ const room = Math.max(200, $("frameWrap").clientWidth - 26);
217
+ view.scale = Math.min(1, room / view.width);
218
+ frame.style.transform = view.scale < 1 ? `scale(${view.scale})` : "";
219
+ const d = doc();
220
+ let h = 480;
221
+ if (d?.documentElement) {
222
+ frame.style.height = "480px";
223
+ h = Math.max(480, d.documentElement.scrollHeight, d.body?.scrollHeight ?? 0) + 4;
224
+ }
225
+ frame.style.height = `${h}px`;
226
+ const sizer = $("sizer");
227
+ sizer.style.width = `${Math.floor(view.width * view.scale)}px`;
228
+ sizer.style.height = `${Math.ceil(h * view.scale)}px`;
229
+ hint();
230
+ }
231
+
232
+ function afterLayout() {
233
+ sizeFrame();
234
+ // Sizes settle after the frame grows; mark once it has.
235
+ requestAnimationFrame(() => {
236
+ markRegions();
237
+ drawPins();
238
+ if ($("showDiff").checked) markDiff();
239
+ });
240
+ }
241
+
242
+ function visible(el) {
243
+ const r = el.getBoundingClientRect();
244
+ return r.width > 0 && r.height > 0;
245
+ }
246
+
247
+ function pageRect(el) {
248
+ const r = el.getBoundingClientRect();
249
+ const w = el.ownerDocument.defaultView;
250
+ return { left: r.left + w.scrollX, top: r.top + w.scrollY, width: r.width, height: r.height };
251
+ }
252
+
253
+ function layer() {
254
+ return doc()?.getElementById("rk-layer");
255
+ }
256
+
257
+ function clearLayer(cls) {
258
+ layer()
259
+ ?.querySelectorAll(`.${cls}`)
260
+ .forEach((n) => n.remove());
261
+ }
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // What to look at: the regions the design marked with data-ac
265
+
266
+ function markRegions() {
267
+ const d = doc();
268
+ if (!d) return;
269
+ d.querySelectorAll(".rk-region").forEach((e) => e.classList.remove("rk-region"));
270
+ clearLayer("rk-badge");
271
+ view.regions = [...d.querySelectorAll("[data-ac]")].filter(visible);
272
+ const show = $("showRegions").checked;
273
+ view.regions.forEach((el, i) => {
274
+ if (!show) return;
275
+ el.classList.add("rk-region");
276
+ const r = pageRect(el);
277
+ const b = d.createElement("div");
278
+ b.className = "rk-badge";
279
+ b.textContent = `${circled(i + 1)} ${el.getAttribute("data-ac")}`;
280
+ b.style.left = `${Math.max(0, r.left - 6)}px`;
281
+ b.style.top = `${Math.max(0, r.top - 14)}px`;
282
+ layer().appendChild(b);
283
+ });
284
+ renderRegions();
285
+ }
286
+
287
+ function criterionText(ac) {
288
+ const ids = String(ac || "")
289
+ .split(/[\s,,、]+/)
290
+ .filter(Boolean);
291
+ return ids
292
+ .map((id) => view.info.criteria.find((c) => c.id === id))
293
+ .filter(Boolean)
294
+ .map((c) => `${c.id}:${c.text}`)
295
+ .join(";");
296
+ }
297
+
298
+ function renderRegions() {
299
+ const ul = $("regions");
300
+ if (view.regions.length === 0) {
301
+ const crit = view.info.criteria;
302
+ ul.replaceChildren(
303
+ li(
304
+ `<p class="muted small">这个原型没有标出改动区域。可以对照下面的验收标准看${view.state ? "(也看看上面切换出来的其他状态)" : ""}。</p>` +
305
+ (crit.length ? `<ul class="diff-list">${crit.map((c) => `<li>${esc(c.id)}:${esc(c.text)}</li>`).join("")}</ul>` : ""),
306
+ ),
307
+ );
308
+ return;
309
+ }
310
+ ul.replaceChildren(
311
+ ...view.regions.map((el, i) => {
312
+ const ac = el.getAttribute("data-ac");
313
+ const label = el.getAttribute("data-label") || "";
314
+ const item = li(
315
+ `<div class="row"><span class="badge region">${circled(i + 1)}</span><div class="grow"><div class="strong">${esc(label || ac)}</div><div class="muted small">${esc(criterionText(ac) || ac)}</div></div><button type="button" class="linkish">看一下</button></div>`,
316
+ );
317
+ item.querySelector("button").addEventListener("click", () => flash(el));
318
+ return item;
319
+ }),
320
+ );
321
+ }
322
+
323
+ function li(html) {
324
+ const n = document.createElement("li");
325
+ n.innerHTML = html;
326
+ return n;
327
+ }
328
+
329
+ function esc(s) {
330
+ return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
331
+ }
332
+
333
+ function flash(el) {
334
+ el.scrollIntoView({ block: "center", behavior: "smooth" });
335
+ const r = pageRect(el);
336
+ const wrap = $("frameWrap");
337
+ wrap.scrollTo({ top: Math.max(0, r.top * view.scale - wrap.clientHeight / 3), behavior: "smooth" });
338
+ el.classList.add("rk-flash");
339
+ setTimeout(() => el.classList.remove("rk-flash"), 1600);
340
+ }
341
+
342
+ // ---------------------------------------------------------------------------
343
+ // Describing what was clicked, in words a person and an agent can both use
344
+
345
+ const TAG_DESC = {
346
+ BUTTON: "按钮",
347
+ A: "链接",
348
+ INPUT: "输入框",
349
+ SELECT: "下拉框",
350
+ TEXTAREA: "多行输入框",
351
+ IMG: "图片",
352
+ H1: "标题",
353
+ H2: "标题",
354
+ H3: "标题",
355
+ H4: "小标题",
356
+ H5: "小标题",
357
+ H6: "小标题",
358
+ TABLE: "表格",
359
+ TH: "表头",
360
+ TD: "表格单元格",
361
+ TR: "表格行",
362
+ LABEL: "标签文字",
363
+ LI: "列表项",
364
+ P: "文字",
365
+ SPAN: "文字",
366
+ STRONG: "文字",
367
+ EM: "文字",
368
+ SMALL: "小字",
369
+ NAV: "导航",
370
+ HEADER: "页头",
371
+ FOOTER: "页脚",
372
+ FORM: "表单",
373
+ UL: "列表",
374
+ OL: "列表",
375
+ };
376
+ const ROLE_DESC = { button: "按钮", link: "链接", tab: "标签页", dialog: "弹窗", checkbox: "勾选框", switch: "开关", menuitem: "菜单项", alert: "提示" };
377
+
378
+ function hex(color) {
379
+ const m = /rgba?\(([^)]+)\)/.exec(color || "");
380
+ if (!m) return color || "";
381
+ const [r, g, b, a = "1"] = m[1].split(/[\s,/]+/).filter(Boolean);
382
+ if (Number(a) === 0) return "";
383
+ return `#${[r, g, b].map((v) => Number(v).toString(16).padStart(2, "0")).join("")}`;
384
+ }
385
+
386
+ function backgroundOf(el) {
387
+ for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
388
+ const c = hex(n.ownerDocument.defaultView.getComputedStyle(n).backgroundColor);
389
+ if (c) return c;
390
+ }
391
+ return "#ffffff";
392
+ }
393
+
394
+ function textOf(el) {
395
+ if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") return el.value || el.getAttribute("placeholder") || "";
396
+ if (el.tagName === "IMG") return el.getAttribute("alt") || "";
397
+ return (el.innerText || el.textContent || "").replace(/\s+/g, " ").trim();
398
+ }
399
+
400
+ function cssPath(el) {
401
+ const parts = [];
402
+ for (let n = el; n && n.nodeType === 1 && n.tagName !== "HTML"; n = n.parentElement) {
403
+ if (n.id && !n.id.startsWith("rk-")) {
404
+ parts.unshift(`#${CSS.escape(n.id)}`);
405
+ break;
406
+ }
407
+ let part = n.tagName.toLowerCase();
408
+ const parent = n.parentElement;
409
+ if (parent) {
410
+ const same = [...parent.children].filter((c) => c.tagName === n.tagName);
411
+ if (same.length > 1) part += `:nth-of-type(${same.indexOf(n) + 1})`;
412
+ }
413
+ parts.unshift(part);
414
+ if (n.tagName === "BODY") break;
415
+ }
416
+ return parts.join(" > ");
417
+ }
418
+
419
+ function describe(el) {
420
+ const w = el.ownerDocument.defaultView;
421
+ const cs = w.getComputedStyle(el);
422
+ const r = el.getBoundingClientRect();
423
+ const role = el.getAttribute("role");
424
+ let desc = (role && ROLE_DESC[role]) || TAG_DESC[el.tagName] || "区域";
425
+ if (el.tagName === "INPUT" && /checkbox|radio/.test(el.type)) desc = el.type === "checkbox" ? "勾选框" : "单选项";
426
+ if (el.tagName === "INPUT" && /submit|button/.test(el.type)) desc = "按钮";
427
+ const regionEl = el.closest("[data-ac]");
428
+ const index = regionEl ? view.regions.indexOf(regionEl) + 1 : 0;
429
+ return {
430
+ desc,
431
+ text: textOf(el).slice(0, 120),
432
+ selector: cssPath(el),
433
+ region: regionEl ? { ac: regionEl.getAttribute("data-ac"), label: regionEl.getAttribute("data-label") || "", index: index > 0 ? circled(index) : null } : null,
434
+ styles: {
435
+ color: hex(cs.color),
436
+ background: backgroundOf(el),
437
+ fontSize: cs.fontSize,
438
+ size: `${Math.round(r.width)}×${Math.round(r.height)}`,
439
+ },
440
+ };
441
+ }
442
+
443
+ /** A click on a big container or the page itself: say where it is by what is around it. */
444
+ function targetOf(el) {
445
+ if (el.id === "rk-layer" || el.closest?.("#rk-layer")) return null;
446
+ if (el.tagName === "HTML" || el.tagName === "BODY") return { el, empty: true };
447
+ return { el, empty: false };
448
+ }
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // Clicking in the prototype
452
+
453
+ let hovered = null;
454
+ function onHover(e) {
455
+ if (view.mode !== "comment") return;
456
+ const t = e.target;
457
+ if (!t || t.nodeType !== 1 || t.closest("#rk-layer") || t.tagName === "BODY" || t.tagName === "HTML") return;
458
+ hovered?.classList.remove("rk-hover");
459
+ hovered = t;
460
+ t.classList.add("rk-hover");
461
+ }
462
+ function onHoverOut(e) {
463
+ if (e.target === hovered) {
464
+ hovered.classList.remove("rk-hover");
465
+ hovered = null;
466
+ }
467
+ }
468
+
469
+ function onClick(e) {
470
+ const pin = e.target.closest?.(".rk-pin");
471
+ if (pin) {
472
+ e.preventDefault();
473
+ e.stopPropagation();
474
+ focusComment(pin.dataset.id);
475
+ return;
476
+ }
477
+ if (view.mode !== "comment") return;
478
+ if (e.target.isContentEditable) return;
479
+ e.preventDefault();
480
+ e.stopPropagation();
481
+ if (view.info.readOnly) return;
482
+ const t = targetOf(e.target);
483
+ if (!t) return;
484
+ // A double click edits the text instead: wait a moment before opening the box.
485
+ clearTimeout(view.clickTimer);
486
+ const { clientX, clientY } = e;
487
+ view.clickTimer = setTimeout(() => openPop(t, clientX, clientY), 260);
488
+ }
489
+
490
+ function onDblClick(e) {
491
+ if (view.mode !== "comment" || view.info.readOnly) return;
492
+ clearTimeout(view.clickTimer);
493
+ e.preventDefault();
494
+ e.stopPropagation();
495
+ const el = e.target;
496
+ if (!el || el.nodeType !== 1 || el.closest("#rk-layer")) return;
497
+ const hasText = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
498
+ if (!hasText || el.tagName === "BODY") {
499
+ openPop({ el, empty: false }, e.clientX, e.clientY, "text");
500
+ return;
501
+ }
502
+ closePop();
503
+ const before = describe(el);
504
+ const original = el.dataset.rkOriginal ?? textOf(el);
505
+ el.dataset.rkOriginal = original;
506
+ el.contentEditable = "true";
507
+ el.focus();
508
+ const range = el.ownerDocument.createRange();
509
+ range.selectNodeContents(el);
510
+ const sel = el.ownerDocument.getSelection();
511
+ sel.removeAllRanges();
512
+ sel.addRange(range);
513
+ const finish = () => {
514
+ el.contentEditable = "false";
515
+ el.removeEventListener("blur", finish);
516
+ el.removeEventListener("keydown", onKey);
517
+ const now = textOf(el);
518
+ if (now && now !== original) {
519
+ el.classList.add("rk-edited");
520
+ const existing = view.comments.find((c) => c.kind === "text" && c.file === view.file && c.target?.selector === before.selector);
521
+ if (existing) existing.newText = now;
522
+ else addComment({ kind: "text", newText: now, note: "", target: { ...before, text: original } });
523
+ renderComments();
524
+ drawPins();
525
+ saveDraft();
526
+ }
527
+ };
528
+ const onKey = (k) => {
529
+ if (k.key === "Enter" && !k.shiftKey) {
530
+ k.preventDefault();
531
+ el.blur();
532
+ }
533
+ if (k.key === "Escape") {
534
+ el.textContent = original;
535
+ el.blur();
536
+ }
537
+ };
538
+ el.addEventListener("blur", finish);
539
+ el.addEventListener("keydown", onKey);
540
+ }
541
+
542
+ // ---------------------------------------------------------------------------
543
+ // The "how to change it" box
544
+
545
+ function openPop(t, x, y, kind) {
546
+ const el = t.el;
547
+ const info = t.empty ? { desc: "空白处", text: "", selector: "body", region: null, styles: null } : describe(el);
548
+ view.pending = { el, info, kind: kind || (t.empty ? "add" : null), edit: null };
549
+ $("popTitle").textContent = t.empty ? "在页面空白处" : `${info.desc}${info.text ? `“${info.text.slice(0, 30)}${info.text.length > 30 ? "…" : ""}”` : ""}`;
550
+ $("popSub").textContent = info.region
551
+ ? `在区域 ${info.region.index ?? ""}${info.region.label ? `「${info.region.label}」` : ""}${info.region.ac ? `(${info.region.ac})` : ""}`
552
+ : "选一种改法,再补一句";
553
+ renderKinds();
554
+ $("note").value = "";
555
+ $("newText").value = info.text || "";
556
+ syncPopFields();
557
+ const pop = $("pop");
558
+ pop.hidden = false;
559
+ const fr = frame.getBoundingClientRect();
560
+ const px = Math.min(window.innerWidth - pop.offsetWidth - 12, Math.max(12, fr.left + x * view.scale + 12));
561
+ const py = Math.min(window.innerHeight - pop.offsetHeight - 12, Math.max(12, fr.top + y * view.scale + 12));
562
+ pop.style.left = `${px}px`;
563
+ pop.style.top = `${py}px`;
564
+ (view.pending.kind ? $("note") : $("kinds").querySelector("button"))?.focus();
565
+ }
566
+
567
+ function editComment(c) {
568
+ const el = findTarget(c);
569
+ view.pending = { el, info: c.target, kind: c.kind, edit: c.id };
570
+ $("popTitle").textContent = `修改第 ${view.comments.indexOf(c) + 1} 条意见`;
571
+ $("popSub").textContent = c.target?.desc ? `${c.target.desc}${c.target.text ? `“${c.target.text.slice(0, 30)}”` : ""}` : "";
572
+ renderKinds();
573
+ $("note").value = c.note || "";
574
+ $("newText").value = c.newText || "";
575
+ syncPopFields();
576
+ const pop = $("pop");
577
+ pop.hidden = false;
578
+ pop.style.left = `${Math.max(12, window.innerWidth - pop.offsetWidth - 400)}px`;
579
+ pop.style.top = "120px";
580
+ $("note").focus();
581
+ }
582
+
583
+ function renderKinds() {
584
+ $("kinds").replaceChildren(
585
+ ...KINDS.map((k) => {
586
+ const b = document.createElement("button");
587
+ b.type = "button";
588
+ b.textContent = k.label;
589
+ b.setAttribute("role", "radio");
590
+ b.setAttribute("aria-checked", String(view.pending.kind === k.id));
591
+ b.addEventListener("click", () => {
592
+ view.pending.kind = k.id;
593
+ renderKinds();
594
+ syncPopFields();
595
+ (k.id === "text" ? $("newText") : $("note")).focus();
596
+ });
597
+ return b;
598
+ }),
599
+ );
600
+ }
601
+
602
+ function syncPopFields() {
603
+ const kind = view.pending?.kind;
604
+ $("newTextField").hidden = kind !== "text";
605
+ $("newTextLabel").textContent = "改成这样的文字";
606
+ $("noteLabel").textContent = NOTE_HINT[kind] || "用你自己的话说说(可以不写)";
607
+ $("popOk").disabled = !kind;
608
+ }
609
+
610
+ function closePop() {
611
+ $("pop").hidden = true;
612
+ view.pending = null;
613
+ }
614
+
615
+ $("popCancel").addEventListener("click", closePop);
616
+ $("pop").addEventListener("keydown", (e) => {
617
+ if (e.key === "Escape") closePop();
618
+ if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) $("popOk").click();
619
+ });
620
+ $("popOk").addEventListener("click", () => {
621
+ const p = view.pending;
622
+ if (!p?.kind) return;
623
+ const note = $("note").value.trim();
624
+ const newText = p.kind === "text" ? $("newText").value.trim() : "";
625
+ if (p.kind === "text" && !newText) {
626
+ $("newText").focus();
627
+ return;
628
+ }
629
+ if (p.edit) {
630
+ const c = view.comments.find((x) => x.id === p.edit);
631
+ if (c) Object.assign(c, { kind: p.kind, note, newText: newText || undefined });
632
+ } else {
633
+ addComment({ kind: p.kind, note, newText: newText || undefined, target: p.info });
634
+ }
635
+ closePop();
636
+ renderComments();
637
+ drawPins();
638
+ saveDraft();
639
+ });
640
+
641
+ function addComment(c) {
642
+ view.comments.push({ id: `c${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`, file: view.file, state: view.state, width: view.width, ...c });
643
+ }
644
+
645
+ // ---------------------------------------------------------------------------
646
+ // The list of comments and their pins
647
+
648
+ function findTarget(c) {
649
+ if (c.file !== view.file || !c.target?.selector) return null;
650
+ try {
651
+ return doc()?.querySelector(c.target.selector) ?? null;
652
+ } catch {
653
+ return null;
654
+ }
655
+ }
656
+
657
+ function drawPins() {
658
+ clearLayer("rk-pin");
659
+ const d = doc();
660
+ if (!d || !layer()) return;
661
+ view.comments.forEach((c, i) => {
662
+ if (c.file !== view.file || (c.state && view.state && c.state !== view.state)) return;
663
+ const el = findTarget(c);
664
+ if (!el || !visible(el)) return;
665
+ const r = pageRect(el);
666
+ const pin = d.createElement("div");
667
+ pin.className = "rk-pin";
668
+ pin.dataset.id = c.id;
669
+ pin.textContent = String(i + 1);
670
+ pin.title = `第 ${i + 1} 条:${KIND_LABEL[c.kind]}`;
671
+ pin.style.left = `${Math.max(0, r.left + r.width - 8)}px`;
672
+ pin.style.top = `${Math.max(0, r.top - 15)}px`;
673
+ layer().appendChild(pin);
674
+ if (c.kind === "text" && c.newText && el.dataset.rkOriginal === undefined && !el.isContentEditable) {
675
+ // Show the requested text in place, as the person typed it (plain text elements only:
676
+ // replacing the text of one with icons inside would drop the icons).
677
+ el.classList.add("rk-edited");
678
+ if (el.childElementCount === 0) {
679
+ el.dataset.rkOriginal = textOf(el);
680
+ el.textContent = c.newText;
681
+ }
682
+ }
683
+ });
684
+ }
685
+
686
+ function summary(c) {
687
+ if (c.kind === "text") return `文字改成“${c.newText ?? ""}”`;
688
+ return KIND_LABEL[c.kind] || "其他";
689
+ }
690
+
691
+ function renderComments() {
692
+ $("count").textContent = view.comments.length ? `(${view.comments.length})` : "";
693
+ const ul = $("comments");
694
+ if (view.comments.length === 0) {
695
+ ul.replaceChildren(li('<p class="muted small">还没有意见。点原型上想改的地方开始。</p>'));
696
+ } else {
697
+ ul.replaceChildren(
698
+ ...view.comments.map((c, i) => {
699
+ const where = [
700
+ view.info.files.length > 1 ? (view.info.files.find((f) => f.path === c.file)?.title ?? c.file) : "",
701
+ c.state ? `状态“${c.state}”` : "",
702
+ c.target?.region?.index ? `区域 ${c.target.region.index}` : "",
703
+ ]
704
+ .filter(Boolean)
705
+ .join(" · ");
706
+ const item = li(
707
+ `<div class="row"><span class="badge pin">${i + 1}</span><div class="grow"><div class="strong">${esc(summary(c))}</div>` +
708
+ `<div class="muted small">${esc(c.target?.desc ?? "")}${c.target?.text ? `“${esc(c.target.text.slice(0, 40))}”` : ""}${where ? ` · ${esc(where)}` : ""}</div>` +
709
+ (c.note ? `<div class="small">${esc(c.note)}</div>` : "") +
710
+ `<div><button type="button" class="linkish" data-act="show">在原型上看</button><button type="button" class="linkish" data-act="edit">修改</button><button type="button" class="linkish danger" data-act="del">删除</button></div></div></div>`,
711
+ );
712
+ item.dataset.id = c.id;
713
+ item.querySelector('[data-act="show"]').addEventListener("click", () => showComment(c));
714
+ item.querySelector('[data-act="edit"]').addEventListener("click", () => editComment(c));
715
+ item.querySelector('[data-act="del"]').addEventListener("click", () => {
716
+ view.comments = view.comments.filter((x) => x.id !== c.id);
717
+ const el = findTarget(c);
718
+ if (el && el.dataset.rkOriginal !== undefined && c.kind === "text") {
719
+ el.textContent = el.dataset.rkOriginal;
720
+ delete el.dataset.rkOriginal;
721
+ el.classList.remove("rk-edited");
722
+ }
723
+ renderComments();
724
+ drawPins();
725
+ saveDraft();
726
+ });
727
+ return item;
728
+ }),
729
+ );
730
+ }
731
+ renderActions();
732
+ }
733
+
734
+ function showComment(c) {
735
+ const go = () => {
736
+ const el = findTarget(c);
737
+ if (el) flash(el);
738
+ focusComment(c.id);
739
+ };
740
+ if (c.file !== view.file) {
741
+ view.file = c.file;
742
+ view.state = c.state ?? null;
743
+ frame.addEventListener("load", () => setTimeout(go, 150), { once: true });
744
+ loadFrame();
745
+ return;
746
+ }
747
+ if (c.state && c.state !== view.state) {
748
+ view.state = c.state;
749
+ applyState();
750
+ afterLayout();
751
+ }
752
+ setTimeout(go, 60);
753
+ }
754
+
755
+ function focusComment(id) {
756
+ const item = [...$("comments").children].find((n) => n.dataset.id === id);
757
+ if (!item) return;
758
+ item.scrollIntoView({ block: "nearest", behavior: "smooth" });
759
+ item.classList.add("flash");
760
+ setTimeout(() => item.classList.remove("flash"), 1500);
761
+ }
762
+
763
+ // ---------------------------------------------------------------------------
764
+ // What changed since the version before
765
+
766
+ function leafTexts(root) {
767
+ const out = [];
768
+ const walk = (n) => {
769
+ if (n.nodeType !== 1 || /^(SCRIPT|STYLE|TEMPLATE)$/.test(n.tagName) || n.id === "rk-layer") return;
770
+ const own = [...n.childNodes]
771
+ .filter((c) => c.nodeType === 3)
772
+ .map((c) => c.textContent)
773
+ .join("")
774
+ .replace(/\s+/g, " ")
775
+ .trim();
776
+ const attr = n.getAttribute("placeholder") || n.getAttribute("alt") || (n.tagName === "INPUT" ? n.getAttribute("value") : "") || "";
777
+ if (own || attr) out.push({ el: n, text: own || attr });
778
+ [...n.children].forEach(walk);
779
+ };
780
+ walk(root);
781
+ return out;
782
+ }
783
+
784
+ async function markDiff() {
785
+ const d = doc();
786
+ if (!d) return;
787
+ d.querySelectorAll(".rk-changed").forEach((e) => e.classList.remove("rk-changed"));
788
+ const prev = view.info.versions.previous;
789
+ const card = $("changesCard");
790
+ if (!$("showDiff").checked || !prev) {
791
+ card.hidden = true;
792
+ return;
793
+ }
794
+ card.hidden = false;
795
+ let html;
796
+ try {
797
+ const r = await fetch(`proto/v/${prev}/${view.file.split("/").map(encodeURIComponent).join("/")}`);
798
+ if (r.status === 404) {
799
+ $("changes").innerHTML = '<p class="muted small">这个页面是这一版新加的。</p>';
800
+ return;
801
+ }
802
+ html = await r.text();
803
+ } catch {
804
+ $("changes").innerHTML = '<p class="muted small">没能读到上一版。</p>';
805
+ return;
806
+ }
807
+ const old = new DOMParser().parseFromString(html, "text/html");
808
+ const oldTexts = new Set(leafTexts(old.body).map((x) => x.text));
809
+ const now = leafTexts(d.body);
810
+ const nowTexts = new Set(now.map((x) => x.text));
811
+ let changed = 0;
812
+ for (const x of now) {
813
+ if (!oldTexts.has(x.text) && visible(x.el)) {
814
+ x.el.classList.add("rk-changed");
815
+ changed++;
816
+ }
817
+ }
818
+ const removed = [...oldTexts].filter((t) => !nowTexts.has(t)).slice(0, 20);
819
+ $("changes").innerHTML =
820
+ `<p class="small">绿色框是这一版新出现或改过文字的地方,共 ${changed} 处。</p>` +
821
+ (removed.length ? `<p class="small muted">上一版有、这一版没有的文字:</p><ul class="diff-list">${removed.map((t) => `<li>${esc(t.slice(0, 60))}</li>`).join("")}</ul>` : "") +
822
+ (changed === 0 && removed.length === 0 ? '<p class="muted small">文字没有变化,改动可能在样式或布局上。</p>' : "");
823
+ }
824
+
825
+ // ---------------------------------------------------------------------------
826
+ // Sending
827
+
828
+ function renderActions() {
829
+ const info = view.info;
830
+ const note = $("actionNote");
831
+ const changes = $("submitChanges");
832
+ const approve = $("approve");
833
+ if (info.readOnly) {
834
+ $("actions").hidden = true;
835
+ return;
836
+ }
837
+ changes.textContent = view.comments.length ? `提交修改意见(${view.comments.length} 条)` : "提交修改意见";
838
+ const hasAny = view.comments.length > 0 || view.general.trim().length > 0;
839
+ if (!info.canReply) {
840
+ note.textContent = "这个需求不是从 Paseo 对话开始的,这里的意见只会保存,请在卡片或终端中处理。";
841
+ changes.disabled = true;
842
+ approve.disabled = !info.waitingForApproval;
843
+ } else if (info.waitingForApproval) {
844
+ note.textContent = hasAny ? "提交后,需求会话会按你的意见改需求说明,再请你确认。" : "看完了,没有要改的就同意;有要改的先点原型提意见。";
845
+ changes.disabled = !hasAny;
846
+ approve.disabled = false;
847
+ } else if (info.waitingForReply) {
848
+ note.textContent = "需求现在在等你回答问题,意见会一起转给需求会话。";
849
+ changes.disabled = !hasAny;
850
+ approve.disabled = true;
851
+ } else {
852
+ note.textContent = `现在不在等你确认方案(${STATUS[info.status]?.[0] ?? info.status ?? "还没开始"})。可以先写意见,会自动保存,等到要确认时再提交。`;
853
+ changes.disabled = true;
854
+ approve.disabled = true;
855
+ }
856
+ approve.hidden = !info.waitingForApproval;
857
+ }
858
+
859
+ async function submit(action) {
860
+ const result = $("result");
861
+ if (action === "approve" && view.comments.length > 0 && !confirm(`还有 ${view.comments.length} 条意见没有提交。确定不改,直接同意开始写代码吗?`)) return;
862
+ $("submitChanges").disabled = true;
863
+ $("approve").disabled = true;
864
+ result.className = "result";
865
+ result.textContent = "正在提交…";
866
+ try {
867
+ const r = await api("api/submit", {
868
+ method: "POST",
869
+ headers: { "content-type": "application/json" },
870
+ body: JSON.stringify({ action, comments: view.comments, general: view.general }),
871
+ });
872
+ result.className = "result ok";
873
+ result.textContent = action === "approve" ? `已确认方案,开始写代码。${r.message ? `(${r.message.split("\n").at(-1)})` : ""}` : "已转给需求会话。它会按你的意见改需求说明,改好后请你确认。";
874
+ view.comments = [];
875
+ view.general = "";
876
+ $("general").value = "";
877
+ await refreshInfo();
878
+ renderComments();
879
+ drawPins();
880
+ } catch (e) {
881
+ result.className = "result err";
882
+ result.textContent = `没有提交成功:${e.message}`;
883
+ renderActions();
884
+ }
885
+ }
886
+ $("submitChanges").addEventListener("click", () => submit("changes"));
887
+ $("approve").addEventListener("click", () => submit("approve"));
888
+ $("general").addEventListener("input", (e) => {
889
+ view.general = e.target.value;
890
+ renderActions();
891
+ saveDraft();
892
+ });
893
+ $("showRegions").addEventListener("change", () => markRegions());
894
+ $("showDiff").addEventListener("change", () => markDiff());
895
+ window.addEventListener("resize", () => afterLayout());
896
+ document.addEventListener("keydown", (e) => {
897
+ if (e.key === "Escape") closePop();
898
+ });
899
+
900
+ // ---------------------------------------------------------------------------
901
+ // Start
902
+
903
+ async function refreshInfo() {
904
+ view.info = await api("api/info");
905
+ const [label, waiting] = STATUS[view.info.status] ?? [view.info.status ? view.info.status : "还没开始", false];
906
+ const pill = $("status");
907
+ pill.hidden = false;
908
+ pill.textContent = label;
909
+ pill.className = waiting ? "pill wait" : "pill";
910
+ $("diffWrap").hidden = !view.info.versions.previous;
911
+ renderActions();
912
+ }
913
+
914
+ async function start() {
915
+ hint();
916
+ try {
917
+ await refreshInfo();
918
+ } catch (e) {
919
+ $("title").textContent = "打不开";
920
+ $("empty").hidden = false;
921
+ $("empty").textContent = e.message;
922
+ $("frameWrap").hidden = true;
923
+ return;
924
+ }
925
+ const info = view.info;
926
+ $("title").textContent = info.title;
927
+ document.title = `原型审查 · ${info.title}`;
928
+ if (info.readOnly) $("guide").open = false;
929
+ try {
930
+ const draft = await api("api/draft");
931
+ view.comments = Array.isArray(draft.comments) ? draft.comments : [];
932
+ view.general = typeof draft.general === "string" ? draft.general : "";
933
+ $("general").value = view.general;
934
+ if (view.comments.length) $("saved").textContent = `上次写的 ${view.comments.length} 条意见还在。`;
935
+ } catch {
936
+ /* start empty */
937
+ }
938
+ renderComments();
939
+ if (info.files.length === 0) {
940
+ $("frameWrap").hidden = true;
941
+ $("empty").hidden = false;
942
+ $("empty").textContent = "这个需求还没有界面原型。界面改动的需求在方案设计完成后才会有,到时再打开这个页面。";
943
+ return;
944
+ }
945
+ view.file = info.files[0].path;
946
+ // On a phone, start with the phone width.
947
+ if (window.innerWidth < 700) view.width = 390;
948
+ // Auto-suggest the diff when the design was revised since the last look.
949
+ if (info.versions.previous) $("showDiff").checked = true;
950
+ loadFrame();
951
+ }
952
+
953
+ start();
954
+ })();