design-playbook 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +28 -0
  2. package/NOTICE +37 -0
  3. package/README.md +143 -0
  4. package/commands/design-io.md +8 -0
  5. package/commands/ui-review.md +8 -0
  6. package/commands/ux-spec.md +8 -0
  7. package/mcp/__init__.py +0 -0
  8. package/mcp/_transport.py +242 -0
  9. package/mcp/evidence/README.md +40 -0
  10. package/mcp/evidence/__init__.py +0 -0
  11. package/mcp/evidence/server.py +450 -0
  12. package/mcp/evidence/test_server_stdio.py +645 -0
  13. package/mcp/preview/__init__.py +0 -0
  14. package/mcp/preview/browser.py +661 -0
  15. package/mcp/preview/confirm.py +255 -0
  16. package/mcp/preview/control.py +1293 -0
  17. package/mcp/preview/i18n.py +162 -0
  18. package/mcp/preview/server.py +126 -0
  19. package/mcp/preview/test_browser_control.py +663 -0
  20. package/mcp/preview/test_server_stdio.py +630 -0
  21. package/mcp/preview/test_transaction.py +436 -0
  22. package/mcp/preview/transaction.py +536 -0
  23. package/mcp/preview/util.py +19 -0
  24. package/mcp/test_transport.py +39 -0
  25. package/package.json +42 -0
  26. package/skills/craft-guard/SKILL.md +59 -0
  27. package/skills/craft-guard/references/craft.md +29 -0
  28. package/skills/craft-guard/references/detectors.md +124 -0
  29. package/skills/design-baseline/SKILL.md +134 -0
  30. package/skills/design-baseline/agents/openai.yaml +4 -0
  31. package/skills/design-baseline/references/design-template.md +73 -0
  32. package/skills/design-baseline/references/extraction-guidance.md +39 -0
  33. package/skills/design-baseline/scripts/design_baseline.py +780 -0
  34. package/skills/design-playbook/SKILL.md +219 -0
  35. package/skills/native-craft/SKILL.md +59 -0
  36. package/skills/native-craft/references/native-feel.md +79 -0
  37. package/skills/reference-intake/SKILL.md +86 -0
  38. package/skills/reference-intake/references/contract-template.md +82 -0
  39. package/skills/ui-evaluator/SKILL.md +110 -0
  40. package/skills/ui-evaluator/references/rubric.md +45 -0
  41. package/skills/ui-picker/SKILL.md +63 -0
  42. package/skills/ui-picker/references/components.md +31 -0
  43. package/skills/ui-picker/references/design.md +21 -0
  44. package/skills/ui-picker/references/domain.md +26 -0
  45. package/skills/ui-picker/references/template.md +24 -0
  46. package/skills/ux-spec/SKILL.md +51 -0
  47. package/skills/ux-spec/references/spec-template.md +43 -0
