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.
- package/mcp/preview/browser.py +77 -14
- package/mcp/preview/control.css +482 -0
- package/mcp/preview/control.html +67 -0
- package/mcp/preview/control.js +669 -0
- package/mcp/preview/control.py +29 -1146
- package/mcp/preview/server.py +5 -2
- package/mcp/preview/test_browser_control.py +38 -34
- package/mcp/preview/transaction.py +84 -7
- package/mcp/preview/util.py +18 -2
- package/package.json +1 -1
- package/mcp/preview/confirm.py +0 -255
package/mcp/preview/browser.py
CHANGED
|
@@ -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
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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>
|