design-playbook 0.7.0 → 0.9.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.
@@ -12,6 +12,7 @@ import html
12
12
  import json
13
13
  import os
14
14
  import re
15
+ import secrets
15
16
  import shutil
16
17
  import subprocess
17
18
  import sys
@@ -23,14 +24,68 @@ from pathlib import Path
23
24
  from typing import Any
24
25
  from urllib.parse import parse_qs
25
26
 
26
- from confirm import (
27
- _DecisionSession,
28
- _generate_decision_token,
29
- prototype_html_digest,
30
- )
31
27
  from control import _build_control
32
28
  from i18n import lang, t
33
- from util import _log
29
+ from util import _log, prototype_html_digest
30
+
31
+
32
+ def _generate_decision_token() -> str:
33
+ """One-time URL-safe decision token (G5 trust boundary).
34
+
35
+ Proves a POST to /decide originated from the trusted parent control bar
36
+ (which renders the hidden field) rather than from prototype scripts running
37
+ inside the sandboxed iframe, which cannot read the parent DOM. Bound to a
38
+ single preview round via :class:`_DecisionSession`.
39
+ """
40
+ return secrets.token_urlsafe(32)
41
+
42
+
43
+ class _DecisionSession:
44
+ """First-decision-wins token lock for a single preview round (G5).
45
+
46
+ ``validate`` returns ``True`` only for the first POST whose token matches
47
+ (constant-time) AND whose round matches. Every other POST — missing token,
48
+ reused token, mismatched round, or wrong token — is rejected so the caller
49
+ can fail the decision closed. The session grants at most one valid decision.
50
+ """
51
+
52
+ def __init__(self, round_n: int, token: str) -> None:
53
+ self.round_n = round_n
54
+ self._token = token
55
+ self._locked = False
56
+ self._lock = threading.Lock()
57
+ self.last_rejection: str = ""
58
+
59
+ @property
60
+ def locked(self) -> bool:
61
+ return self._locked
62
+
63
+ def validate(self, posted_round: int, posted_token: str | None) -> bool:
64
+ # LOW-1 (secure-ship-0.4.4): the check-then-set on ``_locked`` must
65
+ # be atomic. ThreadingHTTPServer handles each POST on its own
66
+ # thread, so two concurrent valid-token POSTs could both pass the
67
+ # ``if self._locked`` check and each consume the session. Hold the
68
+ # lock for the whole decision so first-decision-wins holds under
69
+ # real concurrency; the lock is uncontended in the single-POST
70
+ # happy path, so the cost is a no-op acquire/release.
71
+ with self._lock:
72
+ if not posted_token:
73
+ self.last_rejection = "missing"
74
+ return False
75
+ if posted_round != self.round_n:
76
+ self.last_rejection = "round_mismatch"
77
+ return False
78
+ if self._locked:
79
+ # First valid decision already consumed the session; every
80
+ # later POST (even with the correct token) is a replay.
81
+ self.last_rejection = "reuse"
82
+ return False
83
+ if not secrets.compare_digest(posted_token, self._token):
84
+ self.last_rejection = "invalid_token"
85
+ return False
86
+ self._locked = True
87
+ self.last_rejection = ""
88
+ return True
34
89
 
35
90
 
36
91
  def _screen_size() -> tuple[int, int]:
