paseo-req-kit 1.12.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 (237) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +8 -3
  3. package/bin/bootstrap.d.mts +13 -0
  4. package/bin/bootstrap.mjs +444 -0
  5. package/bin/req-docs.d.mts +5 -0
  6. package/bin/req-docs.mjs +169 -0
  7. package/bin/req.mjs +21 -1
  8. package/contracts/cli-json.md +2 -0
  9. package/docs/adr/0032-npm-only-install.md +27 -0
  10. package/docs/adr/0033-prototype-review-and-ui-check.md +28 -0
  11. package/docs/adr/README.md +2 -0
  12. package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +8 -0
  13. package/docs/guide//345/256/211/350/243/205/344/270/216/345/215/207/347/272/247.md +22 -2
  14. package/docs/guide//347/273/264/346/212/244/350/200/205/346/214/207/345/215/227.md +1 -1
  15. 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
  16. package/engine/prompts/03-design.md +5 -1
  17. package/engine/prompts/04b-design-revise.md +1 -1
  18. package/engine/prompts/browser-verify.md +2 -2
  19. package/engine/prompts/ui-check.md +15 -0
  20. package/engine/src/api.ts +17 -2
  21. package/engine/src/config-view.ts +1 -1
  22. package/engine/src/pipeline.ts +15 -7
  23. package/engine/src/review-assets/app.css +420 -0
  24. package/engine/src/review-assets/app.html +95 -0
  25. package/engine/src/review-assets/app.js +954 -0
  26. package/engine/src/review.ts +570 -0
  27. package/engine/src/ui-check-page.js +208 -0
  28. package/engine/src/ui-check.ts +366 -0
  29. package/package.json +3 -1
  30. package/paseo-plugin/client/data.ts +12 -1
  31. package/paseo-plugin/client/detail.tsx +3 -0
  32. package/paseo-plugin/client/inbox.tsx +2 -0
  33. package/paseo-plugin/client/review-link.tsx +29 -0
  34. package/paseo-plugin/index.server.ts +2 -1
  35. package/paseo-plugin/package.json +1 -1
  36. package/paseo-plugin/server/handlers.ts +7 -1
  37. package/paseo-plugin/shared/contracts.ts +9 -0
  38. package/site-dist/404.html +27 -0
  39. package/site-dist/assets/app.DWPPPhFm.js +1 -0
  40. package/site-dist/assets/changelog.md.CoelFFCL.js +1 -0
  41. package/site-dist/assets/changelog.md.CoelFFCL.lean.js +1 -0
  42. package/site-dist/assets/chunks/@localSearchIndexroot.Hdq0-4L3.js +1 -0
  43. package/site-dist/assets/chunks/VPLocalSearchBox.D3_9dRGY.js +9 -0
  44. package/site-dist/assets/chunks/framework.B9qR_F1m.js +20 -0
  45. package/site-dist/assets/chunks/theme.Bm8aLiLN.js +2 -0
  46. package/site-dist/assets/guide_getting-started.md.B4GJeoPz.js +6 -0
  47. package/site-dist/assets/guide_getting-started.md.B4GJeoPz.lean.js +1 -0
  48. package/site-dist/assets/guide_how-it-works.md.D-kQsF1t.js +25 -0
  49. package/site-dist/assets/guide_how-it-works.md.D-kQsF1t.lean.js +1 -0
  50. package/site-dist/assets/guide_index.md.CLnGCmGW.js +1 -0
  51. package/site-dist/assets/guide_index.md.CLnGCmGW.lean.js +1 -0
  52. package/site-dist/assets/guide_install.md.DkCyZsOU.js +42 -0
  53. package/site-dist/assets/guide_install.md.DkCyZsOU.lean.js +42 -0
  54. package/site-dist/assets/guide_maintainer.md.RZIzyCTV.js +1 -0
  55. package/site-dist/assets/guide_maintainer.md.RZIzyCTV.lean.js +1 -0
  56. package/site-dist/assets/guide_phases.md.BpGAb1re.js +1 -0
  57. package/site-dist/assets/guide_phases.md.BpGAb1re.lean.js +1 -0
  58. package/site-dist/assets/guide_project.md.nGlTCo4D.js +77 -0
  59. package/site-dist/assets/guide_project.md.nGlTCo4D.lean.js +77 -0
  60. package/site-dist/assets/guide_user.md.tiHFxIsQ.js +1 -0
  61. package/site-dist/assets/guide_user.md.tiHFxIsQ.lean.js +1 -0
  62. package/site-dist/assets/index.md.Cf_sSf_f.js +1 -0
  63. package/site-dist/assets/index.md.Cf_sSf_f.lean.js +1 -0
  64. package/site-dist/assets/inter-italic-cyrillic-ext.r48I6akx.woff2 +0 -0
  65. package/site-dist/assets/inter-italic-cyrillic.By2_1cv3.woff2 +0 -0
  66. package/site-dist/assets/inter-italic-greek-ext.1u6EdAuj.woff2 +0 -0
  67. package/site-dist/assets/inter-italic-greek.DJ8dCoTZ.woff2 +0 -0
  68. package/site-dist/assets/inter-italic-latin-ext.CN1xVJS-.woff2 +0 -0
  69. package/site-dist/assets/inter-italic-latin.C2AdPX0b.woff2 +0 -0
  70. package/site-dist/assets/inter-italic-vietnamese.BSbpV94h.woff2 +0 -0
  71. package/site-dist/assets/inter-roman-cyrillic-ext.BBPuwvHQ.woff2 +0 -0
  72. package/site-dist/assets/inter-roman-cyrillic.C5lxZ8CY.woff2 +0 -0
  73. package/site-dist/assets/inter-roman-greek-ext.CqjqNYQ-.woff2 +0 -0
  74. package/site-dist/assets/inter-roman-greek.BBVDIX6e.woff2 +0 -0
  75. package/site-dist/assets/inter-roman-latin-ext.4ZJIpNVo.woff2 +0 -0
  76. package/site-dist/assets/inter-roman-latin.Di8DUHzh.woff2 +0 -0
  77. package/site-dist/assets/inter-roman-vietnamese.BjW4sHH5.woff2 +0 -0
  78. package/site-dist/assets/reference_adr_0001-step-agents-under-origin-chat.md.RtRTq7oi.js +1 -0
  79. package/site-dist/assets/reference_adr_0001-step-agents-under-origin-chat.md.RtRTq7oi.lean.js +1 -0
  80. package/site-dist/assets/reference_adr_0002-no-directory-switch-in-chat.md.BxtN-Aa2.js +1 -0
  81. package/site-dist/assets/reference_adr_0002-no-directory-switch-in-chat.md.BxtN-Aa2.lean.js +1 -0
  82. package/site-dist/assets/reference_adr_0003-script-engine-not-hub.md.66EgZtqx.js +1 -0
  83. package/site-dist/assets/reference_adr_0003-script-engine-not-hub.md.66EgZtqx.lean.js +1 -0
  84. package/site-dist/assets/reference_adr_0004-result-files.md.DbqcCozI.js +1 -0
  85. package/site-dist/assets/reference_adr_0004-result-files.md.DbqcCozI.lean.js +1 -0
  86. package/site-dist/assets/reference_adr_0005-no-default-branch-local-commits-only.md.CkImA4s6.js +1 -0
  87. package/site-dist/assets/reference_adr_0005-no-default-branch-local-commits-only.md.CkImA4s6.lean.js +1 -0
  88. package/site-dist/assets/reference_adr_0006-skill-install-targets.md.CwY8YepV.js +1 -0
  89. package/site-dist/assets/reference_adr_0006-skill-install-targets.md.CwY8YepV.lean.js +1 -0
  90. package/site-dist/assets/reference_adr_0007-own-interview-skill.md.CryhxRyG.js +1 -0
  91. package/site-dist/assets/reference_adr_0007-own-interview-skill.md.CryhxRyG.lean.js +1 -0
  92. package/site-dist/assets/reference_adr_0008-entries-do-not-call-entries.md.DsRWAgeA.js +1 -0
  93. package/site-dist/assets/reference_adr_0008-entries-do-not-call-entries.md.DsRWAgeA.lean.js +1 -0
  94. package/site-dist/assets/reference_adr_0009-versioned-install-layout.md.CSg1VqlW.js +1 -0
  95. package/site-dist/assets/reference_adr_0009-versioned-install-layout.md.CSg1VqlW.lean.js +1 -0
  96. package/site-dist/assets/reference_adr_0010-generated-feature-overview.md.D8F3MK-h.js +1 -0
  97. package/site-dist/assets/reference_adr_0010-generated-feature-overview.md.D8F3MK-h.lean.js +1 -0
  98. package/site-dist/assets/reference_adr_0011-resume-after-completion.md.BCrZrKjf.js +1 -0
  99. package/site-dist/assets/reference_adr_0011-resume-after-completion.md.BCrZrKjf.lean.js +1 -0
  100. package/site-dist/assets/reference_adr_0012-review-panel.md.k9rrHFFc.js +1 -0
  101. package/site-dist/assets/reference_adr_0012-review-panel.md.k9rrHFFc.lean.js +1 -0
  102. package/site-dist/assets/reference_adr_0013-no-release-tooling.md.BQeJ8dEI.js +1 -0
  103. package/site-dist/assets/reference_adr_0013-no-release-tooling.md.BQeJ8dEI.lean.js +1 -0
  104. package/site-dist/assets/reference_adr_0014-notifier-slot.md.C23QR1xO.js +1 -0
  105. package/site-dist/assets/reference_adr_0014-notifier-slot.md.C23QR1xO.lean.js +1 -0
  106. package/site-dist/assets/reference_adr_0015-incremental-continue.md.Buy7wwO8.js +1 -0
  107. package/site-dist/assets/reference_adr_0015-incremental-continue.md.Buy7wwO8.lean.js +1 -0
  108. package/site-dist/assets/reference_adr_0016-upgrade-from-tags.md.qfIW-tqJ.js +1 -0
  109. package/site-dist/assets/reference_adr_0016-upgrade-from-tags.md.qfIW-tqJ.lean.js +1 -0
  110. package/site-dist/assets/reference_adr_0017-use-paseo-capabilities.md.CjcGli87.js +1 -0
  111. package/site-dist/assets/reference_adr_0017-use-paseo-capabilities.md.CjcGli87.lean.js +1 -0
  112. package/site-dist/assets/reference_adr_0018-own-worktree-option.md.fVDpgGSE.js +1 -0
  113. package/site-dist/assets/reference_adr_0018-own-worktree-option.md.fVDpgGSE.lean.js +1 -0
  114. package/site-dist/assets/reference_adr_0019-parallel-orchestration.md.DrfrV0To.js +1 -0
  115. package/site-dist/assets/reference_adr_0019-parallel-orchestration.md.DrfrV0To.lean.js +1 -0
  116. package/site-dist/assets/reference_adr_0020-scheduling-through-the-chat.md.BqSemGFG.js +1 -0
  117. package/site-dist/assets/reference_adr_0020-scheduling-through-the-chat.md.BqSemGFG.lean.js +1 -0
  118. package/site-dist/assets/reference_adr_0021-npm-version-manager.md.CU3PG6y5.js +1 -0
  119. package/site-dist/assets/reference_adr_0021-npm-version-manager.md.CU3PG6y5.lean.js +1 -0
  120. package/site-dist/assets/reference_adr_0022-develop-and-verify-phases.md.C6fQDLq4.js +1 -0
  121. package/site-dist/assets/reference_adr_0022-develop-and-verify-phases.md.C6fQDLq4.lean.js +1 -0
  122. package/site-dist/assets/reference_adr_0023-types-gate-slices.md.D2E6yyYQ.js +1 -0
  123. package/site-dist/assets/reference_adr_0023-types-gate-slices.md.D2E6yyYQ.lean.js +1 -0
  124. package/site-dist/assets/reference_adr_0024-confirm-early-review-axes.md.FmK_ke0x.js +1 -0
  125. package/site-dist/assets/reference_adr_0024-confirm-early-review-axes.md.FmK_ke0x.lean.js +1 -0
  126. package/site-dist/assets/reference_adr_0025-joint-requirements.md.ChoKvMUx.js +1 -0
  127. package/site-dist/assets/reference_adr_0025-joint-requirements.md.ChoKvMUx.lean.js +1 -0
  128. package/site-dist/assets/reference_adr_0026-machine-records-and-api.md.veybjBPq.js +1 -0
  129. package/site-dist/assets/reference_adr_0026-machine-records-and-api.md.veybjBPq.lean.js +1 -0
  130. package/site-dist/assets/reference_adr_0027-paseo-plugin.md.BMX33ZNU.js +1 -0
  131. package/site-dist/assets/reference_adr_0027-paseo-plugin.md.BMX33ZNU.lean.js +1 -0
  132. package/site-dist/assets/reference_adr_0028-external-integrations.md.DtdMe8RK.js +1 -0
  133. package/site-dist/assets/reference_adr_0028-external-integrations.md.DtdMe8RK.lean.js +1 -0
  134. package/site-dist/assets/reference_adr_0029-requirements-from-cards.md.BHd1SQPe.js +1 -0
  135. package/site-dist/assets/reference_adr_0029-requirements-from-cards.md.BHd1SQPe.lean.js +1 -0
  136. package/site-dist/assets/reference_adr_0030-card-intake-process.md.kLc-DG9_.js +1 -0
  137. package/site-dist/assets/reference_adr_0030-card-intake-process.md.kLc-DG9_.lean.js +1 -0
  138. package/site-dist/assets/reference_adr_0031-fewer-interventions.md.BDnQcz8g.js +1 -0
  139. package/site-dist/assets/reference_adr_0031-fewer-interventions.md.BDnQcz8g.lean.js +1 -0
  140. package/site-dist/assets/reference_adr_0032-npm-only-install.md.C-3L1FkX.js +1 -0
  141. package/site-dist/assets/reference_adr_0032-npm-only-install.md.C-3L1FkX.lean.js +1 -0
  142. package/site-dist/assets/reference_adr_0033-prototype-review-and-ui-check.md.hFcQzmaa.js +1 -0
  143. package/site-dist/assets/reference_adr_0033-prototype-review-and-ui-check.md.hFcQzmaa.lean.js +1 -0
  144. package/site-dist/assets/reference_adr_index.md.BYF-Ofor.js +1 -0
  145. package/site-dist/assets/reference_adr_index.md.BYF-Ofor.lean.js +1 -0
  146. package/site-dist/assets/reference_commands.md.ClKbS-t8.js +1 -0
  147. package/site-dist/assets/reference_commands.md.ClKbS-t8.lean.js +1 -0
  148. package/site-dist/assets/reference_config.md.CRe27LaA.js +6 -0
  149. package/site-dist/assets/reference_config.md.CRe27LaA.lean.js +1 -0
  150. package/site-dist/assets/reference_contracts_cli-json.md.N-v2HNUd.js +1 -0
  151. package/site-dist/assets/reference_contracts_cli-json.md.N-v2HNUd.lean.js +1 -0
  152. package/site-dist/assets/reference_contracts_feature-files.md.DSiA8OEH.js +26 -0
  153. package/site-dist/assets/reference_contracts_feature-files.md.DSiA8OEH.lean.js +26 -0
  154. package/site-dist/assets/reference_contracts_index.md.Bkznz4px.js +1 -0
  155. package/site-dist/assets/reference_contracts_index.md.Bkznz4px.lean.js +1 -0
  156. package/site-dist/assets/reference_contracts_notifier-event.md.CCfrkm_A.js +24 -0
  157. package/site-dist/assets/reference_contracts_notifier-event.md.CCfrkm_A.lean.js +24 -0
  158. package/site-dist/assets/reference_contracts_result-file.md.DmR4fjB-.js +1 -0
  159. package/site-dist/assets/reference_contracts_result-file.md.DmR4fjB-.lean.js +1 -0
  160. package/site-dist/assets/reference_contracts_spec.md.BwwS0L_j.js +8 -0
  161. package/site-dist/assets/reference_contracts_spec.md.BwwS0L_j.lean.js +8 -0
  162. package/site-dist/assets/reference_contracts_state.md.5bNTC3ow.js +1 -0
  163. package/site-dist/assets/reference_contracts_state.md.5bNTC3ow.lean.js +1 -0
  164. package/site-dist/assets/reference_contracts_usage-record.md._C0OXWeq.js +10 -0
  165. package/site-dist/assets/reference_contracts_usage-record.md._C0OXWeq.lean.js +10 -0
  166. package/site-dist/assets/reference_contracts_verify.md.CbA88u6S.js +29 -0
  167. package/site-dist/assets/reference_contracts_verify.md.CbA88u6S.lean.js +29 -0
  168. package/site-dist/assets/reference_scope.md.Kzqw8U_f.js +1 -0
  169. package/site-dist/assets/reference_scope.md.Kzqw8U_f.lean.js +1 -0
  170. package/site-dist/assets/style.Dwsa4SMe.css +1 -0
  171. package/site-dist/changelog.html +30 -0
  172. package/site-dist/guide/getting-started.html +35 -0
  173. package/site-dist/guide/how-it-works.html +54 -0
  174. package/site-dist/guide/index.html +30 -0
  175. package/site-dist/guide/install.html +71 -0
  176. package/site-dist/guide/maintainer.html +30 -0
  177. package/site-dist/guide/phases.html +30 -0
  178. package/site-dist/guide/project.html +106 -0
  179. package/site-dist/guide/user.html +30 -0
  180. package/site-dist/hashmap.json +1 -0
  181. package/site-dist/icons/agents.svg +1 -0
  182. package/site-dist/icons/chat.svg +1 -0
  183. package/site-dist/icons/paseo.svg +1 -0
  184. package/site-dist/icons/phases.svg +1 -0
  185. package/site-dist/icons/review.svg +1 -0
  186. package/site-dist/icons/safe.svg +1 -0
  187. package/site-dist/index.html +30 -0
  188. package/site-dist/install.ps1 +118 -0
  189. package/site-dist/install.sh +198 -0
  190. package/site-dist/logo.svg +11 -0
  191. package/site-dist/reference/adr/0001-step-agents-under-origin-chat.html +30 -0
  192. package/site-dist/reference/adr/0002-no-directory-switch-in-chat.html +30 -0
  193. package/site-dist/reference/adr/0003-script-engine-not-hub.html +30 -0
  194. package/site-dist/reference/adr/0004-result-files.html +30 -0
  195. package/site-dist/reference/adr/0005-no-default-branch-local-commits-only.html +30 -0
  196. package/site-dist/reference/adr/0006-skill-install-targets.html +30 -0
  197. package/site-dist/reference/adr/0007-own-interview-skill.html +30 -0
  198. package/site-dist/reference/adr/0008-entries-do-not-call-entries.html +30 -0
  199. package/site-dist/reference/adr/0009-versioned-install-layout.html +30 -0
  200. package/site-dist/reference/adr/0010-generated-feature-overview.html +30 -0
  201. package/site-dist/reference/adr/0011-resume-after-completion.html +30 -0
  202. package/site-dist/reference/adr/0012-review-panel.html +30 -0
  203. package/site-dist/reference/adr/0013-no-release-tooling.html +30 -0
  204. package/site-dist/reference/adr/0014-notifier-slot.html +30 -0
  205. package/site-dist/reference/adr/0015-incremental-continue.html +30 -0
  206. package/site-dist/reference/adr/0016-upgrade-from-tags.html +30 -0
  207. package/site-dist/reference/adr/0017-use-paseo-capabilities.html +30 -0
  208. package/site-dist/reference/adr/0018-own-worktree-option.html +30 -0
  209. package/site-dist/reference/adr/0019-parallel-orchestration.html +30 -0
  210. package/site-dist/reference/adr/0020-scheduling-through-the-chat.html +30 -0
  211. package/site-dist/reference/adr/0021-npm-version-manager.html +30 -0
  212. package/site-dist/reference/adr/0022-develop-and-verify-phases.html +30 -0
  213. package/site-dist/reference/adr/0023-types-gate-slices.html +30 -0
  214. package/site-dist/reference/adr/0024-confirm-early-review-axes.html +30 -0
  215. package/site-dist/reference/adr/0025-joint-requirements.html +30 -0
  216. package/site-dist/reference/adr/0026-machine-records-and-api.html +30 -0
  217. package/site-dist/reference/adr/0027-paseo-plugin.html +30 -0
  218. package/site-dist/reference/adr/0028-external-integrations.html +30 -0
  219. package/site-dist/reference/adr/0029-requirements-from-cards.html +30 -0
  220. package/site-dist/reference/adr/0030-card-intake-process.html +30 -0
  221. package/site-dist/reference/adr/0031-fewer-interventions.html +30 -0
  222. package/site-dist/reference/adr/0032-npm-only-install.html +30 -0
  223. package/site-dist/reference/adr/0033-prototype-review-and-ui-check.html +30 -0
  224. package/site-dist/reference/adr/index.html +30 -0
  225. package/site-dist/reference/commands.html +30 -0
  226. package/site-dist/reference/config.html +35 -0
  227. package/site-dist/reference/contracts/cli-json.html +30 -0
  228. package/site-dist/reference/contracts/feature-files.html +55 -0
  229. package/site-dist/reference/contracts/index.html +30 -0
  230. package/site-dist/reference/contracts/notifier-event.html +53 -0
  231. package/site-dist/reference/contracts/result-file.html +30 -0
  232. package/site-dist/reference/contracts/spec.html +37 -0
  233. package/site-dist/reference/contracts/state.html +30 -0
  234. package/site-dist/reference/contracts/usage-record.html +39 -0
  235. package/site-dist/reference/contracts/verify.html +58 -0
  236. package/site-dist/reference/scope.html +30 -0
  237. package/site-dist/vp-icons.css +1 -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
+ })();