design-playbook 0.7.0 → 0.8.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.
@@ -593,14 +593,22 @@ def _collect_via_browser(
593
593
  validated = session.validate(posted_round, posted_token)
594
594
  if not validated:
595
595
  # 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
- })
596
+ # First-decision-wins also guards the shared ``result`` slot:
597
+ # once a valid POST owns the result, a later rejected POST (replay,
598
+ # mismatch) must NOT clobber it — otherwise a double-click or
599
+ # browser retry after a valid confirm overwrites the confirmed
600
+ # result with an aborted/rejected one and G5 fails despite the
601
+ # user's confirm. Only mutate ``result`` before any valid
602
+ # decision has landed (session not yet locked).
603
+ if not session.locked:
604
+ result = with_prototype_hash({
605
+ "choice": "",
606
+ "feedback": feedback,
607
+ "aborted": True,
608
+ "anchors": anchors,
609
+ "rejected": True,
610
+ "rejection": session.last_rejection,
611
+ })
604
612
  else:
605
613
  result = with_prototype_hash({
606
614
  "choice": choice,
@@ -1,21 +1,19 @@
1
- """ADR-0008 floor logic + confirm/log records + prototype target resolution.
1
+ """ADR-0008 floor logic, G5 trust token, and prototype target resolution.
2
2
 
3
3
  Sibling module split from server.py; behavior unchanged. Holds the
4
- feedback-floor check, confirm JSON + log writers, prototype path helpers,
5
- and the ``--self-check`` floor cases.
4
+ feedback-floor check, the G5 one-time decision token + first-decision-wins
5
+ session, the prototype html digest primitive, and the ``--self-check``
6
+ floor cases.
6
7
  """
7
8
  from __future__ import annotations
8
9
 
9
10
  import hashlib
10
- import json
11
11
  import secrets
12
12
  import sys
13
13
  import threading
14
14
  from pathlib import Path
15
15
  from typing import Any
16
16
 
17
- from util import _now_iso
18
-
19
17
 
20
18
  def prototype_html_digest(raw: bytes) -> str:
21
19
  """SHA-256 of prototype bytes with newlines normalized to LF.
@@ -56,76 +54,6 @@ def _ensure_prototype(path_arg: str | None, html: str | None, round_n: int,
56
54
  return target
57
55
 
58
56
 
59
-
60
- def _append_log(preview_dir: Path, *, round_n: int, report_ref: str,
61
- feedback: str, aborted: bool, selected: list[str],
62
- anchors: list[dict[str, Any]] | None = None,
63
- floor_pass: bool | None = None,
64
- floor_failure: str = "",
65
- rejected: bool = False,
66
- rejection: str = "") -> None:
67
- preview_dir.mkdir(parents=True, exist_ok=True)
68
- log_path = preview_dir / "log.md"
69
- if not log_path.is_file():
70
- log_path.write_text("# preview log\n", encoding="utf-8")
71
- block = (
72
- f"\n## round {round_n}\n"
73
- f"- report_ref: {report_ref}\n"
74
- f"- timestamp: {_now_iso()}\n"
75
- f"- feedback: {feedback or ''}\n"
76
- f"- selected: {', '.join(selected) if selected else ''}\n"
77
- f"- aborted: {str(aborted).lower()}\n"
78
- f"- anchors: {len(anchors or [])}\n"
79
- )
80
- if floor_pass is not None:
81
- block += f"- floor_pass: {str(floor_pass).lower()}\n"
82
- if floor_failure:
83
- block += f"- floor_failure: {floor_failure}\n"
84
- # LOW-4 (secure-ship-0.4.4): persist G5 fail-closed rejections (forged
85
- # token / replay / round mismatch) to log.md so the event is auditable
86
- # on disk, not just in the ephemeral MCP payload. Only emitted when a
87
- # decision was actually rejected — a normal confirm/revise/abort leaves
88
- # no rejection line.
89
- if rejected:
90
- block += "- rejected: true\n"
91
- if rejection:
92
- block += f"- rejection: {rejection}\n"
93
- if anchors:
94
- for i, a in enumerate(anchors, 1):
95
- sel = a.get("selector") or ""
96
- note = a.get("comment") or ""
97
- label = a.get("label") or ""
98
- block += f" - [{i}] {sel} | {label} | {note}\n"
99
- with log_path.open("a", encoding="utf-8") as fh:
100
- fh.write(block)
101
-
102
-
103
-
104
- def _write_confirm(preview_dir: Path, *, round_n: int, report_ref: str,
105
- selected: list[str], feedback: str,
106
- confirmed: bool, floor_pass: bool,
107
- prototype_html_hash: str,
108
- floor_failure: str = "") -> Path:
109
- record = {
110
- "round": round_n,
111
- "report_ref": report_ref,
112
- "confirmed": confirmed,
113
- "floor_pass": floor_pass,
114
- "selected_options": selected,
115
- "feedback": feedback,
116
- "timestamp": _now_iso(),
117
- "prototype_path": f"preview/round-{round_n}.html",
118
- "prototype_html_hash": prototype_html_hash,
119
- }
120
- if floor_failure:
121
- record["floor_failure"] = floor_failure
122
- out = preview_dir / f"confirm-round-{round_n}.json"
123
- out.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n",
124
- encoding="utf-8")
125
- return out
126
-
127
-
128
-
129
57
  def _check_feedback_floor(feedback: str,
130
58
  anchors: list[dict[str, Any]]) -> tuple[bool, str]:
131
59
  """ADR-0008 preview feedback floor (structural, machine-checkable).
@@ -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>