@@ -593,14 +648,22 @@ def _collect_via_browser(
593
648
  validated = session.validate(posted_round, posted_token)
594
649
  if not validated:
595
650
  # Fail closed: missing / reused / mismatched token -> NOT confirmed.
596
- result = with_prototype_hash({
597
- "choice": "",
598
- "feedback": feedback,
599
- "aborted": True,
600
- "anchors": anchors,
601
- "rejected": True,
602
- "rejection": session.last_rejection,
603
- })
651
+ # First-decision-wins also guards the shared ``result`` slot:
652
+ # once a valid POST owns the result, a later rejected POST (replay,
653
+ # mismatch) must NOT clobber it — otherwise a double-click or
654
+ # browser retry after a valid confirm overwrites the confirmed
655
+ # result with an aborted/rejected one and G5 fails despite the
656
+ # user's confirm. Only mutate ``result`` before any valid
657
+ # decision has landed (session not yet locked).
658
+ if not session.locked:
659
+ result = with_prototype_hash({
660
+ "choice": "",
661
+ "feedback": feedback,
662
+ "aborted": True,
663
+ "anchors": anchors,
664
+ "rejected": True,
665
+ "rejection": session.last_rejection,
666
+ })
604
667
  else:
605
668
  result = with_prototype_hash({
606
669
  "choice": choice,
@@ -0,0 +1,482 @@
1
+ #dpb-preview-bar,
2
+ #dpb-float-root {
3
+ --dpb-bg: #1e2330;
4
+ --dpb-surface: #252b38;
5
+ --dpb-elev: #2a3140;
6
+ --dpb-line: #2c3444;
7
+ --dpb-line-strong: #3d475a;
8
+ --dpb-ink: #f3f4f6;
9
+ --dpb-muted: #9aa3b2;
10
+ --dpb-soft: #c5cbd6;
11
+ --dpb-accent: #14b8a6;
12
+ --dpb-accent-ink: #ffffff;
13
+ --dpb-accent-deep: #0f766e;
14
+ --dpb-danger: #f87171;
15
+ --dpb-chrome: #131722;
16
+ --dpb-hover: #3a4150;
17
+ --dpb-secondary-hover: #252b39;
18
+ --dpb-border-hover: #5b6578;
19
+ --dpb-border-hover-strong: #6b7588;
20
+ --dpb-accent-hover: #2dd4bf;
21
+ --dpb-accent-contrast: #042f2e;
22
+ --dpb-accent-soft: #99f6e4;
23
+ --dpb-ready: #5eead4;
24
+ --dpb-placeholder: #7d8698;
25
+ --dpb-input-focus: #12161e;
26
+ --dpb-quiet-hover: rgba(255,255,255,.04);
27
+ --dpb-note-bg: #1f2430;
28
+ --dpb-note-ink: #f3f4f6;
29
+ --dpb-radius: 10px;
30
+ --dpb-control-h: 40px;
31
+ }
32
+ #dpb-preview-bar {
33
+ font: 13px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif;
34
+ color: var(--dpb-ink);
35
+ }
36
+ #dpb-preview-bar[data-theme="light"],
37
+ #dpb-float-root[data-theme="light"] {
38
+ --dpb-bg: #ffffff;
39
+ --dpb-surface: #f9fafb;
40
+ --dpb-elev: #f3f4f6;
41
+ --dpb-line: #e5e7eb;
42
+ --dpb-line-strong: #d1d5db;
43
+ --dpb-ink: #111827;
44
+ --dpb-muted: #6b7280;
45
+ --dpb-soft: #4b5563;
46
+ --dpb-accent: #0f766e;
47
+ --dpb-accent-ink: #ffffff;
48
+ --dpb-accent-deep: #134e4a;
49
+ --dpb-danger: #ef4444;
50
+ --dpb-chrome: #f3f4f6;
51
+ --dpb-hover: #e5e7eb;
52
+ --dpb-secondary-hover: #e5e7eb;
53
+ --dpb-border-hover: #9ca3af;
54
+ --dpb-border-hover-strong: #6b7280;
55
+ --dpb-accent-hover: #115e59;
56
+ --dpb-accent-contrast: #ffffff;
57
+ --dpb-accent-soft: #115e59;
58
+ --dpb-ready: #0f766e;
59
+ --dpb-placeholder: #6b7280;
60
+ --dpb-input-focus: #ffffff;
61
+ --dpb-quiet-hover: rgba(17,24,39,.05);
62
+ --dpb-note-bg: #ffffff;
63
+ --dpb-note-ink: #111827;
64
+ }
65
+ /* ---- floating pill (default state) ---- */
66
+ #dpb-preview-bar {
67
+ position: fixed;
68
+ right: 24px;
69
+ bottom: 24px;
70
+ left: auto;
71
+ top: auto;
72
+ z-index: 1000;
73
+ background: transparent;
74
+ border: 0;
75
+ padding: 0;
76
+ border-radius: 0;
77
+ border-top: 0;
78
+ }
79
+ #dpb-preview-bar .dpb-pill {
80
+ display: inline-flex;
81
+ align-items: center;
82
+ gap: 8px;
83
+ padding: 8px 8px 8px 16px;
84
+ background: var(--dpb-bg);
85
+ border: 1px solid var(--dpb-line);
86
+ border-radius: 999px;
87
+ box-shadow: 0 8px 28px rgba(0,0,0,.15), 0 2px 6px rgba(0,0,0,.1);
88
+ }
89
+ #dpb-preview-bar .dpb-pill .dpb-pill-info {
90
+ display: inline-flex; align-items: center; gap: 8px; max-width: 260px;
91
+ }
92
+ #dpb-preview-bar .dpb-round {
93
+ flex: 0 0 auto; display: inline-flex; align-items: center; height: 22px;
94
+ padding: 0 8px; border-radius: 999px; font-size: 11px; font-weight: 700;
95
+ font-variant-numeric: tabular-nums; color: var(--dpb-accent-soft);
96
+ background: rgba(20,184,166,.14); border: 1px solid rgba(20,184,166,.35);
97
+ }
98
+ #dpb-preview-bar .dpb-pill .dpb-summary {
99
+ margin: 0; min-width: 0; color: var(--dpb-soft);
100
+ font-size: 12.5px; font-weight: 500; overflow: hidden;
101
+ text-overflow: ellipsis; white-space: nowrap;
102
+ }
103
+ #dpb-preview-bar .dpb-pill .dpb-pill-actions {
104
+ display: inline-flex; align-items: center; gap: 6px;
105
+ }
106
+ #dpb-preview-bar .dpb-pill .dpb-btn-ghost {
107
+ appearance: none; cursor: pointer; height: 32px; padding: 0 12px;
108
+ border-radius: 999px; border: 1px solid var(--dpb-line-strong);
109
+ background: var(--dpb-surface); color: var(--dpb-soft);
110
+ font: 650 12.5px/1 system-ui, sans-serif;
111
+ display: inline-flex; align-items: center; gap: 6px;
112
+ transition: background 140ms ease, border-color 140ms ease, color 140ms ease;
113
+ }
114
+ #dpb-preview-bar .dpb-pill .dpb-btn-ghost:hover {
115
+ background: var(--dpb-elev); border-color: var(--dpb-border-hover); color: var(--dpb-ink);
116
+ }
117
+ #dpb-preview-bar .dpb-pill .dpb-btn-ghost:focus-visible {
118
+ outline: 2px solid var(--dpb-accent); outline-offset: 2px;
119
+ }
120
+ #dpb-preview-bar .dpb-pill .dpb-badge-dot {
121
+ display: none; min-width: 18px; height: 18px; padding: 0 5px;
122
+ align-items: center; justify-content: center; border-radius: 999px;
123
+ font-size: 11px; font-weight: 700; color: var(--dpb-accent-contrast);
124
+ background: var(--dpb-accent);
125
+ }
126
+ #dpb-preview-bar .dpb-pill .dpb-badge-dot.is-on { display: inline-flex; }
127
+ #dpb-preview-bar .dpb-pill .dpb-btn-primary {
128
+ appearance: none; cursor: pointer; height: 32px; padding: 0 16px;
129
+ border-radius: 999px; border: 1px solid var(--dpb-accent-deep);
130
+ background: var(--dpb-accent); color: var(--dpb-accent-ink);
131
+ font: 650 12.5px/1 system-ui, sans-serif;
132
+ box-shadow: inset 0 1px 0 rgba(255,255,255,.18);
133
+ transition: background 140ms ease, transform 100ms ease;
134
+ }
135
+ #dpb-preview-bar .dpb-pill .dpb-btn-primary:hover { background: var(--dpb-accent-hover); color: var(--dpb-accent-contrast); }
136
+ #dpb-preview-bar .dpb-pill .dpb-btn-primary:active { transform: translateY(1px); }
137
+ #dpb-preview-bar .dpb-pill .dpb-btn-primary:focus-visible {
138
+ outline: 2px solid var(--dpb-accent); outline-offset: 2px;
139
+ }
140
+ /* Direct confirm state on pill primary when readiness allows immediate action */
141
+ #dpb-preview-bar .dpb-pill .dpb-btn-primary.is-direct-confirm {
142
+ box-shadow: 0 0 0 3px rgba(20,184,166,0.25);
143
+ font-weight: 700;
144
+ }
145
+ /* Pill direct-confirm arm (二级保护): first click arms, second submits */
146
+ #dpb-preview-bar .dpb-pill .dpb-btn-primary.is-armed {
147
+ font-weight: 700;
148
+ animation: dpb-confirm-pulse 1s ease-in-out infinite;
149
+ }
150
+ @keyframes dpb-confirm-pulse {
151
+ 0%,100% { box-shadow: 0 0 0 3px rgba(20,184,166,0.35); }
152
+ 50% { box-shadow: 0 0 0 7px rgba(20,184,166,0.12); }
153
+ }
154
+ /* secondary action surfaced on the pill (e.g. the revise label) */
155
+ #dpb-preview-bar .dpb-pill .dpb-btn-pill-secondary {
156
+ appearance: none; cursor: pointer; height: 32px; padding: 0 14px;
157
+ border-radius: 999px; border: 1px solid var(--dpb-line-strong);
158
+ background: var(--dpb-elev); color: var(--dpb-soft);
159
+ font: 650 12.5px/1 system-ui, sans-serif;
160
+ display: inline-flex; align-items: center;
161
+ transition: background 140ms ease, border-color 140ms ease, color 140ms ease;
162
+ }
163
+ #dpb-preview-bar .dpb-pill .dpb-btn-pill-secondary:hover {
164
+ background: var(--dpb-hover); border-color: var(--dpb-border-hover-strong); color: var(--dpb-ink);
165
+ }
166
+ #dpb-preview-bar .dpb-pill .dpb-btn-pill-secondary:focus-visible {
167
+ outline: 2px solid var(--dpb-accent); outline-offset: 2px;
168
+ }
169
+ @media (max-width: 720px) {
170
+ #dpb-preview-bar .dpb-pill .dpb-btn-pill-secondary { display: inline-flex; font-size: 11px; padding: 0 8px; }
171
+ /* Per debate: do not hide revise on mobile; allow stacking if needed */
172
+ }
173
+
174
+ /* ---- drawer (expanded state) ---- */
175
+ #dpb-preview-bar dialog.dpb-drawer {
176
+ position: fixed;
177
+ right: 24px;
178
+ bottom: 24px;
179
+ margin: 0;
180
+ width: 380px;
181
+ max-width: calc(100vw - 48px);
182
+ max-height: calc(100vh - 48px);
183
+ flex-direction: column;
184
+ background: var(--dpb-bg);
185
+ border: 1px solid var(--dpb-line);
186
+ border-radius: 16px;
187
+ box-shadow: 0 16px 48px rgba(0,0,0,.22), 0 4px 12px rgba(0,0,0,.12);
188
+ overflow: hidden;
189
+ color: var(--dpb-ink);
190
+ padding: 0;
191
+ }
192
+ /* non-modal <dialog>.show() is not in the top layer, so we keep the hand-rolled
193
+ scrim (stronger than before per UX V4) so the drawer reads as a focused layer. */
194
+ #dpb-preview-bar dialog.dpb-drawer[open] { display: flex; }
195
+ /* show() failure / no-dialog fallback: is-open alone must reveal the drawer */
196
+ #dpb-preview-bar.is-open dialog.dpb-drawer { display: flex; }
197
+ #dpb-preview-bar.is-open::before {
198
+ content: "";
199
+ position: fixed; inset: 0; z-index: -1;
200
+ background: rgba(15,23,42,.5);
201
+ pointer-events: auto; /* P1.5: intercept clicks outside drawer (close on scrim) */
202
+ }
203
+ body.dpb-pin-mode #dpb-preview-bar.is-open::before {
204
+ pointer-events: none; /* P1.5: let pin-mode clicks reach prototype elements */
205
+ }
206
+ #dpb-preview-bar.is-open .dpb-pill { display: none; }
207
+
208
+ #dpb-preview-bar .dpb-drawer-head {
209
+ display: flex; align-items: center; justify-content: space-between;
210
+ gap: 10px; padding: 12px 14px;
211
+ border-bottom: 1px solid var(--dpb-line);
212
+ background: linear-gradient(180deg, var(--dpb-chrome), var(--dpb-bg));
213
+ }
214
+ #dpb-preview-bar .dpb-drawer-head .dpb-head-left {
215
+ display: inline-flex; align-items: center; gap: 8px; min-width: 0;
216
+ }
217
+ #dpb-preview-bar .dpb-drawer-head .dpb-title {
218
+ margin: 0; font-size: 13px; font-weight: 650; color: var(--dpb-ink);
219
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
220
+ }
221
+ #dpb-preview-bar .dpb-drawer-head .dpb-icon-btn {
222
+ appearance: none; cursor: pointer; width: 28px; height: 28px;
223
+ border-radius: 8px; border: 1px solid transparent;
224
+ background: transparent; color: var(--dpb-muted);
225
+ font: 650 16px/1 system-ui, sans-serif;
226
+ display: inline-grid; place-items: center;
227
+ transition: background 140ms ease, color 140ms ease;
228
+ }
229
+ #dpb-preview-bar .dpb-drawer-head .dpb-icon-btn:hover {
230
+ background: var(--dpb-elev); color: var(--dpb-ink);
231
+ }
232
+ #dpb-preview-bar .dpb-drawer-head .dpb-icon-btn:focus-visible {
233
+ outline: 2px solid var(--dpb-accent); outline-offset: 1px;
234
+ }
235
+
236
+ #dpb-preview-bar .dpb-drawer-body {
237
+ display: flex; flex-direction: column; min-height: 0;
238
+ overflow: auto; padding: 10px 14px 14px; gap: 14px;
239
+ }
240
+ #dpb-preview-bar .dpb-drawer-body .dpb-subhead {
241
+ margin: 0; font-size: 11px; font-weight: 700; letter-spacing: 0.05em;
242
+ color: var(--dpb-muted); text-transform: uppercase;
243
+ }
244
+
245
+ /* pin toggle in drawer */
246
+ #dpb-preview-bar .dpb-pin-toggle {
247
+ appearance: none; cursor: pointer; height: 34px; padding: 0 12px;
248
+ border-radius: 8px; border: 1px solid var(--dpb-line-strong);
249
+ background: var(--dpb-surface); color: var(--dpb-soft);
250
+ font: 650 12.5px/1 system-ui, sans-serif;
251
+ display: inline-flex; align-items: center; gap: 7px;
252
+ transition: background 140ms ease, border-color 140ms ease, color 140ms ease;
253
+ }
254
+ #dpb-preview-bar .dpb-pin-toggle:hover {
255
+ background: var(--dpb-elev); border-color: var(--dpb-border-hover); color: var(--dpb-ink);
256
+ }
257
+ #dpb-preview-bar .dpb-pin-toggle.is-on {
258
+ color: var(--dpb-accent-contrast); background: var(--dpb-accent); border-color: var(--dpb-accent-deep);
259
+ }
260
+ #dpb-preview-bar .dpb-pin-toggle:focus-visible {
261
+ outline: 2px solid var(--dpb-accent); outline-offset: 2px;
262
+ }
263
+ #dpb-preview-bar .dpb-pin-row {
264
+ display: flex; align-items: center; justify-content: space-between; gap: 10px;
265
+ }
266
+ #dpb-preview-bar .dpb-pin-count {
267
+ font-size: 11.5px; color: var(--dpb-muted); font-variant-numeric: tabular-nums;
268
+ }
269
+
270
+ /* anchor list */
271
+ #dpb-preview-bar .dpb-anchors {
272
+ display: none; flex-direction: column; gap: 6px;
273
+ max-height: 180px; overflow: auto; padding: 8px;
274
+ border-radius: 8px; background: var(--dpb-surface);
275
+ border: 1px solid var(--dpb-line);
276
+ }
277
+ #dpb-preview-bar .dpb-anchors.has-items { display: flex; }
278
+ #dpb-preview-bar .dpb-empty {
279
+ display: none; padding: 10px 4px; font-size: 12px; color: var(--dpb-muted);
280
+ text-align: center;
281
+ }
282
+ #dpb-preview-bar .dpb-anchors:not(.has-items) + .dpb-empty { display: block; }
283
+ #dpb-preview-bar .dpb-anchor {
284
+ display: grid; grid-template-columns: 18px minmax(0, 1fr) auto; gap: 8px;
285
+ align-items: start; padding: 6px 8px; border-radius: 8px;
286
+ background: var(--dpb-elev); border: 1px solid var(--dpb-line);
287
+ }
288
+ #dpb-preview-bar .dpb-anchor .n {
289
+ font-size: 11px; font-weight: 700; color: var(--dpb-accent); padding-top: 4px;
290
+ }
291
+ #dpb-preview-bar .dpb-anchor .meta {
292
+ min-width: 0; display: grid; gap: 4px;
293
+ }
294
+ #dpb-preview-bar .dpb-anchor .label {
295
+ font-size: 12px; font-weight: 650; color: var(--dpb-ink);
296
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
297
+ appearance: none; border: 0; background: transparent; padding: 0;
298
+ text-align: left; cursor: pointer; width: 100%;
299
+ display: block; min-width: 0;
300
+ }
301
+ #dpb-preview-bar .dpb-anchor .label:hover { color: var(--dpb-accent); }
302
+ #dpb-preview-bar .dpb-anchor .label:focus-visible {
303
+ outline: 2px solid var(--dpb-accent); outline-offset: 1px; border-radius: 4px;
304
+ }
305
+ #dpb-preview-bar .dpb-anchor .sel {
306
+ font-size: 11px; color: var(--dpb-muted);
307
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
308
+ }
309
+ #dpb-preview-bar .dpb-anchor input {
310
+ width: 100%; box-sizing: border-box; height: 28px; margin: 0; padding: 0 8px;
311
+ border-radius: 6px; border: 1px solid var(--dpb-line-strong);
312
+ background: var(--dpb-surface); color: var(--dpb-ink); font: inherit; outline: none;
313
+ }
314
+ #dpb-preview-bar .dpb-anchor input:focus {
315
+ border-color: var(--dpb-accent); box-shadow: 0 0 0 2px rgba(20,184,166,.2);
316
+ }
317
+ #dpb-preview-bar .dpb-anchor .rm {
318
+ appearance: none; cursor: pointer; height: 28px; padding: 0 8px;
319
+ border: 0; border-radius: 6px; background: transparent; color: var(--dpb-muted);
320
+ font: 650 12px/1 system-ui, sans-serif;
321
+ }
322
+ #dpb-preview-bar .dpb-anchor .rm:hover { color: var(--dpb-danger); background: rgba(248,113,113,.08); }
323
+
324
+ /* overall comment */
325
+ #dpb-preview-bar .dpb-field { display: grid; gap: 6px; min-width: 0; }
326
+ #dpb-preview-bar .dpb-label-row {
327
+ display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
328
+ }
329
+ #dpb-preview-bar .dpb-label { font-size: 12px; font-weight: 650; color: var(--dpb-muted); }
330
+ /* I4: neutral hint up-front; .is-on only adds emphasis (was red-danger-only). */
331
+ #dpb-preview-bar .dpb-hint { margin: 0; font-size: 12px; color: var(--dpb-muted); display: block; }
332
+ #dpb-preview-bar .dpb-hint.is-on { color: var(--dpb-danger); font-weight: 600; }
333
+ /* I4: pill readiness indicator (live status on the resting pill). */
334
+ #dpb-preview-bar .dpb-pill .dpb-pill-ready {
335
+ font-size: 11px; color: var(--dpb-muted); white-space: nowrap;
336
+ }
337
+ #dpb-preview-bar .dpb-pill .dpb-pill-ready.is-ready {
338
+ color: var(--dpb-ready); font-weight: 600;
339
+ }
340
+ #dpb-preview-bar textarea[name="feedback"] {
341
+ width: 100%; box-sizing: border-box; min-height: 56px; max-height: 120px;
342
+ resize: vertical; margin: 0; padding: 8px 12px; color: var(--dpb-ink);
343
+ background: var(--dpb-surface); border: 1px solid var(--dpb-line-strong);
344
+ border-radius: var(--dpb-radius); font: inherit; line-height: 1.45; outline: none;
345
+ transition: border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
346
+ }
347
+ #dpb-preview-bar textarea[name="feedback"]::placeholder { color: var(--dpb-placeholder); }
348
+ #dpb-preview-bar textarea[name="feedback"]:hover { border-color: var(--dpb-border-hover); background: var(--dpb-elev); }
349
+ #dpb-preview-bar textarea[name="feedback"]:focus {
350
+ border-color: var(--dpb-accent); box-shadow: 0 0 0 3px rgba(20,184,166,.22); background: var(--dpb-input-focus);
351
+ }
352
+ #dpb-preview-bar textarea[name="feedback"][aria-invalid="true"] {
353
+ border-color: var(--dpb-danger); box-shadow: 0 0 0 3px rgba(248,113,113,.18);
354
+ }
355
+
356
+ /* footer actions */
357
+ #dpb-preview-bar .dpb-drawer-foot {
358
+ display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between;
359
+ align-items: center; padding: 12px 14px;
360
+ border-top: 1px solid var(--dpb-line);
361
+ background: linear-gradient(180deg, var(--dpb-bg), var(--dpb-chrome));
362
+ }
363
+ #dpb-preview-bar .dpb-btns {
364
+ display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; align-items: center;
365
+ }
366
+ #dpb-preview-bar .dpb-btn {
367
+ appearance: none; cursor: pointer; box-sizing: border-box; height: var(--dpb-control-h);
368
+ min-width: 96px; padding: 0 16px; border-radius: var(--dpb-radius);
369
+ font: 650 13px/1 system-ui, -apple-system, "Segoe UI", sans-serif;
370
+ border: 1px solid transparent;
371
+ transition: background 140ms ease, border-color 140ms ease, color 140ms ease, transform 100ms ease;
372
+ }
373
+ #dpb-preview-bar .dpb-btn:active { transform: translateY(1px); }
374
+ #dpb-preview-bar .dpb-btn:focus-visible { outline: 2px solid var(--dpb-accent); outline-offset: 2px; }
375
+ #dpb-preview-bar .dpb-btn-primary {
376
+ background: var(--dpb-accent); color: var(--dpb-accent-ink); border-color: var(--dpb-accent-deep);
377
+ box-shadow: inset 0 1px 0 rgba(255,255,255,.18);
378
+ }
379
+ #dpb-preview-bar .dpb-btn-primary:hover { background: var(--dpb-accent-hover); color: var(--dpb-accent-contrast); }
380
+ #dpb-preview-bar .dpb-btn-secondary {
381
+ background: var(--dpb-elev); color: var(--dpb-ink); border-color: var(--dpb-line-strong);
382
+ }
383
+ #dpb-preview-bar .dpb-btn-secondary:hover { background: var(--dpb-secondary-hover); border-color: var(--dpb-border-hover); }
384
+ #dpb-preview-bar .dpb-btn-quiet {
385
+ min-width: 72px; background: transparent; color: var(--dpb-muted); border-color: transparent;
386
+ }
387
+ #dpb-preview-bar .dpb-btn-quiet:hover { color: var(--dpb-ink); background: var(--dpb-quiet-hover); }
388
+ #dpb-preview-bar .dpb-btn-danger {
389
+ min-width: 72px; background: transparent; color: var(--dpb-danger); border-color: transparent;
390
+ }
391
+ #dpb-preview-bar .dpb-btn-danger:hover { color: #fff; background: var(--dpb-danger); border-color: var(--dpb-danger); }
392
+ #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; }
393
+ /* ring pulse (not opacity) so the armed label stays at full contrast */
394
+ @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); } }
395
+ /* visually hidden live region (screen-reader announcements, e.g. abort arming) */
396
+ #dpb-preview-bar .dpb-sr-only {
397
+ position: absolute; width: 1px; height: 1px; overflow: hidden;
398
+ clip-path: inset(50%); white-space: nowrap;
399
+ }
400
+
401
+ /* floating annotation bubbles pinned to targeted elements.
402
+ z-index 999 < #dpb-preview-bar (1000): float-note sits above the prototype
403
+ page content but BELOW the bar/drawer, so pin bubbles never cover the
404
+ confirm controls (was 1001 -> covered drawer, d46682d follow-up). */
405
+ .dpb-float-note {
406
+ position: absolute;
407
+ z-index: 999;
408
+ max-width: 240px;
409
+ min-width: 120px;
410
+ padding: 6px 10px 6px 8px;
411
+ border-radius: 10px;
412
+ background: var(--dpb-note-bg);
413
+ color: var(--dpb-note-ink);
414
+ border: 1px solid var(--dpb-line);
415
+ box-shadow: 0 8px 24px rgba(0,0,0,.32), 0 2px 6px rgba(0,0,0,.18);
416
+ font: 12px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif;
417
+ pointer-events: none;
418
+ transform: translateY(4px);
419
+ opacity: 0;
420
+ transition: opacity 160ms ease, transform 160ms ease;
421
+ }
422
+ .dpb-float-note.is-visible { opacity: 1; transform: translateY(0); }
423
+ .dpb-float-note .dpb-float-n {
424
+ position: absolute; top: -7px; left: -7px;
425
+ width: 18px; height: 18px; border-radius: 999px;
426
+ background: var(--dpb-accent); color: var(--dpb-accent-contrast);
427
+ font-size: 11px; font-weight: 700;
428
+ display: inline-grid; place-items: center;
429
+ box-shadow: 0 1px 3px rgba(0,0,0,.3);
430
+ }
431
+ .dpb-float-note .dpb-float-label {
432
+ display: block; font-weight: 650; color: var(--dpb-accent-soft);
433
+ font-size: 11px; margin-bottom: 2px; word-break: break-word;
434
+ }
435
+ .dpb-float-note .dpb-float-comment {
436
+ color: var(--dpb-soft); word-break: break-word; white-space: pre-wrap;
437
+ }
438
+
439
+ .dpb-pin-target {
440
+ outline: 1.5px solid rgba(20,184,166,.9) !important;
441
+ outline-offset: 1px !important;
442
+ background-color: rgba(20,184,166,.06) !important;
443
+ cursor: crosshair !important;
444
+ }
445
+ .dpb-pin-hover {
446
+ outline: 1px dashed rgba(20,184,166,.45) !important;
447
+ outline-offset: 1px !important;
448
+ }
449
+ /* brief flash when an anchor is located from the list */
450
+ .dpb-pin-flash {
451
+ animation: dpb-flash 900ms ease-out 1;
452
+ }
453
+ @keyframes dpb-flash {
454
+ 0% { box-shadow: 0 0 0 0 rgba(20,184,166,.55); outline-color: rgba(20,184,166,.9) !important; }
455
+ 50% { box-shadow: 0 0 0 6px rgba(20,184,166,.25); }
456
+ 100% { box-shadow: 0 0 0 0 rgba(20,184,166,0); }
457
+ }
458
+ body.dpb-pin-mode, body.dpb-pin-mode * { cursor: crosshair !important; }
459
+ body.dpb-pin-mode #dpb-preview-bar, body.dpb-pin-mode #dpb-preview-bar * {
460
+ cursor: default !important;
461
+ }
462
+
463
+ @media (max-width: 720px) {
464
+ #dpb-preview-bar { right: 12px; bottom: 12px; }
465
+ #dpb-preview-bar .dpb-drawer { width: calc(100vw - 24px); }
466
+ #dpb-preview-bar .dpb-pill .dpb-pill-info { display: none; }
467
+ #dpb-preview-bar .dpb-drawer-body { padding: 10px 10px 14px; }
468
+ #dpb-preview-bar .dpb-drawer-foot { justify-content: flex-start; flex-wrap: wrap; }
469
+ #dpb-preview-bar .dpb-btn.dpb-btn-danger { flex: 0 0 auto; }
470
+ #dpb-preview-bar .dpb-btns { flex: 1 1 auto; }
471
+ #dpb-preview-bar .dpb-btns .dpb-btn { flex: 1 1 auto; min-width: 0; }
472
+ }
473
+ @media (prefers-reduced-motion: reduce) {
474
+ #dpb-preview-bar .dpb-btn,
475
+ #dpb-preview-bar textarea[name="feedback"],
476
+ .dpb-float-note { transition: none; }
477
+ #dpb-preview-bar .dpb-btn:active { transform: none; }
478
+ .dpb-pin-flash { animation: none; }
479
+ /* armed state still reads via solid danger fill + bold label, no pulse */
480
+ #dpb-preview-bar .dpb-btn-danger.is-armed { animation: none; }
481
+ #dpb-preview-bar .dpb-pill .dpb-btn-primary.is-armed { animation: none; }
482
+ }
@@ -0,0 +1,67 @@
1
+ <div id="dpb-preview-bar" role="region" aria-label="{t_region}">
2
+ <form method="POST" action="/decide" id="dpb-decide-form">
3
+ <input type="hidden" name="anchors_json" id="dpb-anchors-json" value="[]" />
4
+
5
+ <!-- floating pill (default) -->
6
+ <div class="dpb-pill">
7
+ <span class="dpb-pill-info">
8
+ <span class="dpb-round">{t_round}</span>
9
+ <p class="dpb-summary" title="{summary_safe}">{summary_safe}</p>
10
+ </span>
11
+ <span class="dpb-pill-ready" id="dpb-pill-ready" role="status">{t_not_ready}</span>
12
+ <span class="dpb-pill-actions">
13
+ {pill_secondary_html}
14
+ <button type="button" class="dpb-btn-ghost" id="dpb-open-drawer" aria-haspopup="dialog">
15
+ {t_annotate}<span class="dpb-badge-dot" id="dpb-pill-count">0</span>
16
+ </button>
17
+ <button type="button" class="dpb-btn-primary" id="dpb-open-primary" aria-haspopup="dialog">{t_pill_open}</button>
18
+ </span>
19
+ <span class="dpb-sr-only" id="dpb-pill-arm-status" role="alert"></span>
20
+ </div>
21
+
22
+ <!-- drawer (expanded) -->
23
+ <dialog class="dpb-drawer" id="dpb-drawer" aria-label="{t_drawer_aria}">
24
+ <div class="dpb-drawer-head">
25
+ <span class="dpb-head-left">
26
+ <span class="dpb-round">{t_round}</span>
27
+ <h2 class="dpb-title">{summary_safe}</h2>
28
+ </span>
29
+ <button type="button" class="dpb-icon-btn" id="dpb-close-drawer" aria-label="{t_collapse}">−</button>
30
+ </div>
31
+ <div class="dpb-drawer-body">
32
+ <div class="dpb-pin-row">
33
+ <button type="button" class="dpb-pin-toggle" id="dpb-pin-toggle" aria-pressed="false">
34
+ <span aria-hidden="true">🎯</span><span class="dpb-pin-label">{t_pin_toggle}</span>
35
+ </button>
36
+ <span class="dpb-pin-count" id="dpb-pin-count">{t_pin_count}</span>
37
+ </div>
38
+
39
+ <div>
40
+ <p class="dpb-subhead">{t_anchors_head}</p>
41
+ <div class="dpb-anchors" id="dpb-anchors" aria-live="polite"></div>
42
+ <p class="dpb-empty">{t_anchors_empty}</p>
43
+ </div>
44
+
45
+ <div class="dpb-field">
46
+ <span class="dpb-label-row">
47
+ <span class="dpb-label">{t_field_label}</span>
48
+ <span class="dpb-hint" id="dpb-feedback-hint" role="alert">{t_field_hint}</span>
49
+ </span>
50
+ <textarea name="feedback" rows="2"
51
+ placeholder="{t_field_placeholder}"
52
+ autocomplete="off"></textarea>
53
+ </div>
54
+ </div>
55
+ <div class="dpb-drawer-foot">
56
+ <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>
57
+ <span class="dpb-sr-only" id="dpb-abort-status" role="alert"></span>
58
+ <div class="dpb-btns">
59
+ <button type="button" class="dpb-btn dpb-btn-quiet" id="dpb-draft" title="{t_draft_desc}" aria-description="{t_draft_desc}">{t_draft}</button>
60
+ {secondary_html}
61
+ <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>
62
+ </div>
63
+ </div>
64
+ </dialog>
65
+ </form>
66
+ </div>
67
+ <div id="dpb-preview-spacer" aria-hidden="true"></div>