@@ -0,0 +1,1293 @@
1
+ """Confirm control-bar template + builder + feedback formatting.
2
+
3
+ Sibling module split from server.py; behavior unchanged. Holds the large
4
+ injected HTML/CSS/JS template (``control_tpl``), the locale-aware builder
5
+ ``_build_control`` (shared by the runtime HTTP page and the playwright
6
+ frontend floor test), and ``_format_feedback``.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import html as html_lib
11
+ import json
12
+ from typing import Any
13
+
14
+ from i18n import CONFIRM_LABELS, REVISE_LABELS, t
15
+
16
+
17
+ control_tpl = r"""
18
+ <style>
19
+ #dpb-preview-bar {{
20
+ --dpb-bg: #1e2330;
21
+ --dpb-surface: #252b38;
22
+ --dpb-elev: #2a3140;
23
+ --dpb-line: #2c3444;
24
+ --dpb-line-strong: #3d475a;
25
+ --dpb-ink: #f3f4f6;
26
+ --dpb-muted: #9aa3b2;
27
+ --dpb-soft: #c5cbd6;
28
+ --dpb-accent: #14b8a6;
29
+ --dpb-accent-ink: #ffffff;
30
+ --dpb-accent-deep: #0f766e;
31
+ --dpb-danger: #f87171;
32
+ --dpb-radius: 10px;
33
+ --dpb-control-h: 40px;
34
+ font: 13px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif;
35
+ color: var(--dpb-ink);
36
+ }}
37
+ /* ---- floating pill (default state) ---- */
38
+ #dpb-preview-bar {{
39
+ position: fixed;
40
+ right: 24px;
41
+ bottom: 24px;
42
+ left: auto;
43
+ top: auto;
44
+ z-index: 1000;
45
+ background: transparent;
46
+ border: 0;
47
+ padding: 0;
48
+ border-radius: 0;
49
+ border-top: 0;
50
+ }}
51
+ #dpb-preview-bar .dpb-pill {{
52
+ display: inline-flex;
53
+ align-items: center;
54
+ gap: 8px;
55
+ padding: 8px 8px 8px 16px;
56
+ background: var(--dpb-bg);
57
+ border: 1px solid var(--dpb-line);
58
+ border-radius: 999px;
59
+ box-shadow: 0 8px 28px rgba(0,0,0,.15), 0 2px 6px rgba(0,0,0,.1);
60
+ }}
61
+ #dpb-preview-bar .dpb-pill .dpb-pill-info {{
62
+ display: inline-flex; align-items: center; gap: 8px; max-width: 260px;
63
+ }}
64
+ #dpb-preview-bar .dpb-round {{
65
+ flex: 0 0 auto; display: inline-flex; align-items: center; height: 22px;
66
+ padding: 0 8px; border-radius: 999px; font-size: 11px; font-weight: 700;
67
+ font-variant-numeric: tabular-nums; color: #99f6e4;
68
+ background: rgba(20,184,166,.14); border: 1px solid rgba(20,184,166,.35);
69
+ }}
70
+ #dpb-preview-bar .dpb-pill .dpb-summary {{
71
+ margin: 0; min-width: 0; color: var(--dpb-soft);
72
+ font-size: 12.5px; font-weight: 500; overflow: hidden;
73
+ text-overflow: ellipsis; white-space: nowrap;
74
+ }}
75
+ #dpb-preview-bar .dpb-pill .dpb-pill-actions {{
76
+ display: inline-flex; align-items: center; gap: 6px;
77
+ }}
78
+ #dpb-preview-bar .dpb-pill .dpb-btn-ghost {{
79
+ appearance: none; cursor: pointer; height: 32px; padding: 0 12px;
80
+ border-radius: 999px; border: 1px solid var(--dpb-line-strong);
81
+ background: var(--dpb-surface); color: var(--dpb-soft);
82
+ font: 650 12.5px/1 system-ui, sans-serif;
83
+ display: inline-flex; align-items: center; gap: 6px;
84
+ transition: background 140ms ease, border-color 140ms ease, color 140ms ease;
85
+ }}
86
+ #dpb-preview-bar .dpb-pill .dpb-btn-ghost:hover {{
87
+ background: var(--dpb-elev); border-color: #5b6578; color: var(--dpb-ink);
88
+ }}
89
+ #dpb-preview-bar .dpb-pill .dpb-btn-ghost:focus-visible {{
90
+ outline: 2px solid var(--dpb-accent); outline-offset: 2px;
91
+ }}
92
+ #dpb-preview-bar .dpb-pill .dpb-badge-dot {{
93
+ display: none; min-width: 18px; height: 18px; padding: 0 5px;
94
+ align-items: center; justify-content: center; border-radius: 999px;
95
+ font-size: 11px; font-weight: 700; color: #042f2e;
96
+ background: var(--dpb-accent);
97
+ }}
98
+ #dpb-preview-bar .dpb-pill .dpb-badge-dot.is-on {{ display: inline-flex; }}
99
+ #dpb-preview-bar .dpb-pill .dpb-btn-primary {{
100
+ appearance: none; cursor: pointer; height: 32px; padding: 0 16px;
101
+ border-radius: 999px; border: 1px solid var(--dpb-accent-deep);
102
+ background: var(--dpb-accent); color: var(--dpb-accent-ink);
103
+ font: 650 12.5px/1 system-ui, sans-serif;
104
+ box-shadow: inset 0 1px 0 rgba(255,255,255,.18);
105
+ transition: background 140ms ease, transform 100ms ease;
106
+ }}
107
+ #dpb-preview-bar .dpb-pill .dpb-btn-primary:hover {{ background: #2dd4bf; color: #042f2e; }}
108
+ #dpb-preview-bar .dpb-pill .dpb-btn-primary:active {{ transform: translateY(1px); }}
109
+ #dpb-preview-bar .dpb-pill .dpb-btn-primary:focus-visible {{
110
+ outline: 2px solid var(--dpb-accent); outline-offset: 2px;
111
+ }}
112
+ /* Direct confirm state on pill primary when readiness allows immediate action */
113
+ #dpb-preview-bar .dpb-pill .dpb-btn-primary.is-direct-confirm {{
114
+ box-shadow: 0 0 0 3px rgba(20,184,166,0.25);
115
+ font-weight: 700;
116
+ }}
117
+ /* Pill direct-confirm arm (二级保护): first click arms, second submits */
118
+ #dpb-preview-bar .dpb-pill .dpb-btn-primary.is-armed {{
119
+ font-weight: 700;
120
+ animation: dpb-confirm-pulse 1s ease-in-out infinite;
121
+ }}
122
+ @keyframes dpb-confirm-pulse {{
123
+ 0%,100% {{ box-shadow: 0 0 0 3px rgba(20,184,166,0.35); }}
124
+ 50% {{ box-shadow: 0 0 0 7px rgba(20,184,166,0.12); }}
125
+ }}
126
+ /* secondary action surfaced on the pill (e.g. the revise label) */
127
+ #dpb-preview-bar .dpb-pill .dpb-btn-pill-secondary {{
128
+ appearance: none; cursor: pointer; height: 32px; padding: 0 14px;
129
+ border-radius: 999px; border: 1px solid var(--dpb-line-strong);
130
+ background: var(--dpb-elev); color: var(--dpb-soft);
131
+ font: 650 12.5px/1 system-ui, sans-serif;
132
+ display: inline-flex; align-items: center;
133
+ transition: background 140ms ease, border-color 140ms ease, color 140ms ease;
134
+ }}
135
+ #dpb-preview-bar .dpb-pill .dpb-btn-pill-secondary:hover {{
136
+ background: #3a4150; border-color: #6b7588; color: var(--dpb-ink);
137
+ }}
138
+ #dpb-preview-bar .dpb-pill .dpb-btn-pill-secondary:focus-visible {{
139
+ outline: 2px solid var(--dpb-accent); outline-offset: 2px;
140
+ }}
141
+ @media (max-width: 720px) {{
142
+ #dpb-preview-bar .dpb-pill .dpb-btn-pill-secondary {{ display: inline-flex; font-size: 11px; padding: 0 8px; }}
143
+ /* Per debate: do not hide revise on mobile; allow stacking if needed */
144
+ }}
145
+
146
+ /* ---- drawer (expanded state) ---- */
147
+ #dpb-preview-bar dialog.dpb-drawer {{
148
+ position: fixed;
149
+ right: 24px;
150
+ bottom: 24px;
151
+ margin: 0;
152
+ width: 380px;
153
+ max-width: calc(100vw - 48px);
154
+ max-height: calc(100vh - 48px);
155
+ flex-direction: column;
156
+ background: var(--dpb-bg);
157
+ border: 1px solid var(--dpb-line);
158
+ border-radius: 16px;
159
+ box-shadow: 0 16px 48px rgba(0,0,0,.22), 0 4px 12px rgba(0,0,0,.12);
160
+ overflow: hidden;
161
+ color: var(--dpb-ink);
162
+ padding: 0;
163
+ }}
164
+ /* non-modal <dialog>.show() is not in the top layer, so we keep the hand-rolled
165
+ scrim (stronger than before per UX V4) so the drawer reads as a focused layer. */
166
+ #dpb-preview-bar dialog.dpb-drawer[open] {{ display: flex; }}
167
+ /* show() failure / no-dialog fallback: is-open alone must reveal the drawer */
168
+ #dpb-preview-bar.is-open dialog.dpb-drawer {{ display: flex; }}
169
+ #dpb-preview-bar.is-open::before {{
170
+ content: "";
171
+ position: fixed; inset: 0; z-index: -1;
172
+ background: rgba(15,23,42,.5);
173
+ pointer-events: auto; /* P1.5: intercept clicks outside drawer (close on scrim) */
174
+ }}
175
+ body.dpb-pin-mode #dpb-preview-bar.is-open::before {{
176
+ pointer-events: none; /* P1.5: let pin-mode clicks reach prototype elements */
177
+ }}
178
+ #dpb-preview-bar.is-open .dpb-pill {{ display: none; }}
179
+
180
+ #dpb-preview-bar .dpb-drawer-head {{
181
+ display: flex; align-items: center; justify-content: space-between;
182
+ gap: 10px; padding: 12px 14px;
183
+ border-bottom: 1px solid var(--dpb-line);
184
+ background: linear-gradient(180deg, #131722, var(--dpb-bg));
185
+ }}
186
+ #dpb-preview-bar .dpb-drawer-head .dpb-head-left {{
187
+ display: inline-flex; align-items: center; gap: 8px; min-width: 0;
188
+ }}
189
+ #dpb-preview-bar .dpb-drawer-head .dpb-title {{
190
+ margin: 0; font-size: 13px; font-weight: 650; color: var(--dpb-ink);
191
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
192
+ }}
193
+ #dpb-preview-bar .dpb-drawer-head .dpb-icon-btn {{
194
+ appearance: none; cursor: pointer; width: 28px; height: 28px;
195
+ border-radius: 8px; border: 1px solid transparent;
196
+ background: transparent; color: var(--dpb-muted);
197
+ font: 650 16px/1 system-ui, sans-serif;
198
+ display: inline-grid; place-items: center;
199
+ transition: background 140ms ease, color 140ms ease;
200
+ }}
201
+ #dpb-preview-bar .dpb-drawer-head .dpb-icon-btn:hover {{
202
+ background: var(--dpb-elev); color: var(--dpb-ink);
203
+ }}
204
+ #dpb-preview-bar .dpb-drawer-head .dpb-icon-btn:focus-visible {{
205
+ outline: 2px solid var(--dpb-accent); outline-offset: 1px;
206
+ }}
207
+
208
+ #dpb-preview-bar .dpb-drawer-body {{
209
+ display: flex; flex-direction: column; min-height: 0;
210
+ overflow: auto; padding: 10px 14px 14px; gap: 14px;
211
+ }}
212
+ #dpb-preview-bar .dpb-drawer-body .dpb-subhead {{
213
+ margin: 0; font-size: 11px; font-weight: 700; letter-spacing: 0.05em;
214
+ color: var(--dpb-muted); text-transform: uppercase;
215
+ }}
216
+
217
+ /* pin toggle in drawer */
218
+ #dpb-preview-bar .dpb-pin-toggle {{
219
+ appearance: none; cursor: pointer; height: 34px; padding: 0 12px;
220
+ border-radius: 8px; border: 1px solid var(--dpb-line-strong);
221
+ background: var(--dpb-surface); color: var(--dpb-soft);
222
+ font: 650 12.5px/1 system-ui, sans-serif;
223
+ display: inline-flex; align-items: center; gap: 7px;
224
+ transition: background 140ms ease, border-color 140ms ease, color 140ms ease;
225
+ }}
226
+ #dpb-preview-bar .dpb-pin-toggle:hover {{
227
+ background: var(--dpb-elev); border-color: #5b6578; color: var(--dpb-ink);
228
+ }}
229
+ #dpb-preview-bar .dpb-pin-toggle.is-on {{
230
+ color: #042f2e; background: var(--dpb-accent); border-color: var(--dpb-accent-deep);
231
+ }}
232
+ #dpb-preview-bar .dpb-pin-toggle:focus-visible {{
233
+ outline: 2px solid var(--dpb-accent); outline-offset: 2px;
234
+ }}
235
+ #dpb-preview-bar .dpb-pin-row {{
236
+ display: flex; align-items: center; justify-content: space-between; gap: 10px;
237
+ }}
238
+ #dpb-preview-bar .dpb-pin-count {{
239
+ font-size: 11.5px; color: var(--dpb-muted); font-variant-numeric: tabular-nums;
240
+ }}
241
+
242
+ /* anchor list */
243
+ #dpb-preview-bar .dpb-anchors {{
244
+ display: none; flex-direction: column; gap: 6px;
245
+ max-height: 180px; overflow: auto; padding: 8px;
246
+ border-radius: 8px; background: var(--dpb-surface);
247
+ border: 1px solid var(--dpb-line);
248
+ }}
249
+ #dpb-preview-bar .dpb-anchors.has-items {{ display: flex; }}
250
+ #dpb-preview-bar .dpb-empty {{
251
+ display: none; padding: 10px 4px; font-size: 12px; color: var(--dpb-muted);
252
+ text-align: center;
253
+ }}
254
+ #dpb-preview-bar .dpb-anchors:not(.has-items) + .dpb-empty {{ display: block; }}
255
+ #dpb-preview-bar .dpb-anchor {{
256
+ display: grid; grid-template-columns: 18px minmax(0, 1fr) auto; gap: 8px;
257
+ align-items: start; padding: 6px 8px; border-radius: 8px;
258
+ background: var(--dpb-elev); border: 1px solid var(--dpb-line);
259
+ }}
260
+ #dpb-preview-bar .dpb-anchor .n {{
261
+ font-size: 11px; font-weight: 700; color: var(--dpb-accent); padding-top: 4px;
262
+ }}
263
+ #dpb-preview-bar .dpb-anchor .meta {{
264
+ min-width: 0; display: grid; gap: 4px;
265
+ }}
266
+ #dpb-preview-bar .dpb-anchor .label {{
267
+ font-size: 12px; font-weight: 650; color: var(--dpb-ink);
268
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
269
+ appearance: none; border: 0; background: transparent; padding: 0;
270
+ text-align: left; cursor: pointer; width: 100%;
271
+ display: block; min-width: 0;
272
+ }}
273
+ #dpb-preview-bar .dpb-anchor .label:hover {{ color: var(--dpb-accent); }}
274
+ #dpb-preview-bar .dpb-anchor .label:focus-visible {{
275
+ outline: 2px solid var(--dpb-accent); outline-offset: 1px; border-radius: 4px;
276
+ }}
277
+ #dpb-preview-bar .dpb-anchor .sel {{
278
+ font-size: 11px; color: var(--dpb-muted);
279
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
280
+ }}
281
+ #dpb-preview-bar .dpb-anchor input {{
282
+ width: 100%; box-sizing: border-box; height: 28px; margin: 0; padding: 0 8px;
283
+ border-radius: 6px; border: 1px solid var(--dpb-line-strong);
284
+ background: var(--dpb-surface); color: var(--dpb-ink); font: inherit; outline: none;
285
+ }}
286
+ #dpb-preview-bar .dpb-anchor input:focus {{
287
+ border-color: var(--dpb-accent); box-shadow: 0 0 0 2px rgba(20,184,166,.2);
288
+ }}
289
+ #dpb-preview-bar .dpb-anchor .rm {{
290
+ appearance: none; cursor: pointer; height: 28px; padding: 0 8px;
291
+ border: 0; border-radius: 6px; background: transparent; color: var(--dpb-muted);
292
+ font: 650 12px/1 system-ui, sans-serif;
293
+ }}
294
+ #dpb-preview-bar .dpb-anchor .rm:hover {{ color: var(--dpb-danger); background: rgba(248,113,113,.08); }}
295
+
296
+ /* overall comment */
297
+ #dpb-preview-bar .dpb-field {{ display: grid; gap: 6px; min-width: 0; }}
298
+ #dpb-preview-bar .dpb-label-row {{
299
+ display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
300
+ }}
301
+ #dpb-preview-bar .dpb-label {{ font-size: 12px; font-weight: 650; color: var(--dpb-muted); }}
302
+ /* I4: neutral hint up-front; .is-on only adds emphasis (was red-danger-only). */
303
+ #dpb-preview-bar .dpb-hint {{ margin: 0; font-size: 12px; color: var(--dpb-muted); display: block; }}
304
+ #dpb-preview-bar .dpb-hint.is-on {{ color: var(--dpb-danger); font-weight: 600; }}
305
+ /* I4: pill readiness indicator (live status on the resting pill). */
306
+ #dpb-preview-bar .dpb-pill .dpb-pill-ready {{
307
+ font-size: 11px; color: var(--dpb-muted); white-space: nowrap;
308
+ }}
309
+ #dpb-preview-bar .dpb-pill .dpb-pill-ready.is-ready {{
310
+ color: #5eead4; font-weight: 600;
311
+ }}
312
+ #dpb-preview-bar textarea[name="feedback"] {{
313
+ width: 100%; box-sizing: border-box; min-height: 56px; max-height: 120px;
314
+ resize: vertical; margin: 0; padding: 8px 12px; color: var(--dpb-ink);
315
+ background: var(--dpb-surface); border: 1px solid var(--dpb-line-strong);
316
+ border-radius: var(--dpb-radius); font: inherit; line-height: 1.45; outline: none;
317
+ transition: border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
318
+ }}
319
+ #dpb-preview-bar textarea[name="feedback"]::placeholder {{ color: #7d8698; }}
320
+ #dpb-preview-bar textarea[name="feedback"]:hover {{ border-color: #525c70; background: var(--dpb-elev); }}
321
+ #dpb-preview-bar textarea[name="feedback"]:focus {{
322
+ border-color: var(--dpb-accent); box-shadow: 0 0 0 3px rgba(20,184,166,.22); background: #12161e;
323
+ }}
324
+ #dpb-preview-bar textarea[name="feedback"][aria-invalid="true"] {{
325
+ border-color: #f87171; box-shadow: 0 0 0 3px rgba(248,113,113,.18);
326
+ }}
327
+
328
+ /* footer actions */
329
+ #dpb-preview-bar .dpb-drawer-foot {{
330
+ display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between;
331
+ align-items: center; padding: 12px 14px;
332
+ border-top: 1px solid var(--dpb-line);
333
+ background: linear-gradient(180deg, var(--dpb-bg), #131722);
334
+ }}
335
+ #dpb-preview-bar .dpb-btns {{
336
+ display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; align-items: center;
337
+ }}
338
+ #dpb-preview-bar .dpb-btn {{
339
+ appearance: none; cursor: pointer; box-sizing: border-box; height: var(--dpb-control-h);
340
+ min-width: 96px; padding: 0 16px; border-radius: var(--dpb-radius);
341
+ font: 650 13px/1 system-ui, -apple-system, "Segoe UI", sans-serif;
342
+ border: 1px solid transparent;
343
+ transition: background 140ms ease, border-color 140ms ease, color 140ms ease, transform 100ms ease;
344
+ }}
345
+ #dpb-preview-bar .dpb-btn:active {{ transform: translateY(1px); }}
346
+ #dpb-preview-bar .dpb-btn:focus-visible {{ outline: 2px solid var(--dpb-accent); outline-offset: 2px; }}
347
+ #dpb-preview-bar .dpb-btn-primary {{
348
+ background: var(--dpb-accent); color: var(--dpb-accent-ink); border-color: var(--dpb-accent-deep);
349
+ box-shadow: inset 0 1px 0 rgba(255,255,255,.18);
350
+ }}
351
+ #dpb-preview-bar .dpb-btn-primary:hover {{ background: #2dd4bf; color: #042f2e; }}
352
+ #dpb-preview-bar .dpb-btn-secondary {{
353
+ background: var(--dpb-elev); color: var(--dpb-ink); border-color: var(--dpb-line-strong);
354
+ }}
355
+ #dpb-preview-bar .dpb-btn-secondary:hover {{ background: #252b39; border-color: #5b6578; }}
356
+ #dpb-preview-bar .dpb-btn-quiet {{
357
+ min-width: 72px; background: transparent; color: var(--dpb-muted); border-color: transparent;
358
+ }}
359
+ #dpb-preview-bar .dpb-btn-quiet:hover {{ color: var(--dpb-ink); background: rgba(255,255,255,.04); }}
360
+ #dpb-preview-bar .dpb-btn-danger {{
361
+ min-width: 72px; background: transparent; color: var(--dpb-danger); border-color: transparent;
362
+ }}
363
+ #dpb-preview-bar .dpb-btn-danger:hover {{ color: #fff; background: var(--dpb-danger); border-color: var(--dpb-danger); }}
364
+ #dpb-preview-bar .dpb-btn-danger.is-armed {{ color: #fff; background: var(--dpb-danger); border-color: var(--dpb-danger); font-weight: 700; animation: dpb-abort-pulse 1s ease-in-out infinite; }}
365
+ /* ring pulse (not opacity) so the armed label stays at full contrast */
366
+ @keyframes dpb-abort-pulse {{ 0%,100% {{ box-shadow: 0 0 0 0 rgba(248,113,113,.55); }} 50% {{ box-shadow: 0 0 0 7px rgba(248,113,113,.12); }} }}
367
+ /* visually hidden live region (screen-reader announcements, e.g. abort arming) */
368
+ #dpb-preview-bar .dpb-sr-only {{
369
+ position: absolute; width: 1px; height: 1px; overflow: hidden;
370
+ clip-path: inset(50%); white-space: nowrap;
371
+ }}
372
+
373
+ /* floating annotation bubbles pinned to targeted elements.
374
+ z-index 999 < #dpb-preview-bar (1000): float-note sits above the prototype
375
+ page content but BELOW the bar/drawer, so pin bubbles never cover the
376
+ confirm controls (was 1001 -> covered drawer, d46682d follow-up). */
377
+ .dpb-float-note {{
378
+ position: absolute;
379
+ z-index: 999;
380
+ max-width: 240px;
381
+ min-width: 120px;
382
+ padding: 6px 10px 6px 8px;
383
+ border-radius: 10px;
384
+ background: #1f2430;
385
+ color: #f3f4f6;
386
+ border: 1px solid #2c3444;
387
+ box-shadow: 0 8px 24px rgba(0,0,0,.32), 0 2px 6px rgba(0,0,0,.18);
388
+ font: 12px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif;
389
+ pointer-events: none;
390
+ transform: translateY(4px);
391
+ opacity: 0;
392
+ transition: opacity 160ms ease, transform 160ms ease;
393
+ }}
394
+ .dpb-float-note.is-visible {{ opacity: 1; transform: translateY(0); }}
395
+ .dpb-float-note .dpb-float-n {{
396
+ position: absolute; top: -7px; left: -7px;
397
+ width: 18px; height: 18px; border-radius: 999px;
398
+ background: var(--dpb-accent); color: #042f2e;
399
+ font-size: 11px; font-weight: 700;
400
+ display: inline-grid; place-items: center;
401
+ box-shadow: 0 1px 3px rgba(0,0,0,.3);
402
+ }}
403
+ .dpb-float-note .dpb-float-label {{
404
+ display: block; font-weight: 650; color: #99f6e4;
405
+ font-size: 11px; margin-bottom: 2px; word-break: break-word;
406
+ }}
407
+ .dpb-float-note .dpb-float-comment {{
408
+ color: var(--dpb-soft); word-break: break-word; white-space: pre-wrap;
409
+ }}
410
+
411
+ .dpb-pin-target {{
412
+ outline: 1.5px solid rgba(20,184,166,.9) !important;
413
+ outline-offset: 1px !important;
414
+ background-color: rgba(20,184,166,.06) !important;
415
+ cursor: crosshair !important;
416
+ }}
417
+ .dpb-pin-hover {{
418
+ outline: 1px dashed rgba(20,184,166,.45) !important;
419
+ outline-offset: 1px !important;
420
+ }}
421
+ /* brief flash when an anchor is located from the list */
422
+ .dpb-pin-flash {{
423
+ animation: dpb-flash 900ms ease-out 1;
424
+ }}
425
+ @keyframes dpb-flash {{
426
+ 0% {{ box-shadow: 0 0 0 0 rgba(20,184,166,.55); outline-color: rgba(20,184,166,.9) !important; }}
427
+ 50% {{ box-shadow: 0 0 0 6px rgba(20,184,166,.25); }}
428
+ 100% {{ box-shadow: 0 0 0 0 rgba(20,184,166,0); }}
429
+ }}
430
+ body.dpb-pin-mode, body.dpb-pin-mode * {{ cursor: crosshair !important; }}
431
+ body.dpb-pin-mode #dpb-preview-bar, body.dpb-pin-mode #dpb-preview-bar * {{
432
+ cursor: default !important;
433
+ }}
434
+
435
+ @media (max-width: 720px) {{
436
+ #dpb-preview-bar {{ right: 12px; bottom: 12px; }}
437
+ #dpb-preview-bar .dpb-drawer {{ width: calc(100vw - 24px); }}
438
+ #dpb-preview-bar .dpb-pill .dpb-pill-info {{ display: none; }}
439
+ #dpb-preview-bar .dpb-drawer-body {{ padding: 10px 10px 14px; }}
440
+ #dpb-preview-bar .dpb-drawer-foot {{ justify-content: flex-start; flex-wrap: wrap; }}
441
+ #dpb-preview-bar .dpb-btn.dpb-btn-danger {{ flex: 0 0 auto; }}
442
+ #dpb-preview-bar .dpb-btns {{ flex: 1 1 auto; }}
443
+ #dpb-preview-bar .dpb-btns .dpb-btn {{ flex: 1 1 auto; min-width: 0; }}
444
+ }}
445
+ @media (prefers-reduced-motion: reduce) {{
446
+ #dpb-preview-bar .dpb-btn,
447
+ #dpb-preview-bar textarea[name="feedback"],
448
+ .dpb-float-note {{ transition: none; }}
449
+ #dpb-preview-bar .dpb-btn:active {{ transform: none; }}
450
+ .dpb-pin-flash {{ animation: none; }}
451
+ /* armed state still reads via solid danger fill + bold label, no pulse */
452
+ #dpb-preview-bar .dpb-btn-danger.is-armed {{ animation: none; }}
453
+ #dpb-preview-bar .dpb-pill .dpb-btn-primary.is-armed {{ animation: none; }}
454
+ }}
455
+ </style>
456
+ <div id="dpb-preview-bar" role="region" aria-label="{t_region}">
457
+ <form method="POST" action="/decide" id="dpb-decide-form">
458
+ <input type="hidden" name="anchors_json" id="dpb-anchors-json" value="[]" />
459
+
460
+ <!-- floating pill (default) -->
461
+ <div class="dpb-pill">
462
+ <span class="dpb-pill-info">
463
+ <span class="dpb-round">{t_round}</span>
464
+ <p class="dpb-summary" title="{summary_safe}">{summary_safe}</p>
465
+ </span>
466
+ <span class="dpb-pill-ready" id="dpb-pill-ready" role="status">{t_not_ready}</span>
467
+ <span class="dpb-pill-actions">
468
+ {pill_secondary_html}
469
+ <button type="button" class="dpb-btn-ghost" id="dpb-open-drawer" aria-haspopup="dialog">
470
+ {t_annotate}<span class="dpb-badge-dot" id="dpb-pill-count">0</span>
471
+ </button>
472
+ <button type="button" class="dpb-btn-primary" id="dpb-open-primary" aria-haspopup="dialog">{t_pill_open}</button>
473
+ </span>
474
+ <span class="dpb-sr-only" id="dpb-pill-arm-status" role="alert"></span>
475
+ </div>
476
+
477
+ <!-- drawer (expanded) -->
478
+ <dialog class="dpb-drawer" id="dpb-drawer" aria-label="{t_drawer_aria}">
479
+ <div class="dpb-drawer-head">
480
+ <span class="dpb-head-left">
481
+ <span class="dpb-round">{t_round}</span>
482
+ <h2 class="dpb-title">{summary_safe}</h2>
483
+ </span>
484
+ <button type="button" class="dpb-icon-btn" id="dpb-close-drawer" aria-label="{t_collapse}">−</button>
485
+ </div>
486
+ <div class="dpb-drawer-body">
487
+ <div class="dpb-pin-row">
488
+ <button type="button" class="dpb-pin-toggle" id="dpb-pin-toggle" aria-pressed="false">
489
+ <span aria-hidden="true">🎯</span><span class="dpb-pin-label">{t_pin_toggle}</span>
490
+ </button>
491
+ <span class="dpb-pin-count" id="dpb-pin-count">{t_pin_count}</span>
492
+ </div>
493
+
494
+ <div>
495
+ <p class="dpb-subhead">{t_anchors_head}</p>
496
+ <div class="dpb-anchors" id="dpb-anchors" aria-live="polite"></div>
497
+ <p class="dpb-empty">{t_anchors_empty}</p>
498
+ </div>
499
+
500
+ <div class="dpb-field">
501
+ <span class="dpb-label-row">
502
+ <span class="dpb-label">{t_field_label}</span>
503
+ <span class="dpb-hint" id="dpb-feedback-hint" role="alert">{t_field_hint}</span>
504
+ </span>
505
+ <textarea name="feedback" rows="2"
506
+ placeholder="{t_field_placeholder}"
507
+ autocomplete="off"></textarea>
508
+ </div>
509
+ </div>
510
+ <div class="dpb-drawer-foot">
511
+ <button type="submit" name="choice" value="__abort__" class="dpb-btn dpb-btn-danger" id="dpb-abort" title="{t_terminate_desc}" aria-description="{t_terminate_desc}">{t_terminate}</button>
512
+ <span class="dpb-sr-only" id="dpb-abort-status" role="alert"></span>
513
+ <div class="dpb-btns">
514
+ <button type="button" class="dpb-btn dpb-btn-quiet" id="dpb-draft" title="{t_draft_desc}" aria-description="{t_draft_desc}">{t_draft}</button>
515
+ {secondary_html}
516
+ <button type="submit" name="choice" value="{primary_val}" class="dpb-btn dpb-btn-primary" title="{t_confirm_desc}" aria-description="{t_confirm_desc}">{primary_label}</button>
517
+ </div>
518
+ </div>
519
+ </dialog>
520
+ </form>
521
+ </div>
522
+ <div id="dpb-preview-spacer" aria-hidden="true"></div>
523
+ <script>window.DPB_I18N = {i18n_json};</script>
524
+ <script>
525
+ (function () {{
526
+ var bar = document.getElementById("dpb-preview-bar");
527
+ var form = document.getElementById("dpb-decide-form");
528
+ if (!form) return;
529
+ var I18N = window.DPB_I18N || {{}};
530
+ var field = form.querySelector('textarea[name="feedback"]');
531
+ var hint = document.getElementById("dpb-feedback-hint");
532
+ var pinBtn = document.getElementById("dpb-pin-toggle");
533
+ var pinLabel = pinBtn ? pinBtn.querySelector(".dpb-pin-label") : null;
534
+ var listEl = document.getElementById("dpb-anchors");
535
+ var hidden = document.getElementById("dpb-anchors-json");
536
+ var openBtn = document.getElementById("dpb-open-drawer");
537
+ var closeBtn = document.getElementById("dpb-close-drawer");
538
+ var pinCountEl = document.getElementById("dpb-pin-count");
539
+ var pillCountEl = document.getElementById("dpb-pill-count");
540
+ var drawerEl = document.getElementById("dpb-drawer");
541
+ var pillReadyEl = document.getElementById("dpb-pill-ready");
542
+ var anchors = [];
543
+ var pinOn = false;
544
+ var hoverEl = null;
545
+ var floatRoot = null;
546
+ var floatMap = {{}}; // selector -> bubble element
547
+
548
+ function ensureFloatRoot() {{
549
+ if (floatRoot) return floatRoot;
550
+ floatRoot = document.createElement("div");
551
+ floatRoot.id = "dpb-float-root";
552
+ document.body.appendChild(floatRoot);
553
+ return floatRoot;
554
+ }}
555
+
556
+ function esc(s) {{
557
+ return String(s || "").replace(/[&<>"']/g, function (c) {{
558
+ return ({{"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}})[c];
559
+ }});
560
+ }}
561
+
562
+ function cssPath(el) {{
563
+ if (!el || el.nodeType !== 1) return "";
564
+ if (el.id) return "#" + CSS.escape(el.id);
565
+ var parts = [];
566
+ var cur = el;
567
+ var depth = 0;
568
+ while (cur && cur.nodeType === 1 && cur !== document.documentElement && depth < 8) {{
569
+ if (cur.id === "dpb-preview-bar" || cur.id === "dpb-preview-spacer" || cur.id === "dpb-float-root") break;
570
+ var part = cur.tagName.toLowerCase();
571
+ if (cur.classList && cur.classList.length) {{
572
+ var cls = Array.prototype.slice.call(cur.classList, 0, 2)
573
+ .filter(function (c) {{ return c && c.indexOf("dpb-") !== 0; }})
574
+ .map(function (c) {{ return "." + CSS.escape(c); }})
575
+ .join("");
576
+ part += cls;
577
+ }}
578
+ var parent = cur.parentElement;
579
+ if (parent) {{
580
+ var kids = parent.children;
581
+ var n = 0, idx = 0, i;
582
+ for (i = 0; i < kids.length; i++) {{
583
+ if (kids[i].tagName === cur.tagName) {{
584
+ n++;
585
+ if (kids[i] === cur) idx = n;
586
+ }}
587
+ }}
588
+ if (n > 1) part += ":nth-of-type(" + idx + ")";
589
+ }}
590
+ parts.unshift(part);
591
+ if (cur.tagName === "BODY") break;
592
+ cur = parent;
593
+ depth++;
594
+ }}
595
+ return parts.join(" > ");
596
+ }}
597
+
598
+ function labelFor(el) {{
599
+ var t = (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ");
600
+ if (t.length > 40) t = t.slice(0, 40) + "…";
601
+ if (t) return el.tagName.toLowerCase() + ' "' + t + '"';
602
+ if (el.getAttribute("aria-label")) return el.tagName.toLowerCase() + " " + el.getAttribute("aria-label");
603
+ if (el.id) return "#" + el.id;
604
+ if (el.className && typeof el.className === "string") return el.tagName.toLowerCase() + "." + el.className.trim().split(/\s+/)[0];
605
+ return el.tagName.toLowerCase();
606
+ }}
607
+
608
+ function labelForTag(tag, selector) {{
609
+ // Cross-origin anchor (el is null): the parent cannot read innerText or
610
+ // aria-label from the sandboxed iframe DOM, so derive a readable label from
611
+ // the tag + the leaf segment of the cssPath selector the bridge sent.
612
+ var parts = String(selector || "").split(/\s*>\s*/);
613
+ var leaf = parts.length ? parts[parts.length - 1] : String(selector || "");
614
+ if (leaf.charAt(0) === "#") return leaf;
615
+ return (tag || "element") + " " + leaf;
616
+ }}
617
+
618
+ function syncHidden() {{
619
+ hidden.value = JSON.stringify(anchors.map(function (a) {{
620
+ return {{ selector: a.selector, label: a.label, comment: a.comment, tag: a.tag }};
621
+ }}));
622
+ }}
623
+
624
+ function positionFloat(a, idx) {{
625
+ // ponytail: orphan guard — if target element left the DOM, drop the bubble
626
+ if (!a.el || !a.el.isConnected) {{ removeBubble(a.selector); return; }}
627
+ var bubble = floatMap[a.selector];
628
+ if (!bubble) return;
629
+ var rect = a.el.getBoundingClientRect();
630
+ var root = ensureFloatRoot();
631
+ var nEl = bubble.querySelector(".dpb-float-n");
632
+ if (nEl) nEl.textContent = String(idx + 1);
633
+ bubble.style.left = (window.scrollX + rect.right + 8) + "px";
634
+ var top = window.scrollY + rect.top;
635
+ // keep on screen
636
+ var maxTop = window.scrollY + window.innerHeight - 60;
637
+ bubble.style.top = Math.min(top, maxTop) + "px";
638
+ // flip to left if overflows right
639
+ if (rect.right + 260 > window.innerWidth) {{
640
+ bubble.style.left = (window.scrollX + rect.left - bubble.offsetWidth - 8) + "px";
641
+ }}
642
+ }}
643
+
644
+ function ensureBubble(a, idx) {{
645
+ if (!a.el || !a.el.isConnected) {{ removeBubble(a.selector); return; }}
646
+ if (!floatMap[a.selector]) {{
647
+ var bubble = document.createElement("div");
648
+ bubble.className = "dpb-float-note";
649
+ bubble.setAttribute("role", "status");
650
+ bubble.innerHTML =
651
+ '<span class="dpb-float-n" aria-hidden="true">' + (idx + 1) + "</span>" +
652
+ '<span class="dpb-float-label">' + esc(a.label) + "</span>" +
653
+ '<span class="dpb-float-comment"></span>';
654
+ ensureFloatRoot().appendChild(bubble);
655
+ floatMap[a.selector] = bubble;
656
+ }}
657
+ var bubble = floatMap[a.selector];
658
+ var commentEl = bubble.querySelector(".dpb-float-comment");
659
+ commentEl.textContent = a.comment || "";
660
+ bubble.style.display = a.comment ? "block" : "none";
661
+ if (a.comment) {{
662
+ // position after layout
663
+ requestAnimationFrame(function () {{
664
+ positionFloat(a, idx);
665
+ bubble.classList.add("is-visible");
666
+ }});
667
+ }} else {{
668
+ bubble.classList.remove("is-visible");
669
+ }}
670
+ }}
671
+
672
+ function removeBubble(selector) {{
673
+ var bubble = floatMap[selector];
674
+ if (bubble) {{
675
+ bubble.remove();
676
+ delete floatMap[selector];
677
+ }}
678
+ }}
679
+
680
+ function render() {{
681
+ listEl.innerHTML = "";
682
+ if (!anchors.length) {{
683
+ listEl.classList.remove("has-items");
684
+ syncHidden();
685
+ updateCounts();
686
+ return;
687
+ }}
688
+ listEl.classList.add("has-items");
689
+ anchors.forEach(function (a, idx) {{
690
+ var row = document.createElement("div");
691
+ row.className = "dpb-anchor";
692
+ row.innerHTML =
693
+ '<span class="n">' + (idx + 1) + "</span>" +
694
+ '<div class="meta">' +
695
+ '<button type="button" class="label" data-locate="' + idx + '" title="' + esc(I18N.locate) + '" aria-label="' + esc(I18N.locate_anchor) + ' ' + (idx + 1) + '">' + esc(a.label) + "</button>" +
696
+ '<div class="sel" title="' + esc(a.selector) + '">' + esc(a.selector) + "</div>" +
697
+ '<input type="text" data-i="' + idx + '" aria-label="' + esc(I18N.anchor_num_pre) + (idx + 1) + esc(I18N.anchor_num_post) + '" placeholder="' + esc(I18N.anchor_placeholder) + '" value="' + esc(a.comment) + '" />' +
698
+ "</div>" +
699
+ '<button type="button" class="rm" data-rm="' + idx + '" aria-label="' + esc(I18N.remove_num_pre) + (idx + 1) + '">' + esc(I18N.remove) + '</button>';
700
+ listEl.appendChild(row);
701
+ ensureBubble(a, idx);
702
+ }});
703
+ syncHidden();
704
+ updateCounts();
705
+ }}
706
+
707
+ function updateCounts() {{
708
+ var n = anchors.length;
709
+ if (pinCountEl) pinCountEl.textContent = (I18N.pin_count_pre || "") + n + (I18N.pin_count_post || "");
710
+ if (pillCountEl) {{
711
+ pillCountEl.textContent = String(n);
712
+ pillCountEl.classList.toggle("is-on", n > 0);
713
+ }}
714
+ setReadiness();
715
+ }}
716
+
717
+ // I4: ADR-0008 substantive predicate (mirror of adapter floor) + live readiness.
718
+ // Structural only, no minimum length (ADR-0008: semantic junk is G6's job).
719
+ function isSubstantive() {{
720
+ var value = (field && field.value || "").trim();
721
+ var anchorsComplete = !anchors.length || anchors.every(function (a) {{
722
+ return (a && (a.selector || "").trim() && (a.comment || "").trim());
723
+ }});
724
+ return (value.length > 0 || anchors.length) && anchorsComplete;
725
+ }}
726
+ var lastReady = null;
727
+ var pillOpenLabel = null; // I13: original pill-primary label, restored on ready->not-ready flip
728
+ var openPrimary = document.getElementById("dpb-open-primary");
729
+ // Pill direct-confirm 二级保护: first click arms, second submits (mirrors abort arm).
730
+ var CONFIRM_ARM_MS = 4000;
731
+ var pillConfirmArmed = false;
732
+ var pillConfirmTimer = null;
733
+ var pillConfirmReadyLabel = null; // label while ready (pre-arm)
734
+ var pillArmStatus = document.getElementById("dpb-pill-arm-status");
735
+ function resetPillConfirmArmed(announceCancel) {{
736
+ if (pillConfirmTimer) {{ clearTimeout(pillConfirmTimer); pillConfirmTimer = null; }}
737
+ var wasArmed = pillConfirmArmed;
738
+ if (openPrimary && pillConfirmArmed) {{
739
+ pillConfirmArmed = false;
740
+ openPrimary.classList.remove("is-armed");
741
+ if (pillConfirmReadyLabel !== null) openPrimary.textContent = pillConfirmReadyLabel;
742
+ }}
743
+ if (pillArmStatus) {{
744
+ pillArmStatus.textContent = (announceCancel && wasArmed)
745
+ ? (I18N.confirm_cancelled || "")
746
+ : "";
747
+ }}
748
+ }}
749
+ function setReadiness() {{
750
+ if (!pillReadyEl) return;
751
+ var ready = isSubstantive();
752
+ pillReadyEl.classList.toggle("is-ready", ready);
753
+ if (ready === lastReady) return; // P1.6: cache to avoid screen-reader jitter on every input
754
+ lastReady = ready;
755
+ if (ready) {{
756
+ pillReadyEl.textContent = I18N.ready || "";
757
+ if (openPrimary) {{
758
+ if (pillOpenLabel === null) pillOpenLabel = openPrimary.textContent; // I13: capture once
759
+ openPrimary.classList.add('is-direct-confirm');
760
+ openPrimary.removeAttribute('aria-haspopup'); // P1.1: direct confirm, no longer a dialog trigger
761
+ // Mirror the confirm button label so user sees the action will confirm directly
762
+ var drawerPrimary = document.querySelector('.dpb-drawer .dpb-btn-primary');
763
+ if (drawerPrimary && drawerPrimary.textContent && openPrimary.textContent !== drawerPrimary.textContent) {{
764
+ openPrimary.textContent = drawerPrimary.textContent;
765
+ }}
766
+ pillConfirmReadyLabel = openPrimary.textContent; // snapshot for arm restore
767
+ }}
768
+ }} else {{
769
+ resetPillConfirmArmed(); // readiness flip undoes any pending arm
770
+ pillReadyEl.textContent = I18N.not_ready || "";
771
+ if (openPrimary) {{
772
+ openPrimary.classList.remove('is-direct-confirm');
773
+ openPrimary.setAttribute('aria-haspopup', 'dialog'); // P1.1: restore dialog trigger
774
+ // I13: restore original label so the pill no longer advertises direct confirm
775
+ if (pillOpenLabel !== null && openPrimary.textContent !== pillOpenLabel) {{
776
+ openPrimary.textContent = pillOpenLabel;
777
+ }}
778
+ }}
779
+ pillConfirmReadyLabel = null;
780
+ }}
781
+ }}
782
+
783
+ function clearHover() {{
784
+ if (hoverEl) {{
785
+ hoverEl.classList.remove("dpb-pin-hover");
786
+ hoverEl = null;
787
+ }}
788
+ }}
789
+
790
+ function setPin(on) {{
791
+ pinOn = !!on;
792
+ document.body.classList.toggle("dpb-pin-mode", pinOn);
793
+ if (pinBtn) {{
794
+ pinBtn.classList.toggle("is-on", pinOn);
795
+ pinBtn.setAttribute("aria-pressed", pinOn ? "true" : "false");
796
+ }}
797
+ if (pinLabel) pinLabel.textContent = pinOn ? (I18N.pin_on || "") : (I18N.pin_off || "");
798
+ if (!pinOn) clearHover();
799
+ }}
800
+
801
+ var lastFocus = null;
802
+ function openDrawer() {{
803
+ lastFocus = document.activeElement;
804
+ resetPillConfirmArmed(); // drawer open: pill is hidden; drop any pending confirm arm
805
+ // Use non-modal <dialog>.show() (NOT showModal): the drawer must NOT make the
806
+ // page inert, because pin-to-annotate requires clicking prototype elements
807
+ // behind the drawer. ::backdrop (modal only) is replaced by the .is-open::before
808
+ // scrim below. ESC + focus-restore handled manually.
809
+ // is-open is the reliable open signal (scrim/pill + CSS display fallback when
810
+ // dialog.show is missing or throws — drawer must not depend on dialog[open]).
811
+ if (drawerEl && typeof drawerEl.show === "function") {{
812
+ try {{ if (!drawerEl.open) drawerEl.show(); }} catch (e) {{ /* fall through to is-open */ }}
813
+ }}
814
+ bar.classList.add("is-open");
815
+ setTimeout(function () {{ if (closeBtn) closeBtn.focus(); }}, 0);
816
+ }}
817
+ function closeDrawer() {{
818
+ bar.classList.remove("is-open");
819
+ resetAbortArmed(); // I18: clear abort arming when drawer closes (no stale armed state on reopen)
820
+ resetPillConfirmArmed(); // pill reappears disarmed
821
+ if (pinOn) setPin(false);
822
+ if (drawerEl && drawerEl.open && typeof drawerEl.close === "function") drawerEl.close();
823
+ if (lastFocus && typeof lastFocus.focus === "function") lastFocus.focus();
824
+ }}
825
+
826
+ // I2: 批注 (annotate) and the not-ready pill primary used to both just open
827
+ // the drawer. Annotate now drops straight into element picking, so the two
828
+ // buttons carry distinct intents (annotate elements vs. review/confirm).
829
+ if (openBtn) openBtn.addEventListener("click", function () {{
830
+ openDrawer();
831
+ setPin(true);
832
+ }});
833
+ // Shared: submit the confirm (drawer primary). Used by pill-primary direct
834
+ // confirm (handlePillPrimary) and Ctrl+Enter (I8) to avoid divergent targets.
835
+ function submitPrimary() {{
836
+ var targetBtn = document.querySelector(".dpb-drawer .dpb-btn-primary");
837
+ if (targetBtn) form.requestSubmit(targetBtn); else form.requestSubmit();
838
+ }}
839
+ function handlePillPrimary(e) {{
840
+ if (isSubstantive()) {{
841
+ // 二级保护: ready pill primary is arm → confirm (not one-click submit).
842
+ // Accidental click on a resting "确认通过" must not kill the review session.
843
+ e.preventDefault();
844
+ if (!pillConfirmArmed) {{
845
+ pillConfirmArmed = true;
846
+ if (pillConfirmReadyLabel === null) pillConfirmReadyLabel = openPrimary.textContent;
847
+ openPrimary.classList.add("is-armed");
848
+ openPrimary.textContent = I18N.confirm_confirm || "";
849
+ if (pillArmStatus) pillArmStatus.textContent = I18N.confirm_confirm || "";
850
+ pillConfirmTimer = setTimeout(function () {{ resetPillConfirmArmed(true); }}, CONFIRM_ARM_MS);
851
+ return;
852
+ }}
853
+ resetPillConfirmArmed();
854
+ submitPrimary();
855
+ }} else {{
856
+ resetPillConfirmArmed();
857
+ openDrawer();
858
+ }}
859
+ }}
860
+ if (openPrimary) openPrimary.addEventListener("click", handlePillPrimary);
861
+ var pillReviseBtns = bar.querySelectorAll('[data-pill-revise]');
862
+ for (var ri = 0; ri < pillReviseBtns.length; ri++) {{
863
+ pillReviseBtns[ri].addEventListener("click", function () {{
864
+ resetPillConfirmArmed();
865
+ openDrawer();
866
+ setTimeout(function () {{ if (field) field.focus(); }}, 0);
867
+ }});
868
+ }}
869
+ if (closeBtn) closeBtn.addEventListener("click", closeDrawer);
870
+ if (pinBtn) pinBtn.addEventListener("click", function () {{ setPin(!pinOn); }});
871
+ // P1.5: click on scrim (outside drawer) closes the drawer. Fires only when pin
872
+ // is OFF (CSS sets scrim pointer-events:none while body.dpb-pin-mode is on, so
873
+ // pin-on clicks reach the page for element selection, not the scrim).
874
+ bar.addEventListener("click", function (e) {{
875
+ if (bar.classList.contains("is-open") && !pinOn && e.target === bar) closeDrawer();
876
+ }});
877
+
878
+ // Draft: keep current feedback/anchors in the form and close without a
879
+ // confirm/revise decision (per debate); nothing is submitted or persisted.
880
+ var draftBtn = document.getElementById("dpb-draft");
881
+ if (draftBtn) draftBtn.addEventListener("click", function () {{ closeDrawer(); }});
882
+
883
+ // I8: Ctrl/Cmd+Enter in the feedback textarea submits the confirm (primary) action
884
+ if (field) {{
885
+ field.addEventListener("keydown", function (e) {{
886
+ if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {{
887
+ e.preventDefault();
888
+ submitPrimary();
889
+ }}
890
+ }});
891
+ }}
892
+
893
+ // I18: abort requires a second click within the confirm window (prevents accidental
894
+ // session kill). First click arms the button for ABORT_ARM_MS; second click submits __abort__.
895
+ // Armed state is announced to screen readers via the #dpb-abort-status alert
896
+ // region (the visible textContent swap alone is not reliably announced).
897
+ // 4000ms: at 2000ms a hesitant user's second click landed AFTER expiry and
898
+ // just re-armed, so the button felt dead no matter how often it was clicked.
899
+ var ABORT_ARM_MS = 4000;
900
+ var abortBtn = document.getElementById("dpb-abort");
901
+ var abortStatus = document.getElementById("dpb-abort-status");
902
+ var abortArmed = false;
903
+ var abortTimer = null;
904
+ var abortLabel = "";
905
+ // announceCancel: when true (4s timeout), write "cancelled" to the sr-only
906
+ // status so screen readers hear the arm expire; other paths clear silently.
907
+ function resetAbortArmed(announceCancel) {{
908
+ if (abortTimer) {{ clearTimeout(abortTimer); abortTimer = null; }}
909
+ var wasArmed = abortArmed;
910
+ if (abortBtn && abortArmed) {{
911
+ abortArmed = false;
912
+ abortBtn.textContent = abortLabel;
913
+ abortBtn.classList.remove("is-armed");
914
+ }}
915
+ if (abortStatus) {{
916
+ abortStatus.textContent = (announceCancel && wasArmed)
917
+ ? (I18N.abort_cancelled || "")
918
+ : "";
919
+ }}
920
+ }}
921
+ if (abortBtn) {{
922
+ abortLabel = abortBtn.textContent;
923
+ abortBtn.addEventListener("click", function (e) {{
924
+ if (!abortArmed) {{
925
+ e.preventDefault();
926
+ abortArmed = true;
927
+ abortBtn.textContent = I18N.terminate_confirm || "";
928
+ abortBtn.classList.add("is-armed");
929
+ if (abortStatus) abortStatus.textContent = I18N.terminate_confirm || "";
930
+ abortTimer = setTimeout(function () {{ resetAbortArmed(true); }}, ABORT_ARM_MS);
931
+ }} // else: let the submit proceed (choice=__abort__)
932
+ }});
933
+ }}
934
+ // MEDIUM: clicking elsewhere in the drawer cancels abort arming (not just
935
+ // ESC / 4s timeout); the abort button's own click is excluded via closest.
936
+ if (drawerEl) drawerEl.addEventListener("click", function (e) {{
937
+ if (abortArmed && abortBtn && !e.target.closest("#dpb-abort")) {{
938
+ resetAbortArmed();
939
+ }}
940
+ }});
941
+
942
+ // Initial readiness (may enable direct confirm on pill primary)
943
+ setReadiness();
944
+
945
+ // non-modal <dialog>.show() has no native focus trap / ESC. Mirror close +
946
+ // trap Tab inside the drawer when pin is OFF; when pin is ON, Tab must reach
947
+ // the prototype so keyboard users can move focus onto page elements.
948
+ if (drawerEl) drawerEl.addEventListener("close", function () {{
949
+ bar.classList.remove("is-open");
950
+ resetAbortArmed(); // I18: defense-in-depth - clear arming if a future path closes the dialog directly
951
+ if (pinOn) setPin(false);
952
+ }});
953
+ function drawerFocusables() {{
954
+ if (!drawerEl) return [];
955
+ var sel = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
956
+ var nodes = drawerEl.querySelectorAll(sel);
957
+ return Array.prototype.filter.call(nodes, function (el) {{
958
+ return !el.hasAttribute("disabled") && el.tabIndex !== -1 &&
959
+ (el.offsetWidth > 0 || el.offsetHeight > 0 || el === document.activeElement);
960
+ }});
961
+ }}
962
+ document.addEventListener("keydown", function (e) {{
963
+ if (e.key === "Escape") {{
964
+ if (pinOn) {{ setPin(false); return; }}
965
+ if (bar.classList.contains("is-open")) {{ e.preventDefault(); closeDrawer(); }}
966
+ return;
967
+ }}
968
+ if (e.key !== "Tab") return;
969
+ if (!bar.classList.contains("is-open") || pinOn) return; // pinOn: let Tab escape to prototype
970
+ var list = drawerFocusables();
971
+ if (!list.length) return;
972
+ var first = list[0];
973
+ var last = list[list.length - 1];
974
+ var active = document.activeElement;
975
+ var outside = !drawerEl.contains(active);
976
+ if (e.shiftKey) {{
977
+ if (outside || active === first) {{ e.preventDefault(); last.focus(); }}
978
+ }} else {{
979
+ if (outside || active === last) {{ e.preventDefault(); first.focus(); }}
980
+ }}
981
+ }});
982
+
983
+ document.addEventListener("mousemove", function (e) {{
984
+ if (!pinOn) return;
985
+ var el = e.target;
986
+ if (!el || el.closest("#dpb-preview-bar") || el.closest("#dpb-preview-spacer") || el.closest("#dpb-float-root")) {{
987
+ clearHover();
988
+ return;
989
+ }}
990
+ if (el === document.body || el === document.documentElement) {{
991
+ clearHover();
992
+ return;
993
+ }}
994
+ if (hoverEl !== el) {{
995
+ clearHover();
996
+ hoverEl = el;
997
+ hoverEl.classList.add("dpb-pin-hover");
998
+ }}
999
+ }}, true);
1000
+
1001
+ document.addEventListener("click", function (e) {{
1002
+ if (!pinOn) return;
1003
+ var raw = e.target;
1004
+ if (!raw || raw.closest("#dpb-preview-bar") || raw.closest("#dpb-preview-spacer") || raw.closest("#dpb-float-root")) return;
1005
+ if (raw === document.body || raw === document.documentElement) return;
1006
+ e.preventDefault();
1007
+ e.stopPropagation();
1008
+ // anchor to the hovered element, not the inner node that received the click
1009
+ var el = (hoverEl && hoverEl.contains(raw)) ? hoverEl : raw;
1010
+ var selector = cssPath(el);
1011
+ if (!selector) return;
1012
+ // de-dupe by selector; clear stale highlight on any previous element ref
1013
+ for (var i = 0; i < anchors.length; i++) {{
1014
+ if (anchors[i].selector === selector) {{
1015
+ if (anchors[i].el && anchors[i].el !== el) anchors[i].el.classList.remove("dpb-pin-target");
1016
+ anchors[i].el = el;
1017
+ el.classList.add("dpb-pin-target");
1018
+ render();
1019
+ return;
1020
+ }}
1021
+ }}
1022
+ el.classList.add("dpb-pin-target");
1023
+ anchors.push({{
1024
+ selector: selector,
1025
+ label: labelFor(el),
1026
+ comment: "",
1027
+ tag: el.tagName.toLowerCase(),
1028
+ el: el
1029
+ }});
1030
+ render();
1031
+ // focus newest comment input
1032
+ setTimeout(function () {{
1033
+ var inputs = listEl.querySelectorAll("input[data-i]");
1034
+ if (inputs.length) inputs[inputs.length - 1].focus();
1035
+ }}, 0);
1036
+ }}, true);
1037
+
1038
+ // G5 sandbox bridge: accept pin anchors postMessaged from the cross-origin
1039
+ // prototype iframe (opaque origin). The iframe cannot read the parent DOM or
1040
+ // the hidden decision token; it only sends {{selector, tag}}. Filter by
1041
+ // pinOn so the always-on bridge only records while the user is annotating
1042
+ // (no pin-state sync needed). el is null cross-origin — the iframe highlights
1043
+ // the element itself (dpb-pin-target), and existing el-guarded code
1044
+ // (positionFloat/ensureBubble/locate) already tolerates el === null.
1045
+ window.addEventListener("message", function (e) {{
1046
+ if (!pinOn) return;
1047
+ var data = e.data;
1048
+ if (!data || !data.dpbPinAnchor) return;
1049
+ var a = data.dpbPinAnchor;
1050
+ var selector = String(a.selector || "");
1051
+ var tag = String(a.tag || "").toLowerCase();
1052
+ if (!selector) return;
1053
+ // de-dupe by selector; no el/classList work (cross-origin)
1054
+ for (var i = 0; i < anchors.length; i++) {{
1055
+ if (anchors[i].selector === selector) return;
1056
+ }}
1057
+ anchors.push({{
1058
+ selector: selector,
1059
+ label: labelForTag(tag, selector),
1060
+ comment: "",
1061
+ tag: tag,
1062
+ el: null
1063
+ }});
1064
+ render();
1065
+ setTimeout(function () {{
1066
+ var inputs = listEl.querySelectorAll("input[data-i]");
1067
+ if (inputs.length) inputs[inputs.length - 1].focus();
1068
+ }}, 0);
1069
+ }});
1070
+
1071
+ listEl.addEventListener("input", function (e) {{
1072
+ var t = e.target;
1073
+ if (!t || !t.getAttribute) return;
1074
+ if (t.getAttribute("data-i") == null) return;
1075
+ var i = Number(t.getAttribute("data-i"));
1076
+ anchors[i].comment = t.value;
1077
+ syncHidden();
1078
+ ensureBubble(anchors[i], i);
1079
+ setReadiness();
1080
+ }});
1081
+
1082
+ listEl.addEventListener("click", function (e) {{
1083
+ var t = e.target;
1084
+ if (!t || !t.getAttribute) return;
1085
+ var loc = t.getAttribute("data-locate");
1086
+ if (loc != null) {{
1087
+ e.preventDefault();
1088
+ var a = anchors[Number(loc)];
1089
+ if (a && a.el && a.el.isConnected) {{
1090
+ a.el.scrollIntoView({{ behavior: "smooth", block: "center" }});
1091
+ a.el.classList.remove("dpb-pin-flash");
1092
+ // force reflow so the animation can restart
1093
+ void a.el.offsetWidth;
1094
+ a.el.classList.add("dpb-pin-flash");
1095
+ }}
1096
+ return;
1097
+ }}
1098
+ var rm = t.getAttribute("data-rm");
1099
+ if (rm == null) return;
1100
+ var i = Number(rm);
1101
+ var a = anchors[i];
1102
+ if (a) {{
1103
+ if (a.el) a.el.classList.remove("dpb-pin-target");
1104
+ removeBubble(a.selector);
1105
+ }}
1106
+ anchors.splice(i, 1);
1107
+ render();
1108
+ setReadiness();
1109
+ }});
1110
+
1111
+ // reposition floats on scroll/resize
1112
+ function repositionAll() {{
1113
+ anchors.forEach(function (a, idx) {{ positionFloat(a, idx); }});
1114
+ }}
1115
+ window.addEventListener("scroll", repositionAll, true);
1116
+ window.addEventListener("resize", repositionAll);
1117
+
1118
+ // ADR-0008: confirm requires substantive feedback too -
1119
+ // (non-empty feedback OR >=1 anchor) AND all anchors complete.
1120
+ // Frontend mirrors adapter _check_feedback_floor via isSubstantive().
1121
+ var reviseLabels = {{{t_revise_labels}}};
1122
+ form.addEventListener("submit", function (e) {{
1123
+ syncHidden();
1124
+ var submitter = e.submitter;
1125
+ var choice = submitter && submitter.name === "choice" ? submitter.value : "";
1126
+ if (!choice || choice === "__abort__") return;
1127
+ var isRevise = !!reviseLabels[choice] || /修改|revise|change/i.test(choice);
1128
+ // For revise actions (e.g. "需要修改"), allow even without substantive feedback (the point is to request changes).
1129
+ // Only enforce floor for actual confirm actions.
1130
+ if (isRevise || isSubstantive()) {{
1131
+ if (field) field.removeAttribute("aria-invalid");
1132
+ if (hint) hint.classList.remove("is-on");
1133
+ return;
1134
+ }}
1135
+ e.preventDefault();
1136
+ if (field) {{ field.setAttribute("aria-invalid", "true"); field.focus(); }}
1137
+ if (hint) hint.classList.add("is-on");
1138
+ if (!bar.classList.contains("is-open")) openDrawer();
1139
+ // I1: do NOT force pin mode on - that was an intent guess; the user may want
1140
+ // overall feedback, not element selection.
1141
+ }});
1142
+ if (field) {{
1143
+ field.addEventListener("input", function () {{
1144
+ if (isSubstantive()) {{
1145
+ field.removeAttribute("aria-invalid");
1146
+ if (hint) hint.classList.remove("is-on");
1147
+ }}
1148
+ setReadiness();
1149
+ }});
1150
+ }}
1151
+ }})();
1152
+ </script>
1153
+ """
1154
+
1155
+
1156
+
1157
+ def _build_control(round_n: int, summary: str, options: list[str]) -> str:
1158
+ """Build the injected confirm control-bar HTML (ADR-0008 floor-aware).
1159
+
1160
+ Shared by _collect_via_browser (runtime) and test_floor_frontend
1161
+ (playwright) so the option/button markup is not duplicated across them.
1162
+ """
1163
+ confirm_cf = {c.casefold() for c in CONFIRM_LABELS}
1164
+ revise_cf = {r.casefold() for r in REVISE_LABELS}
1165
+
1166
+ def display_label(opt: str) -> str:
1167
+ """Render known confirm/revise labels in the ACTIVE locale.
1168
+
1169
+ The submitted value stays the raw option (CONFIRM/REVISE union sets
1170
+ still classify it); only the visible label is localized, so options
1171
+ a caller copied from another locale's docs cannot mix languages into
1172
+ an otherwise locale-consistent control bar.
1173
+ """
1174
+ if opt in CONFIRM_LABELS or opt.casefold() in confirm_cf:
1175
+ return t("confirm")
1176
+ if opt in REVISE_LABELS or opt.casefold() in revise_cf:
1177
+ return t("revise")
1178
+ return opt
1179
+
1180
+ # JS object literal of revise labels across all locales (frontend classifies
1181
+ # a revise regardless of UI language). Keys are JSON-quoted/escaped.
1182
+ revise_js = ", ".join(
1183
+ f"{json.dumps(lbl, ensure_ascii=False)}: 1" for lbl in sorted(REVISE_LABELS))
1184
+ primary_bits: list[str] = []
1185
+ secondary_bits: list[str] = []
1186
+ confirm_desc = html_lib.escape(t("confirm_desc"), quote=True)
1187
+ revise_desc = html_lib.escape(t("revise_desc"), quote=True)
1188
+ for opt in options:
1189
+ safe_val = html_lib.escape(opt, quote=True)
1190
+ safe_label = html_lib.escape(display_label(opt))
1191
+ primary = opt in CONFIRM_LABELS or opt.casefold() in confirm_cf
1192
+ is_revise = opt in REVISE_LABELS or opt.casefold() in revise_cf
1193
+ cls = "dpb-btn dpb-btn-primary" if primary else "dpb-btn dpb-btn-secondary"
1194
+ desc = confirm_desc if primary else (revise_desc if is_revise else "")
1195
+ desc_attr = f' title="{desc}" aria-description="{desc}"' if desc else ""
1196
+ bit = (
1197
+ f'<button type="submit" name="choice" value="{safe_val}" class="{cls}"'
1198
+ f"{desc_attr}>{safe_label}</button>"
1199
+ )
1200
+ (primary_bits if primary else secondary_bits).append(bit)
1201
+ secondary_html = "\n".join(secondary_bits)
1202
+ # secondary actions surfaced on the floating pill (so revise is not hidden in the drawer)
1203
+ # Second replace rewrites the full class attr; a third partial replace would be dead.
1204
+ pill_secondary_html = "\n".join(
1205
+ b.replace('type="submit" name="choice"', 'type="button" data-pill-revise')
1206
+ .replace('class="dpb-btn dpb-btn-secondary"', 'class="dpb-btn-pill-secondary"')
1207
+ for b in secondary_bits
1208
+ )
1209
+ summary_safe = html_lib.escape(summary)
1210
+ primary_opt = next(
1211
+ (o for o in options if o in CONFIRM_LABELS or o.casefold() in confirm_cf),
1212
+ options[0] if options else t("confirm"),
1213
+ )
1214
+ primary_val = html_lib.escape(primary_opt, quote=True)
1215
+ primary_label = html_lib.escape(display_label(primary_opt))
1216
+ # JS-side strings: inject via JSON script (not .format into JS literals).
1217
+ # Translations with quotes, braces, or "/{" must not break JS or raise KeyError.
1218
+ # HTML {t_xxx} placeholders stay on .format (html.escape-safe static chrome).
1219
+ JS_KEYS = (
1220
+ "locate",
1221
+ "locate_anchor",
1222
+ "anchor_num_pre",
1223
+ "anchor_num_post",
1224
+ "anchor_placeholder",
1225
+ "remove_num_pre",
1226
+ "remove",
1227
+ "pin_count_pre",
1228
+ "pin_count_post",
1229
+ "ready",
1230
+ "not_ready",
1231
+ "pin_on",
1232
+ "pin_off",
1233
+ "terminate_confirm",
1234
+ "abort_cancelled", # 4s-timeout a11y broadcast (window.DPB_I18N.abort_cancelled)
1235
+ "confirm_confirm", # pill direct-confirm arm label
1236
+ "confirm_cancelled", # 4s-timeout a11y broadcast for pill arm undo
1237
+ )
1238
+ # json.dumps is JS-safe for quotes/backslashes; also neutralize </script>
1239
+ # and U+2028/2029 (pre-ES2019 JS string breaks) in case translations ever
1240
+ # carry them — defense, not a current risk.
1241
+ i18n_json = (
1242
+ json.dumps({k: t(k) for k in JS_KEYS}, ensure_ascii=False)
1243
+ .replace("</", "<\\/")
1244
+ .replace("
", "\\u2028")
1245
+ .replace("
", "\\u2029")
1246
+ )
1247
+ return control_tpl.format(
1248
+ t_revise_labels=revise_js,
1249
+ i18n_json=i18n_json,
1250
+ summary_safe=summary_safe,
1251
+ secondary_html=secondary_html,
1252
+ pill_secondary_html=pill_secondary_html,
1253
+ primary_val=primary_val,
1254
+ primary_label=primary_label,
1255
+ t_region=html_lib.escape(t("region_label"), quote=True),
1256
+ t_round=html_lib.escape(t("round_n", n=round_n)),
1257
+ t_annotate=html_lib.escape(t("annotate")),
1258
+ t_pill_open=html_lib.escape(t("pill_open")),
1259
+ t_not_ready=html_lib.escape(t("not_ready")),
1260
+ t_drawer_aria=html_lib.escape(t("drawer_aria"), quote=True),
1261
+ t_collapse=html_lib.escape(t("collapse"), quote=True),
1262
+ t_pin_toggle=html_lib.escape(t("pin_toggle")),
1263
+ t_pin_count=html_lib.escape(t("pin_count", n=0)),
1264
+ t_anchors_head=html_lib.escape(t("anchors_head")),
1265
+ t_anchors_empty=html_lib.escape(t("anchors_empty")),
1266
+ t_field_label=html_lib.escape(t("field_label")),
1267
+ t_field_hint=html_lib.escape(t("field_hint")),
1268
+ t_field_placeholder=html_lib.escape(t("field_placeholder"), quote=True),
1269
+ t_terminate=html_lib.escape(t("terminate")),
1270
+ t_terminate_desc=html_lib.escape(t("terminate_desc"), quote=True),
1271
+ t_draft=html_lib.escape(t("draft")),
1272
+ t_draft_desc=html_lib.escape(t("draft_desc"), quote=True),
1273
+ t_confirm_desc=html_lib.escape(t("confirm_desc"), quote=True),
1274
+ )
1275
+
1276
+
1277
+
1278
+ def _format_feedback(feedback: str, anchors: list[dict[str, Any]]) -> str:
1279
+ feedback = (feedback or "").strip()
1280
+ if not anchors:
1281
+ return feedback
1282
+ lines = []
1283
+ if feedback:
1284
+ lines.append(feedback)
1285
+ lines.append("")
1286
+ lines.append(t("anchor_note_label", n=len(anchors)))
1287
+ for i, a in enumerate(anchors, 1):
1288
+ label = a.get("label") or a.get("tag") or "?"
1289
+ note = a.get("comment") or t("no_text")
1290
+ sel = a.get("selector") or ""
1291
+ lines.append(f"{i}. [{label}] {note} — {sel}")
1292
+ return "\n".join(lines)
1293
+